Files
gbrain/test/extract-atoms-page-discovery.test.ts
T
d036a97f9c v0.41.10.1 fix-wave: dream.* config + batch retry + extract_atoms idempotency + ze-switch env-gate (#1445)
* fix-wave: dream.* DB merge + batch retry + extract_atoms idempotency + ze-switch env-gate + doctor check

Closes PRs #1414, #1416, #1421 (rebuilt from designs by @garrytan-agents
with structural improvements from /plan-eng-review + codex outside-voice).

Three production reliability fixes in one wave:

1. dream.* DB-config merge (closes PR #1416 silent-config gap)
   - loadConfigWithEngine() sparse-merge extends with 7 dream.* keys
   - File > DB > defaults precedence (no GBRAIN_DREAM_* env vars)
   - extract-atoms switches to loadConfigWithEngine() so DB-plane keys reach it

2. Batch retry on transient connection drops (closes PR #1416 ~30%-loss bug)
   - withRetry() pure primitive exported from src/commands/extract.ts
   - 6 flush() sites snapshot-before-clear with onRetry callback
   - Reuses isRetryableConnError from src/core/retry-matcher.ts
   - retry-matcher extended with GBrainError{problem:'No database connection'}

3. extract_atoms source-hash idempotency + page-based discovery (closes #1414)
   - One raw SQL with NOT EXISTS subquery replaces 6 listPages + N atom checks
   - sourceId threaded through every putPage call (codex caught real bug)
   - NULL content_hash filter + dream_generated exclusion + transcript-side idempotency
   - cycle.ts passes union of syncPagesAffected + synthesizeWrittenSlugs

4. ze-switch pre-apply + pre-resume env-override gate (closes PR #1421)
   - Gate fires FIRST in apply AND resume; zero setConfig calls on refusal
   - ASCII warning box (no Unicode per repo D10)
   - --ignore-env-override escape hatch for power users
   - ApplyResult extended with refused variant

5. doctor embedding_env_override check (defense-in-depth for #1421)
   - Cross-surface parity: buildChecks() + doctorReportRemote()
   - Uses Check.details (not Check.issues per codex schema review)

Co-Authored-By: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

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

Adds 61 new tests across 5 new files pinning the fix-wave contracts:
- test/extract-batch-retry.test.ts (16 cases) — withRetry primitive + snapshot contract
- test/extract-atoms-page-discovery.test.ts (17 cases) — discovery SQL + dual-source idempotency
- test/ze-switch-env-override.test.ts (17 cases) — env-gate apply + resume + ZERO-setConfig assertion
- test/doctor-embedding-env-override.test.ts (7 cases) — cross-surface parity
- test/e2e/extract-atoms-discovery-sql.test.ts (4 cases) — real-Postgres parity for raw SQL

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

* fix(test): pin gateway to 1536-dim in 2 PGLite tests that hardcode 1536-vector inserts

CI shards 1 + 4 failed persistently (not flake — confirmed via retry) after the
v0.41.6.0 merge with this error:

  error: expected 1280 dimensions, not 1536
  file: "vector.c", routine: "CheckExpectedDim"

Two test files insert 1536-dim Float32Array vectors into `content_chunks.embedding`
/ `facts.embedding`, but v0.41.5.0 flipped `DEFAULT_EMBEDDING_DIMENSIONS` from
1536 to 1280 (ZE Matryoshka default). On a fresh CI bun process where no prior
test pre-configured the gateway, `initSchema()` sizes the vector column at
vector(1280) and the inserts throw.

Locally this is hidden when an earlier test file in the shard happens to have
called `configureGateway({embedding_dimensions: 1536})` — that state leaks
forward through bun's shared process. The v0.41.6.0 LPT shard re-balancing
reordered files so these two ran cold, surfacing the latent bug.

Fix follows the canonical hermetic pattern from
test/consolidate-valid-until.test.ts:23-34: pin the gateway to 1536d in
beforeAll, reset in afterAll. Test is now isolated from shard ordering.

  test/search-types-filter.test.ts     — shard 1 fail
  test/operations-find-trajectory.test.ts — shard 4 (6 fails)

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

* chore: empty commit to trigger CI

* chore: trigger CI again

* chore: renumber v0.41.10.0 -> v0.41.10.1

Per request — version slot moved to .1 micro tier to leave .0 available
for unrelated wave landing on master.

---------

Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:10:23 -07:00

396 lines
16 KiB
TypeScript

// v0.41.2.1 — page-based discovery + source-hash idempotency for
// extract_atoms. Hermetic PGLite tests; no DATABASE_URL needed.
//
// Pins the contracts from /plan-eng-review:
// D1: source-hash existence check replaces frontmatter marker
// D2: single raw SQL with NOT EXISTS subquery
// D9 #1: sourceId threaded through every putPage
// D9 #3: content_hash IS NOT NULL filter (no crash on null)
// D9 #4: dream_generated:'true' pages excluded
// D9 #5: integration assertion deferred to cycle.ts test
//
// Plus PageDiscovery shape, transcript-side idempotency, dual-source
// merge, dedup, dry-run, fail-soft on executeRaw errors.
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import {
runPhaseExtractAtoms,
discoverExtractablePages,
} from '../src/core/cycle/extract-atoms.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import type { ChatOpts, ChatResult } from '../src/core/ai/gateway.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 60000);
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
function stubChat(text: string): (o: ChatOpts) => Promise<ChatResult> {
return async (_o: ChatOpts) => ({
text,
blocks: [{ type: 'text', text }],
stopReason: 'end',
usage: { input_tokens: 100, output_tokens: 50, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-haiku-4-5',
providerId: 'anthropic',
});
}
/**
* Stub that returns a unique-title atom on each call so atoms write to
* distinct slugs (`atoms/${date}/${slugify(title)}`) instead of upserting
* into one row. Needed for tests that count atoms after multiple work items.
*/
function stubChatUnique(): (o: ChatOpts) => Promise<ChatResult> {
let counter = 0;
return async (_o: ChatOpts) => {
counter++;
const text = `[{"title":"unique-atom-${counter}","atom_type":"insight","body":"b${counter}"}]`;
return {
text,
blocks: [{ type: 'text', text }],
stopReason: 'end',
usage: { input_tokens: 100, output_tokens: 50, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-haiku-4-5',
providerId: 'anthropic',
};
};
}
const LONG_CONTENT = 'a'.repeat(800); // > MIN_PAGE_CHARS_FOR_EXTRACTION (500)
async function seedPage(opts: {
slug: string;
type: string;
content_hash?: string | null;
frontmatter?: Record<string, unknown>;
source_id?: string;
compiled_truth?: string;
}) {
await engine.putPage(
opts.slug,
{
type: opts.type as never,
title: opts.slug,
compiled_truth: opts.compiled_truth ?? LONG_CONTENT,
timeline: '',
frontmatter: opts.frontmatter ?? {},
content_hash: opts.content_hash === null ? undefined : (opts.content_hash ?? `hash-for-${opts.slug}`),
},
{ sourceId: opts.source_id ?? 'default' },
);
// PGLite stores content_hash as the engine's computed hash by default.
// For tests that need a specific hash, overwrite via raw SQL.
if (opts.content_hash && opts.content_hash !== `hash-for-${opts.slug}`) {
await engine.executeRaw(
`UPDATE pages SET content_hash = $1 WHERE slug = $2 AND source_id = $3`,
[opts.content_hash, opts.slug, opts.source_id ?? 'default'],
);
}
if (opts.content_hash === null) {
await engine.executeRaw(
`UPDATE pages SET content_hash = NULL WHERE slug = $1 AND source_id = $2`,
[opts.slug, opts.source_id ?? 'default'],
);
}
}
describe('v0.41.2.1: discoverExtractablePages SQL contract', () => {
test('filters by all 6 extractable types', async () => {
for (const type of ['meeting', 'source', 'article', 'video', 'book', 'original']) {
await seedPage({ slug: `${type}/x`, type });
}
// Add a non-extractable page that should NOT appear
await seedPage({ slug: 'notes/skip-me', type: 'note' });
const discovered = await discoverExtractablePages(engine, 'default');
const slugs = discovered.map((d) => d.slug).sort();
expect(slugs).toEqual([
'article/x',
'book/x',
'meeting/x',
'original/x',
'source/x',
'video/x',
]);
});
test('NOT EXISTS subquery skips pages whose source_hash has existing atoms', async () => {
// Page content_hash is 20 chars; substring(from 1 for 16) yields the
// first 16 chars. The seeded atom must carry exactly those 16 chars
// in frontmatter.source_hash to match the subquery's comparison.
await seedPage({ slug: 'meeting/old', type: 'meeting', content_hash: 'oldhash1234567890abc' });
await seedPage({ slug: 'meeting/new', type: 'meeting', content_hash: 'newhash1234567890abc' });
await engine.putPage(
'atoms/2026-05-20/old-insight',
{
type: 'atom' as never,
title: 'Old',
compiled_truth: 'body',
timeline: '',
// 'oldhash1234567890abc'.slice(0, 16) = 'oldhash123456789'
frontmatter: { source_hash: 'oldhash123456789' },
},
{ sourceId: 'default' },
);
const discovered = await discoverExtractablePages(engine, 'default');
expect(discovered.map((d) => d.slug)).toEqual(['meeting/new']);
});
test('markdown-greenfield pages excluded', async () => {
await seedPage({ slug: 'meeting/normal', type: 'meeting' });
await seedPage({
slug: 'meeting/greenfield',
type: 'meeting',
frontmatter: { imported_from: 'markdown-greenfield' },
});
const discovered = await discoverExtractablePages(engine, 'default');
expect(discovered.map((d) => d.slug)).toEqual(['meeting/normal']);
});
test('dream_generated:true pages excluded (D9 #4 — no self-consumption)', async () => {
await seedPage({ slug: 'original/normal', type: 'original' });
await seedPage({
slug: 'original/from-dream',
type: 'original',
frontmatter: { dream_generated: true },
});
const discovered = await discoverExtractablePages(engine, 'default');
expect(discovered.map((d) => d.slug)).toEqual(['original/normal']);
});
test('pages with NULL content_hash excluded (D9 #3 — no .slice crash)', async () => {
await seedPage({ slug: 'meeting/with-hash', type: 'meeting' });
await seedPage({ slug: 'meeting/no-hash', type: 'meeting', content_hash: null });
const discovered = await discoverExtractablePages(engine, 'default');
expect(discovered.map((d) => d.slug)).toEqual(['meeting/with-hash']);
// .slice(0, 16) on hash would crash for null; the filter prevents that
expect(() => discovered[0].contentHash.slice(0, 16)).not.toThrow();
});
test('pages shorter than MIN_PAGE_CHARS_FOR_EXTRACTION excluded', async () => {
await seedPage({ slug: 'meeting/long', type: 'meeting', compiled_truth: 'a'.repeat(600) });
await seedPage({ slug: 'meeting/short', type: 'meeting', compiled_truth: 'short' });
const discovered = await discoverExtractablePages(engine, 'default');
expect(discovered.map((d) => d.slug)).toEqual(['meeting/long']);
});
test('sourceId scopes both candidate AND atom-existence subquery (federated-brain isolation)', async () => {
await engine.executeRaw(
`INSERT INTO sources (id, name) VALUES ('dept-x', 'dept-x') ON CONFLICT DO NOTHING`,
);
await seedPage({ slug: 'meeting/a-default', type: 'meeting' });
await seedPage({ slug: 'meeting/a-dept-x', type: 'meeting', source_id: 'dept-x' });
const discoveredDefault = await discoverExtractablePages(engine, 'default');
expect(discoveredDefault.map((d) => d.slug)).toEqual(['meeting/a-default']);
const discoveredDept = await discoverExtractablePages(engine, 'dept-x');
expect(discoveredDept.map((d) => d.slug)).toEqual(['meeting/a-dept-x']);
});
test('affectedSlugs filter narrows candidates when provided', async () => {
await seedPage({ slug: 'meeting/a', type: 'meeting' });
await seedPage({ slug: 'meeting/b', type: 'meeting' });
await seedPage({ slug: 'meeting/c', type: 'meeting' });
const discovered = await discoverExtractablePages(engine, 'default', ['meeting/a', 'meeting/c']);
expect(discovered.map((d) => d.slug).sort()).toEqual(['meeting/a', 'meeting/c']);
});
test('executeRaw failure returns [] (fail-soft, transcript path proceeds)', async () => {
// Inject a SQL error by passing a sourceId that breaks the query —
// actually easier: temporarily replace executeRaw to throw.
const realExecute = engine.executeRaw.bind(engine);
(engine as unknown as { executeRaw: typeof engine.executeRaw }).executeRaw =
async () => { throw new Error('synthetic discovery failure'); };
try {
const discovered = await discoverExtractablePages(engine, 'default');
expect(discovered).toEqual([]);
} finally {
(engine as unknown as { executeRaw: typeof engine.executeRaw }).executeRaw = realExecute;
}
});
});
describe('v0.41.2.1: runPhaseExtractAtoms — dual-source merge + idempotency', () => {
test('dual-source merge: transcripts win on contentHash collision', async () => {
const chat = stubChat(`[{"title":"x","atom_type":"insight","body":"b"}]`);
const result = await runPhaseExtractAtoms(engine, {
_transcripts: [
{ filePath: '/transcript-A.txt', content: 'shared content', contentHash: 'sharedhash1234' },
],
_pages: [
// Same content hash → should be dedup-skipped in favor of transcript
{ slug: 'meeting/shared', content: 'shared content', contentHash: 'sharedhash1234' },
{ slug: 'meeting/unique', content: 'unique content', contentHash: 'uniquehash5678' },
],
_chat: chat,
});
expect(result.status).toBe('ok');
expect(result.details?.atoms_extracted).toBe(2); // transcript + page-unique
expect(result.details?.duplicates_skipped).toBe(1); // page collided with transcript
expect(result.details?.transcripts_processed).toBe(1);
expect(result.details?.pages_processed).toBe(1);
});
test('atom frontmatter: page-origin uses source_slug, transcript-origin uses source_path', async () => {
// Use stubChatUnique so the two work-items write to distinct slugs;
// a constant title would upsert into one slug and mask one origin.
const chat = stubChatUnique();
await runPhaseExtractAtoms(engine, {
_transcripts: [{ filePath: '/T.txt', content: 'tc', contentHash: 'tchash1234567890ab' }],
_pages: [{ slug: 'meeting/P', content: 'pc', contentHash: 'pchash1234567890ab' }],
_chat: chat,
});
const rows = await engine.executeRaw<{ slug: string; frontmatter: Record<string, unknown> }>(
`SELECT slug, frontmatter FROM pages WHERE type = 'atom' ORDER BY slug`,
);
expect(rows.length).toBe(2);
const transcriptAtom = rows.find((r) => r.frontmatter.source_path !== undefined);
const pageAtom = rows.find((r) => r.frontmatter.source_slug !== undefined);
expect(transcriptAtom).toBeDefined();
expect(transcriptAtom!.frontmatter.source_path).toBe('/T.txt');
expect(transcriptAtom!.frontmatter.source_hash).toBe('tchash1234567890');
expect(pageAtom).toBeDefined();
expect(pageAtom!.frontmatter.source_slug).toBe('meeting/P');
expect(pageAtom!.frontmatter.source_hash).toBe('pchash1234567890');
});
test('sourceId threads into putPage call (D9 #1 regression — atoms land in correct source)', async () => {
await engine.executeRaw(
`INSERT INTO sources (id, name) VALUES ('dept-x', 'dept-x') ON CONFLICT DO NOTHING`,
);
const chat = stubChat(`[{"title":"x","atom_type":"insight","body":"b"}]`);
await runPhaseExtractAtoms(engine, {
sourceId: 'dept-x',
_transcripts: [{ filePath: '/T.txt', content: 'tc', contentHash: 'tchash1234567890ab' }],
_pages: [],
_chat: chat,
});
const rows = await engine.executeRaw<{ source_id: string }>(
`SELECT source_id FROM pages WHERE type = 'atom'`,
);
expect(rows.length).toBe(1);
expect(rows[0].source_id).toBe('dept-x');
});
test('transcript-side idempotency: re-discovered same-hash transcript skipped (closes pre-existing bug)', async () => {
const chat = stubChatUnique();
// First run writes the atom
await runPhaseExtractAtoms(engine, {
_transcripts: [{ filePath: '/T.txt', content: 'c', contentHash: 'dedup1234567890abcd' }],
_pages: [],
_chat: chat,
});
// Second run on same content_hash should skip (no second atom written)
const result2 = await runPhaseExtractAtoms(engine, {
_transcripts: [{ filePath: '/T.txt', content: 'c', contentHash: 'dedup1234567890abcd' }],
_pages: [],
_chat: chat,
});
expect(result2.details?.duplicates_skipped).toBe(1);
expect(result2.details?.atoms_extracted).toBe(0);
const rows = await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM pages WHERE type = 'atom'`,
);
expect(rows[0].count).toBe(1);
});
test('idempotency: full re-run on same brain produces zero new atoms', async () => {
const chat = stubChatUnique();
await seedPage({ slug: 'meeting/M', type: 'meeting', content_hash: 'mhash1234567890ab' });
await runPhaseExtractAtoms(engine, {
_transcripts: [{ filePath: '/T.txt', content: 'tc', contentHash: 'thash1234567890ab' }],
_chat: chat,
});
const before = await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM pages WHERE type = 'atom'`,
);
expect(before[0].count).toBe(2); // 1 transcript-atom + 1 page-atom
// Second invocation — no work expected; both dedup paths fire
const result2 = await runPhaseExtractAtoms(engine, {
_transcripts: [{ filePath: '/T.txt', content: 'tc', contentHash: 'thash1234567890ab' }],
_chat: chat,
});
expect(result2.details?.atoms_extracted).toBe(0);
expect(result2.details?.duplicates_skipped).toBe(1); // transcript skipped
expect(result2.details?.pages_total).toBe(0); // discovery returned empty
const after = await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM pages WHERE type = 'atom'`,
);
expect(after[0].count).toBe(before[0].count);
});
test('PhaseResult.details has additive page fields populated', async () => {
const chat = stubChat(`[{"title":"x","atom_type":"insight","body":"b"}]`);
const result = await runPhaseExtractAtoms(engine, {
_transcripts: [],
_pages: [
{ slug: 'meeting/a', content: 'a', contentHash: 'a1234567890abcde' },
{ slug: 'meeting/b', content: 'b', contentHash: 'b1234567890abcde' },
],
_chat: chat,
});
expect(result.details?.pages_processed).toBe(2);
expect(result.details?.pages_total).toBe(2);
expect(result.details?.pages_skipped_budget).toBe(0);
expect(result.details?.duplicates_skipped).toBe(0);
expect(result.details?.atoms_extracted).toBe(2);
});
test('dry-run skips putPage for atoms', async () => {
const chat = stubChat(`[{"title":"x","atom_type":"insight","body":"b"}]`);
const result = await runPhaseExtractAtoms(engine, {
_transcripts: [{ filePath: '/T.txt', content: 'tc', contentHash: 'th1234567890abcde' }],
_pages: [{ slug: 'meeting/M', content: 'mc', contentHash: 'mh1234567890abcde' }],
_chat: chat,
dryRun: true,
});
expect(result.details?.dry_run).toBe(true);
expect(result.details?.atoms_extracted).toBe(2); // counted but not written
const rows = await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM pages WHERE type = 'atom'`,
);
expect(rows[0].count).toBe(0);
});
test('_pages: undefined triggers discovery; _pages: [] explicitly suppresses', async () => {
await seedPage({ slug: 'meeting/discoverable', type: 'meeting', content_hash: 'discovered1234567' });
const chat = stubChat(`[{"title":"x","atom_type":"insight","body":"b"}]`);
// _pages explicitly [] → no page work
const suppressed = await runPhaseExtractAtoms(engine, {
_transcripts: [],
_pages: [],
_chat: chat,
});
expect(suppressed.details?.pages_total).toBe(0);
// _pages undefined → discovery fires
const discovered = await runPhaseExtractAtoms(engine, {
_transcripts: [],
_chat: chat,
});
expect(discovered.details?.pages_total).toBe(1);
expect(discovered.details?.atoms_extracted).toBe(1);
});
});