Files
gbrain/test/doctor-links-extraction-lag.test.ts
T
ca68a551db v0.42.7.0 feat(extract): link/timeline extraction freshness watermark — gbrain extract --stale + doctor lag check (#1696) (#1755)
* feat(extract): link/timeline extraction freshness watermark (#1696)

Closes the "imported != curated" gap: plain `gbrain sync` only extracts
CHANGED pages, so a brain with autopilot off accumulated a links table that
was ~99.7% untyped `mentions` with nothing surfacing it. Adds a per-page
freshness watermark (pages.links_extracted_at, migration v112) and three
things built on it:

- `gbrain extract --stale [--source-id] [--catch-up] [--dry-run] [--json]`:
  incremental DB-source link+timeline sweep over pages whose extraction is
  stale (never extracted, edited since, or extractor version bumped). Small
  byte-bounded batches, non-swallowing flush, stamp-after-flush so a crash
  re-extracts idempotently. Stamps with the row's READ updated_at (not now())
  so a concurrent edit during the sweep stays stale instead of being lost.
- `links_extraction_lag` doctor check (local + remote): warn-only by default
  (>20%), hard-fail only via GBRAIN_EXTRACTION_LAG_FAIL_PCT. Vacuous-skip
  <100 pages; pre-v112 brains graceful-skip.
- `gbrain sync --no-extract` flag + end-of-sync nudge (fires on
  synced|first_sync|up_to_date so the initial import surfaces its backlog).

Three new BrainEngine methods (countStalePagesForExtraction /
listStalePagesForExtraction / markPagesExtractedBatch) with Postgres<->PGLite
parity + bootstrap probes. Schema parity: schema.sql + regenerated
pglite-schema.ts + schema-embedded.ts + bootstrap-coverage test. Migration
v112 (composite (source_id, links_extracted_at) index, no backfill so the
real backlog surfaces on first doctor run).

* test(audit): hermetic GBRAIN_AUDIT_DIR override for prune ENOENT case

The "no-op when audit dir does not exist (ENOENT)" case called
pruneOldBatchRetryAuditFiles without a GBRAIN_AUDIT_DIR override, so it read
the developer's real ~/.gbrain/audit and flaked (kept>0) on any machine with
prior gbrain audit history. Point it at a guaranteed-nonexistent temp path so
it tests the real missing-dir branch hermetically — matching the file
header's "never touches ~/.gbrain/audit" contract. Pre-existing flake
(introduced by v0.41.19.0 #1537), unrelated to #1696.

* chore: bump version and changelog (v0.42.2.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: CLAUDE.md key-files entry for the #1696 extract-stale wave + regen llms-full

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:28:20 -07:00

122 lines
4.8 KiB
TypeScript

/**
* Tests for the `links_extraction_lag` doctor check (v0.42.7, #1696).
* Hermetic PGLite. Bulk-seeds pages via raw SQL (the check only does COUNT +
* countStalePagesForExtraction — no real ingestion needed).
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { withEnv } from './helpers/with-env.ts';
import { checkLinksExtractionLag } from '../src/commands/doctor.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 60_000);
afterAll(async () => {
if (engine) await engine.disconnect();
}, 60_000);
beforeEach(async () => {
await resetPgliteState(engine);
});
/** Bulk-insert N pages under a source; all start with links_extracted_at NULL. */
async function seedPages(n: number, sourceId = 'default', prefix = 'p'): Promise<void> {
await engine.executeRaw(
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at)
SELECT $1 || '/' || g, $2, 'concept', 'T' || g, '', '', '{}'::jsonb, $1 || 'h' || g, now(), now()
FROM generate_series(1, $3) g`,
[prefix, sourceId, n],
);
}
describe('links_extraction_lag doctor check', () => {
test('no pages → ok (not applicable)', async () => {
const c = await checkLinksExtractionLag(engine);
expect(c.status).toBe('ok');
expect(c.message).toContain('no pages');
});
test('<100 pages, no --source → ok (vacuous-skip)', async () => {
await seedPages(50);
const c = await checkLinksExtractionLag(engine);
expect(c.status).toBe('ok');
expect(c.message).toContain('too few');
});
test('>100 pages, all un-extracted → warn (>20%)', async () => {
await seedPages(120);
const c = await checkLinksExtractionLag(engine);
expect(c.status).toBe('warn');
expect(c.message).toContain('un-extracted edges');
expect(c.message).toContain('gbrain extract --stale');
expect((c.details as any).pct).toBe(100);
});
test('>100 pages, all stamped fresh → ok', async () => {
await seedPages(120);
await engine.executeRaw(`UPDATE pages SET links_extracted_at = now()`);
const c = await checkLinksExtractionLag(engine);
expect(c.status).toBe('ok');
expect(c.message).toContain('Extraction current');
});
test('warn-only by default: 100% stale does NOT fail without fail-pct', async () => {
await seedPages(120);
const c = await checkLinksExtractionLag(engine);
expect(c.status).toBe('warn'); // never 'fail' by default
});
test('GBRAIN_EXTRACTION_LAG_FAIL_PCT opts into hard fail', async () => {
await seedPages(120);
await withEnv({ GBRAIN_EXTRACTION_LAG_FAIL_PCT: '50' }, async () => {
const c = await checkLinksExtractionLag(engine);
expect(c.status).toBe('fail');
expect(c.message).toContain('fail threshold');
});
});
test('GBRAIN_EXTRACTION_LAG_WARN_PCT raises the warn bar', async () => {
// 10 of 120 stale = ~8%. Default warn 20% → ok. Lower to 5% → warn.
await seedPages(120);
await engine.executeRaw(`UPDATE pages SET links_extracted_at = now() WHERE slug NOT IN (SELECT slug FROM pages ORDER BY id LIMIT 10)`);
const ok = await checkLinksExtractionLag(engine);
expect(ok.status).toBe('ok');
await withEnv({ GBRAIN_EXTRACTION_LAG_WARN_PCT: '5' }, async () => {
const warn = await checkLinksExtractionLag(engine);
expect(warn.status).toBe('warn');
});
});
test('--source scope: small source IS assessed (no vacuous-skip)', async () => {
// 10 pages under source 'dept-x' — below the 100 floor, but explicit
// --source means we assess it anyway (mirrors orphan_ratio).
await engine.executeRaw(`INSERT INTO sources (id, name, config) VALUES ('dept-x', 'Dept X', '{}'::jsonb) ON CONFLICT DO NOTHING`);
await seedPages(10, 'dept-x', 'dx');
const c = await checkLinksExtractionLag(engine, { sourceId: 'dept-x' });
expect(c.status).toBe('warn'); // all 10 stale, assessed despite < 100
expect(c.message).toContain("source 'dept-x'");
});
test('pre-v112 brain (column missing) → ok (graceful)', async () => {
await seedPages(120);
// Simulate a pre-v112 brain by dropping the column.
await engine.executeRaw(`ALTER TABLE pages DROP COLUMN links_extracted_at`);
try {
const c = await checkLinksExtractionLag(engine);
expect(c.status).toBe('ok');
expect(c.message).toContain('pre-v112');
} finally {
// Restore so resetPgliteState's TRUNCATE-only reset leaves a valid schema
// for the next test (the column is re-added; data is wiped by beforeEach).
await engine.executeRaw(`ALTER TABLE pages ADD COLUMN links_extracted_at TIMESTAMPTZ`);
}
});
});