mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-10 11:12:10 +02:00
fix: accept ingest wiki forward refs (#125)
This commit is contained in:
parent
74be832aea
commit
f49672ba5b
8 changed files with 403 additions and 66 deletions
|
|
@ -412,6 +412,127 @@ describe('IngestBundleRunner — Stages 1 → 7', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('fails before squash when reconciliation leaves a touched wiki page with dangling refs', async () => {
|
||||
const deps = makeDeps();
|
||||
let currentToolSession: any = null;
|
||||
const scopedWiki = {
|
||||
listPageKeys: vi.fn().mockResolvedValue(['page-a']),
|
||||
readPage: vi.fn().mockImplementation((_scope: string, _scopeId: string | null, key: string) => {
|
||||
if (key === 'page-a') {
|
||||
return Promise.resolve({
|
||||
pageKey: 'page-a',
|
||||
frontmatter: { summary: 'Page A', usage_mode: 'auto', refs: ['missing-page'] },
|
||||
content: 'See [[missing-page]].',
|
||||
});
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}),
|
||||
};
|
||||
deps.wikiService.forWorktree.mockReturnValue(scopedWiki);
|
||||
deps.toolsetFactory.createIngestWuToolset.mockImplementation((toolSession: any) => {
|
||||
currentToolSession = toolSession;
|
||||
return {
|
||||
toRuntimeTools: vi.fn().mockReturnValue({}),
|
||||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
getToolNames: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
});
|
||||
deps.agentRunner.runLoop.mockImplementation(async (params: any) => {
|
||||
if (params.telemetryTags.operationName === 'ingest-bundle-wu') {
|
||||
currentToolSession.actions.push({ target: 'sl', type: 'updated', key: 'orders', detail: 'Orders source' });
|
||||
}
|
||||
if (params.telemetryTags.operationName === 'ingest-bundle-reconcile') {
|
||||
currentToolSession.actions.push({ target: 'wiki', type: 'created', key: 'page-a', detail: 'Page A' });
|
||||
}
|
||||
return { stopReason: 'natural' };
|
||||
});
|
||||
|
||||
const runner = buildRunner(deps);
|
||||
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
|
||||
currentHashes: new Map([['a.yml', 'h1']]),
|
||||
rawDirInWorktree: 'raw-sources/c1/fake/s',
|
||||
});
|
||||
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
|
||||
|
||||
await expect(
|
||||
runner.run({
|
||||
jobId: 'j1',
|
||||
connectionId: 'c1',
|
||||
sourceKey: 'fake',
|
||||
trigger: 'upload',
|
||||
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
|
||||
}),
|
||||
).rejects.toThrow(/wiki references target missing page\(s\): page-a -> missing-page/);
|
||||
|
||||
expect(deps.runsRepo.markFailed).toHaveBeenCalledWith('run-1');
|
||||
expect(deps.gitService.squashMergeIntoMain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows reconciliation to save circular wiki refs once both pages exist', async () => {
|
||||
const deps = makeDeps();
|
||||
let currentToolSession: any = null;
|
||||
const scopedWiki = {
|
||||
listPageKeys: vi.fn().mockResolvedValue(['page-a', 'page-b']),
|
||||
readPage: vi.fn().mockImplementation((_scope: string, _scopeId: string | null, key: string) => {
|
||||
if (key === 'page-a') {
|
||||
return Promise.resolve({
|
||||
pageKey: 'page-a',
|
||||
frontmatter: { summary: 'Page A', usage_mode: 'auto', refs: ['page-b'] },
|
||||
content: 'See [[page-b]].',
|
||||
});
|
||||
}
|
||||
if (key === 'page-b') {
|
||||
return Promise.resolve({
|
||||
pageKey: 'page-b',
|
||||
frontmatter: { summary: 'Page B', usage_mode: 'auto', refs: ['page-a'] },
|
||||
content: 'See [[page-a]].',
|
||||
});
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}),
|
||||
};
|
||||
deps.wikiService.forWorktree.mockReturnValue(scopedWiki);
|
||||
deps.toolsetFactory.createIngestWuToolset.mockImplementation((toolSession: any) => {
|
||||
currentToolSession = toolSession;
|
||||
return {
|
||||
toRuntimeTools: vi.fn().mockReturnValue({}),
|
||||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
getToolNames: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
});
|
||||
deps.agentRunner.runLoop.mockImplementation(async (params: any) => {
|
||||
if (params.telemetryTags.operationName === 'ingest-bundle-wu') {
|
||||
currentToolSession.actions.push({ target: 'sl', type: 'updated', key: 'orders', detail: 'Orders source' });
|
||||
}
|
||||
if (params.telemetryTags.operationName === 'ingest-bundle-reconcile') {
|
||||
currentToolSession.actions.push(
|
||||
{ target: 'wiki', type: 'created', key: 'page-a', detail: 'Page A' },
|
||||
{ target: 'wiki', type: 'created', key: 'page-b', detail: 'Page B' },
|
||||
);
|
||||
}
|
||||
return { stopReason: 'natural' };
|
||||
});
|
||||
|
||||
const runner = buildRunner(deps);
|
||||
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
|
||||
currentHashes: new Map([['a.yml', 'h1']]),
|
||||
rawDirInWorktree: 'raw-sources/c1/fake/s',
|
||||
});
|
||||
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
|
||||
|
||||
const result = await runner.run({
|
||||
jobId: 'j1',
|
||||
connectionId: 'c1',
|
||||
sourceKey: 'fake',
|
||||
trigger: 'upload',
|
||||
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
|
||||
});
|
||||
|
||||
expect(result.failedWorkUnits).toEqual([]);
|
||||
expect(deps.gitService.squashMergeIntoMain).toHaveBeenCalled();
|
||||
expect(deps.runsRepo.markFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('threads target warehouse connection names into WorkUnit and reconcile tool sessions', async () => {
|
||||
const deps = makeDeps();
|
||||
const sessions: any[] = [];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { createRuntimeToolDescriptorFromAiTool, type KtxRuntimeToolSet } from '.
|
|||
import type { CaptureSession, MemoryAction } from '../memory/index.js';
|
||||
import type { SemanticLayerService, SemanticLayerSource, SlValidationDeps } from '../sl/index.js';
|
||||
import { createTouchedSlSources, type ToolContext, type ToolSession } from '../tools/index.js';
|
||||
import { findDanglingWikiRefsForActions } from '../wiki/wiki-ref-validation.js';
|
||||
import { actionTargetConnectionId } from './action-identity.js';
|
||||
import { NOTION_DEFAULT_MAX_KNOWLEDGE_CREATES_PER_RUN } from './adapters/notion/types.js';
|
||||
import { selectRelevantCanonicalPins } from './canonical-pins.js';
|
||||
|
|
@ -762,6 +763,13 @@ export class IngestBundleRunner {
|
|||
agentRunner: this.deps.agentRunner,
|
||||
validateTouchedSources: (touched) =>
|
||||
validateWuTouchedSources({ ...slValidationDeps, slValidator: this.deps.slValidator }, touched),
|
||||
validateWikiRefs: (actions) =>
|
||||
findDanglingWikiRefsForActions({
|
||||
wikiService: scopedWikiService,
|
||||
scope: 'GLOBAL',
|
||||
scopeId: null,
|
||||
actions,
|
||||
}),
|
||||
resetHardTo: (targetSha) => sessionWorktree.git.resetHardTo(targetSha),
|
||||
buildSystemPrompt: () => systemPrompt,
|
||||
buildUserPrompt: (wuInner) => buildWuUserPrompt({ wu: wuInner, wikiIndex, slIndex, priorProvenance }),
|
||||
|
|
@ -1128,6 +1136,17 @@ export class IngestBundleRunner {
|
|||
});
|
||||
}
|
||||
|
||||
const danglingReconcileWikiRefs = await findDanglingWikiRefsForActions({
|
||||
wikiService: rcScopedWiki,
|
||||
scope: 'GLOBAL',
|
||||
scopeId: null,
|
||||
actions: reconcileActions,
|
||||
});
|
||||
if (danglingReconcileWikiRefs.length > 0) {
|
||||
await this.deps.runs.markFailed(runRow.id);
|
||||
throw new Error(`wiki references target missing page(s): ${danglingReconcileWikiRefs.join(', ')}`);
|
||||
}
|
||||
|
||||
const candidateSummaryAfterReconcile =
|
||||
contextReport && this.deps.contextEvidenceCandidates
|
||||
? await this.deps.contextEvidenceCandidates.getCandidateSummary(runRow.id)
|
||||
|
|
|
|||
|
|
@ -121,6 +121,41 @@ describe('Stage 3 — executeWorkUnit', () => {
|
|||
expect(deps.resetHardTo).toHaveBeenCalledWith('pre');
|
||||
});
|
||||
|
||||
it('dangling wiki refs reset to the pre-WU SHA and mark WU failed after the agent loop', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.sessionWorktreeGit.revParseHead = vi.fn().mockResolvedValueOnce('pre').mockResolvedValueOnce('post');
|
||||
deps.agentRunner.runLoop = vi.fn().mockImplementation(() => {
|
||||
deps.sessionActions.push({ target: 'wiki', type: 'created', key: 'page-a', detail: 'Page A' });
|
||||
return Promise.resolve({ stopReason: 'natural' });
|
||||
});
|
||||
(deps as any).validateWikiRefs = vi.fn().mockResolvedValue(['page-a -> page-b']);
|
||||
|
||||
const outcome = await executeWorkUnit(deps, makeWu());
|
||||
|
||||
expect(outcome.status).toBe('failed');
|
||||
expect(outcome.reason).toContain('wiki references target missing page(s): page-a -> page-b');
|
||||
expect(outcome.actions).toEqual([]);
|
||||
expect(outcome.touchedSlSources).toEqual([]);
|
||||
expect(deps.resetHardTo).toHaveBeenCalledWith('pre');
|
||||
});
|
||||
|
||||
it('resolved wiki refs pass post-WU validation and preserve actions', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.sessionWorktreeGit.revParseHead = vi.fn().mockResolvedValueOnce('pre').mockResolvedValueOnce('post');
|
||||
deps.agentRunner.runLoop = vi.fn().mockImplementation(() => {
|
||||
deps.sessionActions.push({ target: 'wiki', type: 'created', key: 'page-a', detail: 'Page A' });
|
||||
deps.sessionActions.push({ target: 'wiki', type: 'created', key: 'page-b', detail: 'Page B' });
|
||||
return Promise.resolve({ stopReason: 'natural' });
|
||||
});
|
||||
(deps as any).validateWikiRefs = vi.fn().mockResolvedValue([]);
|
||||
|
||||
const outcome = await executeWorkUnit(deps, makeWu());
|
||||
|
||||
expect(outcome.status).toBe('success');
|
||||
expect(outcome.actions.map((action) => action.key)).toEqual(['page-a', 'page-b']);
|
||||
expect(deps.resetHardTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runner loop thrown exception resets to the pre-WU SHA and marks WU failed', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.sessionWorktreeGit.revParseHead = vi.fn().mockResolvedValueOnce('pre').mockResolvedValueOnce('post');
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface TouchedValidationResult {
|
|||
export interface WorkUnitExecutionDeps {
|
||||
sessionWorktreeGit: { revParseHead(): Promise<string | null> };
|
||||
agentRunner: AgentRunnerPort;
|
||||
validateWikiRefs?: (actions: MemoryAction[]) => Promise<string[]>;
|
||||
validateTouchedSources: (touched: TouchedSlSource[]) => Promise<TouchedValidationResult>;
|
||||
resetHardTo: (targetSha: string) => Promise<void>;
|
||||
buildSystemPrompt: (wu: WorkUnit) => string;
|
||||
|
|
@ -133,6 +134,11 @@ export async function executeWorkUnit(deps: WorkUnitExecutionDeps, wu: WorkUnit)
|
|||
return failWithReset(`${toolFailureCount} tool call(s) failed during WorkUnit ${wu.unitKey}`);
|
||||
}
|
||||
|
||||
const danglingWikiRefs = (await deps.validateWikiRefs?.(deps.sessionActions)) ?? [];
|
||||
if (danglingWikiRefs.length > 0) {
|
||||
return failWithReset(`wiki references target missing page(s): ${danglingWikiRefs.join(', ')}`);
|
||||
}
|
||||
|
||||
const touched = listTouchedSlSources(deps.captureSession.touchedSlSources);
|
||||
if (touched.length > 0) {
|
||||
const validation = await deps.validateTouchedSources(touched);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue