Files
gbrain/test/put-page-provenance.test.ts
T
ffac8ce0f4 v0.41.27.0 fix: withRetry self-heals on null singleton + facts:absorb drain + disconnect audit (closes #1570) (#1608)
* merge master: rebump v0.41.25.0 → v0.41.27.0 (queue collision)

Master shipped v0.41.25.0 (#1538 batched sync deletes) and v0.41.26.0
(#1571 dream --source fix) while this branch was in flight. Conflict
resolution rebumps to the next available slot.

- VERSION: 0.41.25.0 → 0.41.27.0
- package.json: synced
- CHANGELOG.md: my v0.41.27.0 entry placed above master's v0.41.26.0
  and v0.41.25.0; in-entry version references updated 0.41.25.0 →
  0.41.27.0 and forward-references bumped to v0.41.28+.
- TODOS.md: kept master's v0.41.20.x section + my v0.41.27.0+ follow-ups

No source-file conflicts during the merge.

* feat(diagnostics): db-disconnect audit + doctor surface (v0.41.27.0)

Instruments every db.disconnect() and PostgresEngine.disconnect() call
with a JSONL audit record so the next user-reported #1570 cycle gives
us the offender's caller stack instead of the symptomatic
"No database connection" error.

Audit shape (~/.gbrain/audit/db-disconnect-YYYY-Www.jsonl):
  {ts, engine_kind, connection_style, caller_stack[], command, pid}

- src/core/audit/db-disconnect-audit.ts (NEW): the audit writer,
  built on the v0.40.4.0 createAuditWriter cathedral. Captures a
  6-frame stack via new Error().stack so the offender is readable
  without spending stderr noise.
- src/core/db.ts: logDbDisconnect call at the top of disconnect()
  (best-effort; never blocks the real teardown).
- src/core/postgres-engine.ts: same instrumentation in
  PostgresEngine.disconnect() — distinguishes 'module' vs 'instance'
  connection_style so we can tell legitimate worker-pool teardowns
  apart from the load-bearing module-singleton class.
- src/commands/doctor.ts: extends batch_retry_health to surface
  24h disconnect count + most-recent caller stack. Warns when the
  caller frame isn't a known CLI-exit frame (e.g. cli.ts's finally
  block at the end of an op-dispatch). This is the diagnostic that
  tells v0.41.28+ where to apply the real ownership fix.
- test/db-disconnect-audit.test.ts: unit coverage for the audit
  writer + caller-stack capture + JSONL shape.
- test/e2e/db-singleton-shared-recovery.test.ts: real-Postgres
  regression that exercises the singleton-null path end-to-end.

Refs #1570

* feat(retry): self-heal on null singleton — closes #1570 symptom (v0.41.27.0)

withRetry gains an opt-in reconnect callback that fires between the
isRetryableConnError classification and the inter-attempt sleep.
PostgresEngine.batchRetry injects this.reconnect() — race-safe via
the existing _reconnecting guard, handles module and instance pools.

Closes the production loss reported in #1570: dream cycles on Supabase
no longer drop ~150 link rows per cycle when the singleton goes null
mid-batch. The retry now rebuilds the connection between attempts so
the second try has somewhere to write to.

- src/core/retry.ts: WithRetryOpts gains `reconnect?: () => Promise<void>`.
  Awaited in the catch branch. onRetry is also now awaited (back-compat-
  safe: every existing in-tree caller is a sync arrow). Reconnect
  failures propagate as the real cause — replaces the symptomatic
  "No database connection" error with whatever the connect() throw
  was, so operators see the truth.
- src/core/postgres-engine.ts:batchRetry — injects
  `reconnect: () => this.reconnect()`. Covers all 9 batch-retry call
  sites (addLinksBatch, addTimelineEntriesBatch, upsertChunks, plus
  the 6 caller-supplied auditSite labels in extract / sync / reindex).
- test/core/retry-reconnect.test.ts: 8 hermetic cases pinning the
  contract — reconnect fires before sleep, only on retryable errors,
  back-compat when omitted, signal-aborted bypasses reconnect,
  onRetry is awaited, full success path end-to-end.

The deeper bug (who's calling disconnect mid-cycle) is left
unaddressed in this commit by design — the diagnostic instrumentation
in the prior commit will tell us in the next production run.

Refs #1570

* feat(facts): drainPending() + CLI await before disconnect (v0.41.27.0)

Closes the silent 'No database connection' tail-end errors after
gbrain capture / put_page: the facts:absorb fire-and-forget queue
sometimes outlived the CLI process's connection lifetime, so absorb
attempts after engine.disconnect() landed in stderr as the
GBrainError shape.

- src/core/facts/queue.ts: new drainPending({timeout: 1000}) method
  distinct from shutdown(). Stops accepting new enqueues, awaits
  in-flight settle, bounded by timeout, returns count of unfinished.
  Semantically different from shutdown() (which aborts in-flight)
  so the symptom — drop work that hasn't started yet but let
  in-flight work finish — matches what CLI exit actually needs.
- src/cli.ts: op-dispatch finally block awaits the drain BEFORE
  engine.disconnect(). Bounded 1s. Opt-out env GBRAIN_NO_FACTS_DRAIN
  for callers that don't enqueue (keeps fast-exit paths fast).
  Mirrors the v0.41.8.0 awaitPendingLastRetrievedWrites pattern.
- test/facts-queue-drain-pending.test.ts: 6 hermetic cases — empty
  drain returns immediately, single in-flight settles, timeout
  bounds wait, shutdown-after-drain is idempotent, post-drain
  enqueues are dropped, signal-aborted skips waiting.

Refs #1570

* docs: update project documentation for v0.41.27.0

README.md: added troubleshooting entry for the v0.41.27.0 retry-reconnect
+ facts:absorb drain fix (closes #1570), pointing operators at
`gbrain doctor --json` to find the offending disconnect caller.

CLAUDE.md: extended `src/core/retry.ts` entry with the new optional
`reconnect` callback (v0.41.27.0); added two new Key Files entries for
`src/core/audit/db-disconnect-audit.ts` (the diagnostic half of the
"instrument first, fix later" pivot) and `FactsQueue.drainPending`;
extended `doctor.ts:checkBatchRetryHealth` entry with the in-place
extension that surfaces 24h disconnect-call count.

llms-full.txt: regenerated to absorb CLAUDE.md edits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: rebump v0.41.27.0 → v0.41.28.0 (queue collision with #1573)

Master shipped v0.41.27.0 (#1573 git-aware sync_freshness) claiming the
same slot. Rebump to the next available version.

- VERSION + package.json → 0.41.28.0
- CHANGELOG.md: my entry header + in-entry refs 0.41.27.0 → 0.41.28.0
- TODOS.md: my #1570 follow-up section header + body refs bumped

* test: pin gateway in put-page-provenance + embedding-dim-check (CI shard fix)

Both files failed on CI shards 1 and 8 under the cross-file gateway-state
leak class (CLAUDE.md "Test-isolation lint and helpers"). The v0.41.28.0
merge reshuffled the weight-based shard bin-packing, landing a
gateway-mutating sibling ahead of these two victims in the same `bun test`
process.

Mechanism:
- put-page-provenance: put_page embeds via the gateway. A sibling left
  the gateway configured with OpenAI + the CI placeholder `sk-test`
  (captured at configureGateway time, survives the withEnv restore as
  cached gateway state). put_page's embed then fired against live OpenAI
  and 401'd. The bunfig legacy-embedding preload's beforeEach only
  re-applies legacy when the gateway was RESET — it does NOT correct a
  sibling that configured a different LIVE config.
- embedding-dim-check: initSchema builds the content_chunks vector column
  at the gateway's configured dim. A sibling leaking ZE/1280 made the
  column 1280-d, so `expect(dims).toBe(1536)` failed.

Fix (victim-side pinning, the escape hatch the preload documents):
- Both: configure the gateway explicitly in beforeAll BEFORE initSchema
  (OpenAI/1536), resetGateway() in afterAll so neither leaks onward.
- put-page-provenance also stubs the embed transport via
  __setEmbedTransportForTests so embed is deterministic and offline; a
  dummy OPENAI_API_KEY is supplied in the gateway env because
  instantiateEmbedding builds the OpenAI client (key check) BEFORE the
  stubbed transport is reached — the stub then intercepts the actual
  call so the key never leaves the process.

Verified: CI shards 1 (1337 pass) + 8 (905 pass) green with
OPENAI_API_KEY unset, plus adversarial sibling orderings (gateway.test /
doctor-ze-checks preceding). Typecheck + check-test-isolation clean.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:04:48 -07:00

348 lines
14 KiB
TypeScript

/**
* v0.39.3.0 WARN-8 / A1 / CV6 / CV12 / T2 — put_page provenance.
*
* Migration v81 added 4 nullable provenance columns to `pages`
* (source_kind, source_uri, ingested_via, ingested_at). This test
* file pins the put_page op contract:
*
* 1. Trusted local callers (ctx.remote === false) can populate
* source_kind / source_uri / ingested_via via params; the engine
* stamps ingested_at server-side.
* 2. Remote MCP callers (ctx.remote !== false) get server-stamped
* `mcp:put_page` REGARDLESS of what they pass — CV6 spoofing guard.
* 3. COALESCE-preserve UPDATE: a second put_page that omits provenance
* does NOT overwrite the original ingestion's audit trail — first
* write wins; routine edits survive without erasing it (CV12).
* 4. Subagent namespace check still fires when provenance params are
* present — adding params must not bypass the v0.26.9 F7b
* fail-closed contract (T2 regression guard).
*
* All cases run against in-memory PGLite (hermetic, no DATABASE_URL).
* Per CLAUDE.md "Test-isolation lint and helpers (v0.26.7)" the engine
* is created in beforeAll, reset in beforeEach, disconnected in afterAll.
*/
import { describe, test, expect, beforeAll, beforeEach, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { operations } from '../src/core/operations.ts';
import type { OperationContext } from '../src/core/operations.ts';
import { OperationError } from '../src/core/operations.ts';
import { configureGateway, resetGateway, __setEmbedTransportForTests } from '../src/core/ai/gateway.ts';
const putPageOp = operations.find((o) => o.name === 'put_page')!;
let engine: PGLiteEngine;
beforeAll(async () => {
// Hermeticity guard (cross-file gateway-state leak class — see CLAUDE.md
// "Test-isolation lint and helpers"). put_page embeds via the gateway.
// A sibling file in the same shard can leave the gateway configured with
// a live provider/key (e.g. OpenAI + a CI placeholder `sk-test`); the
// weight-based shard bin-packing reshuffles which files share a process,
// so we cannot rely on a benign neighbor. Pin the gateway to legacy
// OpenAI/1536 (so initSchema builds a 1536-d column) AND stub the embed
// transport so put_page's embed never touches the network — these tests
// assert provenance columns, not embeddings. A dummy key is required in
// the gateway env because instantiateEmbedding builds the OpenAI client
// (which checks for a key) BEFORE the stubbed transport is reached; the
// stub then intercepts the actual call, so the key never leaves the
// process. Reset in afterAll so this file doesn't leak onward.
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { ...process.env, OPENAI_API_KEY: process.env.OPENAI_API_KEY || 'sk-test-stub' },
});
__setEmbedTransportForTests(async ({ values }: any) => ({
embeddings: values.map(() => new Array(1536).fill(0)),
usage: { tokens: 0 },
}) as any);
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
__setEmbedTransportForTests(null);
resetGateway();
});
beforeEach(async () => {
// Wipe pages so each test starts from a known empty state. We use
// executeRaw rather than a TRUNCATE-by-name sweep because this file
// only touches one table.
await engine.executeRaw('DELETE FROM pages', []);
});
function makeCtx(opts: Partial<OperationContext> = {}): OperationContext {
return {
engine,
config: { engine: 'pglite' as const },
logger: {
info: () => { /* noop */ },
warn: () => { /* noop */ },
error: () => { /* noop */ },
},
dryRun: false,
remote: false,
sourceId: 'default',
...opts,
};
}
// Helper — read provenance columns straight from the DB so we don't depend
// on the get_page op (Phase 3b extension).
async function readProvenance(slug: string): Promise<{
source_kind: string | null;
source_uri: string | null;
ingested_via: string | null;
ingested_at: Date | null;
}> {
const rows = await engine.executeRaw(
'SELECT source_kind, source_uri, ingested_via, ingested_at FROM pages WHERE slug = $1',
[slug],
) as Array<{ source_kind: unknown; source_uri: unknown; ingested_via: unknown; ingested_at: unknown }>;
const r = rows[0];
return {
source_kind: (r?.source_kind as string | null) ?? null,
source_uri: (r?.source_uri as string | null) ?? null,
ingested_via: (r?.ingested_via as string | null) ?? null,
ingested_at: r?.ingested_at ? new Date(r.ingested_at as string) : null,
};
}
describe('put_page provenance — trusted local caller (ctx.remote === false)', () => {
test('client params honored: source_kind / source_uri / ingested_via populate DB', async () => {
const ctx = makeCtx({ remote: false });
await putPageOp.handler(ctx, {
slug: 'wiki/p3a-local-write',
content: '---\ntype: note\ntitle: Local Write\n---\n\nbody',
source_kind: 'capture-cli',
source_uri: 'file:///tmp/test.md',
ingested_via: 'put_page',
});
const prov = await readProvenance('wiki/p3a-local-write');
expect(prov.source_kind).toBe('capture-cli');
expect(prov.source_uri).toBe('file:///tmp/test.md');
expect(prov.ingested_via).toBe('put_page');
// Server stamps ingested_at when ANY provenance field fires
expect(prov.ingested_at).toBeInstanceOf(Date);
expect(prov.ingested_at!.getTime()).toBeGreaterThan(Date.now() - 60_000);
});
test('omitting all provenance params leaves all 4 DB columns null', async () => {
const ctx = makeCtx({ remote: false });
await putPageOp.handler(ctx, {
slug: 'wiki/p3a-no-provenance',
content: '---\ntype: note\ntitle: No Prov\n---\n\nbody',
});
const prov = await readProvenance('wiki/p3a-no-provenance');
expect(prov.source_kind).toBeNull();
expect(prov.source_uri).toBeNull();
expect(prov.ingested_via).toBeNull();
expect(prov.ingested_at).toBeNull();
});
test('partial provenance (source_kind only) still triggers ingested_at stamp', async () => {
const ctx = makeCtx({ remote: false });
await putPageOp.handler(ctx, {
slug: 'wiki/p3a-partial',
content: '---\ntype: note\ntitle: Partial\n---\n\nbody',
source_kind: 'capture-cli',
});
const prov = await readProvenance('wiki/p3a-partial');
expect(prov.source_kind).toBe('capture-cli');
expect(prov.source_uri).toBeNull();
expect(prov.ingested_via).toBeNull();
expect(prov.ingested_at).toBeInstanceOf(Date);
});
});
describe('put_page provenance — CV6 spoofing guard (ctx.remote !== false)', () => {
test('remote caller cannot claim source_kind: capture-cli', async () => {
const ctx = makeCtx({ remote: true });
await putPageOp.handler(ctx, {
slug: 'wiki/p3a-remote-spoof-attempt',
content: '---\ntype: note\ntitle: Spoof\n---\n\nbody',
source_kind: 'capture-cli', // client lies: pretends to be local CLI
source_uri: 'spoofed://attacker-supplied',
ingested_via: 'file-watcher', // client lies: claims daemon source
});
const prov = await readProvenance('wiki/p3a-remote-spoof-attempt');
// Server overrode with mcp:put_page; client claims discarded
expect(prov.source_kind).toBe('mcp:put_page');
expect(prov.source_uri).toBeNull();
expect(prov.ingested_via).toBe('mcp:put_page');
expect(prov.ingested_at).toBeInstanceOf(Date);
});
test('ctx.remote === undefined (no explicit trust) is treated as remote', async () => {
// v0.26.9 F7b discipline: anything that isn't strictly `false` is remote.
const ctx: OperationContext = {
engine,
config: { engine: 'pglite' as const },
logger: { info: () => {}, warn: () => {}, error: () => {} },
dryRun: false,
// remote: explicitly undefined to exercise the fail-closed path
remote: undefined as unknown as boolean,
sourceId: 'default',
};
await putPageOp.handler(ctx, {
slug: 'wiki/p3a-undefined-trust',
content: '---\ntype: note\ntitle: Undefined\n---\n\nbody',
source_kind: 'capture-cli',
});
const prov = await readProvenance('wiki/p3a-undefined-trust');
expect(prov.source_kind).toBe('mcp:put_page');
});
});
describe('put_page provenance — CV12 COALESCE-preserve UPDATE', () => {
test('second write WITHOUT provenance preserves first-write audit', async () => {
const ctx = makeCtx({ remote: false });
// First write stamps capture-cli
await putPageOp.handler(ctx, {
slug: 'wiki/p3a-preserve',
content: '---\ntype: note\ntitle: V1\n---\n\noriginal body',
source_kind: 'capture-cli',
ingested_via: 'put_page',
});
const first = await readProvenance('wiki/p3a-preserve');
expect(first.source_kind).toBe('capture-cli');
const firstStamp = first.ingested_at!.getTime();
// Second write — same slug, no provenance params
await new Promise((r) => setTimeout(r, 10)); // ensure now() would tick
await putPageOp.handler(ctx, {
slug: 'wiki/p3a-preserve',
content: '---\ntype: note\ntitle: V2\n---\n\nedited body',
});
const second = await readProvenance('wiki/p3a-preserve');
// CV12: provenance preserved — first-write wins
expect(second.source_kind).toBe('capture-cli');
expect(second.ingested_via).toBe('put_page');
// ingested_at preserved too (audit trail truthful about WHEN the first
// ingestion happened, not WHEN the edit landed)
expect(second.ingested_at!.getTime()).toBe(firstStamp);
});
test('second write WITH new provenance overwrites (explicit re-ingestion)', async () => {
const ctx = makeCtx({ remote: false });
await putPageOp.handler(ctx, {
slug: 'wiki/p3a-reingest',
content: '---\ntype: note\ntitle: V1\n---\n\nbody',
source_kind: 'capture-cli',
});
await new Promise((r) => setTimeout(r, 10));
await putPageOp.handler(ctx, {
slug: 'wiki/p3a-reingest',
content: '---\ntype: note\ntitle: V2\n---\n\nbody',
source_kind: 'file-watcher', // explicit re-ingest under different kind
source_uri: 'file:///watched/path.md',
ingested_via: 'file-watcher',
});
const prov = await readProvenance('wiki/p3a-reingest');
// COALESCE(EXCLUDED.source_kind, pages.source_kind) — EXCLUDED non-null wins
expect(prov.source_kind).toBe('file-watcher');
expect(prov.source_uri).toBe('file:///watched/path.md');
expect(prov.ingested_via).toBe('file-watcher');
});
test('remote second write WITHOUT explicit provenance does NOT erase local first-write', async () => {
// First: local CLI capture
const localCtx = makeCtx({ remote: false });
await putPageOp.handler(localCtx, {
slug: 'wiki/p3a-local-then-remote',
content: '---\ntype: note\ntitle: V1\n---\n\nbody',
source_kind: 'capture-cli',
ingested_via: 'put_page',
});
// Second: remote MCP edit (server stamps mcp:put_page)
const remoteCtx = makeCtx({ remote: true });
await putPageOp.handler(remoteCtx, {
slug: 'wiki/p3a-local-then-remote',
content: '---\ntype: note\ntitle: V2\n---\n\nremote edit',
});
// Remote second write is itself a provenance write (server-stamped),
// so COALESCE(EXCLUDED.source_kind='mcp:put_page', pages.source_kind='capture-cli')
// resolves to EXCLUDED's non-null value: 'mcp:put_page'. This is the
// honest answer — the MOST RECENT ingestion source is mcp:put_page,
// and the system says so. CV12 first-write-wins applies when the second
// write OMITS provenance entirely (covered by the earlier test); when
// the second write IS an ingestion, it gets to record itself.
const prov = await readProvenance('wiki/p3a-local-then-remote');
expect(prov.source_kind).toBe('mcp:put_page');
});
});
describe('put_page provenance — T2 subagent namespace regression', () => {
test('subagent with provenance params STILL gets wiki/agents/<id>/ rejection', async () => {
// Adding provenance params to put_page must NOT bypass the
// viaSubagent + sandbox namespace check (operations.ts:556-578).
// The check runs against `ctx.viaSubagent`, NOT against the params.
// This regression guard pins it.
const ctx = makeCtx({
remote: true,
viaSubagent: true,
subagentId: 42,
// No allowedSlugPrefixes — falls through to legacy default agent-namespace check
});
// Attempt to write OUTSIDE the wiki/agents/42/ namespace WITH spoofed
// provenance params (trying every angle to escape).
await expect(
putPageOp.handler(ctx, {
slug: 'wiki/secret/leak',
content: '---\ntype: note\ntitle: Leak\n---\n\nbody',
source_kind: 'capture-cli',
source_uri: 'file:///tmp/spoof',
ingested_via: 'put_page',
}),
).rejects.toThrow(/wiki\/agents\/42/);
});
test('subagent within wiki/agents/<id>/ namespace succeeds; provenance respects CV6', async () => {
const ctx = makeCtx({
remote: true,
viaSubagent: true,
subagentId: 42,
});
await putPageOp.handler(ctx, {
slug: 'wiki/agents/42/scratch',
content: '---\ntype: note\ntitle: Subagent OK\n---\n\nbody',
source_kind: 'capture-cli', // Spoof attempt — ignored by CV6
});
const prov = await readProvenance('wiki/agents/42/scratch');
// CV6 gate fires AFTER the namespace check; client param ignored
expect(prov.source_kind).toBe('mcp:put_page');
});
test('subagent missing subagentId fails-closed regardless of provenance params', async () => {
const ctx: OperationContext = {
engine,
config: { engine: 'pglite' as const },
logger: { info: () => {}, warn: () => {}, error: () => {} },
dryRun: false,
remote: true,
sourceId: 'default',
viaSubagent: true,
// subagentId intentionally absent
};
await expect(
putPageOp.handler(ctx, {
slug: 'wiki/agents/42/anything',
content: '---\ntype: note\n---\n\nbody',
source_kind: 'capture-cli',
}),
).rejects.toThrow(OperationError);
});
});