mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c275fa3fab | ||
|
|
ee095f1ca2 | ||
|
|
5f123c1404 | ||
|
|
948ccc7b4f |
+43
-1
@@ -54,7 +54,7 @@ export function bigintToStringReplacer(_key: string, value: unknown): unknown {
|
||||
}
|
||||
|
||||
// CLI-only commands that bypass the operation layer
|
||||
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'maintain', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']);
|
||||
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'bench', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'maintain', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']);
|
||||
// CLI-only commands whose handlers print their own --help text. These are
|
||||
// excluded from the generic short-circuit so detailed per-command and
|
||||
// per-subcommand usage stays reachable.
|
||||
@@ -106,6 +106,9 @@ const CLI_ONLY_SELF_HELP = new Set([
|
||||
// `gbrain connect --help` prints its own usage (flags + examples) from
|
||||
// runConnect; route around the generic one-line short-circuit.
|
||||
'connect',
|
||||
// #1474: bench-publish ships its own detailed HELP (flags, exit codes,
|
||||
// the export → publish → gate loop). Route around the generic stub.
|
||||
'bench',
|
||||
]);
|
||||
|
||||
// v114 (#1941): alias -> operation lookup, kept separate from `cliOps` so
|
||||
@@ -1429,6 +1432,29 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// #1474: `gbrain bench publish` is pure file I/O (reads a captured
|
||||
// eval-candidates NDJSON from `gbrain eval export`, writes a baseline
|
||||
// NDJSON). No DB access; bypass connectEngine entirely so the documented
|
||||
// export → publish → gate loop works on machines without a brain.
|
||||
// The v0.41.1 wave shipped bench-publish.ts + docs/eval-bench.md but this
|
||||
// dispatcher case was never added, so the command hit 'Unknown command'.
|
||||
if (command === 'bench') {
|
||||
if (args[0] === 'publish') {
|
||||
const { runBenchPublish } = await import('./commands/bench-publish.ts');
|
||||
await runBenchPublish(args.slice(1));
|
||||
return;
|
||||
}
|
||||
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
||||
const { runBenchPublish } = await import('./commands/bench-publish.ts');
|
||||
await runBenchPublish(['--help']);
|
||||
return;
|
||||
}
|
||||
console.error(`Unknown bench subcommand: ${args[0]}`);
|
||||
console.error('Usage: gbrain bench publish --from <captured.ndjson> --to <baseline.ndjson> [flags]');
|
||||
console.error(' See docs/eval-bench.md for the full loop: eval export → bench publish → eval gate');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// v0.42.x (#2390): `gbrain eval chronicle` is deterministic — brings its own
|
||||
// in-memory PGLite, no DB/gateway. CI fixture gate runs anywhere.
|
||||
if (command === 'eval' && args[0] === 'chronicle') {
|
||||
@@ -2224,6 +2250,22 @@ async function connectEngine(opts?: { probeOnly?: boolean }): Promise<BrainEngin
|
||||
if (merged.embedding_image_ocr_model !== undefined) {
|
||||
process.env.GBRAIN_EMBEDDING_IMAGE_OCR_MODEL = merged.embedding_image_ocr_model;
|
||||
}
|
||||
// #1475: stash the merged eval.* flags the same way. The capture gate
|
||||
// (isEvalCaptureEnabled / isEvalScrubEnabled) runs against ctx.config,
|
||||
// which is built from the sync file-plane loadConfig() in both the CLI
|
||||
// op path and MCP dispatch — it never sees the DB plane directly. The
|
||||
// gates consult this stash when the file plane is silent, so
|
||||
// `gbrain config set eval.capture true` actually turns capture on.
|
||||
// A pre-set env value wins over the DB plane (env-above-config, the
|
||||
// incident escape hatch) — unlike GBRAIN_EMBEDDING_MULTIMODAL these
|
||||
// keys have no loadConfig() env mapping, so without this guard the
|
||||
// DB stash would silently clobber an operator's export.
|
||||
if (process.env.GBRAIN_EVAL_CAPTURE === undefined && merged.eval?.capture !== undefined) {
|
||||
process.env.GBRAIN_EVAL_CAPTURE = String(merged.eval.capture);
|
||||
}
|
||||
if (process.env.GBRAIN_EVAL_SCRUB_PII === undefined && merged.eval?.scrub_pii !== undefined) {
|
||||
process.env.GBRAIN_EVAL_SCRUB_PII = String(merged.eval.scrub_pii);
|
||||
}
|
||||
// Always re-configure with merged values when DB merge succeeded. The
|
||||
// trigger used to be field-name-gated (only when embedding_multimodal_model
|
||||
// was set); that coupled the gate to the field set and would silently
|
||||
|
||||
@@ -816,6 +816,25 @@ export async function loadConfigWithEngine(
|
||||
merged.dream = mergedDream;
|
||||
}
|
||||
|
||||
// #1475: eval.* DB-plane merge. `gbrain config set eval.capture true`
|
||||
// writes the DB plane (both keys are in KNOWN_CONFIG_KEYS, so `set`
|
||||
// accepts them silently), but the capture gate (isEvalCaptureEnabled)
|
||||
// reads the merged config. Without this merge the DB value was written
|
||||
// and never read — capture only fired via GBRAIN_CONTRIBUTOR_MODE=1.
|
||||
// Sparse per-key merge: file/env wins per key, DB fills the gaps.
|
||||
const dbEvalCapture = await dbBool('eval.capture');
|
||||
const dbEvalScrub = await dbBool('eval.scrub_pii');
|
||||
const mergedEval: NonNullable<GBrainConfig['eval']> = { ...(merged.eval ?? {}) };
|
||||
if (mergedEval.capture === undefined && dbEvalCapture !== undefined) {
|
||||
mergedEval.capture = dbEvalCapture;
|
||||
}
|
||||
if (mergedEval.scrub_pii === undefined && dbEvalScrub !== undefined) {
|
||||
mergedEval.scrub_pii = dbEvalScrub;
|
||||
}
|
||||
if (Object.keys(mergedEval).length > 0) {
|
||||
merged.eval = mergedEval;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +251,14 @@ registerBackgroundWorkDrainer({
|
||||
export function isEvalCaptureEnabled(config: GBrainConfig | null | undefined): boolean {
|
||||
if (config?.eval?.capture === true) return true;
|
||||
if (config?.eval?.capture === false) return false;
|
||||
// #1475: DB-plane stash. `gbrain config set eval.capture true` lands in the
|
||||
// config table; connectEngine stamps the merged value here because
|
||||
// ctx.config is the sync file-plane load and never sees the DB plane.
|
||||
// Explicit per-key setting (file above, DB here) beats the broad
|
||||
// CONTRIBUTOR_MODE flag, matching how file-plane `false` already wins.
|
||||
// Doubles as a direct operator env knob.
|
||||
if (process.env.GBRAIN_EVAL_CAPTURE === 'true') return true;
|
||||
if (process.env.GBRAIN_EVAL_CAPTURE === 'false') return false;
|
||||
return process.env.GBRAIN_CONTRIBUTOR_MODE === '1';
|
||||
}
|
||||
|
||||
@@ -263,5 +271,8 @@ export function isEvalCaptureEnabled(config: GBrainConfig | null | undefined): b
|
||||
* have explicit `capture: true`.
|
||||
*/
|
||||
export function isEvalScrubEnabled(config: GBrainConfig | null | undefined): boolean {
|
||||
return config?.eval?.scrub_pii !== false;
|
||||
if (config?.eval?.scrub_pii === false) return false;
|
||||
if (config?.eval?.scrub_pii === true) return true;
|
||||
// #1475: DB-plane stash — see isEvalCaptureEnabled. Default stays true.
|
||||
return process.env.GBRAIN_EVAL_SCRUB_PII !== 'false';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// #1474: the v0.41.1 wave shipped bench-publish.ts + docs/eval-bench.md
|
||||
// advertising `gbrain bench publish`, but the cli.ts dispatcher case was never
|
||||
// added — the documented command hit 'Unknown command'. These tests spawn the
|
||||
// real CLI (no DB needed; bench publish is pure file I/O) and fail on any
|
||||
// regression of the dispatcher wiring.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
function runCli(args: string[]): { stdout: string; stderr: string; code: number } {
|
||||
const result = spawnSync(process.execPath, ['run', 'src/cli.ts', 'bench', ...args], {
|
||||
encoding: 'utf8',
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env },
|
||||
});
|
||||
return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', code: result.status ?? -1 };
|
||||
}
|
||||
|
||||
describe('gbrain bench dispatcher (#1474)', () => {
|
||||
test('bench --help reaches bench-publish help without a DB (was: Unknown command)', () => {
|
||||
const { stdout, stderr, code } = runCli(['--help']);
|
||||
expect(stderr).not.toContain('Unknown command');
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('gbrain bench publish');
|
||||
expect(stdout).toContain('--from');
|
||||
});
|
||||
|
||||
test('unknown bench subcommand exits 2 with usage', () => {
|
||||
const { stderr, code } = runCli(['bogus']);
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('Unknown bench subcommand');
|
||||
expect(stderr).toContain('bench publish');
|
||||
});
|
||||
|
||||
test('bench publish roundtrip: captured NDJSON in, baseline file out', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'bench-cli-'));
|
||||
try {
|
||||
const row = {
|
||||
tool_name: 'query',
|
||||
query: 'hello world',
|
||||
retrieved_slugs: ['slug-a'],
|
||||
retrieved_chunk_ids: [1],
|
||||
source_ids: ['default'],
|
||||
expand_enabled: false,
|
||||
detail: 'medium',
|
||||
detail_resolved: 'medium',
|
||||
vector_enabled: true,
|
||||
expansion_applied: false,
|
||||
latency_ms: 100,
|
||||
remote: false,
|
||||
job_id: null,
|
||||
subagent_id: null,
|
||||
};
|
||||
const from = join(tmp, 'captured.ndjson');
|
||||
const to = join(tmp, 'personal.baseline.ndjson');
|
||||
writeFileSync(from, `${JSON.stringify(row)}\n`);
|
||||
const { code, stderr } = runCli(['publish', '--from', from, '--to', to]);
|
||||
expect(stderr).not.toContain('Unknown command');
|
||||
expect(code).toBe(0);
|
||||
expect(existsSync(to)).toBe(true);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -309,3 +309,62 @@ describe('isEvalCaptureEnabled / isEvalScrubEnabled (CONTRIBUTOR_MODE-gated)', (
|
||||
} finally { restore(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('DB-plane stash (#1475): GBRAIN_EVAL_CAPTURE / GBRAIN_EVAL_SCRUB_PII', () => {
|
||||
// connectEngine stamps `gbrain config set eval.capture` (DB plane) onto
|
||||
// these env vars because ctx.config is the sync file-plane load. Without
|
||||
// the stash check the DB value was written and never read.
|
||||
const origCapture = process.env.GBRAIN_EVAL_CAPTURE;
|
||||
const origScrub = process.env.GBRAIN_EVAL_SCRUB_PII;
|
||||
const origMode = process.env.GBRAIN_CONTRIBUTOR_MODE;
|
||||
const restore = () => {
|
||||
if (origCapture === undefined) delete process.env.GBRAIN_EVAL_CAPTURE;
|
||||
else process.env.GBRAIN_EVAL_CAPTURE = origCapture;
|
||||
if (origScrub === undefined) delete process.env.GBRAIN_EVAL_SCRUB_PII;
|
||||
else process.env.GBRAIN_EVAL_SCRUB_PII = origScrub;
|
||||
if (origMode === undefined) delete process.env.GBRAIN_CONTRIBUTOR_MODE;
|
||||
else process.env.GBRAIN_CONTRIBUTOR_MODE = origMode;
|
||||
};
|
||||
|
||||
test('stash=true turns capture on when file plane is silent (the #1475 repro)', () => {
|
||||
delete process.env.GBRAIN_CONTRIBUTOR_MODE;
|
||||
process.env.GBRAIN_EVAL_CAPTURE = 'true';
|
||||
try {
|
||||
expect(isEvalCaptureEnabled(null)).toBe(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const noEval: any = { engine: 'pglite' };
|
||||
expect(isEvalCaptureEnabled(noEval)).toBe(true);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('stash=false wins over CONTRIBUTOR_MODE=1 (explicit per-key beats broad flag)', () => {
|
||||
process.env.GBRAIN_CONTRIBUTOR_MODE = '1';
|
||||
process.env.GBRAIN_EVAL_CAPTURE = 'false';
|
||||
try {
|
||||
expect(isEvalCaptureEnabled(null)).toBe(false);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('file-plane explicit value still wins over the stash', () => {
|
||||
process.env.GBRAIN_EVAL_CAPTURE = 'true';
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const disabled: any = { engine: 'pglite', eval: { capture: false } };
|
||||
expect(isEvalCaptureEnabled(disabled)).toBe(false);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('scrub stash: false disables, file plane wins, default stays true', () => {
|
||||
process.env.GBRAIN_EVAL_SCRUB_PII = 'false';
|
||||
try {
|
||||
expect(isEvalScrubEnabled(null)).toBe(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fileWins: any = { engine: 'pglite', eval: { scrub_pii: true } };
|
||||
expect(isEvalScrubEnabled(fileWins)).toBe(true);
|
||||
} finally { restore(); }
|
||||
delete process.env.GBRAIN_EVAL_SCRUB_PII;
|
||||
try {
|
||||
expect(isEvalScrubEnabled(null)).toBe(true);
|
||||
} finally { restore(); }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -302,4 +302,29 @@ describe('loadConfigWithEngine (Phase 4 / F3)', () => {
|
||||
expect(merged?.engine).toBe('pglite');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eval.* DB-plane merge (#1475)', () => {
|
||||
test('gbrain config set eval.capture true reaches the merged config', async () => {
|
||||
// The #1475 repro: DB plane has eval.capture=true, file plane silent.
|
||||
// Pre-fix the merge skipped eval.* entirely and capture never fired.
|
||||
const base: GBrainConfig = { engine: 'pglite' };
|
||||
const engine = makeEngine({ 'eval.capture': 'true', 'eval.scrub_pii': 'false' });
|
||||
const merged = await loadConfigWithEngine(engine, base);
|
||||
expect(merged?.eval?.capture).toBe(true);
|
||||
expect(merged?.eval?.scrub_pii).toBe(false);
|
||||
});
|
||||
|
||||
test('file plane wins per key; DB fills only the gaps', async () => {
|
||||
const base: GBrainConfig = { engine: 'pglite', eval: { capture: false } };
|
||||
const engine = makeEngine({ 'eval.capture': 'true', 'eval.scrub_pii': 'false' });
|
||||
const merged = await loadConfigWithEngine(engine, base);
|
||||
expect(merged?.eval?.capture).toBe(false); // file wins
|
||||
expect(merged?.eval?.scrub_pii).toBe(false); // DB fills the gap
|
||||
});
|
||||
|
||||
test('no eval keys anywhere leaves cfg.eval undefined', async () => {
|
||||
const merged = await loadConfigWithEngine(makeEngine({}), { engine: 'pglite' });
|
||||
expect(merged?.eval).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,15 +218,21 @@ describe('progress reporter', () => {
|
||||
test('only one process-level signal handler installed across many reporters', () => {
|
||||
// Baseline: one handler already installed by prior tests in this file.
|
||||
const installedBefore = __signalHandlerInstalledForTest();
|
||||
// liveReporters is process-global: earlier test files in the same shard
|
||||
// can leave a live entry behind (e.g. a production path that skips
|
||||
// finish() on an error branch). Assert NET-zero leak from THIS test's
|
||||
// lifecycles, not an absolute zero we don't control — same tolerance
|
||||
// the handler assertion below already applies via `installedBefore`.
|
||||
const liveBefore = __liveReporterCountForTest();
|
||||
const { stream } = sink(false);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const p = createProgress({ mode: 'json', stream, minIntervalMs: 0, minItems: 1 });
|
||||
p.start(`phase_${i}`, 1);
|
||||
p.finish();
|
||||
}
|
||||
// After 50 reporter lifecycles, still exactly one handler and zero leaked live entries.
|
||||
// After 50 reporter lifecycles, still exactly one handler and zero NEWLY leaked live entries.
|
||||
expect(__signalHandlerInstalledForTest()).toBe(installedBefore || true);
|
||||
expect(__liveReporterCountForTest()).toBe(0);
|
||||
expect(__liveReporterCountForTest()).toBe(liveBefore);
|
||||
});
|
||||
|
||||
test('startHeartbeat() fires heartbeats and stop() clears', async () => {
|
||||
|
||||
Reference in New Issue
Block a user