Files
gbrain/test/thin-client-routing-audit.test.ts
T
17b190e227 v0.33.0 feat: gbrain recall morning pulse + thin-client routing fix (9 commands) (#879)
* feat(engine): add countUnconsolidatedFacts to BrainEngine + both engines

New `BrainEngine.countUnconsolidatedFacts(sourceId): Promise<number>` returns
the count of active + unconsolidated facts for a source. Single SQL:
COUNT(*) WHERE source_id = $1 AND consolidated_at IS NULL AND expired_at IS NULL.

Backs the v0.33 `gbrain recall --pending` flag and the `recall` MCP op's new
`include_pending` param. Source-scoped, no index needed (existing
facts(source_id) index covers the predicate).

* feat(recall): cursor state + recall rewrite + thin-client routing + watch loop

`gbrain recall` gains four new flags backed by a new cursor-state file:

- `--since-last-run` reads ~/.gbrain/recall-cursors/<source>.json. First run
  defaults to 24h. Cursor is T_start (captured BEFORE the read SQL), not
  T_finish, so facts inserted during render don't fall in a black hole
  (Codex round 1 #2).
- `--pending` appends a "Pending consolidation: N" footer. Backed by the
  new engine method; remote round-trips through one MCP call via the
  recall op's new `include_pending` param.
- `--rollup` prepends a "Top mentions" header — top-5 entities by fact
  count over the FULL result set, not a LIMIT slice (Codex round 1 #8).
  JSON shape `top_entities: [{entity_slug, count}]` matches the existing
  pinned key at test/facts-doctor-shape.test.ts:49.
- `--watch [SECONDS]` re-runs on interval. Default 60, range [1, 3600].
  TTY: clear-and-redraw. Non-TTY: plain `--- <ts> ---` delimited blocks.
  SIGINT-only clean exit. Per-tick try/catch + exponential backoff
  `min(SECONDS × 2^(N-1), 5×SECONDS)`; exit after 5 consecutive failures
  with briefing cursor NOT advanced. Watch uses a separate cursor file
  (<source>.watch.json) so operator quitting watch doesn't clobber the
  standalone briefing cursor (Codex round 2 #8).

Thin-client routing: runRecall + runForget mirror the salience.ts:80
pattern. On `gbrain init --mcp-only` installs the local engine call is
swapped for callRemoteTool('recall' | 'forget_fact', ...). The local
canonical source resolver's assertSourceExists check is skipped on
thin-client (empty local sources table); the kebab-case SOURCE_ID_RE
syntactic gate still runs locally. Fixes pre-existing silent-empty-results
on thin-client recall — the v0.31.1 wave missed it (Codex round 2 #6).

`recall` MCP op extended with optional `include_pending` param +
`pending_consolidation_count` output field. Backward-compatible.
No new MCP op. No schema migration.

State file uses atomic write via unique per-call tmp filename
(<source>.json.tmp.<pid>.<random>) + rename(2) (Codex round 1 #7).
Read returns null on missing/corrupt/future-shifted timestamps; caller
falls back to 24h.

* feat(thin-client): route jobs list/get + REFUSE 7 host-bound commands

Continues the v0.31.1 thin-client routing wave. v0.33 audit (Codex round 2
#4) source-grounded against operations.ts + each command file:

ROUTE additions (have MCP ops, mirror salience.ts:80 pattern):
- `gbrain jobs list` → callRemoteTool('list_jobs', ...)
- `gbrain jobs get <id>` → callRemoteTool('get_job', ...)
  Other jobs subcommands (submit, cancel, retry, work, supervisor, prune,
  stats, smoke) stay host-bound — they manage local queue state.

REFUSE additions to cli.ts THIN_CLIENT_REFUSED_COMMANDS + matching hints
in THIN_CLIENT_REFUSE_HINTS:
- `pages` — purge-deleted is admin+localOnly (operations.ts:856-864)
- `files` — file_list / file_url MCP ops are localOnly:true
- `eval` — export/prune/replay touch local engine; no MCP equivalent
- `code-def` / `code-refs` / `code-callers` / `code-callees` — NO MCP ops
  exist for symbol lookup in operations.ts:2630-2671; deferred as a v0.34
  candidate to add them

Each refuse hint names the host-side path the user should use instead.
Closes the silent-wrong-brain bug class for 9 commands total (recall +
forget routing landed in the prior commit).

* test: cover v0.33 recall extensions + thin-client routing audit (45 cases)

Three new test files pinning the v0.33 behavior + critical regression
guards from both Codex review rounds:

- test/recall-extensions.test.ts (17 cases, PGLite-backed). Covers
  countUnconsolidatedFacts SQL semantics (ignores expired, ignores
  consolidated, source-scoped, returns 0 on empty), cursor state file
  round-trip + corrupt/future fallback + briefing vs watch separation
  (Codex round 2 #8 regression guard) + atomic write tmp suffix
  (Codex round 1 #7 regression guard) + non-fatal write failures.
  Uses withEnv() for GBRAIN_HOME isolation per check-test-isolation.sh R1.

- test/recall-rollup.test.ts (8 pure-function cases). CRITICAL
  regression guards for Codex round 1 #8:
    1. Top-K computed over the FULL FactRow[], not a LIMIT-100 slice
       (seeded with 150 facts to prove full-window math)
    2. JSON shape pinned to `{entity_slug, count}` matching
       test/facts-doctor-shape.test.ts:49 (the existing shape pin)
    3. null entity_slug skipped, NOT bucketed as "(no entity)"
    4. Ties broken alphabetically for stable output

- test/thin-client-routing-audit.test.ts (20 source-grounded cases).
  Pins every v0.33 REFUSE addition in THIN_CLIENT_REFUSED_COMMANDS +
  every matching hint in THIN_CLIENT_REFUSE_HINTS + every v0.31.1-era
  original (no accidental removals). Pins every ROUTE addition's
  callRemoteTool import + call site in recall.ts and jobs.ts. Catches
  the audit-table regression mode that motivated the v0.31.1 wave
  originally.

Net: 45 new test cases. All pass green against the v0.33 implementation.

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

v0.33.0 — agent integration: gbrain recall morning pulse + thin-client routing fix.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 23:20:17 -07:00

130 lines
5.6 KiB
TypeScript

/**
* v0.32 — thin-client routing audit regression guard.
*
* The v0.32 audit (eng-review D3 + Codex round 2 #4) classified every
* `case '...'` in src/cli.ts's dispatch switch as one of:
* - already routed (4 commands)
* - already refused (14 commands)
* - CLI-local (24 commands)
* - route fix added (recall, forget, jobs list/get)
* - REFUSE added (7 commands: pages, files, eval, code-def, code-refs,
* code-callers, code-callees)
*
* This test pins the REFUSE additions. A future refactor that drops one of
* these from THIN_CLIENT_REFUSED_COMMANDS would silently re-introduce the
* silent-empty-results bug class v0.31.1 was fixing.
*
* The deeper transport-mock routing tests (recall/forget/jobs actually call
* callRemoteTool with the right params) live in a serial test follow-up; the
* structural invariants pinned here catch the most common regression mode.
*/
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const CLI_TS_PATH = join(import.meta.dir, '..', 'src', 'cli.ts');
const CLI_SOURCE = readFileSync(CLI_TS_PATH, 'utf8');
// Codex round 2 #4 + audit table: every member of this list must be in
// THIN_CLIENT_REFUSED_COMMANDS and have a hint in THIN_CLIENT_REFUSE_HINTS.
// Justifications:
// - pages: pages.purge-deleted is admin+localOnly (operations.ts:856-864)
// - files: file_list + file_url MCP ops are localOnly:true
// - eval: export/prune/replay touch local engine; no MCP equivalent
// - code-def / code-refs / code-callers / code-callees: NO MCP ops exist
const V032_REFUSE_ADDITIONS = [
'pages', 'files', 'eval',
'code-def', 'code-refs', 'code-callers', 'code-callees',
];
describe('thin-client routing audit — v0.32 REFUSE additions stay in the table', () => {
test('THIN_CLIENT_REFUSED_COMMANDS set declaration is intact', () => {
expect(CLI_SOURCE).toContain('const THIN_CLIENT_REFUSED_COMMANDS = new Set([');
});
for (const command of V032_REFUSE_ADDITIONS) {
test(`'${command}' is in THIN_CLIENT_REFUSED_COMMANDS`, () => {
// We look for the literal in the set declaration. The set is plain
// text in src/cli.ts so a simple string check is honest: a future
// refactor that drops the entry would also drop the literal.
const setStart = CLI_SOURCE.indexOf('const THIN_CLIENT_REFUSED_COMMANDS = new Set([');
const setEnd = CLI_SOURCE.indexOf(']);', setStart);
const setBlock = CLI_SOURCE.slice(setStart, setEnd);
expect(setBlock).toContain(`'${command}'`);
});
test(`'${command}' has a hint in THIN_CLIENT_REFUSE_HINTS`, () => {
const hintsStart = CLI_SOURCE.indexOf(
'const THIN_CLIENT_REFUSE_HINTS: Record<string, string> = {',
);
const hintsEnd = CLI_SOURCE.indexOf('};', hintsStart);
const hintsBlock = CLI_SOURCE.slice(hintsStart, hintsEnd);
// Hint keys with embedded dashes are quoted (`'code-def':`); others
// can be bare (`pages:`). Accept either shape.
const bareKey = new RegExp(`\\b${command.replace(/-/g, '\\-')}\\s*:`);
const quotedKey = new RegExp(`['"]${command.replace(/-/g, '\\-')}['"]\\s*:`);
expect(bareKey.test(hintsBlock) || quotedKey.test(hintsBlock)).toBe(true);
});
}
test('every v0.31.1-era REFUSED command is still in the set (no accidental removals)', () => {
const setStart = CLI_SOURCE.indexOf('const THIN_CLIENT_REFUSED_COMMANDS = new Set([');
const setEnd = CLI_SOURCE.indexOf(']);', setStart);
const setBlock = CLI_SOURCE.slice(setStart, setEnd);
const v0_31_originals = [
'sync', 'embed', 'extract', 'migrate', 'apply-migrations',
'repair-jsonb', 'orphans', 'integrity', 'serve',
'dream', 'transcripts', 'storage', 'takes', 'sources',
];
for (const cmd of v0_31_originals) {
expect(setBlock).toContain(`'${cmd}'`);
}
});
});
describe('thin-client routing audit — v0.32 ROUTE additions wire callRemoteTool', () => {
// The route additions are: recall, forget (in recall.ts) + jobs list / get
// (in jobs.ts). Each file must import callRemoteTool from mcp-client AND
// call it at least once. If a future refactor removes the import without
// removing the routing path, the call would fail at runtime — easier to
// catch at the source-string level.
test('src/commands/recall.ts imports callRemoteTool + isThinClient', () => {
const src = readFileSync(
join(import.meta.dir, '..', 'src', 'commands', 'recall.ts'),
'utf8',
);
expect(src).toContain(`from '../core/config.ts'`);
expect(src).toContain('isThinClient');
expect(src).toContain(`from '../core/mcp-client.ts'`);
expect(src).toContain('callRemoteTool');
});
test('src/commands/recall.ts: recall routing branch calls callRemoteTool with op="recall"', () => {
const src = readFileSync(
join(import.meta.dir, '..', 'src', 'commands', 'recall.ts'),
'utf8',
);
expect(src).toContain(`callRemoteTool(cfg!, 'recall'`);
});
test('src/commands/recall.ts: forget routing branch calls callRemoteTool with op="forget_fact"', () => {
const src = readFileSync(
join(import.meta.dir, '..', 'src', 'commands', 'recall.ts'),
'utf8',
);
expect(src).toContain(`callRemoteTool(cfg!, 'forget_fact'`);
});
test('src/commands/jobs.ts: list/get routing branches call callRemoteTool', () => {
const src = readFileSync(
join(import.meta.dir, '..', 'src', 'commands', 'jobs.ts'),
'utf8',
);
expect(src).toContain(`from '../core/mcp-client.ts'`);
expect(src).toContain(`callRemoteTool(cfg!, 'list_jobs'`);
expect(src).toContain(`callRemoteTool(cfg!, 'get_job'`);
});
});