mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-25 08:48:08 +02:00
Align the tree with AGENTS.md/CLAUDE.md conventions: - Rewrite user-facing strings, docs, and tests to lowercase `ktx` (no bare uppercase `KTX` tokens remain outside literal identifiers). - Drop the legacy `historicSql` migration path and its now-unused helpers, per the no-backward-compat rule. - Remove `as unknown as` / `any` casts: narrow `BaseTool` generics to `z.ZodObject`, add a typed `createLookerClient`, and delete the dead `getParametersSchema`/`toAnthropicFormat` pre-AI-SDK helpers. - Use `InvalidArgumentError` for Commander parse failures. - Finish the adapter→connector prose conversion in the `ktx.yaml` docs while keeping the literal `adapters` config key.
240 lines
8.2 KiB
TypeScript
240 lines
8.2 KiB
TypeScript
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { dirname, join } from 'node:path';
|
|
import { z } from 'zod';
|
|
import type { AgentRunnerPort, KtxRuntimeToolSet } from '../../../context/llm/runtime-port.js';
|
|
import type { IngestTraceWriter } from '../ingest-trace.js';
|
|
import { traceTimed } from '../ingest-trace.js';
|
|
|
|
export type TextualConflictResolutionResult =
|
|
| { status: 'repaired'; attempts: number; changedPaths: string[] }
|
|
| { status: 'failed'; attempts: number; reason: string };
|
|
|
|
export interface ResolveTextualConflictInput {
|
|
agentRunner: AgentRunnerPort;
|
|
workdir: string;
|
|
unitKey: string;
|
|
patchPath: string;
|
|
touchedPaths: string[];
|
|
trace: IngestTraceWriter;
|
|
reason: string;
|
|
maxAttempts?: number;
|
|
stepBudget?: number;
|
|
abortSignal?: AbortSignal;
|
|
}
|
|
|
|
const readIntegrationFileSchema = z.object({
|
|
path: z.string().min(1),
|
|
});
|
|
|
|
const writeIntegrationFileSchema = z.object({
|
|
path: z.string().min(1),
|
|
content: z.string(),
|
|
});
|
|
|
|
const deleteIntegrationFileSchema = z.object({
|
|
path: z.string().min(1),
|
|
});
|
|
|
|
function normalizeRepoPath(path: string): string {
|
|
const normalized = path.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
const parts = normalized.split('/').filter((part) => part.length > 0);
|
|
if (parts.length === 0 || parts.some((part) => part === '.' || part === '..')) {
|
|
throw new Error(`resolver path must be a repository-relative path: ${path}`);
|
|
}
|
|
return parts.join('/');
|
|
}
|
|
|
|
function assertAllowedPath(path: string, allowedPaths: ReadonlySet<string>): string {
|
|
const normalized = normalizeRepoPath(path);
|
|
if (!allowedPaths.has(normalized)) {
|
|
throw new Error(`resolver path not allowed: ${normalized}`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
async function readOptionalFile(path: string): Promise<{ exists: boolean; content: string }> {
|
|
try {
|
|
return { exists: true, content: await readFile(path, 'utf-8') };
|
|
} catch (error) {
|
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
return { exists: false, content: '' };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function buildResolverSystemPrompt(): string {
|
|
return `<role>
|
|
You repair one failed ktx isolated-diff patch inside the integration worktree.
|
|
</role>
|
|
|
|
<rules>
|
|
- Preserve accepted integration content that is unrelated to the failed patch.
|
|
- Incorporate the failed patch only when the patch evidence is compatible with the current file.
|
|
- Edit only paths exposed by the resolver tools.
|
|
- Prefer the smallest text edit that makes the composed artifact coherent.
|
|
- Do not create new facts that are absent from the current file or failed patch.
|
|
- Stop after writing the repaired file content.
|
|
</rules>`;
|
|
}
|
|
|
|
function buildResolverUserPrompt(input: {
|
|
unitKey: string;
|
|
patchPath: string;
|
|
touchedPaths: string[];
|
|
reason: string;
|
|
attempt: number;
|
|
maxAttempts: number;
|
|
}): string {
|
|
return `Repair isolated-diff textual conflict.
|
|
|
|
WorkUnit: ${input.unitKey}
|
|
Attempt: ${input.attempt} of ${input.maxAttempts}
|
|
Patch path: ${input.patchPath}
|
|
Touched paths:
|
|
${input.touchedPaths.map((path) => `- ${path}`).join('\n')}
|
|
|
|
Git apply failure:
|
|
${input.reason}
|
|
|
|
Use read_failed_patch first. Then read the touched integration files, write the
|
|
repaired content, and stop.`;
|
|
}
|
|
|
|
function buildToolSet(input: {
|
|
workdir: string;
|
|
patchPath: string;
|
|
allowedPaths: ReadonlySet<string>;
|
|
editedPaths: Set<string>;
|
|
}): KtxRuntimeToolSet {
|
|
return {
|
|
read_failed_patch: {
|
|
name: 'read_failed_patch',
|
|
description: 'Read the failed Git patch that could not be applied to the integration worktree.',
|
|
inputSchema: z.object({}),
|
|
execute: async () => {
|
|
const patch = await readFile(input.patchPath, 'utf-8');
|
|
return {
|
|
markdown: patch,
|
|
structured: { patchPath: input.patchPath, bytes: Buffer.byteLength(patch) },
|
|
};
|
|
},
|
|
},
|
|
read_integration_file: {
|
|
name: 'read_integration_file',
|
|
description: 'Read one allowed file from the current integration worktree.',
|
|
inputSchema: readIntegrationFileSchema,
|
|
execute: async ({ path }: z.infer<typeof readIntegrationFileSchema>) => {
|
|
const normalized = assertAllowedPath(path, input.allowedPaths);
|
|
const file = await readOptionalFile(join(input.workdir, normalized));
|
|
return {
|
|
markdown: file.exists ? file.content : `(missing file: ${normalized})`,
|
|
structured: { path: normalized, exists: file.exists },
|
|
};
|
|
},
|
|
},
|
|
write_integration_file: {
|
|
name: 'write_integration_file',
|
|
description: 'Replace one allowed integration worktree file with repaired text content.',
|
|
inputSchema: writeIntegrationFileSchema,
|
|
execute: async ({ path, content }: z.infer<typeof writeIntegrationFileSchema>) => {
|
|
const normalized = assertAllowedPath(path, input.allowedPaths);
|
|
const fullPath = join(input.workdir, normalized);
|
|
await mkdir(dirname(fullPath), { recursive: true });
|
|
await writeFile(fullPath, content, 'utf-8');
|
|
input.editedPaths.add(normalized);
|
|
return {
|
|
markdown: `Wrote ${normalized}`,
|
|
structured: { path: normalized, bytes: Buffer.byteLength(content) },
|
|
};
|
|
},
|
|
},
|
|
delete_integration_file: {
|
|
name: 'delete_integration_file',
|
|
description: 'Delete one allowed integration worktree file when the failed patch proves the deletion is correct.',
|
|
inputSchema: deleteIntegrationFileSchema,
|
|
execute: async ({ path }: z.infer<typeof deleteIntegrationFileSchema>) => {
|
|
const normalized = assertAllowedPath(path, input.allowedPaths);
|
|
await rm(join(input.workdir, normalized), { force: true });
|
|
input.editedPaths.add(normalized);
|
|
return {
|
|
markdown: `Deleted ${normalized}`,
|
|
structured: { path: normalized },
|
|
};
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function resolveTextualConflict(
|
|
input: ResolveTextualConflictInput,
|
|
): Promise<TextualConflictResolutionResult> {
|
|
const allowedPaths = new Set(input.touchedPaths.map(normalizeRepoPath));
|
|
const maxAttempts = input.maxAttempts ?? 1;
|
|
const stepBudget = input.stepBudget ?? 12;
|
|
let lastFailure = 'resolver did not run';
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
const editedPaths = new Set<string>();
|
|
const traceData = {
|
|
unitKey: input.unitKey,
|
|
patchPath: input.patchPath,
|
|
touchedPaths: [...allowedPaths].sort(),
|
|
attempt,
|
|
maxAttempts,
|
|
reason: input.reason,
|
|
};
|
|
const result = await traceTimed(input.trace, 'resolver', 'textual_conflict_resolver', traceData, async () =>
|
|
input.agentRunner.runLoop({
|
|
modelRole: 'repair',
|
|
systemPrompt: buildResolverSystemPrompt(),
|
|
userPrompt: buildResolverUserPrompt({
|
|
unitKey: input.unitKey,
|
|
patchPath: input.patchPath,
|
|
touchedPaths: [...allowedPaths].sort(),
|
|
reason: input.reason,
|
|
attempt,
|
|
maxAttempts,
|
|
}),
|
|
toolSet: buildToolSet({
|
|
workdir: input.workdir,
|
|
patchPath: input.patchPath,
|
|
allowedPaths,
|
|
editedPaths,
|
|
}),
|
|
stepBudget,
|
|
telemetryTags: {
|
|
operationName: 'ingest-isolated-diff-textual-resolver',
|
|
source: input.trace.context.sourceKey,
|
|
jobId: input.trace.context.jobId,
|
|
unitKey: input.unitKey,
|
|
},
|
|
abortSignal: input.abortSignal,
|
|
}),
|
|
);
|
|
|
|
if (result.stopReason === 'error') {
|
|
lastFailure = result.error?.message ?? 'resolver agent loop errored';
|
|
await input.trace.event('error', 'resolver', 'textual_conflict_resolver_failed', traceData, result.error);
|
|
continue;
|
|
}
|
|
|
|
const changedPaths = [...editedPaths].sort();
|
|
if (changedPaths.length === 0) {
|
|
lastFailure = 'resolver completed without editing an allowed path';
|
|
await input.trace.event('error', 'resolver', 'textual_conflict_resolver_failed', {
|
|
...traceData,
|
|
reason: lastFailure,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
await input.trace.event('debug', 'resolver', 'textual_conflict_resolver_repaired', {
|
|
...traceData,
|
|
changedPaths,
|
|
});
|
|
return { status: 'repaired', attempts: attempt, changedPaths };
|
|
}
|
|
|
|
return { status: 'failed', attempts: maxAttempts, reason: lastFailure };
|
|
}
|