mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(doctor): doctor-categories foundation — BRAIN/SKILL/OPS/META sets + drift guard
Categorizes every doctor check name into exactly one of four categories. Exported
constants + categorizeCheck(name) helper are the single source of truth for the
v0.41.20.0 brain_checks_score + category_scores + --scope=brain wave. Drift guard
test parses doctor.ts source for both inline {name: 'foo'} and helper
const name = 'foo' patterns; CI fires if any check name lacks a category.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(doctor): brain_checks_score + category_scores + --scope=brain skip-computation
Extends Check with optional category and DoctorReport with brain_checks_score +
category_scores (additive — schema_version stays at 2; back-compat health_score
math byte-identical). buildChecks gains --scope=brain with explicit early-skip
gates around the SKILL check group (resolver_health + skill_conformance +
skill_brain_first + whoknows_health). Sub-second doctor on a brain with thousands
of skills. computeDoctorReport tags every check via categorizeCheck() at compute
time. Human output leads with the brain figure and renders the weighted
BrainHealth.brain_score alongside.
Test seam fix in test/doctor-home-dir-in-worktree.test.ts: the pre-existing
fragile JSON parser walked back from "checks" to find the envelope's outer
brace; v0.41.20.0's new nested category_scores object broke that heuristic.
Anchored on the canonical {"schema_version" envelope prefix instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(status): gbrain status — single-screen brain health dashboard + get_status_snapshot MCP op
NEW gbrain status command (src/commands/status.ts) composes 6 sections:
sync (per-source last_sync_at + staleness via buildSyncStatusReport),
cycle (TWO rows: last autopilot-cycle + last autopilot-* of any kind —
reflects v0.36.4.0 health-aware autopilot's targeted handler routing;
totals read from result.report.totals per the canonical handler shape),
locks (gbrain_cycle_locks active rows), workers (readSupervisorEvents +
summarizeCrashes), queue (LIVE counts NO time-window — old stuck jobs
are exactly what status surfaces), autopilot (PID liveness via kill -0).
Stable --json envelope (schema_version: 1). Exit codes 0=ok / 1=snapshot
failed / 2=usage. --section filter.
Thin-client mode routes Sync + Cycle through NEW get_status_snapshot MCP
op (admin scope, NOT localOnly; payload deliberately omits Locks /
Workers / Queue / Autopilot so feature creep can't quietly widen the
admin-scoped data exposure). Local-only sections render "local-only —
N/A on remote brain" honestly instead of pretending the local install's
empty state is the remote brain's.
CLI dispatch: pre-engine-bind branch for thin-client (no PGLite needed)
+ engine-connected dispatch case for local mode. CLI-only architecture
per codex MAJOR-4 (status owns its own thin-client branch inside
runStatus, not routed through op dispatch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version to v0.41.20.0 + CHANGELOG + TODOS + llms regen
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(doctor-categories): categorize batch_retry_health from v0.41.19.0 Supavisor wave
The drift guard correctly caught a new check introduced by master's v0.41.19.0
Supavisor Retry Cathedral (PR #1537). batch_retry_health surfaces batch-write
retry events from the new src/core/audit/batch-retry-audit.ts module — OPS
category (infrastructure liveness).
This is exactly why the drift guard exists: any future check added to doctor.ts
without a category entry fails CI immediately instead of silently degrading
to 'meta'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
204 lines
7.1 KiB
TypeScript
204 lines
7.1 KiB
TypeScript
/**
|
|
* E2E `gbrain status` against a real PGLite brain.
|
|
*
|
|
* Seeds:
|
|
* - 1 source row
|
|
* - 1 `autopilot-cycle` completed row with `result.report.totals`
|
|
* - 1 `autopilot-embed` completed row (newer; covers the dual-row
|
|
* "Last full" + "Last targeted" output per D3)
|
|
* - 1 active gbrain_cycle_locks row
|
|
* - some minion_jobs counts (waiting/active/dead)
|
|
*
|
|
* Asserts:
|
|
* - dual cycle rows surface (full < targeted in timestamp)
|
|
* - cycle totals come from `result.report.totals`, NOT `result.totals`
|
|
* (codex MINOR-3)
|
|
* - JSON envelope shape (schema_version: 1)
|
|
* - active lock surfaces
|
|
* - live queue counts (NO time-window filter — codex MAJOR-6)
|
|
*/
|
|
|
|
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 { runStatus } from '../../src/commands/status.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);
|
|
});
|
|
|
|
async function seedAutopilotCycle(engine: PGLiteEngine, opts: {
|
|
name: string;
|
|
finishedAt: string;
|
|
totals?: Record<string, unknown>;
|
|
}) {
|
|
const result = opts.totals
|
|
? { partial: false, status: 'ok', report: { totals: opts.totals } }
|
|
: { partial: false, status: 'ok', report: {} };
|
|
await engine.executeRaw(
|
|
`INSERT INTO minion_jobs (queue, name, data, status, started_at, finished_at, result)
|
|
VALUES ('default', $1, '{}'::jsonb, 'completed',
|
|
$2::timestamptz - INTERVAL '5 seconds', $2::timestamptz, $3::jsonb)`,
|
|
[opts.name, opts.finishedAt, JSON.stringify(result)],
|
|
);
|
|
}
|
|
|
|
async function seedSource(engine: PGLiteEngine, id: string) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`,
|
|
[id, id],
|
|
);
|
|
}
|
|
|
|
describe('gbrain status E2E (PGLite)', () => {
|
|
test('JSON envelope shape includes schema_version + sync + cycle + locks + workers + queue + autopilot', async () => {
|
|
await seedSource(engine, 'default');
|
|
let jsonOut = '';
|
|
const r = await runStatus(engine, ['--json'], {
|
|
stdout: (s) => {
|
|
jsonOut += s;
|
|
},
|
|
stderr: () => {},
|
|
});
|
|
expect(r.exitCode).toBe(0);
|
|
const parsed = JSON.parse(jsonOut.trim());
|
|
expect(parsed.schema_version).toBe(1);
|
|
expect(parsed.mode).toBe('local');
|
|
expect(parsed).toHaveProperty('sync');
|
|
expect(parsed).toHaveProperty('cycle');
|
|
expect(parsed).toHaveProperty('locks');
|
|
expect(parsed).toHaveProperty('workers');
|
|
expect(parsed).toHaveProperty('queue');
|
|
expect(parsed).toHaveProperty('autopilot');
|
|
});
|
|
|
|
test('dual cycle rows: last_full + last_targeted surface independently', async () => {
|
|
await seedSource(engine, 'default');
|
|
// Older full cycle row + newer targeted (embed) row.
|
|
await seedAutopilotCycle(engine, {
|
|
name: 'autopilot-cycle',
|
|
finishedAt: '2026-05-20T10:00:00Z',
|
|
totals: { synth_pages_written: 7, facts_consolidated: 12 },
|
|
});
|
|
await seedAutopilotCycle(engine, {
|
|
name: 'autopilot-embed',
|
|
finishedAt: '2026-05-26T22:30:00Z',
|
|
totals: { chunks_embedded: 100 },
|
|
});
|
|
|
|
let jsonOut = '';
|
|
await runStatus(engine, ['--json'], {
|
|
stdout: (s) => {
|
|
jsonOut += s;
|
|
},
|
|
stderr: () => {},
|
|
});
|
|
const parsed = JSON.parse(jsonOut.trim());
|
|
expect(parsed.cycle.last_full).toBeDefined();
|
|
expect(parsed.cycle.last_full.name).toBe('autopilot-cycle');
|
|
expect(parsed.cycle.last_full.totals).toEqual({
|
|
synth_pages_written: 7,
|
|
facts_consolidated: 12,
|
|
});
|
|
expect(parsed.cycle.last_targeted).toBeDefined();
|
|
// Last targeted should be the newer autopilot-embed row (since it
|
|
// matches `name LIKE 'autopilot-%'` and is newer than autopilot-cycle).
|
|
expect(parsed.cycle.last_targeted.name).toBe('autopilot-embed');
|
|
expect(parsed.cycle.last_targeted.totals).toEqual({ chunks_embedded: 100 });
|
|
});
|
|
|
|
test('cycle totals come from result.report.totals, NOT result.totals (codex MINOR-3)', async () => {
|
|
await seedSource(engine, 'default');
|
|
// Hand-craft a row where totals are mis-placed at the top level — the
|
|
// status renderer should IGNORE them (returns null), not silently surface
|
|
// the wrong shape.
|
|
await engine.executeRaw(
|
|
`INSERT INTO minion_jobs (queue, name, data, status, started_at, finished_at, result)
|
|
VALUES ('default', 'autopilot-cycle', '{}'::jsonb, 'completed',
|
|
NOW() - INTERVAL '5 seconds', NOW(),
|
|
'{"totals":{"wrong_place":42}}'::jsonb)`,
|
|
);
|
|
let jsonOut = '';
|
|
await runStatus(engine, ['--json'], {
|
|
stdout: (s) => {
|
|
jsonOut += s;
|
|
},
|
|
stderr: () => {},
|
|
});
|
|
const parsed = JSON.parse(jsonOut.trim());
|
|
expect(parsed.cycle.last_full).toBeDefined();
|
|
// totals at the WRONG path should NOT be surfaced.
|
|
expect(parsed.cycle.last_full.totals).toBeNull();
|
|
});
|
|
|
|
test('active lock surfaces in the locks section', async () => {
|
|
await seedSource(engine, 'default');
|
|
await engine.executeRaw(
|
|
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at, last_refreshed_at)
|
|
VALUES ('gbrain-cycle', 1234, 'test-host', NOW(), NOW() + INTERVAL '30 minutes', NOW())`,
|
|
);
|
|
let jsonOut = '';
|
|
await runStatus(engine, ['--json'], {
|
|
stdout: (s) => {
|
|
jsonOut += s;
|
|
},
|
|
stderr: () => {},
|
|
});
|
|
const parsed = JSON.parse(jsonOut.trim());
|
|
expect(Array.isArray(parsed.locks)).toBe(true);
|
|
expect(parsed.locks).toHaveLength(1);
|
|
expect(parsed.locks[0].id).toBe('gbrain-cycle');
|
|
expect(parsed.locks[0].holder_pid).toBe(1234);
|
|
});
|
|
|
|
test('live queue counts include OLD waiting/active jobs (no time window — codex MAJOR-6)', async () => {
|
|
await seedSource(engine, 'default');
|
|
// Seed an OLD waiting job (created 10 days ago). The status query MUST
|
|
// surface it — that's exactly the kind of stuck job operators want to see.
|
|
await engine.executeRaw(
|
|
`INSERT INTO minion_jobs (queue, name, data, status, created_at)
|
|
VALUES ('default', 'stale-waiting', '{}'::jsonb, 'waiting',
|
|
NOW() - INTERVAL '10 days')`,
|
|
);
|
|
let jsonOut = '';
|
|
await runStatus(engine, ['--json'], {
|
|
stdout: (s) => {
|
|
jsonOut += s;
|
|
},
|
|
stderr: () => {},
|
|
});
|
|
const parsed = JSON.parse(jsonOut.trim());
|
|
expect(parsed.queue.waiting).toBe(1);
|
|
});
|
|
|
|
test('--section sync emits only the sync section (filter works)', async () => {
|
|
await seedSource(engine, 'default');
|
|
let jsonOut = '';
|
|
await runStatus(engine, ['--json', '--section', 'sync'], {
|
|
stdout: (s) => {
|
|
jsonOut += s;
|
|
},
|
|
stderr: () => {},
|
|
});
|
|
const parsed = JSON.parse(jsonOut.trim());
|
|
expect(parsed.sync).toBeDefined();
|
|
expect(parsed.cycle).toBeUndefined();
|
|
expect(parsed.locks).toBeUndefined();
|
|
expect(parsed.workers).toBeUndefined();
|
|
expect(parsed.queue).toBeUndefined();
|
|
expect(parsed.autopilot).toBeUndefined();
|
|
});
|
|
});
|