diff --git a/src/commands/extract.ts b/src/commands/extract.ts index 928f1fc7a..b2aabc5fd 100644 --- a/src/commands/extract.ts +++ b/src/commands/extract.ts @@ -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) { diff --git a/test/extract-incremental.test.ts b/test/extract-incremental.test.ts index a4453d77c..dc47069f6 100644 --- a/test/extract-incremental.test.ts +++ b/test/extract-incremental.test.ts @@ -60,6 +60,51 @@ async function seedPage(slug: string, body: string): Promise { } 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, {