mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(schema): op_checkpoints table + doctor_run_id partial GIN (v67+v68) T1 of brain-health-100 wave. Two new migrations underpin autonomous remediation via Minions: - v67 op_checkpoints — shared checkpoint table for long-running ops (embed, extract, lint, backlinks, reindex, integrity). Pre-fix each op had its own file-backed checkpoint or none. PRIMARY KEY (op, fingerprint) lets `extract links` and `extract timeline` (or `reindex --markdown` vs `--code`) coexist without colliding on shared keys. - v68 minion_jobs_doctor_run_id_idx — partial GIN on `minion_jobs.data WHERE data ? 'doctor_run_id'`. Indexes only doctor-submitted jobs so audit-trail queries don't sequential-scan months of unrelated cron history. PGLite skips via empty sqlFor. Applied to src/schema.sql + src/core/pglite-schema.ts so both engines get the table on fresh-install. Bootstrap coverage test + 122-case migrate test both pass. Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (D12 + folded scope B from outside-voice review). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(core): op-checkpoint module — DB-backed checkpoint primitive T2 of brain-health-100 wave. Six exports plus per-op fingerprint helpers: loadOpCheckpoint(engine, key) → string[] (completed keys; [] if none) recordCompleted(engine, key, ks) → void (UPSERT atomic) clearOpCheckpoint(engine, key) → void (clean-exit drop) resumeFilter(all, completed) → string[] (pure; drives batched walks) purgeStaleCheckpoints(engine, ttl)→ number (cycle purge phase consumer) Fingerprint helpers: fingerprint(params) — sha8 of canonical-JSON embedFingerprint(p) — model+dim+slug+source variation extractFingerprint(p) — mode (links vs timeline) reindexFingerprint(p) — markdown vs code vs slug + chunker_version lintFingerprint, backlinksFingerprint, integrityFingerprint, importFingerprint Canonical-JSON over keys-sorted ensures the same params produce the same fingerprint across runs and hosts. sha8 (8 hex chars from sha256) is short enough for filenames + UI but collision-resistant for the expected per-op invocation diversity. DB-backed for both engines (PGLite has the table too via v67). Lost- write on partial DB failure is non-fatal — caller continues, next run re-walks (cheap for hash-short-circuited ops like embed/import). Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (D12 + codex #10–16 from outside-voice review). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(core): brain-score-recommendations — shared data layer T4 of brain-health-100 wave. Pure module — no engine I/O. Takes a BrainHealth snapshot + RecommendationContext, returns ordered Remediation[] ready to feed the doctor remediation plan OR features --auto-fix. Three public exports: computeRecommendations(health, ctx) → Remediation[] classifyChecks(checks, ctx) → CheckClassification[] maxReachableScore(health, classes) → number (0-100 ceiling) D13 — three-state classification per check: remediable / human_only / blocked. The plan ONLY emits remediable items; blocked surfaces alongside as informational with the missing prereq (no API key, etc.). Closes the spin-loop bug on empty / API-key-missing brains (codex #20). D14 — every Remediation has a stable string id (sync.repo, embed.stale, backlinks.fix, extract.all). depends_on references ids, not check names. D9 — idempotency_key is content-hash from canonical-JSON of params. Same intent across runs = same key; failed-row replay via :r<N> suffix is the --remediate loop's job, not this module's. Scope item +A (cost-budget gate) — Remediation.est_usd_cost populated for embed (chars × pricePerMTok from embedding-pricing.ts) and Anthropic jobs (estimateAnthropicCost helper). doctor --remediate --max-usd N gates submission against est_total_usd_cost. Both consumers (doctor + features per D15) import from here. Features executes inline (D15 contract preserved), doctor submits via queue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(handlers): 11 new Minion handlers + 3 added to PROTECTED + sync noExtract fix T5 of brain-health-100 wave. PROTECTED_JOB_NAMES extension (D11): synthesize, patterns, consolidate. These cycle phases internally submit `subagent` jobs with allowProtectedSubmit=true, so they CAN spend Anthropic credits. Treating them as "data-quality maintenance" was a misread surfaced by the codex outside-voice review (#6). Protected gate ensures only trusted local callers (CLI, autopilot, doctor --remediate) can submit; an OAuth-scoped MCP client can't burn the user's API budget by submitting a synthesize job over HTTP. 11 new handlers registered in jobs.ts registerBuiltinHandlers: PROTECTED (3) — phase-wrappers that spawn subagent children: synthesize, patterns, consolidate Open (8) — DB/fs writes only, no LLM spend: reindex, repair-jsonb, orphans, integrity, purge, extract_facts, resolve_symbol_edges, recompute_emotional_weight Phase-wrappers all delegate to `runCycle({ phases: [name] })` rather than extracting standalone phase functions. Cycle.ts already owns the lock + abort signal + progress reporter per D10, so the wrapper is a one-liner and cycle.ts remains the single source of truth for phase semantics. Pragmatic deviation from the plan's "extract 6 standalone runXxxPhase functions" — smaller diff, equivalent correctness. Standalone `sync` handler now passes `noExtract: true` (codex #5 fix). Pre-fix, doctor's remediation plan emitting [sync, extract] caused double-extraction (performSync inline-extract + standalone extract job). Now sync defers extract to the dedicated handler. Callers that want inline extract pass { noExtract: false } in job params. Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T5 + D10 + D11 + codex #5/#6 from outside-voice review). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(doctor): --remediation-plan + --remediate CLI surfaces T6 of brain-health-100 wave. The headline user-facing capability: agents drive brain health to target score via autonomous Minions remediation. Two new flags on `gbrain doctor`: --remediation-plan [--json] [--target-score N] Read-only. Emits ordered Remediation[] from BrainHealth + context. Uses cheap path (D7) — engine.getHealth() + computeRecommendations, NOT a full doctor walk. JSON shape is stable agent contract. --remediate [--yes] [--target-score N] [--max-jobs N] [--max-usd N] [--dry-run] [--json] Sequential submit (D3) with D5 cascade on failure, D7 scoped recheck between steps, D9 content-hash idempotency keys, D13 three-state remediation filtering (only remediable jobs enter the loop), +A cost-budget gate via --max-usd. Check.remediation field added as additive optional (DoctorReport schema_version stays at 2 per D4). PGLite path: synchronous in-process execution with short polling. Postgres path: durable queue submission with waitForCompletion. The --remediate loop: 1. Compute initial plan from BrainHealth 2. Refuse if --target-score > maxReachableScore(health, classes) 3. Refuse if est_total_usd_cost > --max-usd 4. For each step in order: - Skip if depends_on intersects aborted set (D5) - queue.add with content-hash idempotency_key (D9) - waitForCompletion with timeout - Recompute plan from fresh health (D7 scoped recheck) 5. Exit 0 if all completed; 1 if any failed/aborted doctor_run_id UUID stamps every submitted job's data field so operators can later query `SELECT * FROM minion_jobs WHERE data->>'doctor_run_id' = '<uuid>'` (indexed via v68 partial GIN). Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T6 + D1/D3/D5/D7/D9/D13 + folded scope A). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): maybeBackground helper + apply --background to embed T7 of brain-health-100 wave. New helper in src/core/cli-options.ts formalizes the --background flag pattern. Same semantics in TTY and cron per D9 (submit-and-exit always; --background --follow execs `gbrain jobs follow <id>` after submission). await maybeBackground({ engine, args, jobName: 'embed', paramBuilder: (cleanArgs) => ({ stale, all, ... }), }) // returns true if backgrounded → caller exits Content-hash idempotency key (D9): `cli:embed:sha8(canonical-JSON(params))`. No time-slot. Same intent across runs = same key. Failed-row replay is the doctor --remediate loop's job, not this path's. PGLite degrades to inline execution with a clear stderr note ("PGLite has no worker daemon; running inline"). NOT a no-op, NOT silent — doc-stated semantic difference because PGLite has no worker daemon. Applied to `gbrain embed` as the reference integration. The other 6 commands (extract, lint, backlinks, reindex, integrity, pages) adopt the same 4-line pattern at the top of their entry function — follow-up in a smaller diff once the helper proves out in production. Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T7 + D9 + Gap 6). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(autopilot): targeted-submit loop + op_checkpoints GC in purge phase T8 of brain-health-100 wave. Autopilot dispatch changes (src/commands/autopilot.ts): Pre-fix: every tick submitted ONE autopilot-cycle job, full phase set, regardless of brain state. On a healthy brain pure overhead; on a degraded brain bundled fast wins with slow phases so user waited for the slowest. New decision logic (T8 from plan): - score >= 95 AND empty plan AND <60min since last full → SLEEP - score >= 95 AND empty plan AND >=60min → submit autopilot-cycle (phase-coupling exercise) - plan <= 3 steps AND est_total < 5min → submit individual handlers (targeted; uses D9 content-hash idempotency keys per step; maxWaiting:1 per submit per codex #17) - else → submit autopilot-cycle (the hammer) D10 cycle-lock invariant guarantees targeted-submit and autopilot-cycle can never run concurrently (both acquire gbrain-cycle), closing the "60-min floor double-processes queued targeted jobs" failure mode. Computation uses cheap path (D7) — engine.getHealth() + computeRecommendations, NOT a full doctor walk. Adds ~1 SQL count query per tick; negligible on a 50K-page brain. PROTECTED handlers (synthesize/patterns/consolidate) are submitted with allowProtectedSubmit:true; autopilot is a trusted local caller. Cycle purge phase (src/core/cycle.ts): Added op_checkpoints GC (+C folded scope item). 7-day TTL — any reasonable long-running op finishes inside that window. Non-fatal on pre-v67 brains (table missing). Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T8 + D7/D9/D10 + codex #17 + folded scope +C). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(core): brain-score-recommendations + op-checkpoint unit tests T10 of brain-health-100 wave — load-bearing decision-pinning tests. test/brain-score-recommendations.test.ts (22 cases): - Healthy brain → empty plan - Per-component remediation paths (sync, embed, backlinks, extract) - depends_on wiring (extract → sync; embed → sync when stale) - Severity ordering (critical > high > medium > low) - D6 #5 determinism: same input twice → byte-identical output - D9 idempotency keys: content-hash format, no time-slot - D9 source isolation: different --source → different key - D13 status field always 'remediable' in output - +A cost-estimate populated for embed - classifyChecks: remediable / blocked / human_only triage - maxReachableScore: all-remediable → 100; all-blocked → current test/op-checkpoint.test.ts (20 cases): - fingerprint stability + key-order invariance (canonical-JSON) - codex #11: extract links vs timeline get different fingerprints - codex #12: reindex markdown vs code get different fingerprints - codex #15: embed model+dim variation produces different fingerprints - reindex chunker_version bump invalidates checkpoint - DB round-trip (load → record → load) - Cross-fingerprint isolation (linksKey vs timelineKey) - clearOpCheckpoint idempotency on missing rows - resumeFilter purity (no I/O, deterministic) - purgeStaleCheckpoints TTL respect 42 new tests, all pass. PGLite engine + resetPgliteState pattern per CLAUDE.md test-isolation guide. Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T10 + D6 #5 + D9 + D12 + D13 + codex #11/#12/#15). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release): v0.36.0.0 — brain-health-100 wave + docs/llms refresh T12 of brain-health-100 wave. VERSION + package.json bumped 0.35.6.0 → 0.36.0.0. CHANGELOG entry leads ELI10 ("your agent can now drive your brain to 90/100 by itself, on a cron, without you watching") then drills into the precise mechanics per CLAUDE.md voice rules. llms.txt + llms-full.txt regenerated via bun run build:llms. Trio audit (CLAUDE.md mandatory pre-push check): VERSION: 0.36.0.0 package.json: 0.36.0.0 CHANGELOG: ## [0.36.0.0] - 2026-05-18 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: update README/CLAUDE/AGENTS/maintain for v0.36.4.0 brain-health-100 wave - README.md: New-in-v0.36.4.0 callout — `gbrain doctor --remediate` headline, autopilot health-aware tick, eleven new background-job types, three PROTECTED. - CLAUDE.md: Key Files entries for `op-checkpoint.ts`, `brain-score-recommendations.ts`, doctor.ts / jobs.ts / protected-names.ts / autopilot.ts / cycle.ts / embed.ts / cli-options.ts extensions; new "Key commands added in v0.36.4.0" section. - AGENTS.md: Common-tasks entry pointing agents at the one-command remediation loop. - skills/maintain/SKILL.md: Autonomous Phase (gbrain doctor --remediate) at the top, manual per-dimension walk preserved as the fallback path. - llms-full.txt: regenerated to pick up the CLAUDE.md changes (project rule). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(changelog): respectful tone on spend caps for v0.36.4.0 Reframed the cost-budget callout. Pre-fix language said the spend cap prevents a synthesize loop from "burning $100 of Anthropic credits while you're at lunch" — casually treating $100 as the throwaway number is tone-deaf. $100 is a meaningful amount for many people. New language: "spend cap so a synthesize loop can't run up your Anthropic bill while you're at lunch. The cap is yours to set per run." And: "Pass --max-usd 5 (or whatever cap you're comfortable with)." And: "Pick the cap that fits your wallet." Also reframed three adjacent lines: - "healthy brains stop burning cycles" → "stop spending tokens on work that has nothing to do" - "agent can't submit them and burn your API budget" → "can't submit them on your behalf. Your provider bill stays in your hands" - Table cell "Cron with cost cap" / "--max-usd 5" → "Cron with spend cap" / "--max-usd N" llms-full.txt regenerated to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
181 lines
6.2 KiB
TypeScript
181 lines
6.2 KiB
TypeScript
import { describe, test, expect, beforeAll, beforeEach, afterAll } from 'bun:test';
|
||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||
import {
|
||
loadOpCheckpoint,
|
||
recordCompleted,
|
||
clearOpCheckpoint,
|
||
resumeFilter,
|
||
purgeStaleCheckpoints,
|
||
fingerprint,
|
||
embedFingerprint,
|
||
extractFingerprint,
|
||
reindexFingerprint,
|
||
} from '../src/core/op-checkpoint.ts';
|
||
|
||
/**
|
||
* D12 pinning tests for src/core/op-checkpoint.ts.
|
||
*
|
||
* Closes codex #10–#16:
|
||
* - per-param fingerprint scoping (no cross-mode collisions)
|
||
* - DB-backed CRUD works on PGLite (single-host fallback path)
|
||
* - resumeFilter is pure
|
||
* - purgeStaleCheckpoints respects TTL
|
||
*/
|
||
|
||
let engine: PGLiteEngine;
|
||
|
||
beforeAll(async () => {
|
||
engine = new PGLiteEngine();
|
||
await engine.connect({});
|
||
await engine.initSchema();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await engine.disconnect();
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
await resetPgliteState(engine);
|
||
});
|
||
|
||
describe('fingerprint helpers', () => {
|
||
test('fingerprint: stable across runs', () => {
|
||
const params = { stale: true, source: 'default' };
|
||
expect(fingerprint(params)).toBe(fingerprint(params));
|
||
});
|
||
|
||
test('fingerprint: key order does not matter (canonical-JSON)', () => {
|
||
const a = fingerprint({ a: 1, b: 2 });
|
||
const b = fingerprint({ b: 2, a: 1 });
|
||
expect(a).toBe(b);
|
||
});
|
||
|
||
test('fingerprint: different values produce different hashes', () => {
|
||
expect(fingerprint({ a: 1 })).not.toBe(fingerprint({ a: 2 }));
|
||
});
|
||
|
||
test('fingerprint returns 8 hex chars', () => {
|
||
expect(fingerprint({ x: 1 })).toMatch(/^[a-f0-9]{8}$/);
|
||
});
|
||
|
||
test('codex #11: extract links vs timeline get different fingerprints', () => {
|
||
const linksFp = extractFingerprint({ mode: 'links', source: 'default' });
|
||
const timelineFp = extractFingerprint({ mode: 'timeline', source: 'default' });
|
||
expect(linksFp).not.toBe(timelineFp);
|
||
});
|
||
|
||
test('codex #12: reindex markdown vs code get different fingerprints', () => {
|
||
const md = reindexFingerprint({ markdown: true, chunker_version: 2 });
|
||
const code = reindexFingerprint({ code: true, chunker_version: 2 });
|
||
expect(md).not.toBe(code);
|
||
});
|
||
|
||
test('codex #15: embed model+dim variation produces different fingerprints', () => {
|
||
const a = embedFingerprint({
|
||
stale: true,
|
||
embedding_model: 'openai:text-embedding-3-large',
|
||
embedding_dimensions: 3072,
|
||
});
|
||
const b = embedFingerprint({
|
||
stale: true,
|
||
embedding_model: 'voyage:voyage-3',
|
||
embedding_dimensions: 1024,
|
||
});
|
||
expect(a).not.toBe(b);
|
||
});
|
||
|
||
test('reindex chunker_version bump invalidates checkpoint', () => {
|
||
const v1 = reindexFingerprint({ markdown: true, chunker_version: 1 });
|
||
const v2 = reindexFingerprint({ markdown: true, chunker_version: 2 });
|
||
expect(v1).not.toBe(v2);
|
||
});
|
||
});
|
||
|
||
describe('loadOpCheckpoint / recordCompleted / clearOpCheckpoint', () => {
|
||
test('empty checkpoint returns []', async () => {
|
||
const result = await loadOpCheckpoint(engine, { op: 'embed', fingerprint: 'abc12345' });
|
||
expect(result).toEqual([]);
|
||
});
|
||
|
||
test('round-trip: write then read', async () => {
|
||
const key = { op: 'embed', fingerprint: 'abc12345' };
|
||
await recordCompleted(engine, key, ['chunk-1', 'chunk-2', 'chunk-3']);
|
||
const result = await loadOpCheckpoint(engine, key);
|
||
expect(result.sort()).toEqual(['chunk-1', 'chunk-2', 'chunk-3']);
|
||
});
|
||
|
||
test('write overwrites prior state', async () => {
|
||
const key = { op: 'embed', fingerprint: 'abc12345' };
|
||
await recordCompleted(engine, key, ['chunk-1']);
|
||
await recordCompleted(engine, key, ['chunk-1', 'chunk-2']);
|
||
const result = await loadOpCheckpoint(engine, key);
|
||
expect(result.sort()).toEqual(['chunk-1', 'chunk-2']);
|
||
});
|
||
|
||
test('different fingerprints stay isolated', async () => {
|
||
const linksKey = { op: 'extract', fingerprint: 'fp-links' };
|
||
const timelineKey = { op: 'extract', fingerprint: 'fp-timeline' };
|
||
await recordCompleted(engine, linksKey, ['file-a.md']);
|
||
await recordCompleted(engine, timelineKey, ['file-b.md']);
|
||
|
||
const links = await loadOpCheckpoint(engine, linksKey);
|
||
const timeline = await loadOpCheckpoint(engine, timelineKey);
|
||
|
||
expect(links).toEqual(['file-a.md']);
|
||
expect(timeline).toEqual(['file-b.md']);
|
||
});
|
||
|
||
test('clearOpCheckpoint drops the row', async () => {
|
||
const key = { op: 'embed', fingerprint: 'to-clear' };
|
||
await recordCompleted(engine, key, ['x']);
|
||
expect(await loadOpCheckpoint(engine, key)).toEqual(['x']);
|
||
await clearOpCheckpoint(engine, key);
|
||
expect(await loadOpCheckpoint(engine, key)).toEqual([]);
|
||
});
|
||
|
||
test('clearOpCheckpoint on missing row is no-op (idempotent)', async () => {
|
||
// Should not throw and load should still return [] afterwards
|
||
await clearOpCheckpoint(engine, { op: 'never-written', fingerprint: 'nope' });
|
||
const after = await loadOpCheckpoint(engine, { op: 'never-written', fingerprint: 'nope' });
|
||
expect(after).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('resumeFilter (pure)', () => {
|
||
test('empty completed returns all', () => {
|
||
expect(resumeFilter(['a', 'b', 'c'], [])).toEqual(['a', 'b', 'c']);
|
||
});
|
||
|
||
test('filters out completed keys', () => {
|
||
expect(resumeFilter(['a', 'b', 'c', 'd'], ['b', 'd'])).toEqual(['a', 'c']);
|
||
});
|
||
|
||
test('no completed keys present in all: identity', () => {
|
||
expect(resumeFilter(['a'], ['z'])).toEqual(['a']);
|
||
});
|
||
|
||
test('all completed: returns empty', () => {
|
||
expect(resumeFilter(['a', 'b'], ['a', 'b'])).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('purgeStaleCheckpoints', () => {
|
||
test('no stale rows: returns 0', async () => {
|
||
await recordCompleted(engine, { op: 'embed', fingerprint: 'fresh' }, ['x']);
|
||
const purged = await purgeStaleCheckpoints(engine, 7);
|
||
expect(purged).toBe(0);
|
||
});
|
||
|
||
test('purges rows older than TTL', async () => {
|
||
// Insert a fake old row directly
|
||
await engine.executeRaw(
|
||
`INSERT INTO op_checkpoints (op, fingerprint, completed_keys, updated_at)
|
||
VALUES ('embed', 'old', '["x"]'::jsonb, now() - interval '10 days')`,
|
||
);
|
||
const purged = await purgeStaleCheckpoints(engine, 7);
|
||
expect(purged).toBe(1);
|
||
expect(await loadOpCheckpoint(engine, { op: 'embed', fingerprint: 'old' })).toEqual([]);
|
||
});
|
||
});
|