fix: accept ingest wiki forward refs (#125)

This commit is contained in:
Andrey Avtomonov 2026-05-17 10:10:14 +02:00 committed by GitHub
parent 74be832aea
commit f49672ba5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 403 additions and 66 deletions

View file

@ -304,4 +304,40 @@ describe('WikiWriteTool', () => {
expect(result.markdown).toMatch(/orbit-team-lanes-detail/);
expect(wikiService.writePage).not.toHaveBeenCalled();
});
it('accepts forward refs during ingest sessions for post-pass validation', async () => {
const { tool, wikiService } = makeTool({
wikiService: {
listPageKeys: vi.fn().mockResolvedValue(['orbit-company-overview']),
},
});
const session: ToolSession = {
connectionId: 'conn-1',
isWorktreeScoped: true,
preHead: null,
touchedSlSources: createTouchedSlSources(),
actions: [],
semanticLayerService: {} as any,
wikiService: wikiService as any,
configService: {} as any,
gitService: {} as any,
ingest: { runId: 'run-1', jobId: 'job-1', syncId: 'sync-1', sourceKey: 'notion' },
};
const result = await tool.call(
{
key: 'orbit-how-we-work',
summary: 'Operating norms',
content: 'See [[orbit-team-lanes-detail]].',
refs: ['orbit-company-overview', 'orbit-team-lanes-detail'],
} as any,
{ ...baseContext, session },
);
expect(result.structured).toMatchObject({ success: true, key: 'orbit-how-we-work', action: 'created' });
expect(wikiService.writePage).toHaveBeenCalledTimes(1);
expect(session.actions).toContainEqual(
expect.objectContaining({ target: 'wiki', type: 'created', key: 'orbit-how-we-work' }),
);
});
});

View file

