mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 21:19:18 +00:00
* feat(core): shared computeSyncDelta + spend-posture module (#2139) sync-delta.ts: ONE implementation of "what changed since last_commit", consumed by both the sync executor and the inline cost estimator so the gate's dollar figure can't drift from what the sync imports. spend-posture.ts: spend.posture config + parseUsdLimit/formatUsdLimit off-switch parsing (off/unlimited/none → Infinity; undefined at the budget boundary so ledger rows never serialize null). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): delta-aware cost estimator + non-TTY auto-defer + per-source failure acks (#2139) The inline-embed cost gate was a ~400x phantom: it priced the entire tree whenever the working tree was dirty (always, on an active brain), then blocked the daily cron with exit 2. Now: - performSyncInner + the estimator both route through computeSyncDelta, so the estimate mirrors execution (fetch-first delta; dirty-but-caught-up tree → $0). - shouldBlockSync is posture-aware; non-TTY above floor AUTO-DEFERS embeds to capped backfill jobs (exit 0) instead of wedging — single shared runInlineCostGate on both --all and single-source paths. - --full prices delta + stale backlog (full sync sweeps it inline). - off/unlimited on the cost knobs; tokenmax bypasses the backfill cap (still ledgered) but never the cooldown. - --skip-failed/--retry-failed scoped per source; the D15 parallel refusal is lifted (the #1939 ledger is per-source + lock-serialized). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(config): register spend-control keys + validate spend.posture (#2139) Adds spend.posture + the five previously --force-only spend knobs to KNOWN_CONFIG_KEYS so `config set` accepts them directly (removes the archaeology the issue complained about), and rejects invalid spend.posture values at set time with a paste-ready hint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reindex,enrich,onboard): spend.posture across the remaining cost gates (#2139) reindex-code: tokenmax makes the cost gate informational; --max-cost accepts off/unlimited. enrich + onboard --auto: tokenmax lifts the refuse-without-cap guardrail and runs UNCAPPED (spend still ledgered by BudgetTracker). Explicit --max-usd always wins over posture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: cost-gate, delta estimator, spend-posture, off-switch coverage (#2139) New sync-delta + sync-cost-estimate unit suites; rewritten cost-gate serial tests (auto-defer instead of exit 2, posture, off-switch, format split, single-source); parseUsdLimit/posture-aware shouldBlockSync; backfill cap-off + tokenmax-bypass + cooldown-still-refuses; config known-key acceptance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(spend-controls): single spend-control surface + ref-map + follow-up TODOs (#2139) New docs/operations/spend-controls.md (every gate, key, default, off switch, posture interaction); CLAUDE.md reference-map row; two P3 follow-up TODOs (measured chunk-count gating, per-source defer granularity). llms bundles regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(spend): SSRF-harden estimator fetch + complete off/uncapped across reindex/enrich/onboard (#2139) Ship-stage codex pre-landing review caught four P1s in the secondary cost gates: - The delta estimator's fetch-first ran `git fetch` through the plain git() helper, bypassing the GIT_SSRF_FLAGS + GIT_TERMINAL_PROMPT=0 hardening that real sync uses. Added `fetchRemote()` to git-remote.ts (same flags as pullRepo) and route the estimator through it — a cost preview / dry-run can no longer hit a remote through a less-protected path. - `reindex --max-cost off`, `enrich --max-usd off`, `onboard --auto --max-usd off` were parsed but didn't actually proceed/uncap. Now: explicit off (and spend.posture=tokenmax) proceed past the confirmation/missing-cap refusal AND run uncapped. enrich threads an Infinity sentinel mapped to "no BudgetTracker ceiling" (never raw Infinity → no null in audit rows); reindex/onboard use their native undefined=uncapped path. Spend still ledgered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.42.45.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(KEY_FILES): update sync/embedding/git-remote/reindex entries to post-#2139 truth document-release pass: the cost-gate entries described the pre-#2139 behavior (full-tree-ceiling estimator, --skip-failed-rejects-under-parallel, exit-2 confirmation gate). Updated to current truth — delta-aware estimator via the shared computeSyncDelta, per-source failure acks under parallel, non-TTY auto-defer (no exit 2), posture-aware shouldBlockSync. Added entries for the two new core modules (sync-delta.ts, spend-posture.ts) + fetchRemote on git-remote.ts + reindex --max-cost off. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
248 lines
12 KiB
TypeScript
248 lines
12 KiB
TypeScript
/**
|
|
* `gbrain sync` cost-gate wiring regressions (PGLite).
|
|
*
|
|
* Pure shouldBlockSync / willEmbedSynchronously / parseUsdLimit logic is pinned
|
|
* in test/sync-cost-preview.test.ts. THIS file pins the end-to-end wiring in
|
|
* runSync's --all AND single-source paths:
|
|
*
|
|
* R-1 (headline): deferred-embed sync --all, non-TTY, with backlog →
|
|
* emits gate:'deferred_notice' and NEVER exit 2.
|
|
* R-2 (v0.42.42.0, #2139): inline sync --all (--serial), non-TTY, above floor
|
|
* → AUTO-DEFERS (exit 0, gate:'auto_deferred_embeds') and enqueues an
|
|
* embed-backfill job. The exit-2 wedge is gone.
|
|
* R-3: chunker drift → full-tree CEILING estimate, auto-defers (not exit 2).
|
|
* + posture tokenmax, off-switch, format split (#1784/D3A), single-source gate.
|
|
*
|
|
* Serial-quarantined: stubs process.exit + console.log (process-global).
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
|
import { execSync } from 'child_process';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { runSources } from '../src/commands/sources.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
import { configureGateway, resetGateway, __setEmbedTransportForTests } from '../src/core/ai/gateway.ts';
|
|
import { CHUNKER_VERSION } from '../src/core/chunkers/code.ts';
|
|
import type { ChunkInput } from '../src/core/types.ts';
|
|
|
|
/** Offline embed stub so inline-proceed paths (posture tokenmax) don't network. */
|
|
function stubOfflineEmbed(): void {
|
|
__setEmbedTransportForTests(async ({ values }: any) => ({
|
|
embeddings: values.map(() => new Array(1536).fill(0)),
|
|
usage: { tokens: 0 },
|
|
}) as any);
|
|
}
|
|
|
|
let engine: PGLiteEngine;
|
|
let repoPath: string;
|
|
let headSha: string;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
if (engine) await engine.disconnect();
|
|
}, 60_000);
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
// Configure the gateway with a dummy key so the pre-gate embedding-creds
|
|
// preflight passes (diagnoseEmbedding reads gateway configure-time state,
|
|
// not live env). The gate runs before any real embed call, so no network
|
|
// request is made.
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { OPENAI_API_KEY: 'sk-test-costgate' },
|
|
});
|
|
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-costgate-'));
|
|
execSync('git init', { cwd: repoPath, stdio: 'pipe' });
|
|
execSync('git config user.email "t@t.com"', { cwd: repoPath, stdio: 'pipe' });
|
|
execSync('git config user.name "T"', { cwd: repoPath, stdio: 'pipe' });
|
|
mkdirSync(join(repoPath, 'topics'), { recursive: true });
|
|
writeFileSync(
|
|
join(repoPath, 'topics/foo.md'),
|
|
['---', 'type: concept', 'title: Foo', '---', '', 'some body content to estimate.'].join('\n'),
|
|
);
|
|
execSync('git add -A && git commit -m initial', { cwd: repoPath, stdio: 'pipe' });
|
|
headSha = execSync('git rev-parse HEAD', { cwd: repoPath, stdio: 'pipe' }).toString().trim();
|
|
});
|
|
|
|
afterEach(() => {
|
|
__setEmbedTransportForTests(null);
|
|
resetGateway();
|
|
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
|
});
|
|
|
|
/** Run runSync(args) with process.exit + console.log captured. */
|
|
async function runSyncCaptured(args: string[]): Promise<{ exitCode: number | undefined; stdout: string }> {
|
|
const { runSync } = await import('../src/commands/sync.ts');
|
|
const origExit = process.exit;
|
|
const origLog = console.log.bind(console);
|
|
const out: string[] = [];
|
|
let exitCode: number | undefined;
|
|
console.log = (...a: unknown[]) => {
|
|
out.push(a.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join(' '));
|
|
};
|
|
process.exit = ((code?: number) => {
|
|
exitCode = code;
|
|
throw new Error('__exit__');
|
|
}) as typeof process.exit;
|
|
try {
|
|
await runSync(engine, args);
|
|
} catch (e) {
|
|
if ((e as Error).message !== '__exit__') throw e;
|
|
} finally {
|
|
process.exit = origExit;
|
|
console.log = origLog;
|
|
}
|
|
return { exitCode, stdout: out.join('\n') };
|
|
}
|
|
|
|
describe('v0.41.31 — sync --all cost gate wiring', () => {
|
|
test('R-1: deferred sync --all (non-TTY) emits deferred_notice and never exit 2', async () => {
|
|
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
|
|
// Make the fan-out a clean no-op: last_commit == HEAD so performSync
|
|
// reports up_to_date (no git pull, no backfill submit).
|
|
await engine.executeRaw(`UPDATE sources SET last_commit = $1 WHERE id = 'vault'`, [headSha]);
|
|
// Seed a stale backlog so the deferred notice has a non-zero figure.
|
|
await engine.putPage('vault/note', { type: 'note', title: 'note', compiled_truth: '# note' }, { sourceId: 'vault' });
|
|
const chunks: ChunkInput[] = [
|
|
{ chunk_index: 0, chunk_text: 'x'.repeat(500), chunk_source: 'compiled_truth', token_count: 4, embedding: undefined },
|
|
];
|
|
await engine.upsertChunks('vault/note', chunks, { sourceId: 'vault' });
|
|
|
|
// v2 default ON → deferred. --json → agent path. NO --yes. --no-pull
|
|
// because the synthetic repo has no remote.
|
|
const { exitCode, stdout } = await runSyncCaptured(['--all', '--json', '--no-pull']);
|
|
|
|
// The headline assertion: NOT blocked.
|
|
expect(exitCode).not.toBe(2);
|
|
expect(stdout).toContain('"gate":"deferred_notice"');
|
|
}, 60_000);
|
|
|
|
test('R-2 (#2139): inline sync --all (--serial) above floor AUTO-DEFERS (exit 0, never exit 2) + enqueues backfill', async () => {
|
|
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
|
|
// Floor 0 → any nonzero inline cost trips the gate. Source is unsynced
|
|
// (last_commit NULL) → first-sync ceiling > 0 > floor.
|
|
await engine.setConfig('sync.cost_gate_min_usd', '0');
|
|
|
|
// --serial forces inline even with v2 on. --json → non-TTY path → AUTO-DEFER.
|
|
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']);
|
|
|
|
expect(exitCode).not.toBe(2);
|
|
expect(stdout).toContain('"gate":"auto_deferred_embeds"');
|
|
expect(stdout).not.toContain('"gate":"confirmation_required"');
|
|
// The run PROCEEDED to import (the wedge is gone) — embeds were deferred,
|
|
// not blocked. (The embed-backfill enqueue wiring + its graceful
|
|
// missing-table tolerance is pinned in embed-backfill-submit.test.ts; the
|
|
// minion_jobs table isn't provisioned in this gate-wiring harness.)
|
|
expect(stdout).toContain('"sync_status":"first_sync"');
|
|
}, 60_000);
|
|
|
|
test('R-3 (#2139): chunker drift → full-tree CEILING estimate, auto-defers (not exit 2)', async () => {
|
|
// git unchanged (HEAD==last_commit) but chunker drifted → the source must
|
|
// NOT price $0 (sync would re-chunk + re-embed everything). The estimate is
|
|
// the full-tree ceiling; the gate auto-defers rather than wedging.
|
|
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
|
|
await engine.executeRaw(`UPDATE sources SET last_commit = $1, chunker_version = $2 WHERE id = 'vault'`, [headSha, 'STALE-0']);
|
|
await engine.setConfig('sync.cost_gate_min_usd', '0');
|
|
|
|
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']);
|
|
|
|
expect(exitCode).not.toBe(2);
|
|
expect(stdout).toContain('"gate":"auto_deferred_embeds"');
|
|
expect(stdout).toContain('"estimateKind":"ceiling"');
|
|
}, 60_000);
|
|
|
|
test('R-3 control: git-unchanged + CURRENT chunker → $0 estimate, below floor (no auto-defer)', async () => {
|
|
// Mirrors the executor's up_to_date predicate: HEAD==last_commit AND chunker
|
|
// matches → 0 new tokens → below floor → proceeds without deferring.
|
|
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
|
|
await engine.executeRaw(`UPDATE sources SET last_commit = $1, chunker_version = $2 WHERE id = 'vault'`, [headSha, String(CHUNKER_VERSION)]);
|
|
await engine.setConfig('sync.cost_gate_min_usd', '0');
|
|
|
|
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']);
|
|
|
|
expect(exitCode).not.toBe(2);
|
|
expect(stdout).not.toContain('"gate":"auto_deferred_embeds"');
|
|
expect(stdout).toContain('"estimateKind":"unchanged"');
|
|
}, 60_000);
|
|
|
|
test('headline regression: HEAD==last_commit + DIRTY untracked file → $0, no gate (the false-fire)', async () => {
|
|
// The exact pre-fix false-fire: a busy brain's working tree is never
|
|
// git-clean, but the commits are caught up. The OLD estimator priced the
|
|
// whole tree (158M-token phantom); the new one mirrors execution → $0.
|
|
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
|
|
await engine.executeRaw(`UPDATE sources SET last_commit = $1, chunker_version = $2 WHERE id = 'vault'`, [headSha, String(CHUNKER_VERSION)]);
|
|
// Dirty the tree with an untracked non-syncable scratch file (agents/crons
|
|
// write constantly) — attached-HEAD sync never imports it.
|
|
writeFileSync(join(repoPath, 'scratch.tmp'), 'uncommitted agent scratch');
|
|
writeFileSync(join(repoPath, 'topics/foo.md'), 'uncommitted edit, not staged');
|
|
await engine.setConfig('sync.cost_gate_min_usd', '0');
|
|
|
|
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']);
|
|
|
|
expect(exitCode).not.toBe(2);
|
|
expect(stdout).not.toContain('"gate":"auto_deferred_embeds"');
|
|
expect(stdout).toContain('"estimateKind":"unchanged"');
|
|
}, 60_000);
|
|
|
|
test('spend.posture=tokenmax → proceeds inline, gate:posture_tokenmax (informational)', async () => {
|
|
stubOfflineEmbed(); // inline embed proceeds — keep it off the network.
|
|
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
|
|
await engine.setConfig('sync.cost_gate_min_usd', '0');
|
|
await engine.setConfig('spend.posture', 'tokenmax');
|
|
|
|
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']);
|
|
|
|
expect(exitCode).not.toBe(2);
|
|
expect(stdout).toContain('"gate":"posture_tokenmax"');
|
|
expect(stdout).not.toContain('"gate":"auto_deferred_embeds"');
|
|
}, 60_000);
|
|
|
|
test('sync.cost_gate_min_usd=off → floor renders "unlimited", never blocks', async () => {
|
|
stubOfflineEmbed();
|
|
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
|
|
await engine.setConfig('sync.cost_gate_min_usd', 'off');
|
|
|
|
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']);
|
|
|
|
expect(exitCode).not.toBe(2);
|
|
expect(stdout).toContain('"floorUsd":"unlimited"');
|
|
expect(stdout).not.toContain('"gate":"auto_deferred_embeds"');
|
|
}, 60_000);
|
|
|
|
test('format split (#1784/D3A): non-TTY WITHOUT --json emits human text, no JSON envelope', async () => {
|
|
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
|
|
await engine.setConfig('sync.cost_gate_min_usd', '0');
|
|
|
|
// No --json: above floor in a non-TTY session → human auto-defer text.
|
|
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--no-pull']);
|
|
|
|
expect(exitCode).not.toBe(2);
|
|
expect(stdout).not.toContain('"gate":'); // no JSON envelope without --json
|
|
expect(stdout.toLowerCase()).toContain('deferring embeds');
|
|
expect(stdout).toContain('spend.posture'); // self-describing hint present
|
|
}, 60_000);
|
|
|
|
test('single-source sync gets the same gate (auto-defers above floor, exit 0)', async () => {
|
|
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
|
|
await engine.setConfig('sync.cost_gate_min_usd', '0');
|
|
|
|
// Single-source (no --all): unsynced → ceiling > 0 → non-TTY auto-defer.
|
|
const { exitCode, stdout } = await runSyncCaptured(['--source', 'vault', '--json', '--no-pull']);
|
|
|
|
expect(exitCode).not.toBe(2);
|
|
expect(stdout).toContain('"gate":"auto_deferred_embeds"');
|
|
// The gate now exists on the single-source path (was ungated before
|
|
// #2139) and proceeds to import rather than blocking.
|
|
expect(stdout.toLowerCase()).toContain('imported');
|
|
}, 60_000);
|
|
});
|