mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
Five specialist reviewers (testing/maintainability/security/performance/ data-migration) on the reconciled diff; every finding applied: Performance: the alias arm now resolves all granted sources CONCURRENTLY (a federated caller paid M sequential RTTs per turn — ~355ms at 5 sources cross-region, inside the reflex's 1.5s budget); watch's session dedupe is O(1) Set membership instead of a monotonically growing priorContext string (O(T²) over a long-lived session); getWindowTurns iterates from the tail (per-turn cost no longer grows with session length); the resolver's provenance maps fold into the existing candidate pass. Security: volunteer_context clamps caller-supplied attribution at the trust boundary — session_id capped at 256 chars (a read-scoped token could bank ~1MiB TEXT per request, retained 90 days), turn logged only when a safe integer (a non-integer threw inside the batched INSERT and silently dropped the whole batch). The privacy comments now state precisely what rationale may contain (the matched entity's surface form — which by construction resolved to an existing alias/title/slug — never free conversation text). Maintainability: TURN_PREFIX_RE + formatVolunteeredPage exported from volunteer.ts and shared by watch/cli (the two surfaces can no longer drift); volunteerEventRowsFrom is the single VolunteerEventRow assembly site for all three channels; watch's window default now honors the same retrieval_reflex_window_turns config knob the reflex reads; the stale pre-v116 comments swept to pre-v117. Testing: the two flake-class CRITICALs fixed (pipe test asserts the backstop banner instead of a cold-CI-hostile 9s wall bound; the SIGINT test waits on watch's new machine-readable ready line instead of a fixed 15s sleep — 2.5s and deterministic now); new coverage for the sink's timeout branch + ghost-reference drop, watch per-turn fail-open, untrusted knob clamps (min_confidence/max_pages/days), window-cap ordering (newest user mention survives), serve-IPC suppression passthrough + channel=reflex logging, windowTurnCount edge semantics, and structural pins for the sink registration + cycle purge wiring. The exit-verdict pin's grep is now operator/whitespace-tolerant. Deferred with TODOs: resolver index shapes for the per-turn query; batched first-prune after a long dream-cycle gap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
186 lines
7.3 KiB
TypeScript
186 lines
7.3 KiB
TypeScript
/**
|
|
* v0.43 (#2095) — `gbrain watch` push transport: streaming loop, rolling
|
|
* window, session dedupe, --json shape, event logging on channel 'watch',
|
|
* and clean EOF return. Hermetic PGLite + injected line/write deps (no
|
|
* subprocess, no real stdin).
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { runWatch, WATCH_HELP } from '../src/commands/watch.ts';
|
|
import { awaitPendingVolunteerEventWrites, _resetPendingVolunteerEventWritesForTests } from '../src/core/context/volunteer-events.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
async function seed(slug: string, title: string, body: string) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline)
|
|
VALUES ($1, 'default', 'person', $2, $3, '')`,
|
|
[slug, title, body],
|
|
);
|
|
}
|
|
|
|
async function* feed(lines: string[]): AsyncGenerator<string> {
|
|
for (const l of lines) yield l;
|
|
}
|
|
|
|
async function watchRun(lines: string[], extraArgs: string[] = []): Promise<string[]> {
|
|
const out: string[] = [];
|
|
await runWatch(engine, ['--source', 'default', ...extraArgs], {
|
|
lines: feed(lines),
|
|
write: (s) => out.push(s),
|
|
isTTY: false,
|
|
});
|
|
return out;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
_resetPendingVolunteerEventWritesForTests();
|
|
await engine.executeRaw('DELETE FROM context_volunteer_events').catch(() => {});
|
|
await engine.executeRaw('DELETE FROM pages');
|
|
});
|
|
|
|
describe('gbrain watch (#2095)', () => {
|
|
test('--help prints WATCH_HELP and touches nothing', async () => {
|
|
const out = await watchRun([], ['--help']);
|
|
expect(out.join('')).toBe(WATCH_HELP);
|
|
});
|
|
|
|
test('volunteers per turn and returns cleanly at EOF', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
|
|
const out = await watchRun(['ping Alice Example about the deal']);
|
|
const text = out.join('');
|
|
expect(text).toContain('people/alice-example');
|
|
expect(text).toContain('exact title match');
|
|
// runWatch RESOLVED — the EOF → clean-return contract (the entrypoint
|
|
// flush-exit + finally drain handle the rest in the real CLI).
|
|
});
|
|
|
|
test('rolling window: assistant-introduced entity fires on the pronoun follow-up turn', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
|
|
const out = await watchRun([
|
|
'user: who should I ask about the round?',
|
|
'assistant: Alice Example led one last year.',
|
|
'user: what did she invest in?',
|
|
]);
|
|
expect(out.join('')).toContain('people/alice-example');
|
|
});
|
|
|
|
test('session dedupe: a slug is volunteered at most once per session', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
|
|
const out = await watchRun([
|
|
'user: ping Alice Example',
|
|
'user: ok',
|
|
'user: Alice Example again please',
|
|
]);
|
|
const hits = out.join('').split('people/alice-example').length - 1;
|
|
expect(hits).toBe(1);
|
|
});
|
|
|
|
test('--json emits one JSONL row per volunteered page with turn attribution', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
|
|
const out = await watchRun(['user: hello there', 'user: ping Alice Example'], ['--json']);
|
|
expect(out.length).toBe(1);
|
|
const row = JSON.parse(out[0]);
|
|
expect(row.slug).toBe('people/alice-example');
|
|
expect(row.turn).toBe(2);
|
|
expect(row.arm).toBe('title');
|
|
expect(typeof row.confidence).toBe('number');
|
|
});
|
|
|
|
test('events land on channel watch with session_id + turn (drained sink)', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
|
|
await watchRun(['user: ping Alice Example']);
|
|
const { unfinished } = await awaitPendingVolunteerEventWrites(5_000);
|
|
expect(unfinished).toBe(0);
|
|
const rows = await engine.executeRaw<{ channel: string; session_id: string; turn: number }>(
|
|
`SELECT channel, session_id, turn FROM context_volunteer_events`, [],
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].channel).toBe('watch');
|
|
expect(rows[0].session_id).toMatch(/^watch-/);
|
|
expect(Number(rows[0].turn)).toBe(1);
|
|
});
|
|
|
|
test('min-confidence flag gates exactly like the op', async () => {
|
|
await seed('projects/widget-co', 'The Widget Company Project', 'A project.');
|
|
const gated = await watchRun(['user: updates on Widget-Co?']);
|
|
expect(gated.join('')).toBe('');
|
|
const loose = await watchRun(['user: updates on Widget-Co?'], ['--min-confidence', '0.5']);
|
|
expect(loose.join('')).toContain('projects/widget-co');
|
|
});
|
|
|
|
test('blank lines and CRLF are tolerated; no turn, no volunteer', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
|
|
const out = await watchRun(['', ' ', 'user: ping Alice Example\r']);
|
|
expect(out.join('')).toContain('people/alice-example');
|
|
});
|
|
});
|
|
|
|
describe('gbrain watch — window + cap flags (ship coverage G4)', () => {
|
|
test('--window-turns 1: fires on the mention turn itself; the pronoun follow-up adds nothing', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
|
|
const out = await watchRun(
|
|
[
|
|
'assistant: Alice Example led one last year.',
|
|
'user: what did she invest in?',
|
|
],
|
|
['--window-turns', '1', '--json'],
|
|
);
|
|
// Watch volunteers per turn: the entity fires at turn 1 (its mention
|
|
// turn). With window=1 the follow-up turn extracts nothing, so exactly
|
|
// one row exists and it carries turn 1 attribution.
|
|
expect(out.length).toBe(1);
|
|
expect(JSON.parse(out[0]).turn).toBe(1);
|
|
});
|
|
|
|
test('--max-pages 1 caps a multi-entity turn to one volunteered page', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Founder.');
|
|
await seed('people/bob-sample', 'Bob Sample', 'Engineer.');
|
|
const out = await watchRun(
|
|
['user: intro Alice Example to Bob Sample'],
|
|
['--max-pages', '1', '--json'],
|
|
);
|
|
expect(out.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('gbrain watch — per-turn fail-open (review hardening)', () => {
|
|
test('a transient DB error on one turn never kills the stream', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
|
|
// Fail the FIRST resolver query against pages (turn 1's resolution);
|
|
// everything else — incl. resolveSourceId's pre-loop check — passes.
|
|
let pagesQueries = 0;
|
|
const flaky = new Proxy(engine, {
|
|
get(target, prop, receiver) {
|
|
if (prop === 'executeRaw') {
|
|
return (sql: string, params: unknown[]) => {
|
|
if (/FROM pages/.test(sql) && ++pagesQueries === 1) {
|
|
return Promise.reject(new Error('transient db hiccup'));
|
|
}
|
|
return target.executeRaw(sql, params);
|
|
};
|
|
}
|
|
return Reflect.get(target, prop, receiver);
|
|
},
|
|
});
|
|
const out: string[] = [];
|
|
await runWatch(flaky as never, ['--source', 'default'], {
|
|
lines: feed(['user: ping Alice Example', 'user: ping Alice Example please']),
|
|
write: (s) => out.push(s),
|
|
isTTY: false,
|
|
});
|
|
// Turn 1 failed open; turn 2 volunteered. The stream survived.
|
|
expect(out.join('')).toContain('people/alice-example');
|
|
});
|
|
});
|