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>
155 lines
6.4 KiB
TypeScript
155 lines
6.4 KiB
TypeScript
/**
|
|
* v0.42.42.0 (#2139) — computeSyncDelta unit coverage.
|
|
*
|
|
* The shared diff/manifest helper that BOTH the sync executor and the inline
|
|
* cost estimator route through (so the gate's dollar figure can't drift from
|
|
* what the sync imports). Real temp git repos; no PGLite, no env writes
|
|
* (R1/R2-clean). The git-runner seam drives the unavailable branches.
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, renameSync } from 'fs';
|
|
import { execSync } from 'child_process';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import {
|
|
computeSyncDelta,
|
|
buildDetachedWorkingTreeManifest,
|
|
_setGitRunnerForTests,
|
|
} from '../src/core/sync-delta.ts';
|
|
|
|
let repo: string;
|
|
|
|
function git(args: string): string {
|
|
return execSync(`git ${args}`, { cwd: repo, stdio: 'pipe' }).toString().trim();
|
|
}
|
|
function commitAll(msg: string): string {
|
|
execSync('git add -A', { cwd: repo, stdio: 'pipe' });
|
|
execSync(`git commit -m "${msg}"`, { cwd: repo, stdio: 'pipe' });
|
|
return git('rev-parse HEAD');
|
|
}
|
|
|
|
beforeEach(() => {
|
|
repo = mkdtempSync(join(tmpdir(), 'gbrain-delta-'));
|
|
execSync('git init', { cwd: repo, stdio: 'pipe' });
|
|
execSync('git config user.email "t@t.com"', { cwd: repo, stdio: 'pipe' });
|
|
execSync('git config user.name "T"', { cwd: repo, stdio: 'pipe' });
|
|
mkdirSync(join(repo, 'topics'), { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
_setGitRunnerForTests(null);
|
|
if (repo) rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('computeSyncDelta — commit diff', () => {
|
|
test('A/M/D classified; only committed changes in the manifest', () => {
|
|
writeFileSync(join(repo, 'topics/a.md'), 'a');
|
|
writeFileSync(join(repo, 'topics/b.md'), 'b');
|
|
const base = commitAll('base');
|
|
writeFileSync(join(repo, 'topics/a.md'), 'a-edited'); // modify
|
|
writeFileSync(join(repo, 'topics/c.md'), 'c'); // add
|
|
rmSync(join(repo, 'topics/b.md')); // delete
|
|
const head = commitAll('change');
|
|
|
|
const r = computeSyncDelta(repo, base, head);
|
|
expect(r.status).toBe('ok');
|
|
if (r.status !== 'ok') return;
|
|
expect(r.manifest.modified).toContain('topics/a.md');
|
|
expect(r.manifest.added).toContain('topics/c.md');
|
|
expect(r.manifest.deleted).toContain('topics/b.md');
|
|
});
|
|
|
|
test('rename → destination path on the renamed list', () => {
|
|
writeFileSync(join(repo, 'topics/old.md'), 'x'.repeat(200));
|
|
const base = commitAll('base');
|
|
renameSync(join(repo, 'topics/old.md'), join(repo, 'topics/new.md'));
|
|
const head = commitAll('rename');
|
|
|
|
const r = computeSyncDelta(repo, base, head);
|
|
expect(r.status).toBe('ok');
|
|
if (r.status !== 'ok') return;
|
|
expect(r.manifest.renamed.map(x => x.to)).toContain('topics/new.md');
|
|
});
|
|
|
|
test('[D2A] attached HEAD: dirty tracked + untracked files are NOT in the manifest', () => {
|
|
writeFileSync(join(repo, 'topics/a.md'), 'a');
|
|
const base = commitAll('base');
|
|
const head = git('rev-parse HEAD'); // HEAD == base, no new commits
|
|
// Dirty the tree: an uncommitted edit + an untracked scratch file.
|
|
writeFileSync(join(repo, 'topics/a.md'), 'uncommitted edit');
|
|
writeFileSync(join(repo, 'scratch.tmp'), 'untracked');
|
|
|
|
const r = computeSyncDelta(repo, base, head); // not detached → commit diff only
|
|
expect(r.status).toBe('ok');
|
|
if (r.status !== 'ok') return;
|
|
expect(r.manifest.added).toHaveLength(0);
|
|
expect(r.manifest.modified).toHaveLength(0);
|
|
expect(r.manifest.deleted).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('computeSyncDelta — detached HEAD merges the working-tree manifest', () => {
|
|
test('detached + working-tree changes → merged into the manifest', () => {
|
|
writeFileSync(join(repo, 'topics/a.md'), 'a');
|
|
const base = commitAll('base');
|
|
// Detach HEAD and dirty the tree.
|
|
execSync(`git checkout --detach ${base}`, { cwd: repo, stdio: 'pipe' });
|
|
writeFileSync(join(repo, 'topics/a.md'), 'detached edit'); // tracked modify
|
|
writeFileSync(join(repo, 'topics/new.md'), 'new'); // untracked add
|
|
|
|
const r = computeSyncDelta(repo, base, base, { detached: true });
|
|
expect(r.status).toBe('ok');
|
|
if (r.status !== 'ok') return;
|
|
expect(r.manifest.modified).toContain('topics/a.md');
|
|
expect(r.manifest.added).toContain('topics/new.md'); // untracked picked up on detached
|
|
});
|
|
|
|
test('buildDetachedWorkingTreeManifest: clean detached tree → empty manifest', () => {
|
|
writeFileSync(join(repo, 'topics/a.md'), 'a');
|
|
const base = commitAll('base');
|
|
execSync(`git checkout --detach ${base}`, { cwd: repo, stdio: 'pipe' });
|
|
const m = buildDetachedWorkingTreeManifest(repo);
|
|
expect(m.added).toHaveLength(0);
|
|
expect(m.modified).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('computeSyncDelta — fail-open ladder', () => {
|
|
test('bogus anchor SHA → unavailable: anchor_missing', () => {
|
|
writeFileSync(join(repo, 'topics/a.md'), 'a');
|
|
const head = commitAll('base');
|
|
const r = computeSyncDelta(repo, 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', head);
|
|
expect(r.status).toBe('unavailable');
|
|
if (r.status === 'unavailable') expect(r.reason).toBe('anchor_missing');
|
|
});
|
|
|
|
test('non-ancestor anchor still diffs (the #1970 property)', () => {
|
|
// git diff A..B is endpoint-tree, no ancestry requirement.
|
|
writeFileSync(join(repo, 'topics/a.md'), 'a');
|
|
const base = commitAll('base');
|
|
// Rewrite history: amend creates a new commit not descended from `base`,
|
|
// but `base` is still on disk (reflog) → diffable.
|
|
writeFileSync(join(repo, 'topics/a.md'), 'rewritten');
|
|
execSync('git add -A && git commit --amend -m rewritten', { cwd: repo, stdio: 'pipe' });
|
|
const head = git('rev-parse HEAD');
|
|
expect(head).not.toBe(base);
|
|
|
|
const r = computeSyncDelta(repo, base, head);
|
|
expect(r.status).toBe('ok'); // orphaned-but-present anchor is still diffable
|
|
});
|
|
|
|
test('injected git failure on the diff → unavailable: diff_failed', () => {
|
|
writeFileSync(join(repo, 'topics/a.md'), 'a');
|
|
const base = commitAll('base');
|
|
const head = git('rev-parse HEAD');
|
|
_setGitRunnerForTests((_repo, args) => {
|
|
if (args[0] === 'cat-file') return 'commit'; // anchor reachable
|
|
if (args[0] === 'diff') throw new Error('simulated oversized diff / timeout');
|
|
return '';
|
|
});
|
|
const r = computeSyncDelta(repo, base, head);
|
|
expect(r.status).toBe('unavailable');
|
|
if (r.status === 'unavailable') expect(r.reason).toBe('diff_failed');
|
|
});
|
|
});
|