Compare commits

..
Author SHA1 Message Date
a11ec9c468 fix(dream): stamp incremental extraction watermark (#2636)
The Dream cycle disables sync's inline extraction and routes changed
slugs through extractForSlugs, which flushed link/timeline batches but
never stamped links_extracted_at — so incrementally extracted pages
stayed permanently visible to `extract --stale` / doctor.

Collect processedRefs per successfully processed page and stamp them
via stampExtracted (best-effort) after both batch flushes, non-dry-run
mode 'all' only. Source-id threading from the original PR #2637 already
landed on master via #1503/#1747, so this rebase carries only the
missing watermark stamp plus regression tests.

Takeover of #2637.

Co-authored-by: JavanC <JavanC@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:34:22 -07:00
4 changed files with 65 additions and 64 deletions
+8 -40
View File
@@ -5,8 +5,8 @@
* checks if back-links exist, and optionally creates them.
*
* Usage:
* gbrain check-backlinks check [dir] [--dir <brain-dir>] # report missing back-links
* gbrain check-backlinks fix [dir] [--dir <brain-dir>] # create missing back-links
* gbrain check-backlinks check [--dir <brain-dir>] # report missing back-links
* gbrain check-backlinks fix [--dir <brain-dir>] # create missing back-links
* gbrain check-backlinks fix --dry-run # preview fixes
*/
@@ -201,40 +201,6 @@ export interface BacklinksResult {
dryRun: boolean;
}
export interface ParsedBacklinksArgs {
subcommand: string | undefined;
brainDir: string;
dryRun: boolean;
}
export function parseBacklinksArgs(args: string[]): ParsedBacklinksArgs {
const subcommand = args[0];
const dryRun = args.includes('--dry-run');
const dirIdx = args.indexOf('--dir');
const flagDir = dirIdx >= 0 && args[dirIdx + 1] && !args[dirIdx + 1].startsWith('--')
? args[dirIdx + 1]
: undefined;
let positionalDir: string | undefined;
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg === '--dir') {
i++;
continue;
}
if (arg === '--dry-run') continue;
if (arg.startsWith('--')) continue;
positionalDir = arg;
break;
}
return {
subcommand,
brainDir: flagDir ?? positionalDir ?? '.',
dryRun,
};
}
/**
* Library-level backlinks check/fix. Throws on validation errors; returns a
* structured result so Minions handlers + autopilot-cycle can surface counts.
@@ -270,14 +236,16 @@ export async function runBacklinksCore(opts: BacklinksOpts): Promise<BacklinksRe
}
export async function runBacklinks(args: string[]) {
const { subcommand, brainDir, dryRun } = parseBacklinksArgs(args);
const subcommand = args[0];
const dirIdx = args.indexOf('--dir');
const brainDir = dirIdx >= 0 ? args[dirIdx + 1] : '.';
const dryRun = args.includes('--dry-run');
if (!subcommand || !['check', 'fix'].includes(subcommand)) {
console.error('Usage: gbrain check-backlinks <check|fix> [dir] [--dir <brain-dir>] [--dry-run]');
console.error('Usage: gbrain check-backlinks <check|fix> [--dir <brain-dir>] [--dry-run]');
console.error(' check Report missing back-links');
console.error(' fix Create missing back-links (appends to Timeline)');
console.error(' dir Brain directory (default: current directory)');
console.error(' --dir Brain directory override');
console.error(' --dir Brain directory (default: current directory)');
console.error(' --dry-run Preview fixes without writing');
process.exit(1);
}
+12
View File
@@ -1025,6 +1025,10 @@ async function extractForSlugs(
let linksCreated = 0;
let timelineCreated = 0;
let pagesProcessed = 0;
// #2636: successfully processed pages get their extraction watermark
// stamped after the final flush (mode 'all' only — a partial-mode run
// hasn't done the full extraction the watermark asserts).
const processedRefs: Array<{ slug: string; source_id: string }> = [];
// Issue #972: read the basename flag once per extract run.
const globalBasename = await isGlobalBasenameEnabled(engine);
@@ -1113,6 +1117,7 @@ async function extractForSlugs(
}
pagesProcessed++;
if (!dryRun) processedRefs.push({ slug, source_id: sourceId ?? 'default' });
} catch { /* skip unreadable */ }
progress.tick(1);
},
@@ -1120,6 +1125,13 @@ async function extractForSlugs(
await flushLinks();
await flushTimeline();
// #2636: the Dream cycle disables sync's inline extraction and routes
// changed slugs through this incremental path — without a stamp here,
// those pages never get links_extracted_at and stay permanently visible
// to `extract --stale` / doctor. Stamp only after BOTH batches flushed.
if (!dryRun && mode === 'all') {
await stampExtracted(engine, processedRefs);
}
progress.finish();
if (!jsonMode) {
-24
View File
@@ -4,7 +4,6 @@ import {
extractPageTitle,
hasBacklink,
buildBacklinkEntry,
parseBacklinksArgs,
} from '../src/commands/backlinks.ts';
describe('extractEntityRefs', () => {
@@ -105,26 +104,3 @@ describe('findBacklinkGaps dedupe (v0.36.x #967 regression)', () => {
}
});
});
describe('parseBacklinksArgs', () => {
test('uses positional dir for check and fix subcommands', () => {
expect(parseBacklinksArgs(['check', '/tmp/brain']).brainDir).toBe('/tmp/brain');
expect(parseBacklinksArgs(['fix', '/tmp/brain']).brainDir).toBe('/tmp/brain');
});
test('defaults to cwd when no dir given', () => {
expect(parseBacklinksArgs(['check']).brainDir).toBe('.');
});
test('--dir overrides positional dir and preserves dry-run', () => {
const parsed = parseBacklinksArgs(['fix', '/tmp/ignored', '--dir', '/tmp/brain', '--dry-run']);
expect(parsed.subcommand).toBe('fix');
expect(parsed.brainDir).toBe('/tmp/brain');
expect(parsed.dryRun).toBe(true);
});
test('--dir missing its value falls back to positional dir', () => {
expect(parseBacklinksArgs(['check', '/tmp/brain', '--dir']).brainDir).toBe('/tmp/brain');
expect(parseBacklinksArgs(['check', '--dir', '--dry-run']).brainDir).toBe('.');
});
});
+45
View File
@@ -60,6 +60,51 @@ async function seedPage(slug: string, body: string): Promise<void> {
}
describe('runExtractCore — incremental cycle path (#417)', () => {
test('Dream incremental all-mode stamps the source-scoped extraction watermark (#2636)', async () => {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path) VALUES ($1, $2, $3)`,
['repo-a', 'repo-a', tempDir],
);
await engine.putPage('people/alice-example', {
type: 'person',
title: 'alice-example',
compiled_truth: '# alice',
timeline: '',
frontmatter: {},
content_hash: 'h',
}, { sourceId: 'repo-a' });
writeFileSync(join(tempDir, 'people/alice-example.md'), '# alice');
await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
slugs: ['people/alice-example'],
sourceId: 'repo-a',
});
const rows = await engine.executeRaw<{ links_extracted_at: string | null }>(
`SELECT links_extracted_at FROM pages WHERE slug = $1 AND source_id = $2`,
['people/alice-example', 'repo-a'],
);
expect(rows[0]?.links_extracted_at).not.toBeNull();
expect(await engine.countStalePagesForExtraction({ sourceId: 'repo-a' })).toBe(0);
});
test('Dream incremental dry-run does NOT stamp the watermark', async () => {
await seedPage('people/alice-example', '# alice');
await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
slugs: ['people/alice-example'],
dryRun: true,
});
const rows = await engine.executeRaw<{ links_extracted_at: string | null }>(
`SELECT links_extracted_at FROM pages WHERE slug = $1`,
['people/alice-example'],
);
expect(rows[0]?.links_extracted_at ?? null).toBeNull();
});
test('1. slugs: [] returns immediately with zero counts (early-return path)', async () => {
await seedPage('people/alice-example', '# alice');
const result = await runExtractCore(engine as unknown as BrainEngine, {