mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(sync): contention-free page-generation clock — sequence swap The page-generation clock backed the query-cache Layer-1 bookmark via a FOR EACH STATEMENT trigger running `UPDATE page_generation_clock SET value=value+1 WHERE id=1`. That took a transaction-length RowExclusiveLock on one tuple, so every concurrent page writer serialized on the prior writer's COMMIT — sync ran at ~0.8 cores regardless of worker count. Swap to a SEQUENCE bumped by nextval() (a microsecond LWLock, never a row lock). The clock's only contract is monotonic advancement on any page INSERT/UPDATE/DELETE; last_value is non-transactional, so rolled-back or concurrent-uncommitted writers only OVER-invalidate the cache (lose a hit), never serve stale. - migration v118: CREATE SEQUENCE + load-bearing 2-arg setval (is_called= true, floor 1, seeded >= old clock and MAX(generation)) + repoint the trigger function body + DELETE query_cache so no old-clock bookmark survives the swap. v107 left immutable. - query-cache-gate.ts: 3 readers -> SELECT last_value FROM page_generation_clock_seq. - schema.sql + pglite-schema.ts (+ regenerated schema-embedded.ts) ship the sequence on fresh install; table + trigger names retained. - tests: clockValue reads last_value; mechanism proof (trigger fn uses nextval not the row UPDATE); rollback-advances-clock safety pin; real PGLite sequence round-trip (is_called gotcha); shape test requires _seq. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): op_checkpoints array-shape guard — CHECK + repair + defensive loader completed_keys is JSONB and the checkpoint loader runs jsonb_array_elements_text over it. A non-array (scalar) value makes that throw "cannot extract elements from a scalar", which takes down the whole UNION load — including the valid op_checkpoint_paths child rows — and loses all checkpoint progress for that key. No current writer produces a scalar, but an older binary / external script / future bug could. Make the corruption class structurally impossible and self-healing: - migration v119: LOCK TABLE (so an out-of-band scalar can't land between repair and constrain; no-op on single-connection PGLite), repair any pre-existing scalar to '[]' (op_checkpoint_paths child rows are the append-only source of truth, so the reset loses nothing), then add the named CHECK (jsonb_typeof(completed_keys) = 'array') via a pg_constraint IF NOT EXISTS guard. A DB-enforced always-on guard — the correct pattern vs a migration verify-hook, which never runs on already-stamped brains. - schema.sql + pglite-schema.ts (+ regenerated schema-embedded.ts) ship the same NAMED inline CHECK so fresh installs match migrated brains and v119 skips the duplicate. - op-checkpoint.ts loader: gate the legacy arm on jsonb_typeof = 'array' so a scalar parent is skipped (children still load) instead of throwing the whole union, and log a specific corruption warning when one is seen. - tests: CHECK rejects a scalar (exactly one constraint, no blob+migration dupe); loader survives a scalar parent and returns the children; v119 repair converts a scalar to '[]'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(doctor): report actively-running sync via live lock, not stale freshness A slow source that makes partial progress every cycle but never fully completes used to read as permanently "stale" / "never synced" because last_sync_at only advances on a full successful sync. The naive fix (treat recent checkpoint banking as "in progress") is unsafe: a blocked sync banks the good files then writes no anchor, so banking can't tell in-progress from wedged. Use the only honest signal: a LIVE, non-expired per-source sync lock (inspectLock + syncLockId against gbrain_cycle_locks). Every non-skipLock sync holds it and refreshes it; a blocked/failed sync's process has exited (no lock row) and a wedged holder stops refreshing (TTL lapses), so either correctly falls through to the stale path and is NEVER masked. An actively-syncing source (including a never-synced source doing its first sync) counts as synced_recently, preserving the pinned 3-bucket invariant. The lock lookup reuses doctor's existing dynamic db-lock import and swallows any throw (stub engine, pre-lock-table brain) to false, so it can only ADD an in-progress verdict, never suppress a real stale one. Tests (real PGLiteEngine + real lock rows): stale+no-lock -> fail; stale+live-lock -> ok; never-synced+live-lock -> ok; never-synced+no-lock -> fail; expired-TTL lock -> fail (wedged not masked); blocked source with banked checkpoint rows but no lock -> still fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): honest --force-break-lock diagnostic when no lock is held --force-break-lock used to emit the same terse "Lock ... is not held (nothing to break)" line and exit 0 even when a sync was genuinely wedged, sending the operator down a dead end — the wedge was not a held lock. Keep rc=0 (breaking a non-existent lock is idempotently successful; flipping the exit code would break automation), but under --force say plainly that nothing was broken and point at the real next step (gbrain sync / gbrain doctor) plus a `wedge_hint` field in --json output. The non-force path is byte-for-byte unchanged. runBreakLock is exported for the test. Tests: force+no-lock -> wedge_hint JSON + human hint, rc 0; non-force+no-lock -> unchanged terse line, no hint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(doctor): surface the in-progress sync holder in the freshness message Plan-completion follow-up to the BUG 4 live-lock signal: when a source is actively syncing, name the holder (pid + host) in the check message instead of silently folding it into synced_recently. The note is appended only when something is in progress, so steady-state messages stay byte-for-byte unchanged (the pinned exact-message + 3-bucket-invariant tests still pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): pre-landing review fixes — monotonic clock seed, scoped CHECK guard Adversarial (codex) review of the implementation diff caught three: - P1 (correctness): the fresh-schema setval was not monotonic. initSchema replays the schema blob, and the unconditional setval(MAX(generation)) could move page_generation_clock_seq.last_value BACKWARD on an already-upgraded brain, letting a stored query_cache bookmark serve stale rows. Seed via GREATEST over the sequence's OWN last_value (+ old table value + MAX(generation)) in all 3 fresh schemas and migration v118, so a replay is idempotent — mirrors the old table's ON CONFLICT DO NOTHING. Pinned by a new monotonic regression test. - P2: v119's CHECK-exists guard keyed on conname only (not globally unique). Scope it to conrelid = 'op_checkpoints'::regclass. - P3: in-progress note ran into the prior sentence in fail/warn doctor messages; separate it with '. '. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: make Anthropic/ZE no-key tests hermetic against a dev config key These "no key" tests cleared only ANTHROPIC_API_KEY / ZEROENTROPY_API_KEY from the env, but hasAnthropicKey() and checkZeEmbeddingHealth() also read the key from ~/.gbrain/config.json. On a dev machine whose real config holds a key, the no-key assertions flipped and the tests failed locally (they passed only in key-less CI). Add a shared with-env emptyHome() helper and point GBRAIN_HOME at an empty dir in every no-key path so loadConfig finds nothing — matching the already-hermetic anthropic-key / gateway-probe tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.44.1.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(key-files): sync doctor + op-checkpoint entries to v0.44.1.0 truth checkSyncFreshness now reports an actively-running sync via the live per-source lock (names holder pid+host, counts as synced_recently) instead of flagging it stale; loadOpCheckpoint gates the legacy union arm on jsonb_typeof = 'array' so a scalar parent can't take down the whole load, and migration v119's CHECK constraint makes the corruption class structurally impossible. Reference docs describe current behavior only — both entries updated in place, no release-clause appends. Guard + llms freshness test green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-version to v0.42.51.0 (natural next-off-master) Maintainer override of the queue allocator's leap to 0.44.1.0 (it jumped past in-flight sibling PR claims at 0.42.50/0.43.0/0.44.0). Take the natural next slot in the 0.42.x line above the immediate sibling claim (0.42.50.0); a merge re-bump resolves any collision if a cathedral PR lands first. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(e2e): bound + retry the OpenClaw install so a transient npm hang can't burn the Tier 2 budget The Tier 2 (LLM Skills) job failed at 30m16s — the `npm install -g openclaw@2026.4.9` step hung on a transient npm/registry stall (orphan `npm install openclaw` was still running at cancel time) and consumed the entire 30m job budget that v0.42.50.0 (#2254) introduced. The install normally finishes in under a minute (Tier 2 is ~4m end to end on master), so this is flaky-install infra, not a test failure. Wrap the install in `timeout 120` + a 3-attempt retry loop with an 8-minute step backstop: a hung attempt is killed in 2 min and retried instead of eating the whole job. Same bound-the-hang philosophy as #2254's job timeouts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
313 lines
13 KiB
TypeScript
313 lines
13 KiB
TypeScript
/**
|
|
* v0.41.13.0 — PGLite tests for the sync break-lock + max-age + abort
|
|
* threading wave.
|
|
*
|
|
* Coverage diagram targets:
|
|
* - tryAcquireDbLock writes last_refreshed_at = NOW() on INSERT (R4 baseline).
|
|
* - withRefreshingLock-style refresh bumps both ttl_expires_at AND
|
|
* last_refreshed_at (R5 + new column).
|
|
* - inspectLock surfaces last_refreshed_at + ms_since_last_refresh.
|
|
* - deleteLockRowIfStale: refuses fresh, breaks stale, holder_pid mismatch
|
|
* refuses, NULL last_refreshed_at refuses (pre-v98-style row).
|
|
* - migration v98 backfills last_refreshed_at = NOW() (R6).
|
|
*
|
|
* Test isolation: canonical PGLite block per CLAUDE.md R3 + R4. No
|
|
* top-level module mocks (R2 — `mock.module` calls leak across files in
|
|
* the shard process); no process.env mutations.
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
import {
|
|
tryAcquireDbLock,
|
|
inspectLock,
|
|
deleteLockRow,
|
|
deleteLockRowIfStale,
|
|
} from '../src/core/db-lock.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
// Also clear gbrain_cycle_locks since resetPgliteState focuses on user data
|
|
// and the lock table is per-test state we want fresh.
|
|
await engine.executeRaw('DELETE FROM gbrain_cycle_locks', []);
|
|
});
|
|
|
|
// Helper: read raw row for assertions against the new column shape.
|
|
async function readLockRow(lockId: string) {
|
|
const rows = await engine.executeRaw<{
|
|
id: string;
|
|
holder_pid: number;
|
|
acquired_at: string;
|
|
ttl_expires_at: string;
|
|
last_refreshed_at: string | null;
|
|
}>(
|
|
`SELECT id, holder_pid, acquired_at, ttl_expires_at, last_refreshed_at
|
|
FROM gbrain_cycle_locks WHERE id = $1`,
|
|
[lockId],
|
|
);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
describe('tryAcquireDbLock writes last_refreshed_at (v0.41.13.0 T5)', () => {
|
|
test('fresh INSERT sets last_refreshed_at to a non-null timestamp', async () => {
|
|
const handle = await tryAcquireDbLock(engine, 'test:fresh-acquire', 30);
|
|
expect(handle).not.toBeNull();
|
|
const row = await readLockRow('test:fresh-acquire');
|
|
expect(row).not.toBeNull();
|
|
expect(row!.last_refreshed_at).not.toBeNull();
|
|
// The acquired_at and last_refreshed_at are set in the same INSERT
|
|
// (both NOW()) so they should be within a few ms of each other.
|
|
const acq = new Date(row!.acquired_at).getTime();
|
|
const ref = new Date(row!.last_refreshed_at!).getTime();
|
|
expect(Math.abs(acq - ref)).toBeLessThan(1000);
|
|
await handle!.release();
|
|
});
|
|
|
|
test('takeover (TTL-expired) refreshes last_refreshed_at too', async () => {
|
|
// Insert a stale lock row directly with TTL already expired AND an OLD
|
|
// last_refreshed_at so we can verify the takeover bumps it.
|
|
const oldTs = new Date(Date.now() - 60 * 60 * 1000).toISOString(); // 1h ago
|
|
await engine.executeRaw(
|
|
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at, last_refreshed_at)
|
|
VALUES ($1, $2, $3, $4, $5, $4)`,
|
|
['test:takeover', 99999, 'fake-host', oldTs, oldTs],
|
|
);
|
|
const before = await readLockRow('test:takeover');
|
|
// PGLite returns timestamps as Date objects; normalize via .toISOString().
|
|
expect(new Date(before!.last_refreshed_at!).toISOString()).toBe(oldTs);
|
|
|
|
const handle = await tryAcquireDbLock(engine, 'test:takeover', 30);
|
|
expect(handle).not.toBeNull();
|
|
|
|
const after = await readLockRow('test:takeover');
|
|
expect(new Date(after!.last_refreshed_at!).toISOString()).not.toBe(oldTs);
|
|
// After takeover, last_refreshed_at should be recent.
|
|
const refMs = new Date(after!.last_refreshed_at!).getTime();
|
|
expect(Date.now() - refMs).toBeLessThan(5000);
|
|
await handle!.release();
|
|
});
|
|
|
|
test('refresh() bumps both ttl_expires_at AND last_refreshed_at', async () => {
|
|
const handle = await tryAcquireDbLock(engine, 'test:refresh', 30);
|
|
expect(handle).not.toBeNull();
|
|
const before = await readLockRow('test:refresh');
|
|
|
|
// Sleep just a hair so the timestamp changes are observable.
|
|
await new Promise(r => setTimeout(r, 50));
|
|
await handle!.refresh();
|
|
|
|
const after = await readLockRow('test:refresh');
|
|
expect(new Date(after!.ttl_expires_at).getTime()).toBeGreaterThan(
|
|
new Date(before!.ttl_expires_at).getTime(),
|
|
);
|
|
expect(new Date(after!.last_refreshed_at!).getTime()).toBeGreaterThan(
|
|
new Date(before!.last_refreshed_at!).getTime(),
|
|
);
|
|
await handle!.release();
|
|
});
|
|
});
|
|
|
|
describe('inspectLock surfaces last_refreshed_at (v0.41.13.0 T5)', () => {
|
|
test('returns last_refreshed_at + ms_since_last_refresh on a live lock', async () => {
|
|
const handle = await tryAcquireDbLock(engine, 'test:inspect', 30);
|
|
expect(handle).not.toBeNull();
|
|
const snap = await inspectLock(engine, 'test:inspect');
|
|
expect(snap).not.toBeNull();
|
|
expect(snap!.last_refreshed_at).toBeInstanceOf(Date);
|
|
expect(snap!.ms_since_last_refresh).not.toBeNull();
|
|
// Fresh acquire → ms_since_last_refresh should be tiny.
|
|
expect(snap!.ms_since_last_refresh!).toBeLessThan(5000);
|
|
await handle!.release();
|
|
});
|
|
|
|
test('returns null for last_refreshed_at when the row has NULL (pre-v98 fallback)', async () => {
|
|
await engine.executeRaw(
|
|
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at, last_refreshed_at)
|
|
VALUES ('test:null-ref', 12345, 'h', NOW(), NOW() + INTERVAL '30 minutes', NULL)`,
|
|
[],
|
|
);
|
|
const snap = await inspectLock(engine, 'test:null-ref');
|
|
expect(snap).not.toBeNull();
|
|
expect(snap!.last_refreshed_at).toBeNull();
|
|
expect(snap!.ms_since_last_refresh).toBeNull();
|
|
});
|
|
|
|
test('returns null for absent lock', async () => {
|
|
const snap = await inspectLock(engine, 'test:does-not-exist');
|
|
expect(snap).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('deleteLockRowIfStale (v0.41.13.0 T4 + D-V4-mech-4/5)', () => {
|
|
test('refuses to break a fresh lock (no rows deleted)', async () => {
|
|
const handle = await tryAcquireDbLock(engine, 'test:fresh', 30);
|
|
expect(handle).not.toBeNull();
|
|
const snap = await inspectLock(engine, 'test:fresh');
|
|
|
|
// max-age 1800s (30 min); lock is fresh → refuse.
|
|
const result = await deleteLockRowIfStale(engine, 'test:fresh', snap!.holder_pid, 1800);
|
|
expect(result.deleted).toBe(false);
|
|
expect(result.lastRefreshedAt).toBeNull();
|
|
|
|
// Row still present after the no-op delete.
|
|
const after = await readLockRow('test:fresh');
|
|
expect(after).not.toBeNull();
|
|
await handle!.release();
|
|
});
|
|
|
|
test('breaks a stale lock (last_refreshed_at older than max-age)', async () => {
|
|
// Insert a row with last_refreshed_at 1 hour ago.
|
|
const oldTs = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
|
await engine.executeRaw(
|
|
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at, last_refreshed_at)
|
|
VALUES ('test:stale', 54321, 'h', NOW(), NOW() + INTERVAL '30 minutes', $1)`,
|
|
[oldTs],
|
|
);
|
|
// max-age 1800s (30 min); lock has not refreshed in 1h → break.
|
|
const result = await deleteLockRowIfStale(engine, 'test:stale', 54321, 1800);
|
|
expect(result.deleted).toBe(true);
|
|
expect(result.lastRefreshedAt).toBeInstanceOf(Date);
|
|
expect(Math.abs(result.lastRefreshedAt!.getTime() - new Date(oldTs).getTime()))
|
|
.toBeLessThan(1000);
|
|
// Row gone.
|
|
expect(await readLockRow('test:stale')).toBeNull();
|
|
});
|
|
|
|
test('refuses on holder_pid mismatch (PID-safe)', async () => {
|
|
const oldTs = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
|
await engine.executeRaw(
|
|
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at, last_refreshed_at)
|
|
VALUES ('test:wrong-pid', 11111, 'h', NOW(), NOW() + INTERVAL '30 minutes', $1)`,
|
|
[oldTs],
|
|
);
|
|
// Even though the lock IS stale, mismatched pid → refuse.
|
|
const result = await deleteLockRowIfStale(engine, 'test:wrong-pid', 22222, 1800);
|
|
expect(result.deleted).toBe(false);
|
|
// Row still present.
|
|
expect(await readLockRow('test:wrong-pid')).not.toBeNull();
|
|
});
|
|
|
|
test('refuses when last_refreshed_at IS NULL (pre-v98 row)', async () => {
|
|
// A row with NULL last_refreshed_at is conservatively kept alive — the
|
|
// operator should run apply-migrations or use --force-break-lock.
|
|
await engine.executeRaw(
|
|
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at, last_refreshed_at)
|
|
VALUES ('test:null-ref-stale', 33333, 'h', NOW() - INTERVAL '2 hours', NOW() + INTERVAL '30 minutes', NULL)`,
|
|
[],
|
|
);
|
|
const result = await deleteLockRowIfStale(engine, 'test:null-ref-stale', 33333, 1800);
|
|
expect(result.deleted).toBe(false);
|
|
expect(await readLockRow('test:null-ref-stale')).not.toBeNull();
|
|
});
|
|
|
|
test('refuses on absent row', async () => {
|
|
const result = await deleteLockRowIfStale(engine, 'test:nonexistent', 12345, 1800);
|
|
expect(result.deleted).toBe(false);
|
|
expect(result.lastRefreshedAt).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('R1 regression: existing deleteLockRow byte-stable', () => {
|
|
test('safe deleteLockRow still works with the new column present', async () => {
|
|
const handle = await tryAcquireDbLock(engine, 'test:r1', 30);
|
|
expect(handle).not.toBeNull();
|
|
const snap = await inspectLock(engine, 'test:r1');
|
|
// Pre-v98 deleteLockRow shape (no maxAge, just id + pid).
|
|
const result = await deleteLockRow(engine, 'test:r1', snap!.holder_pid);
|
|
expect(result.deleted).toBe(true);
|
|
expect(await readLockRow('test:r1')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('R6 regression: schema bootstrap includes last_refreshed_at column', () => {
|
|
test('CREATE TABLE shape (from pglite-schema.ts snapshot) has the column', async () => {
|
|
// information_schema.columns is the canonical introspection. If the
|
|
// column is missing, the SELECT returns 0 rows.
|
|
const rows = await engine.executeRaw<{ column_name: string; data_type: string; is_nullable: string }>(
|
|
`SELECT column_name, data_type, is_nullable
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'gbrain_cycle_locks' AND column_name = 'last_refreshed_at'`,
|
|
[],
|
|
);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].data_type).toMatch(/timestamp/i);
|
|
expect(rows[0].is_nullable).toBe('YES');
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// BUG 5 (v0.42.x) — honest --force-break-lock diagnostic when no lock is held.
|
|
// Previously --force-break-lock emitted the same terse "not held (nothing to
|
|
// break)" line and exited 0, sending operators down a dead end when a sync was
|
|
// wedged for a reason other than a held lock.
|
|
// ============================================================================
|
|
describe('BUG 5 — --force-break-lock honest no-lock diagnostic', () => {
|
|
const LOCK = 'gbrain-sync:wiki';
|
|
|
|
test('force + no lock → wedge_hint in JSON, status absent, rc 0', async () => {
|
|
const { runBreakLock } = await import('../src/commands/sync.ts');
|
|
const logs: string[] = [];
|
|
const orig = console.log;
|
|
console.log = (...a: unknown[]) => { logs.push(a.map(String).join(' ')); };
|
|
let rc: number;
|
|
try {
|
|
rc = await runBreakLock(engine, LOCK, 'wiki', { force: true, json: true });
|
|
} finally {
|
|
console.log = orig;
|
|
}
|
|
expect(rc).toBe(0);
|
|
const parsed = JSON.parse(logs[0]);
|
|
expect(parsed.status).toBe('absent');
|
|
expect(parsed.lock).toBe(LOCK);
|
|
expect(typeof parsed.wedge_hint).toBe('string');
|
|
expect(parsed.wedge_hint).toContain('not a held lock');
|
|
});
|
|
|
|
test('force + no lock → human output carries the wedge hint, not the terse line', async () => {
|
|
const { runBreakLock } = await import('../src/commands/sync.ts');
|
|
const logs: string[] = [];
|
|
const orig = console.log;
|
|
console.log = (...a: unknown[]) => { logs.push(a.map(String).join(' ')); };
|
|
let rc: number;
|
|
try {
|
|
rc = await runBreakLock(engine, LOCK, 'wiki', { force: true, json: false });
|
|
} finally {
|
|
console.log = orig;
|
|
}
|
|
expect(rc).toBe(0);
|
|
const out = logs.join('\n');
|
|
expect(out).toContain('nothing to break');
|
|
expect(out).toContain('gbrain doctor');
|
|
expect(out).not.toBe(`Lock ${LOCK} is not held (nothing to break).`);
|
|
});
|
|
|
|
test('non-force + no lock → unchanged terse line, no wedge_hint', async () => {
|
|
const { runBreakLock } = await import('../src/commands/sync.ts');
|
|
const logs: string[] = [];
|
|
const orig = console.log;
|
|
console.log = (...a: unknown[]) => { logs.push(a.map(String).join(' ')); };
|
|
let rc: number;
|
|
try {
|
|
rc = await runBreakLock(engine, LOCK, 'wiki', { force: false, json: true });
|
|
} finally {
|
|
console.log = orig;
|
|
}
|
|
expect(rc).toBe(0);
|
|
const parsed = JSON.parse(logs[0]);
|
|
expect(parsed.status).toBe('absent');
|
|
expect(parsed.wedge_hint).toBeUndefined();
|
|
});
|
|
});
|