@ -4,6 +4,7 @@ import type { KnowledgeEventPort } from '../ports.js';
type BlockScope = 'GLOBAL' | 'USER';
import { KnowledgeWikiService, type WikiFrontmatter } from '../index.js';
import { validateFlatWikiKey } from '../keys.js';
import { findMissingWikiRefs } from '../wiki-ref-validation.js';
import { applySqlEdits } from '../../tools/sql-edit-replacer.js';
import { BaseTool, type ToolContext, type ToolOutput, validateActionRawPaths } from '../../tools/index.js';
@ -69,71 +70,6 @@ function normalizeAccidentalEscapedMarkdownNewlines(content: string): string {
return content.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\\r/g, '\n');
}
function isWikiPageKeyRef(ref: string): boolean {
return /^[a-z0-9][a-z0-9_-]*(?:-[a-z0-9_]+)*$/.test(ref);
}
function extractInlineWikiRefs(content: string): string[] {
const refs = new Set<string>();
const re = /\[\[([^\]\n]+)\]\]/g;
for (const match of content.matchAll(re)) {
const target = match[1]?.split('|', 1)[0]?.trim();
if (target && isWikiPageKeyRef(target)) {
refs.add(target);
}
}
return [...refs].sort();
}
async function visibleWikiPageKeys(
wikiService: KnowledgeWikiService,
scope: BlockScope,
scopeId: string | null,
): Promise<Set<string>> {
const keys = new Set<string>();
if (scope === 'USER') {
for (const key of await wikiService.listPageKeys('GLOBAL', null)) {
keys.add(key);
}
for (const key of await wikiService.listPageKeys('USER', scopeId)) {
keys.add(key);
}
return keys;
}
for (const key of await wikiService.listPageKeys('GLOBAL', null)) {
keys.add(key);
}
return keys;
}
async function findMissingWikiRefs(input: {
wikiService: KnowledgeWikiService;
scope: BlockScope;
scopeId: string | null;
pageKey: string;
refs?: string[];
content: string;
}): Promise<string[]> {
const candidates = new Set<string>();
for (const ref of input.refs ?? []) {
if (isWikiPageKeyRef(ref)) {
candidates.add(ref);
}
}
for (const ref of extractInlineWikiRefs(input.content)) {
candidates.add(ref);
}
if (candidates.size === 0) {
return [];
}
const available = await visibleWikiPageKeys(input.wikiService, input.scope, input.scopeId);
available.add(input.pageKey);
return [...candidates].filter((ref) => !available.has(ref)).sort();
}
export class WikiWriteTool extends BaseTool<typeof wikiWriteInputSchema> {
readonly name = 'wiki_write';
@ -253,7 +189,8 @@ Keys must be flat file names, not directory paths. Use tags/source frontmatter f
refs: finalFm.refs,
content: finalContent,
});
if (missingRefs.length > 0) {
const deferMissingRefs = !!context.session?.ingest;
if (!deferMissingRefs && missingRefs.length > 0) {
return {
markdown:
`Error: wiki references target missing page(s): ${missingRefs.join(', ')}. ` +

View file

@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest';
import { findDanglingWikiRefsForActions } from './wiki-ref-validation.js';
function makeWikiService(pages: Record<string, { refs?: string[]; content?: string }>) {
return {
listPageKeys: vi.fn().mockResolvedValue(Object.keys(pages)),
readPage: vi.fn().mockImplementation((_scope: string, _scopeId: string | null, pageKey: string) => {
const page = pages[pageKey];
if (!page) {
return Promise.resolve(null);
}
return Promise.resolve({
pageKey,
frontmatter: { summary: pageKey, usage_mode: 'auto', refs: page.refs },
content: page.content ?? '',
});
}),
};
}
describe('wiki ref validation', () => {
it('allows circular refs once both touched pages exist', async () => {
const wikiService = makeWikiService({
'page-a': { refs: ['page-b'], content: 'See [[page-b]].' },
'page-b': { refs: ['page-a'], content: 'See [[page-a]].' },
});
const dangling = await findDanglingWikiRefsForActions({
wikiService: wikiService as any,
scope: 'GLOBAL',
scopeId: null,
actions: [
{ target: 'wiki', type: 'created', key: 'page-a', detail: 'Page A' },
{ target: 'wiki', type: 'created', key: 'page-b', detail: 'Page B' },
],
});
expect(dangling).toEqual([]);
});
it('treats removed pages as unavailable ref targets', async () => {
const wikiService = makeWikiService({
'page-a': { refs: ['page-b'], content: 'See [[page-b]].' },
});
const dangling = await findDanglingWikiRefsForActions({
wikiService: wikiService as any,
scope: 'GLOBAL',
scopeId: null,
actions: [
{ target: 'wiki', type: 'updated', key: 'page-a', detail: 'Page A' },
{ target: 'wiki', type: 'removed', key: 'page-b', detail: 'Page B' },
],
});
expect(dangling).toEqual(['page-a -> page-b']);
});
it('does not validate existing dangling refs on untouched pages', async () => {
const wikiService = makeWikiService({
'page-a': { refs: [], content: '' },
'old-page': { refs: ['missing-page'], content: 'See [[missing-page]].' },
});
const dangling = await findDanglingWikiRefsForActions({
wikiService: wikiService as any,
scope: 'GLOBAL',
scopeId: null,
actions: [{ target: 'wiki', type: 'updated', key: 'page-a', detail: 'Page A' }],
});
expect(dangling).toEqual([]);
});
});

View file

@ -0,0 +1,109 @@
import type { MemoryAction } from '../tools/index.js';
import { isFlatWikiKey } from './keys.js';
import type { KnowledgeWikiService } from './knowledge-wiki.service.js';
import type { WikiScope } from './types.js';
function isWikiPageKeyRef(ref: string): boolean {
return /^[a-z0-9][a-z0-9_-]*(?:-[a-z0-9_]+)*$/.test(ref);
}
function extractInlineWikiRefs(content: string): string[] {
const refs = new Set<string>();
const re = /\[\[([^\]\n]+)\]\]/g;
for (const match of content.matchAll(re)) {
const target = match[1]?.split('|', 1)[0]?.trim();
if (target && isWikiPageKeyRef(target)) {
refs.add(target);
}
}
return [...refs].sort();
}
async function visibleWikiPageKeys(
wikiService: KnowledgeWikiService,
scope: WikiScope,
scopeId: string | null,
): Promise<Set<string>> {
const keys = new Set<string>();
if (scope === 'USER') {
for (const key of await wikiService.listPageKeys('GLOBAL', null)) {
keys.add(key);
}
for (const key of await wikiService.listPageKeys('USER', scopeId)) {
keys.add(key);
}
return keys;
}
for (const key of await wikiService.listPageKeys('GLOBAL', null)) {
keys.add(key);
}
return keys;
}
export async function findMissingWikiRefs(input: {
wikiService: KnowledgeWikiService;
scope: WikiScope;
scopeId: string | null;
pageKey: string;
refs?: string[];
content: string;
}): Promise<string[]> {
const candidates = new Set<string>();
for (const ref of input.refs ?? []) {
if (isWikiPageKeyRef(ref)) {
candidates.add(ref);
}
}
for (const ref of extractInlineWikiRefs(input.content)) {
candidates.add(ref);
}
if (candidates.size === 0) {
return [];
}
const available = await visibleWikiPageKeys(input.wikiService, input.scope, input.scopeId);
available.add(input.pageKey);
return [...candidates].filter((ref) => !available.has(ref)).sort();
}
export async function findDanglingWikiRefsForActions(input: {
wikiService: KnowledgeWikiService;
scope: WikiScope;
scopeId: string | null;
actions: MemoryAction[];
}): Promise<string[]> {
const latestWikiActionByKey = new Map<string, MemoryAction['type']>();
for (const action of input.actions) {
if (action.target === 'wiki' && isFlatWikiKey(action.key)) {
latestWikiActionByKey.set(action.key, action.type);
}
}
const dangling: string[] = [];
for (const [pageKey, actionType] of [...latestWikiActionByKey.entries()].sort(([left], [right]) =>
left.localeCompare(right),
)) {
if (actionType === 'removed') {
continue;
}
const page = await input.wikiService.readPage(input.scope, input.scopeId, pageKey);
if (!page) {
dangling.push(`${pageKey} -> (missing page)`);
continue;
}
const missingRefs = await findMissingWikiRefs({
wikiService: input.wikiService,
scope: input.scope,
scopeId: input.scopeId,
pageKey,
refs: page.frontmatter.refs,
content: page.content,
});
for (const missingRef of missingRefs) {
dangling.push(`${pageKey} -> ${missingRef}`);
}
}
return dangling;
}