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>
226 lines
11 KiB
TypeScript
226 lines
11 KiB
TypeScript
/**
|
|
* v0.20.0 Cathedral II Layer 8 D1 — sync --all cost preview tests.
|
|
*
|
|
* Cathedral I DX review identified "first sync surprise bill" as the #1
|
|
* DX pain for large repos. v0.19.0 ran `sync --all` without telling the
|
|
* user/agent how much it would cost. Cathedral II D1 gates --all on an
|
|
* estimate: TTY prompts, non-TTY emits a ConfirmationRequired envelope
|
|
* and exits 2, --yes skips, --dry-run shows + exits 0, --no-embed
|
|
* skips the cost gate entirely (user already opted out of the spend).
|
|
*
|
|
* These tests exercise the cost envelope + flag behavior against a
|
|
* real git repo fixture, no PGLite needed. The --yes / --dry-run /
|
|
* envelope paths don't depend on DB state.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach } from 'bun:test';
|
|
import {
|
|
EMBEDDING_COST_PER_1K_TOKENS,
|
|
estimateEmbeddingCostUsd,
|
|
willEmbedSynchronously,
|
|
shouldBlockSync,
|
|
} from '../src/core/embedding.ts';
|
|
import { lookupEmbeddingPrice } from '../src/core/embedding-pricing.ts';
|
|
import { resetGateway } from '../src/core/ai/gateway.ts';
|
|
import { estimateTokens } from '../src/core/chunkers/code.ts';
|
|
import {
|
|
parseUsdLimit,
|
|
formatUsdLimit,
|
|
usdLimitToCap,
|
|
normalizeSpendPosture,
|
|
isValidSpendPosture,
|
|
} from '../src/core/spend-posture.ts';
|
|
|
|
describe('Layer 8 D1 — embedding cost model', () => {
|
|
// The estimateEmbeddingCostUsd tests assert the gateway-UNCONFIGURED OpenAI
|
|
// fallback ($0.13/Mtok). That fallback only fires when no gateway is
|
|
// configured — so guarantee that precondition rather than depending on
|
|
// ambient state. Without this, a sibling test file in the same shard that
|
|
// configured (and didn't reset) a cheaper model leaks its rate in here and
|
|
// these assertions flip (the legacy-embedding preload only restores when the
|
|
// gateway slot is EMPTY, so a non-empty foreign config survives).
|
|
beforeEach(() => resetGateway());
|
|
|
|
|
|
test('EMBEDDING_COST_PER_1K_TOKENS back-compat constant is the OpenAI 3-large rate', () => {
|
|
// Retained only for back-compat imports. Live cost math now resolves the
|
|
// CONFIGURED model's rate via embedding-pricing.ts (see model-aware test
|
|
// below). As of 2026-04-24: $0.00013 / 1k tokens.
|
|
expect(EMBEDDING_COST_PER_1K_TOKENS).toBe(0.00013);
|
|
});
|
|
|
|
test('estimateEmbeddingCostUsd scales linearly (gateway-unconfigured fallback = OpenAI rate)', () => {
|
|
// With no gateway configured (unit-test context) the estimator falls back
|
|
// to the OpenAI text-embedding-3-large rate ($0.13/Mtok = $0.00013/1k).
|
|
expect(estimateEmbeddingCostUsd(0)).toBe(0);
|
|
expect(estimateEmbeddingCostUsd(1000)).toBeCloseTo(0.00013, 5);
|
|
expect(estimateEmbeddingCostUsd(10_000)).toBeCloseTo(0.0013, 4);
|
|
expect(estimateEmbeddingCostUsd(1_000_000)).toBeCloseTo(0.13, 4);
|
|
});
|
|
|
|
test('cost preview uses the CONFIGURED model rate, not a hardcoded OpenAI rate', () => {
|
|
// Regression: the cost gate previously hardcoded $0.00013/1k (OpenAI
|
|
// text-embedding-3-large) regardless of the configured embedding model,
|
|
// so a brain on a cheaper model (e.g. zeroentropyai:zembed-1 @ $0.05/Mtok)
|
|
// saw a preview that named the wrong provider and over-stated spend ~2.6x.
|
|
// The pricing table is the single source of truth per provider:model.
|
|
const TOKENS = 2_590_710_262; // a real large-brain sync preview
|
|
const openai = lookupEmbeddingPrice('openai:text-embedding-3-large');
|
|
const zeroentropy = lookupEmbeddingPrice('zeroentropyai:zembed-1');
|
|
expect(openai.kind).toBe('known');
|
|
expect(zeroentropy.kind).toBe('known');
|
|
if (openai.kind === 'known' && zeroentropy.kind === 'known') {
|
|
const openaiCost = (TOKENS / 1_000_000) * openai.pricePerMTok;
|
|
const zeCost = (TOKENS / 1_000_000) * zeroentropy.pricePerMTok;
|
|
// The two models must produce materially different previews; a fix that
|
|
// collapses both to the OpenAI number would regress this assertion.
|
|
expect(openaiCost).toBeCloseTo(336.79, 1);
|
|
expect(zeCost).toBeCloseTo(129.54, 1);
|
|
expect(zeCost).toBeLessThan(openaiCost);
|
|
}
|
|
});
|
|
|
|
test('5K-file TS repo sanity check: ~$5 at ~400k tokens', () => {
|
|
// A 5K-file TS repo at ~80 tokens/file averages ~400k tokens. Cost:
|
|
// 400_000 / 1000 * 0.00013 = $0.052 ≈ $0.05. Not $5. The CHANGELOG
|
|
// prose claim "~$5 one-time" was conservative for very-large repos
|
|
// (100k+ tokens/file megaliths). This test pins the formula, not
|
|
// the prose estimate.
|
|
const cost = estimateEmbeddingCostUsd(400_000);
|
|
expect(cost).toBeGreaterThan(0.04);
|
|
expect(cost).toBeLessThan(0.07);
|
|
});
|
|
});
|
|
|
|
describe('v0.41.31 — willEmbedSynchronously (embed-mode resolver)', () => {
|
|
// Mirrors sync.ts:2346 effectiveNoEmbed = v2 && !serial && !noEmbed ? true : noEmbed.
|
|
// Embed runs INLINE iff that resolves to false.
|
|
test('v2 off → inline (legacy synchronous embed)', () => {
|
|
expect(willEmbedSynchronously({ v2Enabled: false, serialFlag: false, noEmbed: false })).toBe('inline');
|
|
});
|
|
test('v2 on + parallel → deferred (backfill jobs)', () => {
|
|
expect(willEmbedSynchronously({ v2Enabled: true, serialFlag: false, noEmbed: false })).toBe('deferred');
|
|
});
|
|
test('v2 on + --serial → inline', () => {
|
|
expect(willEmbedSynchronously({ v2Enabled: true, serialFlag: true, noEmbed: false })).toBe('inline');
|
|
});
|
|
test('--no-embed forces deferred regardless of v2/serial', () => {
|
|
expect(willEmbedSynchronously({ v2Enabled: false, serialFlag: false, noEmbed: true })).toBe('deferred');
|
|
expect(willEmbedSynchronously({ v2Enabled: true, serialFlag: true, noEmbed: true })).toBe('deferred');
|
|
});
|
|
});
|
|
|
|
describe('v0.41.31 — shouldBlockSync (cost-gate decision)', () => {
|
|
// R-1: deferred NEVER blocks, even at absurd cost (the headline fix — a
|
|
// nightly cron over a synced corpus must not exit 2).
|
|
test('R-1: deferred never blocks, even at $999', () => {
|
|
expect(shouldBlockSync(999, 0.5, 'deferred')).toBe(false);
|
|
expect(shouldBlockSync(0, 0.5, 'deferred')).toBe(false);
|
|
});
|
|
// R-2: inline still blocks above the floor (protection preserved where
|
|
// sync actually spends synchronously).
|
|
test('R-2: inline blocks above floor', () => {
|
|
expect(shouldBlockSync(0.51, 0.5, 'inline')).toBe(true);
|
|
expect(shouldBlockSync(130, 0.5, 'inline')).toBe(true);
|
|
});
|
|
test('inline at exactly the floor does NOT block (boundary)', () => {
|
|
expect(shouldBlockSync(0.5, 0.5, 'inline')).toBe(false);
|
|
});
|
|
test('inline below floor does not block (kills cents-level cron noise)', () => {
|
|
expect(shouldBlockSync(0.03, 0.5, 'inline')).toBe(false);
|
|
expect(shouldBlockSync(0, 0.5, 'inline')).toBe(false);
|
|
});
|
|
test('floor of 0 makes inline block on any nonzero cost', () => {
|
|
expect(shouldBlockSync(0.0001, 0, 'inline')).toBe(true);
|
|
expect(shouldBlockSync(0, 0, 'inline')).toBe(false);
|
|
});
|
|
|
|
// v0.42.42.0 (#2139): posture + Infinity-floor behavior.
|
|
test('spend.posture=tokenmax never blocks, even above floor', () => {
|
|
expect(shouldBlockSync(999, 0.5, 'inline', 'tokenmax')).toBe(false);
|
|
expect(shouldBlockSync(999, 0, 'inline', 'tokenmax')).toBe(false);
|
|
});
|
|
test('default posture (gated) preserves the legacy decision', () => {
|
|
expect(shouldBlockSync(0.51, 0.5, 'inline', 'gated')).toBe(true);
|
|
expect(shouldBlockSync(0.03, 0.5, 'inline', 'gated')).toBe(false);
|
|
});
|
|
test('off/unlimited floor (Infinity) is never exceeded → never blocks', () => {
|
|
expect(shouldBlockSync(999, Infinity, 'inline')).toBe(false);
|
|
expect(shouldBlockSync(1e9, Infinity, 'inline', 'gated')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('v0.42.42.0 (#2139) — spend-posture USD-limit parsing', () => {
|
|
test('off / unlimited / none (case-insensitive) → Infinity', () => {
|
|
for (const v of ['off', 'OFF', 'unlimited', 'Unlimited', 'none', 'NONE', ' off ']) {
|
|
expect(parseUsdLimit(v, 25)).toBe(Infinity);
|
|
}
|
|
});
|
|
test('finite positive numbers pass through', () => {
|
|
expect(parseUsdLimit('5', 25)).toBe(5);
|
|
expect(parseUsdLimit(0.5, 25)).toBe(0.5);
|
|
expect(parseUsdLimit('100000', 25)).toBe(100000);
|
|
});
|
|
test('0 falls back to default unless allowZero', () => {
|
|
expect(parseUsdLimit('0', 25)).toBe(25); // backfill cap: off ≠ 0
|
|
expect(parseUsdLimit('0', 0.5, { allowZero: true })).toBe(0); // floor: 0 = block-on-any
|
|
});
|
|
test('garbage / negative / empty / null → default', () => {
|
|
expect(parseUsdLimit('abc', 25)).toBe(25);
|
|
expect(parseUsdLimit('-3', 25)).toBe(25);
|
|
expect(parseUsdLimit('', 25)).toBe(25);
|
|
expect(parseUsdLimit(null, 25)).toBe(25);
|
|
expect(parseUsdLimit(undefined, 0.5)).toBe(0.5);
|
|
});
|
|
test('formatUsdLimit: Infinity → "unlimited" (never the JSON.stringify=null trap), finite passthrough', () => {
|
|
expect(formatUsdLimit(Infinity)).toBe('unlimited');
|
|
expect(formatUsdLimit(5)).toBe(5);
|
|
expect(formatUsdLimit(0)).toBe(0);
|
|
// The trap this guards: raw Infinity serializes to null.
|
|
expect(JSON.stringify({ cap: Infinity })).toBe('{"cap":null}');
|
|
expect(JSON.stringify({ cap: formatUsdLimit(Infinity) })).toBe('{"cap":"unlimited"}');
|
|
});
|
|
test('usdLimitToCap: Infinity → undefined (no cap), finite passthrough', () => {
|
|
expect(usdLimitToCap(Infinity)).toBeUndefined();
|
|
expect(usdLimitToCap(10)).toBe(10);
|
|
});
|
|
test('normalizeSpendPosture: only tokenmax is tokenmax; everything else gated', () => {
|
|
expect(normalizeSpendPosture('tokenmax')).toBe('tokenmax');
|
|
expect(normalizeSpendPosture('TokenMax')).toBe('tokenmax');
|
|
expect(normalizeSpendPosture('gated')).toBe('gated');
|
|
expect(normalizeSpendPosture('max')).toBe('gated');
|
|
expect(normalizeSpendPosture('')).toBe('gated');
|
|
expect(normalizeSpendPosture(null)).toBe('gated');
|
|
expect(normalizeSpendPosture(42)).toBe('gated');
|
|
});
|
|
test('isValidSpendPosture accepts gated/tokenmax (case-insensitive), rejects the rest', () => {
|
|
expect(isValidSpendPosture('gated')).toBe(true);
|
|
expect(isValidSpendPosture('tokenmax')).toBe(true);
|
|
expect(isValidSpendPosture('TokenMax')).toBe(true); // normalized lowercase
|
|
expect(isValidSpendPosture('max')).toBe(false);
|
|
expect(isValidSpendPosture('')).toBe(false);
|
|
expect(isValidSpendPosture(7)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Layer 8 D1 — estimateTokens (exported from chunkers/code.ts)', () => {
|
|
test('empty string is 0 tokens', () => {
|
|
expect(estimateTokens('')).toBe(0);
|
|
});
|
|
|
|
test('short text is a small token count', () => {
|
|
const t = estimateTokens('Hello, world!');
|
|
expect(t).toBeGreaterThan(0);
|
|
expect(t).toBeLessThan(10);
|
|
});
|
|
|
|
test('longer text scales roughly with length', () => {
|
|
const short = 'The quick brown fox jumps over the lazy dog.';
|
|
const long = short.repeat(100);
|
|
const shortTokens = estimateTokens(short);
|
|
const longTokens = estimateTokens(long);
|
|
// Not strictly 100x because of tokenizer encoding, but should be >50x.
|
|
expect(longTokens).toBeGreaterThan(shortTokens * 50);
|
|
});
|
|
});
|