mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
115 lines
5.2 KiB
TypeScript
115 lines
5.2 KiB
TypeScript
/**
|
|
* CRITICAL regression (v0.42.7, #1696, CDX-6) — inline sync extract stamps the
|
|
* link-extraction watermark.
|
|
*
|
|
* `performSync`'s INCREMENTAL path runs link/timeline extraction inline for the
|
|
* changed pages (the `gbrain sync` default — see performSyncInner's auto-extract
|
|
* block). v0.42.7 adds a `stampExtracted` call at that call site (after
|
|
* extractLinksForSlugs/extractTimelineForSlugs) so a normal incremental sync
|
|
* marks the pages it just extracted as fresh — otherwise every synced page
|
|
* would show as stale forever in the links_extraction_lag doctor check.
|
|
*
|
|
* NOTE: a FULL / first sync routes to performFullSync which does NOT extract
|
|
* inline (pre-existing behavior — exactly the "imported ≠ curated" gap that
|
|
* `extract --stale` closes). So this regression test drives the INCREMENTAL
|
|
* path: full sync to seed, then edit + incremental sync.
|
|
*
|
|
* IRON RULE: pins (a) an incremental sync NOW stamps links_extracted_at for the
|
|
* pages it processed, and (b) the existing link extraction is unchanged. Plus
|
|
* --no-extract: the changed page stays unstamped AND no links are created.
|
|
*
|
|
* Marked .serial.test.ts — spawns git subprocesses + shares one PGLite engine.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
|
import { execSync } from 'child_process';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let repoPath: string;
|
|
|
|
function git(cmd: string): void { execSync(cmd, { cwd: repoPath, stdio: 'pipe' }); }
|
|
|
|
function writeAcme(body: string): void {
|
|
writeFileSync(join(repoPath, 'companies/acme.md'), [
|
|
'---', 'type: company', 'title: Acme', '---', '', body,
|
|
].join('\n'));
|
|
}
|
|
|
|
async function stampOf(slug: string): Promise<string | null> {
|
|
const rows = await engine.executeRaw<{ links_extracted_at: string | null }>(
|
|
`SELECT links_extracted_at FROM pages WHERE slug = $1 AND source_id = 'default'`, [slug],
|
|
);
|
|
return rows[0]?.links_extracted_at ?? null;
|
|
}
|
|
|
|
describe('#1696 — inline sync extract stamps links_extracted_at', () => {
|
|
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);
|
|
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-stamp-'));
|
|
execSync('git init', { cwd: repoPath, stdio: 'pipe' });
|
|
execSync('git config user.email "t@t.com"', { cwd: repoPath, stdio: 'pipe' });
|
|
execSync('git config user.name "T"', { cwd: repoPath, stdio: 'pipe' });
|
|
mkdirSync(join(repoPath, 'people'), { recursive: true });
|
|
mkdirSync(join(repoPath, 'companies'), { recursive: true });
|
|
writeFileSync(join(repoPath, 'people/alice.md'), [
|
|
'---', 'type: person', 'title: Alice', '---', '', 'Alice is a founder.',
|
|
].join('\n'));
|
|
writeAcme('Acme is a company.');
|
|
git('git add -A && git commit -m "initial"');
|
|
|
|
// Seed: full first sync imports both pages (no inline extract on this path).
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
|
});
|
|
|
|
test('(a) incremental sync stamps the watermark AND (b) still extracts the link', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
// Edit acme to add the link, commit → incremental sync processes it.
|
|
// Disk-relative link form (how real brain files reference each other on
|
|
// disk; the FS extractor resolves relative to the file's directory).
|
|
writeAcme('[Alice](../people/alice.md) is the CEO of Acme.');
|
|
git('git add -A && git commit -m "add link"');
|
|
const result = await performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
|
expect(['synced', 'first_sync']).toContain(result.status);
|
|
|
|
// (b) extraction unchanged — the CEO-of link is created.
|
|
const links = await engine.getLinks('companies/acme');
|
|
expect(links.some(l => l.to_slug === 'people/alice')).toBe(true);
|
|
|
|
// (a) the changed page sync extracted is now stamped (not stale).
|
|
expect(await stampOf('companies/acme')).not.toBeNull();
|
|
}, 60_000);
|
|
|
|
test('--no-extract: changed page is NOT stamped and no links are created', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
// Disk-relative link form (how real brain files reference each other on
|
|
// disk; the FS extractor resolves relative to the file's directory).
|
|
writeAcme('[Alice](../people/alice.md) is the CEO of Acme.');
|
|
git('git add -A && git commit -m "add link"');
|
|
const result = await performSync(engine, { repoPath, noPull: true, noEmbed: true, noExtract: true });
|
|
expect(['synced', 'first_sync']).toContain(result.status);
|
|
|
|
expect(await engine.getLinks('companies/acme')).toHaveLength(0);
|
|
expect(await stampOf('companies/acme')).toBeNull();
|
|
}, 60_000);
|
|
});
|