mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
204 lines
8.8 KiB
TypeScript
204 lines
8.8 KiB
TypeScript
/**
|
|
* v0.41.19.0 — T5 of ops-fix-wave.
|
|
*
|
|
* Pins the by-mention checkpoint/resume contract + codex's 4 correctness
|
|
* fixes:
|
|
* 1. Persist checkpoint AFTER flush() succeeds (not per-page) so a
|
|
* crash between batch.push and flush leaves pages un-checkpointed
|
|
* and resume re-scans them.
|
|
* 2. Dry-run does NOT persist OR load the checkpoint.
|
|
* 3. Gazetteer hash is part of the fingerprint — adding/removing
|
|
* entity pages between paused runs invalidates the checkpoint.
|
|
* 4. Filtered pages (--type/--since miss/empty body) DO get marked
|
|
* completed so resume doesn't re-fetch them.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { runExtract } from '../src/commands/extract.ts';
|
|
import { setCliOptions } from '../src/core/cli-options.ts';
|
|
import { loadOpCheckpoint, mentionsFingerprint } from '../src/core/op-checkpoint.ts';
|
|
import { createHash } from 'crypto';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
// Suppress console output during runs (we're testing DB-side state).
|
|
const origLog = console.log;
|
|
const origErr = console.error;
|
|
const origStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
|
|
|
function silenceCli(): void {
|
|
console.log = () => {};
|
|
console.error = () => {};
|
|
(process.stdout as unknown as { write: unknown }).write = (() => true) as unknown as typeof process.stdout.write;
|
|
(process.stderr as unknown as { write: unknown }).write = (() => true) as unknown as typeof process.stderr.write;
|
|
}
|
|
|
|
function restoreCli(): void {
|
|
console.log = origLog;
|
|
console.error = origErr;
|
|
(process.stdout as unknown as { write: unknown }).write = origStdoutWrite;
|
|
(process.stderr as unknown as { write: unknown }).write = origStderrWrite;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
setCliOptions({ quiet: true, progressJson: false, progressInterval: 1000, explain: false, timeoutMs: null, brain: null });
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await engine.executeRaw('DELETE FROM links');
|
|
await engine.executeRaw('DELETE FROM pages');
|
|
await engine.executeRaw('DELETE FROM op_checkpoints');
|
|
});
|
|
|
|
async function seedEntities(): Promise<void> {
|
|
await engine.putPage('companies/acme', { type: 'company', title: 'Acme Corp', compiled_truth: 'acme body', timeline: '', frontmatter: {} });
|
|
await engine.putPage('people/alice', { type: 'person', title: 'Alice Example', compiled_truth: 'alice body', timeline: '', frontmatter: {} });
|
|
}
|
|
|
|
async function seedContentPage(slug: string, body: string, type = 'note', timeline = ''): Promise<void> {
|
|
await engine.putPage(slug, { type, title: slug, compiled_truth: body, timeline, frontmatter: {} });
|
|
}
|
|
|
|
async function runByMention(args: string[]): Promise<void> {
|
|
silenceCli();
|
|
try {
|
|
await runExtract(engine, ['links', '--by-mention', '--source', 'db', ...args]);
|
|
} catch (e) {
|
|
// process.exit throws in some paths — only swallow that one.
|
|
if (!(e instanceof Error && e.message.startsWith('__test_exit:'))) throw e;
|
|
} finally {
|
|
restoreCli();
|
|
}
|
|
}
|
|
|
|
/** Compute the canonical gazetteer hash the way the production code does. */
|
|
async function expectedGazetteerHash(): Promise<string> {
|
|
// The gazetteer is built from entity pages by buildGazetteer; for tests
|
|
// we just build it the same way the prod code does and hash sorted keys.
|
|
const { buildGazetteer } = await import('../src/core/by-mention.ts');
|
|
const gz = await buildGazetteer(engine);
|
|
return createHash('sha256').update([...gz.keys()].sort().join('|')).digest('hex').slice(0, 8);
|
|
}
|
|
|
|
describe('by-mention checkpoint/resume (T5)', () => {
|
|
test('clean exit clears the checkpoint (no row left in op_checkpoints)', async () => {
|
|
await seedEntities();
|
|
await seedContentPage('writing/post-1', 'We met with Acme Corp.');
|
|
await runByMention([]);
|
|
|
|
const gh = await expectedGazetteerHash();
|
|
const fp = mentionsFingerprint({ source: undefined, type: undefined, since: undefined, gazetteerHash: gh });
|
|
const rows = await loadOpCheckpoint(engine, { op: 'extract-by-mention', fingerprint: fp });
|
|
expect(rows.length).toBe(0); // cleared on clean exit
|
|
});
|
|
|
|
test('dry-run does NOT write to op_checkpoints', async () => {
|
|
await seedEntities();
|
|
await seedContentPage('writing/post-1', 'Acme Corp here.');
|
|
await runByMention(['--dry-run']);
|
|
const rows = await engine.executeRaw<{ c: string }>(
|
|
`SELECT COUNT(*)::text AS c FROM op_checkpoints WHERE op = 'extract-by-mention'`,
|
|
[],
|
|
);
|
|
expect(Number(rows[0]!.c)).toBe(0);
|
|
});
|
|
|
|
test('pre-seeded checkpoint causes resume — completed pages get skipped', async () => {
|
|
await seedEntities();
|
|
await seedContentPage('writing/already-scanned', 'Mentions Acme Corp here.');
|
|
await seedContentPage('writing/pending', 'Mentions Alice Example here.');
|
|
|
|
// Seed a checkpoint that marks `writing/already-scanned` as completed.
|
|
const gh = await expectedGazetteerHash();
|
|
const fp = mentionsFingerprint({ source: undefined, type: undefined, since: undefined, gazetteerHash: gh });
|
|
await engine.executeRaw(
|
|
`INSERT INTO op_checkpoints (op, fingerprint, completed_keys, updated_at)
|
|
VALUES ('extract-by-mention', $1, $2::jsonb, NOW())`,
|
|
[fp, JSON.stringify(['default::writing/already-scanned'])],
|
|
);
|
|
|
|
await runByMention([]);
|
|
|
|
// Only the pending page should have links created.
|
|
const linksFromPending = await engine.executeRaw<{ c: string }>(
|
|
`SELECT COUNT(*)::text AS c FROM links l
|
|
JOIN pages fp ON fp.id = l.from_page_id
|
|
WHERE fp.slug = 'writing/pending' AND l.link_source = 'mentions'`,
|
|
[],
|
|
);
|
|
const linksFromSkipped = await engine.executeRaw<{ c: string }>(
|
|
`SELECT COUNT(*)::text AS c FROM links l
|
|
JOIN pages fp ON fp.id = l.from_page_id
|
|
WHERE fp.slug = 'writing/already-scanned' AND l.link_source = 'mentions'`,
|
|
[],
|
|
);
|
|
expect(Number(linksFromPending[0]!.c)).toBeGreaterThanOrEqual(1);
|
|
expect(Number(linksFromSkipped[0]!.c)).toBe(0); // skipped via checkpoint
|
|
});
|
|
|
|
test('gazetteer change invalidates checkpoint — new entity → re-scan', async () => {
|
|
// Run #1: 1 entity, 1 content page mentioning it → checkpoint cleared on exit
|
|
await seedEntities();
|
|
await seedContentPage('writing/post-1', 'Acme Corp.');
|
|
await runByMention([]);
|
|
|
|
// Now add a new entity. The gazetteer hash changes → different
|
|
// fingerprint → fresh checkpoint state (codex fix #3 regression guard).
|
|
await engine.putPage('people/charlie', { type: 'person', title: 'Charlie Example', compiled_truth: 'body', timeline: '', frontmatter: {} });
|
|
|
|
const oldHash = createHash('sha256').update(
|
|
['acme corp', 'alice example'].sort().join('|'),
|
|
).digest('hex').slice(0, 8);
|
|
const newHash = await expectedGazetteerHash();
|
|
expect(newHash).not.toBe(oldHash);
|
|
|
|
const oldFp = mentionsFingerprint({ source: undefined, type: undefined, since: undefined, gazetteerHash: oldHash });
|
|
const newFp = mentionsFingerprint({ source: undefined, type: undefined, since: undefined, gazetteerHash: newHash });
|
|
expect(oldFp).not.toBe(newFp);
|
|
});
|
|
|
|
test('filtered pages (--type miss) DO get checkpointed (codex fix #4)', async () => {
|
|
await seedEntities();
|
|
// Two pages: one matches --type filter, one doesn't
|
|
await seedContentPage('writing/match', 'Acme Corp.', 'meeting');
|
|
await seedContentPage('writing/no-match', 'Acme Corp.', 'note');
|
|
|
|
await runByMention(['--type', 'meeting']);
|
|
|
|
const gh = await expectedGazetteerHash();
|
|
const fp = mentionsFingerprint({ source: undefined, type: 'meeting', since: undefined, gazetteerHash: gh });
|
|
// Checkpoint should have been cleared on clean exit. But the
|
|
// observable signal that filtered pages got checkpointed too is
|
|
// that the run finishes cleanly without errors AND completes.
|
|
// (The pre-clear state would have all pages marked completed; we
|
|
// verify on a paused run below.)
|
|
const final = await loadOpCheckpoint(engine, { op: 'extract-by-mention', fingerprint: fp });
|
|
expect(final.length).toBe(0); // cleared on clean exit
|
|
|
|
// Indirect check: confirm only the matching page produced links.
|
|
const matchLinks = await engine.executeRaw<{ c: string }>(
|
|
`SELECT COUNT(*)::text AS c FROM links l
|
|
JOIN pages fp ON fp.id = l.from_page_id
|
|
WHERE fp.slug = 'writing/match' AND l.link_source = 'mentions'`,
|
|
[],
|
|
);
|
|
const nomatchLinks = await engine.executeRaw<{ c: string }>(
|
|
`SELECT COUNT(*)::text AS c FROM links l
|
|
JOIN pages fp ON fp.id = l.from_page_id
|
|
WHERE fp.slug = 'writing/no-match' AND l.link_source = 'mentions'`,
|
|
[],
|
|
);
|
|
expect(Number(matchLinks[0]!.c)).toBeGreaterThanOrEqual(1);
|
|
expect(Number(nomatchLinks[0]!.c)).toBe(0);
|
|
});
|
|
});
|