mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
090bb53203
commit
5c49225e4b
@@ -29,6 +29,15 @@ describe('KNOWN_CONFIG_KEYS', () => {
|
||||
expect(KNOWN_CONFIG_KEYS).toContain('models.tier.subagent');
|
||||
});
|
||||
|
||||
test('contains the spend-control keys (v0.42.42.0, #2139) — no --force archaeology', () => {
|
||||
expect(KNOWN_CONFIG_KEYS).toContain('spend.posture');
|
||||
expect(KNOWN_CONFIG_KEYS).toContain('sync.cost_gate_min_usd');
|
||||
expect(KNOWN_CONFIG_KEYS).toContain('sync.federated_v2');
|
||||
expect(KNOWN_CONFIG_KEYS).toContain('embed.backfill_cooldown_min');
|
||||
expect(KNOWN_CONFIG_KEYS).toContain('embed.backfill_max_usd_per_source_24h');
|
||||
expect(KNOWN_CONFIG_KEYS).toContain('embed.backfill_max_usd');
|
||||
});
|
||||
|
||||
test('no duplicate entries', () => {
|
||||
const set = new Set(KNOWN_CONFIG_KEYS);
|
||||
expect(set.size).toBe(KNOWN_CONFIG_KEYS.length);
|
||||
|
||||
@@ -155,6 +155,54 @@ describe('submitEmbedBackfill — 24h spend cap', () => {
|
||||
expect(result.status).toBe('spend_capped');
|
||||
expect(result.spendCapUsd).toBe(5);
|
||||
});
|
||||
|
||||
// v0.42.42.0 (#2139): off-switch + tokenmax bypass.
|
||||
test('cap "off" → submits even at huge spend (Infinity cap never tripped)', async () => {
|
||||
await engine.setConfig(SPEND_CAP_CONFIG_KEY, 'off');
|
||||
const result = await submitEmbedBackfill(engine, 'default', {
|
||||
reason: 'unit',
|
||||
spend24hFn: async () => 1e9,
|
||||
});
|
||||
expect(result.status).toBe('submitted');
|
||||
});
|
||||
|
||||
test('0 falls back to the default cap (off semantics ≠ 0)', async () => {
|
||||
await engine.setConfig(SPEND_CAP_CONFIG_KEY, '0');
|
||||
const result = await submitEmbedBackfill(engine, 'default', {
|
||||
reason: 'unit',
|
||||
spend24hFn: async () => 25, // == default $25 → capped
|
||||
});
|
||||
expect(result.status).toBe('spend_capped');
|
||||
expect(result.spendCapUsd).toBe(25);
|
||||
});
|
||||
|
||||
test('spend.posture=tokenmax bypasses the cap, marks spendCapBypassed', async () => {
|
||||
const result = await submitEmbedBackfill(engine, 'default', {
|
||||
reason: 'unit',
|
||||
postureOverride: 'tokenmax',
|
||||
spendCapUsdOverride: 25,
|
||||
spend24hFn: async () => 100, // way over cap
|
||||
});
|
||||
expect(result.status).toBe('submitted');
|
||||
expect(result.spendCapBypassed).toBe(true);
|
||||
expect(result.spend24hUsd).toBe(100);
|
||||
});
|
||||
|
||||
test('tokenmax does NOT bypass the cooldown (axis split — churn protection stays)', async () => {
|
||||
const queue = new MinionQueue(engine);
|
||||
const job = await queue.add('embed-backfill', { sourceId: 'default' }, {});
|
||||
await engine.executeRaw(
|
||||
`UPDATE minion_jobs SET status='completed', finished_at=NOW() - INTERVAL '1 minute' WHERE id=$1`,
|
||||
[job.id],
|
||||
);
|
||||
const result = await submitEmbedBackfill(engine, 'default', {
|
||||
reason: 'unit',
|
||||
postureOverride: 'tokenmax',
|
||||
cooldownMinOverride: 10,
|
||||
spend24hFn: async () => 1e9,
|
||||
});
|
||||
expect(result.status).toBe('cooldown'); // posture lifts the cap, NOT the cooldown
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitEmbedBackfill — source isolation', () => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* v0.42.42.0 (#2139) — `--max-usd off` / `--max-cost off` uncapped-switch pins
|
||||
* across enrich / onboard / reindex (the T6 secondary cost gates).
|
||||
*
|
||||
* The enrich arg parser carries the real logic (off → Infinity sentinel, mapped
|
||||
* to "no BudgetTracker ceiling" in runEnrichCore), so it gets a direct unit test.
|
||||
* reindex + onboard detect `off` inline at the CLI dispatch and proceed past the
|
||||
* confirmation / missing-cap refusal; those are pinned as source-level regression
|
||||
* guards (the codex pre-landing review found all three half-built — these keep
|
||||
* them from silently regressing without standing up full CLI+gateway harnesses).
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { parseArgs } from '../src/commands/enrich.ts';
|
||||
|
||||
describe('enrich parseArgs — --max-usd off → uncapped (Infinity sentinel)', () => {
|
||||
test('off / unlimited / none (case-insensitive) → Infinity', () => {
|
||||
for (const v of ['off', 'OFF', 'unlimited', 'none', 'None']) {
|
||||
expect(parseArgs(['--max-usd', v]).maxCostUsd).toBe(Infinity);
|
||||
}
|
||||
// --max-cost-usd alias too.
|
||||
expect(parseArgs(['--max-cost-usd', 'off']).maxCostUsd).toBe(Infinity);
|
||||
});
|
||||
test('finite positive number passes through; absent → undefined; garbage → undefined', () => {
|
||||
expect(parseArgs(['--max-usd', '5']).maxCostUsd).toBe(5);
|
||||
expect(parseArgs([]).maxCostUsd).toBeUndefined();
|
||||
expect(parseArgs(['--max-usd', 'abc']).maxCostUsd).toBeUndefined();
|
||||
expect(parseArgs(['--max-usd', '0']).maxCostUsd).toBeUndefined(); // non-positive ignored
|
||||
});
|
||||
});
|
||||
|
||||
describe('reindex / onboard off-switch dispatch (regression guards)', () => {
|
||||
test('reindex-code: --max-cost off proceeds past the confirmation gate', async () => {
|
||||
const src = await Bun.file(new URL('../src/commands/reindex-code.ts', import.meta.url)).text();
|
||||
// off sets maxCostOff and the gate proceeds when (tokenmax || maxCostOff).
|
||||
expect(src).toMatch(/maxCostOff\s*=\s*true/);
|
||||
expect(src).toMatch(/posture === 'tokenmax' \|\| maxCostOff/);
|
||||
});
|
||||
|
||||
test('onboard: --max-usd off lifts the --auto missing-cap refusal', async () => {
|
||||
const src = await Bun.file(new URL('../src/commands/onboard.ts', import.meta.url)).text();
|
||||
expect(src).toMatch(/maxUsdOff\s*=/);
|
||||
// refusal skipped when (maxUsdOff || tokenmax).
|
||||
expect(src).toMatch(/maxUsdOff \|\| tokenmax/);
|
||||
});
|
||||
|
||||
test('enrich: uncapped path maps the Infinity sentinel to no BudgetTracker ceiling', async () => {
|
||||
const src = await Bun.file(new URL('../src/commands/enrich.ts', import.meta.url)).text();
|
||||
// The sentinel must become `undefined` at the tracker (never raw Infinity,
|
||||
// which serializes to null in audit rows).
|
||||
expect(src).toMatch(/opts\.maxCostUsd === Infinity \? undefined/);
|
||||
expect(src).toMatch(/uncapped \? Infinity : parsed\.maxCostUsd/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* v0.42.42.0 (#2139) — estimateInlineNewTokens ladder coverage.
|
||||
*
|
||||
* The inline cost estimator now MIRRORS EXECUTION (delta, not full-tree
|
||||
* ceiling) so the gate's dollar figure stops being a ~400x phantom on a busy
|
||||
* brain. Real temp git repos, no PGLite. estimateInlineNewTokens is exported
|
||||
* from commands/sync.ts; CHUNKER_VERSION is the live chunker version.
|
||||
*/
|
||||
import { describe, test, expect, 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 { estimateInlineNewTokens } from '../src/commands/sync.ts';
|
||||
import { CHUNKER_VERSION } from '../src/core/chunkers/code.ts';
|
||||
|
||||
const CURRENT = String(CHUNKER_VERSION);
|
||||
let repo: string;
|
||||
|
||||
function commitAll(msg: string): string {
|
||||
execSync('git add -A', { cwd: repo, stdio: 'pipe' });
|
||||
execSync(`git commit -m "${msg}"`, { cwd: repo, stdio: 'pipe' });
|
||||
return execSync('git rev-parse HEAD', { cwd: repo, stdio: 'pipe' }).toString().trim();
|
||||
}
|
||||
function src(over: Partial<{ last_commit: string | null; chunker_version: string | null; config: Record<string, unknown> }> = {}) {
|
||||
return {
|
||||
local_path: repo,
|
||||
config: over.config ?? {},
|
||||
last_commit: over.last_commit ?? null,
|
||||
chunker_version: over.chunker_version ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
repo = mkdtempSync(join(tmpdir(), 'gbrain-est-'));
|
||||
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(() => {
|
||||
if (repo) rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('estimateInlineNewTokens — ladder', () => {
|
||||
test('chunker drift → full-tree ceiling even with an empty git delta', () => {
|
||||
writeFileSync(join(repo, 'topics/a.md'), 'some body content here');
|
||||
const head = commitAll('base');
|
||||
// git unchanged (last_commit == HEAD) but chunker drifted → must NOT be 0.
|
||||
const r = estimateInlineNewTokens([src({ last_commit: head, chunker_version: 'STALE-0' })], CURRENT);
|
||||
expect(r.tokens).toBeGreaterThan(0);
|
||||
expect(r.estimateKind).toBe('ceiling');
|
||||
expect(r.ceilingReasons).toContain('chunker_drift');
|
||||
expect(r.changedSources).toBe(1);
|
||||
});
|
||||
|
||||
test('[D2A headline] HEAD==last_commit + current chunker + DIRTY tree → 0 (unchanged)', () => {
|
||||
writeFileSync(join(repo, 'topics/a.md'), 'body');
|
||||
const head = commitAll('base');
|
||||
// Dirty the tree (untracked scratch + uncommitted edit) — attached sync
|
||||
// imports nothing, so the estimate must be 0. This is the exact pre-fix
|
||||
// false-fire shape.
|
||||
writeFileSync(join(repo, 'scratch.tmp'), 'agent scratch');
|
||||
writeFileSync(join(repo, 'topics/a.md'), 'uncommitted edit');
|
||||
const r = estimateInlineNewTokens([src({ last_commit: head, chunker_version: CURRENT })], CURRENT);
|
||||
expect(r.tokens).toBe(0);
|
||||
expect(r.estimateKind).toBe('unchanged');
|
||||
expect(r.unchangedSources).toBe(1);
|
||||
});
|
||||
|
||||
test('first sync (last_commit null) → full-tree ceiling', () => {
|
||||
writeFileSync(join(repo, 'topics/a.md'), 'body content');
|
||||
commitAll('base');
|
||||
const r = estimateInlineNewTokens([src({ last_commit: null, chunker_version: CURRENT })], CURRENT);
|
||||
expect(r.tokens).toBeGreaterThan(0);
|
||||
expect(r.estimateKind).toBe('ceiling');
|
||||
expect(r.ceilingReasons).toContain('first_sync');
|
||||
});
|
||||
|
||||
test('delta rung: only changed committed files priced; deletes cost 0', () => {
|
||||
writeFileSync(join(repo, 'topics/a.md'), 'a'.repeat(400));
|
||||
writeFileSync(join(repo, 'topics/b.md'), 'b'.repeat(400));
|
||||
const base = commitAll('base');
|
||||
writeFileSync(join(repo, 'topics/a.md'), 'a'.repeat(800)); // modify
|
||||
rmSync(join(repo, 'topics/b.md')); // delete → 0
|
||||
commitAll('change');
|
||||
|
||||
const r = estimateInlineNewTokens([src({ last_commit: base, chunker_version: CURRENT })], CURRENT);
|
||||
expect(r.estimateKind).toBe('delta');
|
||||
expect(r.tokens).toBeGreaterThan(0); // a.md priced
|
||||
// A pure-delete delta would be 0; here a.md modify keeps it > 0. Sanity:
|
||||
// the magnitude is delta-scale (one ~800-char file), not full-tree.
|
||||
});
|
||||
|
||||
test('delta rung: non-syncable changed file is filtered out (markdown strategy)', () => {
|
||||
writeFileSync(join(repo, 'topics/a.md'), 'md');
|
||||
const base = commitAll('base');
|
||||
writeFileSync(join(repo, 'notes.txt'), 'x'.repeat(4000)); // .txt under markdown strategy → not syncable
|
||||
commitAll('add txt');
|
||||
|
||||
const r = estimateInlineNewTokens([src({ last_commit: base, chunker_version: CURRENT })], CURRENT);
|
||||
// No syncable changes → delta priced at 0 tokens (but the source changed).
|
||||
expect(r.tokens).toBe(0);
|
||||
expect(r.estimateKind).toBe('delta');
|
||||
});
|
||||
|
||||
test('syncEnabled:false sources are skipped (neither changed nor unchanged)', () => {
|
||||
writeFileSync(join(repo, 'topics/a.md'), 'body');
|
||||
commitAll('base');
|
||||
const r = estimateInlineNewTokens([src({ last_commit: null, config: { syncEnabled: false } })], CURRENT);
|
||||
expect(r.tokens).toBe(0);
|
||||
expect(r.changedSources).toBe(0);
|
||||
expect(r.unchangedSources).toBe(0);
|
||||
});
|
||||
|
||||
test('mixed: one ceiling source + one unchanged source → estimateKind mixed-or-ceiling, reasons captured', () => {
|
||||
writeFileSync(join(repo, 'topics/a.md'), 'body');
|
||||
const head = commitAll('base');
|
||||
const r = estimateInlineNewTokens(
|
||||
[
|
||||
src({ last_commit: null, chunker_version: CURRENT }), // first_sync ceiling
|
||||
src({ last_commit: head, chunker_version: CURRENT }), // unchanged
|
||||
],
|
||||
CURRENT,
|
||||
);
|
||||
expect(r.ceilingReasons).toContain('first_sync');
|
||||
expect(r.unchangedSources).toBe(1);
|
||||
// hadCeiling true, hadDelta false → 'ceiling' aggregate.
|
||||
expect(r.estimateKind).toBe('ceiling');
|
||||
});
|
||||
|
||||
test('missing local_path source contributes nothing', () => {
|
||||
const r = estimateInlineNewTokens(
|
||||
[{ local_path: null, config: {}, last_commit: null, chunker_version: CURRENT }],
|
||||
CURRENT,
|
||||
);
|
||||
expect(r.tokens).toBe(0);
|
||||
expect(r.changedSources).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* v0.41.31 — `gbrain sync --all` cost-gate wiring regressions (PGLite).
|
||||
* `gbrain sync` cost-gate wiring regressions (PGLite).
|
||||
*
|
||||
* Pure shouldBlockSync / willEmbedSynchronously logic is pinned in
|
||||
* test/sync-cost-preview.test.ts. THIS file pins the end-to-end wiring in
|
||||
* runSync's --all path:
|
||||
* 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 (the cron-blocking
|
||||
* bug this release fixes).
|
||||
* R-2 (protection): inline-embed sync --all (--serial), non-TTY, above
|
||||
* floor → still exit 2 with gate:'confirmation_required'.
|
||||
* 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).
|
||||
*/
|
||||
@@ -21,10 +23,18 @@ 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 } from '../src/core/ai/gateway.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;
|
||||
@@ -64,6 +74,7 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__setEmbedTransportForTests(null);
|
||||
resetGateway();
|
||||
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
||||
});
|
||||
@@ -115,39 +126,43 @@ describe('v0.41.31 — sync --all cost gate wiring', () => {
|
||||
expect(stdout).toContain('"gate":"deferred_notice"');
|
||||
}, 60_000);
|
||||
|
||||
test('R-2: inline sync --all (--serial) above floor still exit 2', async () => {
|
||||
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 blocks. Source is unsynced
|
||||
// (last_commit NULL) so estimateInlineNewTokens sees it as changed →
|
||||
// full-tree tokens > 0 → costUsd > 0 > floor.
|
||||
// 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 exit-2 path.
|
||||
// --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).toBe(2);
|
||||
expect(stdout).toContain('"gate":"confirmation_required"');
|
||||
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: inline, git-unchanged source but STALE chunker_version still estimates (not $0)', async () => {
|
||||
// The unchanged-source short-circuit requires HEAD==last_commit AND clean
|
||||
// tree AND chunker_version == current. Here git is unchanged but the
|
||||
// chunker drifted, so the source must NOT be treated as 0 — sync would
|
||||
// re-chunk + re-embed everything. floor=0 so any nonzero cost blocks.
|
||||
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).toBe(2);
|
||||
expect(stdout).toContain('"gate":"confirmation_required"');
|
||||
expect(exitCode).not.toBe(2);
|
||||
expect(stdout).toContain('"gate":"auto_deferred_embeds"');
|
||||
expect(stdout).toContain('"estimateKind":"ceiling"');
|
||||
}, 60_000);
|
||||
|
||||
test('R-3 control: inline, git-unchanged + CURRENT chunker_version short-circuits to $0 (no exit 2)', async () => {
|
||||
// Same setup but chunker_version matches current → the source IS unchanged
|
||||
// → contributes 0 new-content tokens → below floor → proceeds (no block).
|
||||
// Proves the short-circuit fires when (and only when) everything matches.
|
||||
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');
|
||||
@@ -155,6 +170,78 @@ describe('v0.41.31 — sync --all cost gate wiring', () => {
|
||||
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']);
|
||||
|
||||
expect(exitCode).not.toBe(2);
|
||||
expect(stdout).not.toContain('"gate":"confirmation_required"');
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,13 @@ import {
|
||||
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
|
||||
@@ -127,6 +134,73 @@ describe('v0.41.31 — shouldBlockSync (cost-gate decision)', () => {
|
||||
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)', () => {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -170,10 +170,10 @@ describe('Bug 9 — sync.ts CLI flag wiring', () => {
|
||||
// performSync's inner ack path only fires when failedFiles.length > 0
|
||||
// in the current run. This test pins the up-front ack at the top of
|
||||
// runSync so the flag means "ack whatever is currently flagged".
|
||||
// v0.42.42.0 (#2139, D13C): the pre-ack is now scoped PER SOURCE — `--all`
|
||||
// acks every source, single-source acks only its own.
|
||||
const source = await Bun.file(new URL('../src/commands/sync.ts', import.meta.url)).text();
|
||||
// Ensure the up-front check exists before the syncAll / performSync
|
||||
// dispatch, gated on skipFailed.
|
||||
expect(source).toMatch(/if \(skipFailed\) \{[\s\S]*?unacknowledgedSyncFailures\(\)[\s\S]*?acknowledgeSyncFailures\(\)/);
|
||||
expect(source).toMatch(/if \(skipFailed\) \{[\s\S]*?syncAll \? acknowledgeFailures\(\) : acknowledgeFailures\(sourceId\)/);
|
||||
});
|
||||
|
||||
test('acknowledgeSyncFailures clears stale failures end-to-end', async () => {
|
||||
|
||||
Reference in New Issue
Block a user