mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +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>
107 lines
4.4 KiB
TypeScript
107 lines
4.4 KiB
TypeScript
/**
|
|
* Spend posture + USD-limit parsing — the single spend-control surface for
|
|
* gbrain's cost gates (issue #2139). Two concerns live here:
|
|
*
|
|
* 1. `spend.posture` (DB-plane config): `'gated'` (default) makes every cost
|
|
* gate behave as before; `'tokenmax'` makes them INFORMATIONAL — print the
|
|
* estimate, proceed, and keep ledgering spend. The operator who sets
|
|
* `tokenmax` has declared "cost is not my constraint." Posture removes the
|
|
* CEILING, never the ACCOUNTING (the spend ledger still records every
|
|
* dollar). It is deliberately SEPARATE from `search.mode=tokenmax` (which
|
|
* governs retrieval payload size, not embedding spend); the gate prints a
|
|
* hint linking the two when only the search mode is set.
|
|
*
|
|
* 2. `parseUsdLimit` / `formatUsdLimit`: first-class `off` / `unlimited` /
|
|
* `none` on the USD gate knobs (so operators stop setting sentinel values
|
|
* like `100000`). The parse layer represents "no limit" as `Infinity` so
|
|
* comparisons stay special-case-free (`cost > Infinity` is never true).
|
|
* `formatUsdLimit` renders it as the string `'unlimited'` — NEVER serialize
|
|
* raw `Infinity`, because `JSON.stringify(Infinity)` emits `null`, which is
|
|
* ambiguous in audit/ledger rows. At the budget-machinery boundary, callers
|
|
* convert `Infinity` → `undefined` ("no cap"), which `BudgetTracker` already
|
|
* treats as cap-absent.
|
|
*
|
|
* LEAF module: imports only the engine type so any cost-gate site can pull it
|
|
* in without a circular dependency.
|
|
*/
|
|
import type { BrainEngine } from './engine.ts';
|
|
|
|
export const SPEND_POSTURE_CONFIG_KEY = 'spend.posture';
|
|
|
|
export type SpendPosture = 'gated' | 'tokenmax';
|
|
|
|
/**
|
|
* Resolve `spend.posture` from DB-plane config. Fail-open to `'gated'` on a
|
|
* missing/unknown value or a config-read error — a posture probe must never
|
|
* crash a sync, and an unrecognized value must never silently disable gates.
|
|
*/
|
|
export async function resolveSpendPosture(engine: BrainEngine): Promise<SpendPosture> {
|
|
try {
|
|
const raw = await engine.getConfig(SPEND_POSTURE_CONFIG_KEY);
|
|
return normalizeSpendPosture(raw);
|
|
} catch {
|
|
return 'gated';
|
|
}
|
|
}
|
|
|
|
/** Pure normalizer (testable without an engine). Anything but `tokenmax` → `gated`. */
|
|
export function normalizeSpendPosture(raw: unknown): SpendPosture {
|
|
if (typeof raw === 'string' && raw.trim().toLowerCase() === 'tokenmax') return 'tokenmax';
|
|
return 'gated';
|
|
}
|
|
|
|
/** True iff `raw` is a valid `spend.posture` value (for `config set` validation). */
|
|
export function isValidSpendPosture(raw: unknown): boolean {
|
|
return typeof raw === 'string' && ['gated', 'tokenmax'].includes(raw.trim().toLowerCase());
|
|
}
|
|
|
|
const OFF_TOKENS = new Set(['off', 'unlimited', 'none']);
|
|
|
|
/**
|
|
* Parse a USD-limit config value.
|
|
* - `'off'` / `'unlimited'` / `'none'` (case-insensitive) → `Infinity` (no limit)
|
|
* - a finite positive number (or `0` when `allowZero`) → that number
|
|
* - anything else (garbage, negative, empty, NaN) → `def`
|
|
*
|
|
* `allowZero` distinguishes the two knob semantics:
|
|
* - `sync.cost_gate_min_usd` uses `allowZero: true` — `0` means "block on any
|
|
* nonzero spend" (a real operator choice). `off` is the no-limit escape.
|
|
* - the backfill caps reject `0` (fall back to the default); only `off`
|
|
* disables them. `off` semantics ≠ `0` (issue #2139).
|
|
*/
|
|
export function parseUsdLimit(
|
|
raw: unknown,
|
|
def: number,
|
|
opts: { allowZero?: boolean } = {},
|
|
): number {
|
|
if (raw === null || raw === undefined) return def;
|
|
if (typeof raw === 'string') {
|
|
const t = raw.trim().toLowerCase();
|
|
if (t === '') return def;
|
|
if (OFF_TOKENS.has(t)) return Infinity;
|
|
}
|
|
const n = Number(raw);
|
|
if (!Number.isFinite(n)) return def;
|
|
if (n < 0) return def;
|
|
if (n === 0) return opts.allowZero ? 0 : def;
|
|
return n;
|
|
}
|
|
|
|
/**
|
|
* Render a USD limit for human/JSON output. `Infinity` → `'unlimited'` (NEVER
|
|
* the raw value — `JSON.stringify(Infinity)` is `null`). Finite values are
|
|
* returned as-is so callers can `$${formatUsdLimit(x)}` or embed in JSON.
|
|
*/
|
|
export function formatUsdLimit(n: number): string | number {
|
|
return Number.isFinite(n) ? n : 'unlimited';
|
|
}
|
|
|
|
/**
|
|
* Convert a parsed USD limit to the budget-machinery cap representation:
|
|
* `Infinity` → `undefined` ("no cap", which `BudgetTracker` treats as
|
|
* cap-absent), finite → the number. Keeps `null` out of ledger rows.
|
|
*/
|
|
export function usdLimitToCap(n: number): number | undefined {
|
|
return Number.isFinite(n) ? n : undefined;
|
|
}
|