diff --git a/CHANGELOG.md b/CHANGELOG.md index ee0b34fc6..a96d992e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,161 @@ All notable changes to GBrain will be documented in this file. +## [0.41.20.0] - 2026-05-26 + +**One command tells you if your brain is healthy. And `gbrain doctor` +no longer says your brain is broken when it's actually your skills +folder that needs attention.** + +Two friction items, both about restoring signal-to-noise. Until this +release: there was no single command to answer "is my brain working?" +You ran `gbrain sources status`, `gbrain stats`, `gbrain jobs +supervisor status`, `gbrain jobs list`, and tailed audit logs. +Separately, `gbrain doctor` would tell you your brain health was 15/100 +when the actual brain was fine — the score was being dragged down by +504 RESOLVER.md warnings from an OpenClaw skills tree. + +After this release: `gbrain status` shows sync, last cycle, locks, +workers, queue, and autopilot state on one screen. `gbrain doctor` +leads with a brain-only figure and renders skill/ops/meta scores +alongside, so a polluted overall score doesn't lie to you about your +actual data. + +What you can do that you couldn't before: + +- `gbrain status` — single-screen dashboard. Per-source sync + freshness, last full cycle vs last targeted run, active locks, + worker health, live queue depth (including OLD stuck jobs — the + whole point of surfacing them), autopilot daemon liveness. +- `gbrain status --json` — stable monitoring envelope + (`schema_version: 1`). Exit codes 0=ok, 1=snapshot-failed, 2=usage. +- `gbrain status --section sync` — drill into one section. +- `gbrain status` against a thin-client install — Sync + Cycle route + through a new admin-scope `get_status_snapshot` MCP op; local-only + sections render `local-only — N/A on remote brain` instead of + pretending the local install's empty state is the remote brain's. +- `gbrain doctor --scope=brain` — sub-second on a brain with thousands + of skills. Skips computation of the SKILL check group (resolver + walk, skill conformance scan, brain-first audit, whoknows health) + entirely. Real skip, not just an output filter. +- `gbrain doctor` JSON envelope adds `brain_checks_score` and + `category_scores` (brain/skill/ops/meta), each computed with the + same penalty math (100 − 20×fails − 5×warns) restricted to the + named category. The legacy `health_score` field is unchanged and + remains byte-identical for any fixed check set (back-compat for + every existing MCP consumer + CI gate). +- Doctor human output leads with the brain figure, then shows + skill/ops/meta subscores and the existing weighted + `BrainHealth.brain_score` alongside, so operators see both lenses + without confusion. + +The two scores are intentionally orthogonal: +- `BrainHealth.brain_score` — weighted data-composition health + (35/25/15/15/10). "How healthy is my brain's data?" +- `brain_checks_score` — penalty over brain-category check failures. + "How many of the brain-category doctor checks failed?" + +## To take advantage of v0.41.20.0 + +`gbrain upgrade` should do this automatically. Try: + +```bash +gbrain status # see the dashboard +gbrain doctor --scope=brain # brain-only doctor, sub-second +gbrain doctor --json | jq .category_scores +gbrain doctor --json | jq .brain_checks_score +``` + +If any step fails or the numbers look wrong, please file an issue at +https://github.com/garrytan/gbrain/issues with the output of +`gbrain doctor` + the contents of `~/.gbrain/upgrade-errors.jsonl` if +it exists. + +### Itemized changes + +- **`gbrain status` (NEW command).** `src/commands/status.ts` + orchestrates 6 section renderers (sync, cycle, locks, workers, + queue, autopilot). Composes existing primitives — + `buildSyncStatusReport` from sync.ts, `readSupervisorEvents` + + `summarizeCrashes` from `supervisor-audit.ts`, direct queries + against `gbrain_cycle_locks` and `minion_jobs`, `kill -0` probe of + `~/.gbrain/autopilot.lock`. No new schema, no new persistence. + Cycle section shows TWO rows ("Last full cycle" = latest + `autopilot-cycle` row, "Last targeted run" = latest `autopilot-*` + row of any kind) to reflect v0.36.4.0's health-aware autopilot + where targeted handlers fire most ticks and a full cycle every + ~60min. Cycle totals are read from `result.report.totals` (the + canonical handler output shape). Queue counts are LIVE (no + time-window filter) so old stuck `waiting` / `active` jobs surface + — that's the failure mode this dashboard exists to expose. +- **`get_status_snapshot` MCP op (NEW).** `src/core/operations.ts`. + Admin scope (NOT localOnly). Payload: + `{schema_version: 1, sync, cycle}` only. Locks / Workers / Queue / + Autopilot deliberately omitted from the remote payload — they're + local-host concerns; the local CLI renders them as "N/A on remote + brain" in thin-client mode instead of pretending the local + install's state is the remote brain's. Admin-scoped from day one + to prevent future feature creep (adding locks/workers counters) + from quietly widening the data exposed under a read-scoped token. +- **`src/core/doctor-categories.ts` (NEW).** Single source of truth + for check categorization. Four exported sets + (BRAIN/SKILL/OPS/META) cover every check name in + `src/commands/doctor.ts` today. The `categorizeCheck(name)` helper + maps unknown names to `'meta'` with a once-per-process stderr warn + so drift surfaces in dev runs before CI catches it. +- **Doctor extensions.** `src/commands/doctor.ts` extends `Check` + with an optional `category?` field and `DoctorReport` with + `brain_checks_score` + `category_scores` (additive — + `schema_version` stays at 2). `computeDoctorReport` tags each check + via `categorizeCheck()` at compute time. `buildChecks` accepts a + `--scope=brain` arg that gates the SKILL check group behind + explicit early-skip branches at each call site (resolver walk, + skill conformance, skill brain-first audit, whoknows health). + Human output leads with the brain figure and renders the weighted + `BrainHealth.brain_score` alongside. +- **`gbrain doctor --remediate` UNCHANGED.** + `src/core/brain-score-recommendations.ts` is not touched. Codex's + outside-voice review caught that `--remediate` already correctly + reads `engine.getHealth().brain_score` (the weighted 35/25/15/15/10 + composite), NOT the doctor's polluted `health_score`. Switching it + to a new field would have replaced a weighted brain metric with a + coarser category counter — a regression the review caught before + any code was written. +- **CLI dispatch.** `src/cli.ts` registers `status` in `CLI_ONLY` and + adds two dispatch sites: a pre-engine-bind branch for thin-client + mode (Sync + Cycle via MCP op, no PGLite needed) and a normal + engine-connected dispatch case for local mode. Architecture: + status is CLI-only with its own thin-client branch inside + `runStatus` (not routed through op dispatch), the only + architecture that honestly composes local-only sections. +- **Tests (7 new files).** + - `test/doctor-categories.test.ts` — drift guard: every check name + in doctor.ts source is categorized exactly once; CI fires loudly + on missed additions. Catches both `name: 'foo'` inline form and + `const name = 'foo'` helper-function form. + - `test/doctor-brain-checks-score.test.ts` — back-compat + byte-identity on `health_score` for a fixed check set; new field + math; renaming regression that asserts NO `brain_health_score` + field ships (it's `brain_checks_score` to avoid colliding with + the weighted `BrainHealth.brain_score`). + - `test/doctor-scope-filter.test.ts` — `--scope=brain` skips + computation of SKILL group entirely; observable absence of + `resolver_health` / `skill_conformance` / `skill_brain_first` / + `whoknows_health` from the returned checks list. + - `test/status-sections.test.ts` — `parseSectionFlag` validation + + exit code policy + runStatus shape. + - `test/get-status-snapshot-op.test.ts` — op definition pins + (scope is admin, localOnly false, payload omits + Locks/Workers/Queue/Autopilot). + - `test/e2e/status-pglite.test.ts` — full PGLite E2E with seeded + minion_jobs rows, gbrain_cycle_locks rows, and supervisor audit. + Asserts dual cycle rows, totals from `result.report.totals`, + live queue counts (no time window). + - `test/doctor-home-dir-in-worktree.test.ts` (UPDATED) — fixed the + pre-existing fragile JSON parser to anchor on the canonical + `{"schema_version"` envelope prefix instead of walking back from + `"checks"` (which broke once `category_scores` introduced a + nested object between). ## [0.41.19.0] - 2026-05-26 **Your dream cycle stops silently losing wiki links.** diff --git a/TODOS.md b/TODOS.md index dc5e22dab..4bb3d0c1e 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,5 +1,49 @@ # TODOS +## v0.41.19.0 status + doctor-categories wave follow-ups (v0.42+) + +- **TODO-V19-A (P3)**: Persistent `cycle_runs` table. v0.41.19.0 infers + "last full cycle" by querying `minion_jobs WHERE name = 'autopilot-cycle'` + for the most recent completed row. This works but conflates "cycle ran + via the autopilot scheduler" with "cycle ran." A dedicated `cycle_runs` + table written from `runCycle` directly would let `gbrain status` + surface manual `gbrain dream` invocations + per-source partial cycles + separately. Defer until the inference's accuracy limits actually bite + someone. + +- **TODO-V19-B (P2)**: Surface `extract_atoms` + `synthesize_concepts` + counts in `CycleReport.totals` top-level. Today the counts live inside + each phase's `details` field; the v0.41.19.0 `gbrain status` cycle + section can't surface them without per-phase parsing. Bump the + `CycleReport.totals` shape additively (the existing field is + documented as additive) and add `atoms_inserted` + + `concepts_inserted` next to `facts_consolidated`. + +- **TODO-V19-C (P3)**: Check-registry refactor for `gbrain doctor`. The + v0.41.19.0 `--scope=brain` uses explicit early-skip gates inline at + each call site (~40 LOC across resolver + skill_conformance + + skill_brain_first + whoknows). If we want to add more scope + dimensions later (e.g. `--scope=ops`, `--exclude-skill`), the right + next step is a check registry: each check declares + `{name, category, run}`, `buildChecks` becomes "run all entries + whose category is in scope." ~300 LOC, touches every check site. + Considered + rejected for v0.41.19.0 as too large for a single fix + wave (D9-B option in the plan). + +- **TODO-V19-D (P3)**: Read installed launchd/cron/systemd schedule + to compute a real "next autopilot tick" timestamp. v0.41.19.0 + status surfaces "Autopilot: running (PID N)" instead. Cross-OS + scheduler probing is a separate, larger problem; macOS launchd + plist parsing alone is ~80 LOC. + +- **TODO-V19-E (P2)**: Apply category-aware exit codes to + `gbrain doctor`. Today doctor exits 0 on all-ok, 1 on any fail. + After categorization, a CI gate could opt into "fail only on + brain-category failures" via `--scope=brain` (already shipping) or + a `--fail-on=brain` flag. Filing this as a discoverability + follow-up — the `--scope=brain` flag already covers most of the + use case. + ## v0.41.18.0 onboard wave follow-ups (v0.42.1+) - **TODO-A (P2)**: Pack-aware `linkable: boolean` per-type field on schema-pack diff --git a/VERSION b/VERSION index c43de66e6..a97bcf086 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.41.19.0 \ No newline at end of file +0.41.20.0 \ No newline at end of file diff --git a/package.json b/package.json index 9a93555c8..7c9b08ba9 100644 --- a/package.json +++ b/package.json @@ -140,5 +140,5 @@ "bun": ">=1.3.10" }, "license": "MIT", - "version": "0.41.19.0" + "version": "0.41.20.0" } diff --git a/src/cli.ts b/src/cli.ts index cf3b886a7..07890c7f1 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,7 +35,7 @@ for (const op of operations) { } // CLI-only commands that bypass the operation layer -const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser']); +const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status']); // CLI-only commands whose handlers print their own --help text. These are // excluded from the generic short-circuit so detailed per-command and // per-subcommand usage stays reachable. @@ -1175,6 +1175,20 @@ async function handleCliOnly(command: string, args: string[]) { } } + // v0.41.19.0: `gbrain status` on thin-client installs bypasses connectEngine + // entirely — Sync + Cycle route through the `get_status_snapshot` MCP op, + // and local-only sections render as "N/A on remote brain". Local mode falls + // through to the engine-connected dispatch path below. (`args` here is the + // subArgs slice already — no need to re-slice past the command.) + if (command === 'status') { + const cfgPre = loadConfig(); + if (cfgPre && isThinClient(cfgPre)) { + const { runStatus } = await import('./commands/status.ts'); + const result = await runStatus(null, args); + process.exit(result.exitCode); + } + } + // v0.37 fix wave (Lane D.4 + CDX2-12): short-circuit `gbrain sync --help` // BEFORE the engine bind. runSync has its own --help branch but can't // reach it without an engine — which means a user running `--help` from @@ -1422,6 +1436,17 @@ async function handleCliOnly(command: string, args: string[]) { await runAnomalies(engine, args); break; } + // v0.41.19.0 — `gbrain status`: single-screen brain health dashboard. + // CLI-only with own thin-client branch INSIDE runStatus (per D2 + codex + // MAJOR-4 architecture). Composes existing exports: buildSyncStatusReport, + // readSupervisorEvents, gbrain_cycle_locks, minion_jobs. + case 'status': { + const { runStatus } = await import('./commands/status.ts'); + const result = await runStatus(engine, args); + process.exit(result.exitCode); + // eslint-disable-next-line no-unreachable + break; + } // v0.38 — Capture: single human-facing entrypoint for ingestion. case 'capture': { const { runCapture } = await import('./commands/capture.ts'); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 143c70e80..e3daf6c30 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -20,6 +20,7 @@ import { import { loadCompletedMigrations } from '../core/preferences.ts'; import { compareVersions } from './migrations/index.ts'; import { createProgress, startHeartbeat, type ProgressReporter } from '../core/progress.ts'; +import { categorizeCheck, type CheckCategory } from '../core/doctor-categories.ts'; import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts'; import type { DbUrlSource } from '../core/config.ts'; import { gbrainPath } from '../core/config.ts'; @@ -54,6 +55,12 @@ export interface Check { remediation?: import('../core/remediation-step.ts').RemediationStep[]; /** Top-level triage state per D13. */ remediation_status?: 'remediable' | 'human_only' | 'blocked'; + /** + * v0.41.19.0 category tag — assigned by `categorizeCheck(name)` at report + * compute time. Optional + additive so legacy consumers ignore it. + * Source of truth: `src/core/doctor-categories.ts`. + */ + category?: CheckCategory; } /** @@ -68,26 +75,92 @@ export interface Check { export interface DoctorReport { schema_version: 2; status: 'healthy' | 'warnings' | 'unhealthy'; + /** + * Legacy all-checks aggregate. `100 − 20×fails − 5×warns`, floor 0. + * + * Preserved verbatim from pre-v0.41.19.0 for back-compat with `gbrain + * doctor --remediate`, `gbrain remote doctor`, the MCP `run_doctor` op, + * and any external monitor / CI gate that reads this field. NO behavior + * change: a fixed check set produces a byte-identical `health_score` + * before and after the v0.41.19.0 wave. + */ health_score: number; + /** + * v0.41.19.0 — same penalty math (100 − 20×fails − 5×warns) restricted to + * checks tagged `category: 'brain'` by `categorizeCheck()`. The "is my + * brain's data healthy?" signal, decoupled from skill routing / ops / + * meta. Orthogonal to `BrainHealth.brain_score` (the weighted + * 35/25/15/15/10 composite surfaced by the `brain_score` doctor check) — + * `brain_checks_score` counts brain-category check failures; + * `brain_score` measures brain-data composition. Doctor renders both. + */ + brain_checks_score: number; + /** + * v0.41.19.0 — per-category penalty scores. Same math as `health_score`, + * restricted to each category in turn. An operator reading `score: 15` + * driven by 504 RESOLVER.md warnings now sees `category_scores.brain: + * ~100` and `category_scores.skill: 0` instead of one polluted number. + */ + category_scores: { + brain: number; + skill: number; + ops: number; + meta: number; + }; checks: Check[]; } -/** - * Compute the {status, health_score} headline from a list of checks. - * Mirrors the calculation in outputResults() so remote callers and the - * existing CLI front-end agree on what "healthy" means. - */ -export function computeDoctorReport(checks: Check[]): DoctorReport { - const hasFail = checks.some(c => c.status === 'fail'); - const hasWarn = checks.some(c => c.status === 'warn'); +function _penaltyScore(checks: Check[]): number { let score = 100; for (const c of checks) { if (c.status === 'fail') score -= 20; else if (c.status === 'warn') score -= 5; } - score = Math.max(0, score); + return Math.max(0, score); +} + +/** + * Compute the {status, health_score, brain_checks_score, category_scores} + * headline from a list of checks. Mirrors the calculation in outputResults() + * so remote callers and the existing CLI front-end agree on what "healthy" + * means. + * + * **Back-compat invariant:** `health_score` math is byte-identical to + * pre-v0.41.19.0 for any fixed `checks` array. The new fields are additive. + * + * **Categorization:** each check is tagged via `categorizeCheck(name)` at + * report-build time if it doesn't already carry a `category` field. The + * categorizer is the single source of truth in + * `src/core/doctor-categories.ts`. + */ +export function computeDoctorReport(checks: Check[]): DoctorReport { + const tagged = checks.map((c) => + c.category ? c : { ...c, category: categorizeCheck(c.name) }, + ); + + const hasFail = tagged.some((c) => c.status === 'fail'); + const hasWarn = tagged.some((c) => c.status === 'warn'); + + const health_score = _penaltyScore(tagged); + const brain = tagged.filter((c) => c.category === 'brain'); + const skill = tagged.filter((c) => c.category === 'skill'); + const ops = tagged.filter((c) => c.category === 'ops'); + const meta = tagged.filter((c) => c.category === 'meta'); + const status: DoctorReport['status'] = hasFail ? 'unhealthy' : hasWarn ? 'warnings' : 'healthy'; - return { schema_version: 2, status, health_score: score, checks }; + return { + schema_version: 2, + status, + health_score, + brain_checks_score: _penaltyScore(brain), + category_scores: { + brain: _penaltyScore(brain), + skill: _penaltyScore(skill), + ops: _penaltyScore(ops), + meta: _penaltyScore(meta), + }, + checks: tagged, + }; } /** @@ -2666,6 +2739,12 @@ export async function buildChecks( const fastMode = args.includes('--fast'); const doFix = args.includes('--fix'); const dryRun = args.includes('--dry-run'); + // v0.41.19.0 — `--scope=brain` SKIPS the SKILL check group (which walks the + // filesystem `skills/` tree, the dominant non-DB cost). Defaults to `all`. + // `runResolverChecks`-equivalent invocations are gated below; the same gate + // covers `whoknows_health` (the one DB-dependent skill check) where it's + // invoked later in the function. + const scope: 'all' | 'brain' = args.includes('--scope=brain') ? 'brain' : 'all'; const checks: Check[] = []; let autoFixReport: AutoFixReport | null = null; @@ -2678,16 +2757,26 @@ export async function buildChecks( // --- Filesystem checks (always run, no DB needed) --- - // 1. Resolver health - // Use the same auto-detect as `check-resolvable` so doctor sees a - // workspace/skills dir reachable via $OPENCLAW_WORKSPACE or - // ~/.openclaw/workspace, not just a `skills/` walked up from cwd. - // Read-only variant adds the install-path fallback so a hosted-CLI install - // run from `~` (e.g., `bun install -g github:garrytan/gbrain && cd ~ && - // gbrain doctor`) can still find the bundled skills/ dir without warning. - const detected = autoDetectSkillsDirReadOnly(); + // 1. Resolver health + 2. Skill conformance + 2b. Skill brain-first. + // + // SKILL check group (gated behind --scope=all). + // + // The resolver walk reads every SKILL.md under the configured skills dir + // (`skills/RESOLVER.md` or workspace-root `AGENTS.md`). On large OpenClaw + // deployments with 200+ skills this is the dominant non-DB cost. The + // v0.41.19.0 `--scope=brain` flag skips this whole block per D9 in the plan. + // + // We also skip `--fix` execution under scope=brain because --fix + // exclusively targets DRY violations inside SKILL.md files. Use the same + // auto-detect as `check-resolvable` so doctor sees a workspace/skills dir + // reachable via $OPENCLAW_WORKSPACE or ~/.openclaw/workspace, not just a + // `skills/` walked up from cwd. Read-only variant adds the install-path + // fallback so a hosted-CLI install run from `~` (e.g., `bun install -g + // github:garrytan/gbrain && cd ~ && gbrain doctor`) can still find the + // bundled skills/ dir without warning. + const detected = scope === 'all' ? autoDetectSkillsDirReadOnly() : { dir: null, source: 'none' as const }; const skillsDir = detected.dir; - if (skillsDir) { + if (scope === 'all' && skillsDir) { // --fix: run auto-repair BEFORE checkResolvable so the post-fix scan // reflects the new state. Auto-fix only targets DRY violations today; @@ -2736,12 +2825,12 @@ export async function buildChecks( }; checks.push(check); } - } else { + } else if (scope === 'all') { checks.push({ name: 'resolver_health', status: 'warn', message: 'Could not find skills directory' }); } - // 2. Skill conformance - if (skillsDir) { + // 2. Skill conformance (SKILL group — gated) + if (scope === 'all' && skillsDir) { const conformanceResult = checkSkillConformance(skillsDir); checks.push(conformanceResult); } @@ -2757,7 +2846,9 @@ export async function buildChecks( // snapshot.json. Writes one detected/resolved JSONL line per state // transition + one fixed line per applied --fix. Stable brain → zero // audit writes per doctor run. - if (skillsDir) { + // + // SKILL group — gated. + if (scope === 'all' && skillsDir) { checks.push(skillBrainFirstCheck(skillsDir)); } @@ -4267,8 +4358,11 @@ export async function buildChecks( // v0.33: whoknows_health — fixture presence + row count. The eval // gate itself runs via `gbrain eval whoknows`; this check is the // "did you do the assignment?" signal. - progress.heartbeat('whoknows_health'); - checks.push(await whoknowsHealthCheck(engine)); + // SKILL group — gated behind --scope=all (v0.41.19.0). + if (scope === 'all') { + progress.heartbeat('whoknows_health'); + checks.push(await whoknowsHealthCheck(engine)); + } // v0.36 cross-modal wave: modality column cleanup. // @@ -5717,26 +5811,21 @@ export function skillBrainFirstCheck(skillsDir: string): Check { } function outputResults(checks: Check[], json: boolean): boolean { - const hasFail = checks.some(c => c.status === 'fail'); - const hasWarn = checks.some(c => c.status === 'warn'); - - // Compute composite health score (0-100) - let score = 100; - for (const c of checks) { - if (c.status === 'fail') score -= 20; - else if (c.status === 'warn') score -= 5; - } - score = Math.max(0, score); + // v0.41.19.0 — render goes through computeDoctorReport so the human + // output, JSON output, and remote MCP envelope all share one shape. + const report = computeDoctorReport(checks); + const hasFail = report.status === 'unhealthy'; + const hasWarn = report.status === 'warnings'; + const score = report.health_score; if (json) { - const status = hasFail ? 'unhealthy' : hasWarn ? 'warnings' : 'healthy'; - console.log(JSON.stringify({ schema_version: 2, status, health_score: score, checks })); + console.log(JSON.stringify(report)); return hasFail; } console.log('\nGBrain Health Check'); console.log('==================='); - for (const c of checks) { + for (const c of report.checks) { const icon = c.status === 'ok' ? 'OK' : c.status === 'warn' ? 'WARN' : 'FAIL'; console.log(` [${icon}] ${c.name}: ${c.message}`); if (c.issues) { @@ -5747,12 +5836,30 @@ function outputResults(checks: Check[], json: boolean): boolean { } } + // v0.41.19.0 — brain-first headline. The user asked "is my brain ok?". + // Lead with the brain-category score; show the legacy aggregate + // alongside as context. The weighted BrainHealth.brain_score (data + // composition) is surfaced separately by the `brain_score` check above — + // it's read out of the check list so we don't duplicate the query. + const brainScoreCheck = report.checks.find((c) => c.name === 'brain_score'); + const brainScoreLine = brainScoreCheck + ? `Weighted brain score: ${brainScoreCheck.status === 'ok' ? '' : `[${brainScoreCheck.status.toUpperCase()}] `}${brainScoreCheck.message}` + : null; + + console.log(''); + console.log(`Brain checks: ${report.brain_checks_score}/100 (category penalty)`); + console.log(`Skill checks: ${report.category_scores.skill}/100`); + console.log(`Ops checks: ${report.category_scores.ops}/100`); + console.log(`Meta checks: ${report.category_scores.meta}/100`); + if (brainScoreLine) console.log(brainScoreLine); + console.log(''); + if (hasFail) { - console.log(`\nHealth score: ${score}/100. Failed checks found.`); + console.log(`Overall health score: ${score}/100. Failed checks found.`); } else if (hasWarn) { - console.log(`\nHealth score: ${score}/100. All checks OK (some warnings).`); + console.log(`Overall health score: ${score}/100. All checks OK (some warnings).`); } else { - console.log(`\nHealth score: ${score}/100. All checks passed.`); + console.log(`Overall health score: ${score}/100. All checks passed.`); } return hasFail; } diff --git a/src/commands/status.ts b/src/commands/status.ts new file mode 100644 index 000000000..55893a384 --- /dev/null +++ b/src/commands/status.ts @@ -0,0 +1,604 @@ +/** + * `gbrain status` — single-screen brain health dashboard. + * + * The command that answers "is my brain healthy and working?" without + * making operators run five other commands (gbrain sources status, gbrain + * stats, gbrain jobs supervisor status, gbrain jobs list, tail audit logs). + * + * Six sections: + * - Sync — per-source last_sync_at + staleness + * - Cycle — TWO rows: last FULL cycle (autopilot-cycle) + + * last TARGETED run (any autopilot-* job). Reflects + * v0.36.4.0's health-aware autopilot (healthy brains run + * targeted handlers most ticks, full cycle every ~60min). + * - Locks — active rows in gbrain_cycle_locks + * - Workers — supervisor health from the audit JSONL + * - Queue — live minion_jobs counts BY status (NO time window — + * old stuck jobs are exactly what status surfaces) + * - Autopilot — daemon PID liveness via kill -0 probe + * + * Exit codes (kubectl-style): + * 0 snapshot produced successfully (even if it carries warnings) + * 1 snapshot could NOT be produced (DB unreachable, fatal IO error) + * 2 usage error (bad --section value) + * + * Thin-client mode (isThinClient(cfg)): + * - Sync + Cycle route through `get_status_snapshot` MCP op (admin scope) + * - Locks/Workers/Queue/Autopilot render "local-only — N/A on remote brain" + * because they're host-local concerns; pretending the local install's + * local-host operational state is the remote brain's would lie to the + * operator. + * + * --json emits a stable envelope: + * { schema_version: 1, sync, cycle, locks?, workers?, queue?, autopilot? } + * Sections may be omitted (thin-client mode, --section filter, or + * section-build failure that didn't break the whole snapshot). + */ + +import type { BrainEngine } from '../core/engine.ts'; +import { existsSync, readFileSync } from 'node:fs'; +import { gbrainPath, loadConfig, isThinClient } from '../core/config.ts'; +import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts'; +import { + buildSyncStatusReport, + type SyncStatusReport, +} from './sync.ts'; +import { + readSupervisorEvents, + summarizeCrashes, +} from '../core/minions/handlers/supervisor-audit.ts'; + +const SCHEMA_VERSION = 1 as const; + +const VALID_SECTIONS = ['sync', 'cycle', 'locks', 'workers', 'queue', 'autopilot'] as const; +type Section = (typeof VALID_SECTIONS)[number]; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CycleRow { + finished_at: string | null; + name: string; + status: string; + duration_ms: number | null; + totals: Record | null; +} + +export interface CycleSnapshot { + /** Most recent fully-completed autopilot-cycle (9-phase full sweep). */ + last_full: CycleRow | null; + /** Most recent autopilot-* job of any kind (full OR targeted). */ + last_targeted: CycleRow | null; +} + +export interface LockRow { + id: string; + holder_pid: number | null; + holder_host: string | null; + acquired_at: string | null; + ttl_expires_at: string | null; +} + +export interface QueueCounts { + active: number; + waiting: number; + completed: number; + failed: number; + dead: number; +} + +export interface WorkerSummary { + crashes_24h: number; + clean_exits_24h: number; + by_cause: Record; + last_event_ts: string | null; +} + +export interface AutopilotStatus { + installed: boolean; + lockfile_present: boolean; + pid: number | null; + running: boolean; +} + +export interface StatusReport { + schema_version: typeof SCHEMA_VERSION; + generated_at: string; + mode: 'local' | 'thin-client'; + sync?: SyncStatusReport; + cycle?: CycleSnapshot; + locks?: LockRow[] | { local_only_remote: true }; + workers?: WorkerSummary | { local_only_remote: true }; + queue?: QueueCounts | { local_only_remote: true }; + autopilot?: AutopilotStatus | { local_only_remote: true }; + warnings?: string[]; +} + +// --------------------------------------------------------------------------- +// Cycle section — composable, also called from the MCP op +// --------------------------------------------------------------------------- + +/** + * Read the latest full cycle + latest targeted-run rows from `minion_jobs`. + * + * Read path is `result.report.totals` per codex MINOR-3 — the autopilot-cycle + * handler returns `{partial, status, report}` where `report.totals` carries + * the additive counters (synth_pages_written, patterns_written, + * facts_consolidated, pages_emotional_weight_recomputed, …). + * + * Exported for `src/core/operations.ts:get_status_snapshot` and for the + * E2E test fixture seed path. + */ +export async function buildCycleSnapshot(engine: BrainEngine): Promise { + type Row = { + finished_at: string | Date | null; + name: string; + status: string; + started_at: string | Date | null; + result: { partial?: unknown; status?: unknown; report?: { totals?: Record } } | null; + }; + + const isoOrNull = (v: string | Date | null): string | null => { + if (!v) return null; + return v instanceof Date ? v.toISOString() : new Date(v).toISOString(); + }; + + const durationMs = (started: string | Date | null, finished: string | Date | null): number | null => { + if (!started || !finished) return null; + const s = started instanceof Date ? started.getTime() : new Date(started).getTime(); + const f = finished instanceof Date ? finished.getTime() : new Date(finished).getTime(); + return Math.max(0, f - s); + }; + + const toCycleRow = (r: Row | undefined): CycleRow | null => { + if (!r) return null; + return { + finished_at: isoOrNull(r.finished_at), + name: r.name, + status: r.status, + duration_ms: durationMs(r.started_at, r.finished_at), + totals: r.result?.report?.totals ?? null, + }; + }; + + let fullRow: Row | undefined; + let targetedRow: Row | undefined; + try { + const fullRows = await engine.executeRaw( + `SELECT finished_at, name, status, started_at, result + FROM minion_jobs + WHERE name = 'autopilot-cycle' AND status = 'completed' + ORDER BY finished_at DESC NULLS LAST + LIMIT 1`, + ); + fullRow = fullRows[0]; + } catch { + /* fall through — no row */ + } + try { + const targetedRows = await engine.executeRaw( + `SELECT finished_at, name, status, started_at, result + FROM minion_jobs + WHERE name LIKE 'autopilot-%' AND status = 'completed' + ORDER BY finished_at DESC NULLS LAST + LIMIT 1`, + ); + targetedRow = targetedRows[0]; + } catch { + /* fall through */ + } + return { last_full: toCycleRow(fullRow), last_targeted: toCycleRow(targetedRow) }; +} + +// --------------------------------------------------------------------------- +// Local-only sections +// --------------------------------------------------------------------------- + +async function buildLocks(engine: BrainEngine): Promise { + type Row = { + id: string; + holder_pid: number | null; + holder_host: string | null; + acquired_at: string | Date | null; + ttl_expires_at: string | Date | null; + }; + const iso = (v: string | Date | null) => + v instanceof Date ? v.toISOString() : v ? new Date(v).toISOString() : null; + try { + const rows = await engine.executeRaw( + `SELECT id, holder_pid, holder_host, acquired_at, ttl_expires_at + FROM gbrain_cycle_locks + WHERE ttl_expires_at > NOW() + ORDER BY acquired_at`, + ); + return rows.map((r) => ({ + id: r.id, + holder_pid: r.holder_pid, + holder_host: r.holder_host, + acquired_at: iso(r.acquired_at), + ttl_expires_at: iso(r.ttl_expires_at), + })); + } catch { + return []; + } +} + +async function buildQueueCounts(engine: BrainEngine): Promise { + type Row = { status: string; count: string | number }; + const counts: QueueCounts = { active: 0, waiting: 0, completed: 0, failed: 0, dead: 0 }; + try { + // Live counts, NO time window (codex MAJOR-6). Old stuck waiting/active + // jobs are the failure mode `gbrain status` should surface, not hide. + const rows = await engine.executeRaw( + `SELECT status, COUNT(*)::text AS count FROM minion_jobs GROUP BY status`, + ); + for (const r of rows) { + const n = typeof r.count === 'string' ? parseInt(r.count, 10) : r.count; + if (r.status in counts) (counts as unknown as Record)[r.status] = n; + } + } catch { + /* PGLite without minion_jobs or pre-migration brain — return zeros */ + } + return counts; +} + +function buildWorkerSummary(): WorkerSummary { + let crashes_24h = 0; + let clean_exits_24h = 0; + const by_cause: Record = {}; + let last_event_ts: string | null = null; + try { + const since = Date.now() - 24 * 60 * 60 * 1000; + const events = readSupervisorEvents({ sinceMs: since }); + if (events.length > 0) { + last_event_ts = events[events.length - 1].ts; + } + const exitEvents = events.filter((e) => e.event === 'worker_exited'); + const summary = summarizeCrashes(exitEvents); + crashes_24h = summary.total; + clean_exits_24h = summary.clean_exits; + Object.assign(by_cause, summary.by_cause); + } catch { + /* audit dir missing or unreadable — return zeros */ + } + return { crashes_24h, clean_exits_24h, by_cause, last_event_ts }; +} + +function buildAutopilotStatus(): AutopilotStatus { + const lockPath = gbrainPath('autopilot.lock'); + const lockfile_present = existsSync(lockPath); + let pid: number | null = null; + let running = false; + if (lockfile_present) { + try { + const raw = readFileSync(lockPath, 'utf-8').trim(); + const parsed = parseInt(raw, 10); + if (Number.isFinite(parsed) && parsed > 0) { + pid = parsed; + try { + // kill -0 probes liveness without sending a real signal. Throws ESRCH + // if the PID is gone, EPERM if alive but owned by another user (which + // still tells us "something with that PID exists"). + process.kill(parsed, 0); + running = true; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + running = code === 'EPERM'; + } + } + } catch { + /* unreadable lockfile, leave pid=null/running=false */ + } + } + return { + installed: lockfile_present, // installed-or-running proxy; daemons writing the lock are installed + lockfile_present, + pid, + running, + }; +} + +// --------------------------------------------------------------------------- +// Orchestrator +// --------------------------------------------------------------------------- + +interface BuildOpts { + sections?: Set
; +} + +async function buildLocalReport( + engine: BrainEngine, + opts: BuildOpts, +): Promise { + const want = (s: Section) => !opts.sections || opts.sections.has(s); + const warnings: string[] = []; + const report: StatusReport = { + schema_version: SCHEMA_VERSION, + generated_at: new Date().toISOString(), + mode: 'local', + }; + + if (want('sync')) { + try { + const sources = await engine.executeRaw<{ + id: string; + name: string; + local_path: string | null; + config: Record | null; + }>(`SELECT id, name, local_path, config FROM sources ORDER BY id`); + report.sync = await buildSyncStatusReport( + engine, + sources.map((s) => ({ id: s.id, name: s.name, local_path: s.local_path, config: s.config ?? {} })), + ); + } catch (err) { + warnings.push(`sync section failed: ${(err as Error).message}`); + } + } + if (want('cycle')) { + try { + report.cycle = await buildCycleSnapshot(engine); + } catch (err) { + warnings.push(`cycle section failed: ${(err as Error).message}`); + } + } + if (want('locks')) { + report.locks = await buildLocks(engine); + } + if (want('workers')) { + report.workers = buildWorkerSummary(); + } + if (want('queue')) { + report.queue = await buildQueueCounts(engine); + } + if (want('autopilot')) { + report.autopilot = buildAutopilotStatus(); + } + if (warnings.length > 0) report.warnings = warnings; + return report; +} + +async function buildThinClientReport( + cfg: ReturnType, + opts: BuildOpts, +): Promise { + const want = (s: Section) => !opts.sections || opts.sections.has(s); + const warnings: string[] = []; + const report: StatusReport = { + schema_version: SCHEMA_VERSION, + generated_at: new Date().toISOString(), + mode: 'thin-client', + }; + + if (want('sync') || want('cycle')) { + try { + const raw = await callRemoteTool(cfg!, 'get_status_snapshot', {}); + const payload = unpackToolResult<{ + schema_version: number; + sync: SyncStatusReport; + cycle: CycleSnapshot; + }>(raw); + if (want('sync')) report.sync = payload.sync; + if (want('cycle')) report.cycle = payload.cycle; + } catch (err) { + warnings.push(`remote snapshot failed: ${(err as Error).message}`); + } + } + if (want('locks')) report.locks = { local_only_remote: true }; + if (want('workers')) report.workers = { local_only_remote: true }; + if (want('queue')) report.queue = { local_only_remote: true }; + if (want('autopilot')) report.autopilot = { local_only_remote: true }; + if (warnings.length > 0) report.warnings = warnings; + return report; +} + +// --------------------------------------------------------------------------- +// Human render +// --------------------------------------------------------------------------- + +function renderHuman(report: StatusReport): string { + const lines: string[] = []; + lines.push(''); + lines.push('GBrain Status'); + lines.push('============='); + lines.push(`Mode: ${report.mode} · ${report.generated_at}`); + lines.push(''); + + // Sync + if (report.sync) { + lines.push('Sync:'); + if (report.sync.sources.length === 0) { + lines.push(' (no sources registered)'); + } else { + for (const s of report.sync.sources) { + const last = s.last_sync_at ?? 'never'; + const stale = s.staleness_class === 'fresh' ? 'OK' : s.staleness_class.toUpperCase(); + lines.push( + ` [${stale}] ${s.source_id.padEnd(20)} ${last} pages=${s.pages} ` + + `embed=${s.embedding_coverage_pct.toFixed(0)}%`, + ); + } + if (report.sync.unacknowledged_failures > 0) { + lines.push(` ${report.sync.unacknowledged_failures} unacknowledged sync failure(s)`); + } + } + lines.push(''); + } + + // Cycle + if (report.cycle) { + lines.push('Cycle:'); + const fmt = (row: CycleRow | null, label: string) => { + if (!row) return ` ${label}: never run`; + const dur = row.duration_ms != null ? ` (${(row.duration_ms / 1000).toFixed(1)}s)` : ''; + const totalsStr = row.totals && Object.keys(row.totals).length > 0 + ? ` totals=${JSON.stringify(row.totals)}` + : ''; + return ` ${label}: ${row.finished_at}${dur}${totalsStr}`; + }; + lines.push(fmt(report.cycle.last_full, 'Last full cycle')); + lines.push(fmt(report.cycle.last_targeted, 'Last targeted run')); + lines.push(''); + } + + // Locks + if (report.locks) { + lines.push('Locks:'); + if ('local_only_remote' in report.locks) { + lines.push(' local-only — N/A on remote brain'); + } else if (report.locks.length === 0) { + lines.push(' (none active)'); + } else { + for (const l of report.locks) { + lines.push( + ` ${l.id.padEnd(28)} pid=${l.holder_pid ?? '?'} expires=${l.ttl_expires_at ?? '?'}`, + ); + } + } + lines.push(''); + } + + // Workers + if (report.workers) { + lines.push('Workers (last 24h):'); + if ('local_only_remote' in report.workers) { + lines.push(' local-only — N/A on remote brain'); + } else { + const w = report.workers; + lines.push(` crashes=${w.crashes_24h} clean_exits=${w.clean_exits_24h}`); + const causes = Object.entries(w.by_cause).filter(([, n]) => n > 0); + if (causes.length > 0) { + lines.push(` by_cause: ${causes.map(([k, v]) => `${k}=${v}`).join(', ')}`); + } + if (w.last_event_ts) lines.push(` last event: ${w.last_event_ts}`); + } + lines.push(''); + } + + // Queue + if (report.queue) { + lines.push('Queue (live):'); + if ('local_only_remote' in report.queue) { + lines.push(' local-only — N/A on remote brain'); + } else { + const q = report.queue; + lines.push( + ` active=${q.active} waiting=${q.waiting} failed=${q.failed} dead=${q.dead} completed=${q.completed}`, + ); + } + lines.push(''); + } + + // Autopilot + if (report.autopilot) { + lines.push('Autopilot:'); + if ('local_only_remote' in report.autopilot) { + lines.push(' local-only — N/A on remote brain'); + } else { + const a = report.autopilot; + if (a.running) { + lines.push(` running (PID ${a.pid})`); + } else if (a.lockfile_present) { + lines.push(` stale lockfile (PID ${a.pid ?? '?'} not alive). Run \`gbrain autopilot --install\` to restart.`); + } else { + lines.push(' not running. Install with `gbrain autopilot --install`.'); + } + } + lines.push(''); + } + + // Warnings + if (report.warnings && report.warnings.length > 0) { + lines.push('Warnings:'); + for (const w of report.warnings) lines.push(` ! ${w}`); + lines.push(''); + } + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// CLI entry +// --------------------------------------------------------------------------- + +/** + * Parse `--section ` (and `--section=` form) from args. + * Returns: + * - undefined → no filter (all sections) + * - Set
→ only these sections + * - 'usage_error' → bad section name (caller exits 2) + */ +export function parseSectionFlag(args: string[]): Set
| undefined | 'usage_error' { + let raw: string | undefined; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--section' && i + 1 < args.length) { + raw = args[i + 1]; + break; + } + if (a.startsWith('--section=')) { + raw = a.slice('--section='.length); + break; + } + } + if (raw == null) return undefined; + if (!VALID_SECTIONS.includes(raw as Section)) return 'usage_error'; + return new Set
([raw as Section]); +} + +export interface RunStatusResult { + exitCode: 0 | 1 | 2; + report?: StatusReport; +} + +/** + * Programmatic entry. `cli.ts` calls this; tests can drive it directly. + * + * Engine is nullable so the thin-client path doesn't require a connected + * engine (matches the v0.31.1 `runThinClientRouted` posture in cli.ts). + */ +export async function runStatus( + engine: BrainEngine | null, + args: string[], + opts: { stdout?: (s: string) => void; stderr?: (s: string) => void } = {}, +): Promise { + const stdout = opts.stdout ?? ((s: string) => process.stdout.write(s)); + const stderr = opts.stderr ?? ((s: string) => process.stderr.write(s)); + + const sectionFlag = parseSectionFlag(args); + if (sectionFlag === 'usage_error') { + stderr( + `gbrain status: invalid --section. Valid: ${VALID_SECTIONS.join('|')}\n`, + ); + return { exitCode: 2 }; + } + const sections = sectionFlag; + const json = args.includes('--json'); + + const cfg = loadConfig(); + const useThinClient = cfg ? isThinClient(cfg) : false; + + let report: StatusReport; + try { + if (useThinClient) { + report = await buildThinClientReport(cfg, { sections }); + } else { + if (!engine) { + stderr('gbrain status: no engine connected (DB unreachable?). Run `gbrain doctor` to diagnose.\n'); + return { exitCode: 1 }; + } + report = await buildLocalReport(engine, { sections }); + } + } catch (err) { + stderr(`gbrain status: snapshot failed: ${(err as Error).message}\n`); + return { exitCode: 1 }; + } + + if (json) { + stdout(JSON.stringify(report) + '\n'); + } else { + stdout(renderHuman(report)); + } + + return { exitCode: 0, report }; +} diff --git a/src/core/doctor-categories.ts b/src/core/doctor-categories.ts new file mode 100644 index 000000000..a2283a1a4 --- /dev/null +++ b/src/core/doctor-categories.ts @@ -0,0 +1,203 @@ +/** + * Doctor check categorization — single source of truth. + * + * Every `Check.name` produced by `src/commands/doctor.ts` is assigned to + * exactly one of four categories: + * + * - brain : data-integrity signals (embedding coverage, page health, sync + * freshness, facts/takes/calibration data quality, contradictions, + * content-sanity audit findings). The "is my brain's data healthy?" + * question lives here. + * - skill : RESOLVER.md / skill conformance / routing-eval / filing-audit / + * whoknows expert routing. The "is my agent's skill dispatcher + * configured?" question. + * - ops : infrastructure liveness — DB connection, pgvector, RLS, + * supervisor, queue depth, OAuth confidential clients, autopilot + * lock scope, reranker/provider reachability. The "is the + * machinery actually running?" question. + * - meta : schema version, migrations, upgrade trail, eval capture, slug + * fallback audit, schema-pack drift. The "is gbrain itself + * coherent?" question. + * + * Why this matters: the doctor's legacy `health_score` ( 100 − 20×fails − + * 5×warns ) weights every check equally. A skill routing miss costs the same + * as a corrupt embedding column. With categorization, the doctor surfaces a + * brain_checks_score and category_scores so operators see signal-to-noise on + * the question they're actually asking. + * + * Naming discipline: this module owns the *category penalty* score + * (`brain_checks_score`), which is ORTHOGONAL to `BrainHealth.brain_score` + * (the weighted 35/25/15/15/10 composite surfaced by the `brain_score` + * doctor check). The two answer different questions: + * + * - brain_score : "how healthy is the brain's data composition?" + * - brain_checks_score : "how many brain-category doctor checks failed?" + * + * The doctor renders both side by side. + * + * Drift contract: every check name that ships in doctor.ts MUST appear in + * exactly one set below. The drift-guard test in + * `test/doctor-categories.test.ts` enforces this by reading doctor.ts source + * via a tagged-string scan and asserting set membership exactly. + * + * If you add a new doctor check, you MUST add its name to the appropriate + * set here. The categorize step in `src/commands/doctor.ts` falls through + * to 'meta' for any unknown name AND emits a once-per-process stderr warn + * so a missing addition surfaces in dev runs even before the test catches + * it in CI. + */ + +export type CheckCategory = 'brain' | 'skill' | 'ops' | 'meta'; + +/** + * Data-integrity signals. Everything that asks "is the brain's actual data + * healthy and complete?" + */ +export const BRAIN_CHECK_NAMES: ReadonlySet = new Set([ + 'abandoned_threads', + 'brain_score', + 'calibration_freshness', + 'child_table_orphans', + 'content_sanity_audit_recent', + 'contextual_retrieval_coverage', + 'contradictions', + 'conversation_facts_backlog', + 'conversation_format_coverage', + 'conversation_parser_probe_health', + 'cross_modal_modality_backfill', + 'cycle_freshness', + 'effective_date_health', + 'embedding_column_registry', + 'embedding_env_override', + 'embedding_provider', + 'embedding_width_consistency', + 'embeddings', + 'eval_drift', + 'facts_embedding_width_consistency', + 'facts_extraction_health', + 'facts_health', + 'frontmatter_integrity', + 'grade_confidence_drift', + 'graph_coverage', + 'graph_signals_coverage', + 'image_assets', + 'integrity', + 'jsonb_integrity', + 'markdown_body_completeness', + 'nightly_quality_probe_health', + 'ocr_health', + 'orphan_ratio', + 'oversized_pages', + 'salience_health', + 'scraper_junk_pages', + 'source_routing_health', + 'stub_guard_24h', + 'sync_failures', + 'sync_freshness', + 'takes_weight_grid', + 'unified_multimodal_coverage', + 'voice_gate_health', +]); + +/** + * Skill dispatcher signals. RESOLVER.md reachability, skill frontmatter + * conformance, brain-first compliance, expert-routing, filing audit. + * + * Deliberately small: only checks that scan the host's `skills/` tree or + * skill-routing fixtures. Brain-data quality checks (even ones with a + * skill-flavored name) live under 'brain'. + */ +export const SKILL_CHECK_NAMES: ReadonlySet = new Set([ + 'resolver_health', + 'skill_brain_first', + 'skill_conformance', + 'whoknows_health', +]); + +/** + * Infrastructure liveness signals. DB, workers, OAuth, RLS, locks, providers. + */ +export const OPS_CHECK_NAMES: ReadonlySet = new Set([ + 'alternative_providers', + 'autopilot_lock_scope', + 'batch_retry_health', + 'brainstorm_health', + 'connection', + 'federation_health', + 'home_dir_in_worktree', + 'index_audit', + 'oauth_confidential_client_health', + 'orphan_clones', + 'pgbouncer_prepare', + 'pgvector', + 'progressive_batch_audit_health', + 'queue_health', + 'reranker_health', + 'rls', + 'rls_event_trigger', + 'search_mode', + 'stale_locks', + 'subagent_capability', + 'subagent_health', + 'supervisor', + 'ze_embedding_health', +]); + +/** + * gbrain-itself coherence signals. Schema migrations, version drift, audit + * housekeeping. Default category for unknown names (with stderr warn). + */ +export const META_CHECK_NAMES: ReadonlySet = new Set([ + 'cycle_phase_scope', + 'eval_capture', + 'minions_migration', + 'multi_source_drift', + 'schema_pack_active', + 'schema_pack_consistency', + 'schema_pack_source_drift', + 'schema_version', + 'slug_fallback_audit', + 'upgrade_errors', +]); + +/** + * Stderr warn-once gate for unknown check names. Exported as a test seam so + * the categorizer test can re-trigger warns. + */ +const _warnedUnknown = new Set(); +export function _resetUnknownCheckWarningsForTest(): void { + _warnedUnknown.clear(); +} + +/** + * Map a check name to its category. Unknown names fall through to 'meta' + * with a once-per-process stderr warning — the test in + * `test/doctor-categories.test.ts` is the structural guard, and the warn is + * the runtime backstop so contributors notice in dev before CI fails. + */ +export function categorizeCheck(name: string): CheckCategory { + if (BRAIN_CHECK_NAMES.has(name)) return 'brain'; + if (SKILL_CHECK_NAMES.has(name)) return 'skill'; + if (OPS_CHECK_NAMES.has(name)) return 'ops'; + if (META_CHECK_NAMES.has(name)) return 'meta'; + if (!_warnedUnknown.has(name)) { + _warnedUnknown.add(name); + process.stderr.write( + `[doctor-categories] unknown check name '${name}' — defaulting to 'meta'. Add it to src/core/doctor-categories.ts.\n`, + ); + } + return 'meta'; +} + +/** + * Skill-category check group. Used by buildChecks's scope-branch gates to + * SKIP the (expensive, filesystem-walking) skill check group when the caller + * asked for scope=brain. This is the load-bearing escape hatch that makes + * `gbrain doctor --scope=brain` sub-second on a brain with thousands of + * skills (per D9 in the plan). + * + * Use this set as the source of truth for "do these checks belong to the + * skill group that the scope-branch gate skips?" — keeping the gate + * categorization and the per-check categorization aligned. + */ +export const SKILL_CHECK_GROUP_NAMES: ReadonlySet = SKILL_CHECK_NAMES; diff --git a/src/core/operations.ts b/src/core/operations.ts index dc6723938..2b23ffe00 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -1929,6 +1929,74 @@ const get_brain_identity: Operation = { // intentionally no cliHints — banner-only op }; +/** + * v0.41.19.0 — `gbrain status` thin-client surface. + * + * Returns a snapshot of sync freshness + last cycle state for thin-client + * `gbrain status` callers. Per D2/D10 in the plan: + * + * - Scope: admin (NOT localOnly). The op exposes operational state + * including sync timestamps and cycle metadata. Locking it to admin + * matches the `run_doctor` posture and prevents future feature creep + * (adding locks/workers/queue counters) from quietly leaking ops state + * to read-scoped clients. + * + * - Payload: `{schema_version: 1, sync, cycle}` ONLY. Locks, Workers, + * Queue, and Autopilot sections are deliberately omitted from the + * remote payload — they are local-host concerns that thin-client + * callers shouldn't see at all (and the local `gbrain status` renders + * them as "N/A on remote brain" instead of pretending they exist). + * + * - The local CLI composes the same data plus the local-only sections + * directly (no MCP round-trip when running against ~/.gbrain). + */ +const get_status_snapshot: Operation = { + name: 'get_status_snapshot', + description: 'Snapshot for `gbrain status` thin-client mode: sync freshness + last cycle. Admin-scope.', + params: {}, + handler: async (ctx) => { + const { buildSyncStatusReport } = await import('../commands/sync.ts'); + const { buildCycleSnapshot } = await import('../commands/status.ts'); + // Pull sources first (handles brains with zero declared sources too). + let sources: Array<{ id: string; name: string; local_path: string | null; config: Record }> = []; + try { + const rows = await ctx.engine.executeRaw<{ + id: string; + name: string; + local_path: string | null; + config: Record | null; + }>( + `SELECT id, name, local_path, config FROM sources WHERE COALESCE(archived, FALSE) = FALSE ORDER BY id`, + ); + sources = rows.map((r) => ({ + id: r.id, + name: r.name, + local_path: r.local_path, + config: r.config ?? {}, + })); + } catch { + // Pre-v0.26.5 brains may lack the `archived` column; degrade to all rows. + const rows = await ctx.engine.executeRaw<{ + id: string; + name: string; + local_path: string | null; + config: Record | null; + }>(`SELECT id, name, local_path, config FROM sources ORDER BY id`); + sources = rows.map((r) => ({ + id: r.id, + name: r.name, + local_path: r.local_path, + config: r.config ?? {}, + })); + } + const sync = await buildSyncStatusReport(ctx.engine, sources); + const cycle = await buildCycleSnapshot(ctx.engine); + return { schema_version: 1 as const, sync, cycle }; + }, + scope: 'admin', + localOnly: false, +}; + /** * Multi-topology v1 (Tier B): structured doctor report for remote callers. * @@ -4296,6 +4364,8 @@ export const operations: Operation[] = [ get_stats, get_health, run_doctor, get_versions, revert_version, // v0.31.1 (Issue #734): thin-client banner identity packet (read-scope, banner-only) get_brain_identity, + // v0.41.19.0: thin-client `gbrain status` payload (admin-scope, sync + cycle only) + get_status_snapshot, // Sync sync_brain, // Raw data diff --git a/test/doctor-brain-checks-score.test.ts b/test/doctor-brain-checks-score.test.ts new file mode 100644 index 000000000..7a10e883c --- /dev/null +++ b/test/doctor-brain-checks-score.test.ts @@ -0,0 +1,181 @@ +/** + * v0.41.19.0 brain_checks_score + category_scores invariants. + * + * Pinned contracts: + * - health_score math is byte-identical to pre-v0.41.19.0 for a fixed + * check set (back-compat invariant for MCP consumers, --remediate, + * remote doctor). + * - brain_checks_score is the same penalty math restricted to brain + * checks. + * - category_scores sum across the 4 categories is NOT the same as + * health_score (different math — each is 100-based). This is by + * design. + * - Renaming regression: no field called `brain_health_score` ships + * in the JSON envelope. The shipped name is `brain_checks_score` + * (codex MAJOR-8 — avoid collision with the existing weighted + * BrainHealth.brain_score). + * - Tags fall through to `categorizeCheck(name)` when not pre-tagged. + * - schema_version stays at 2 (additive fields are non-breaking). + */ + +import { describe, test, expect } from 'bun:test'; +import { computeDoctorReport, type Check } from '../src/commands/doctor.ts'; + +function check(name: string, status: Check['status'], message = ''): Check { + return { name, status, message }; +} + +describe('computeDoctorReport — back-compat health_score invariant', () => { + test('health_score = 100 on all-OK fixed check set', () => { + const checks: Check[] = [ + check('brain_score', 'ok'), + check('connection', 'ok'), + check('resolver_health', 'ok'), + check('schema_version', 'ok'), + ]; + const r = computeDoctorReport(checks); + expect(r.health_score).toBe(100); + expect(r.status).toBe('healthy'); + }); + + test('health_score = 100 − 20×fails − 5×warns, floor 0', () => { + const checks: Check[] = [ + check('connection', 'fail'), // -20 + check('brain_score', 'warn'), // -5 + check('resolver_health', 'warn'), // -5 + check('schema_version', 'ok'), + ]; + const r = computeDoctorReport(checks); + expect(r.health_score).toBe(70); + }); + + test('health_score floors at 0 even with many fails', () => { + const checks: Check[] = Array.from({ length: 10 }, (_, i) => + check(`fail_${i}`, 'fail'), + ); + const r = computeDoctorReport(checks); + expect(r.health_score).toBe(0); + }); + + test('schema_version stays at 2', () => { + const r = computeDoctorReport([check('connection', 'ok')]); + expect(r.schema_version).toBe(2); + }); + + test('504 skill warns DO drag down health_score (this is the legacy behavior we are preserving)', () => { + const checks: Check[] = [ + check('connection', 'ok'), + check('brain_score', 'ok'), + check('resolver_health', 'warn', '504 issues'), + ]; + const r = computeDoctorReport(checks); + // Just one check, warn = -5. + expect(r.health_score).toBe(95); + }); +}); + +describe('computeDoctorReport — brain_checks_score', () => { + test('brain_checks_score is 100 when all brain checks are OK, even if skill checks are failing', () => { + const checks: Check[] = [ + check('brain_score', 'ok'), + check('embedding_provider', 'ok'), + check('sync_freshness', 'ok'), + check('resolver_health', 'fail', '504 warnings'), // skill, not counted + check('skill_conformance', 'warn'), // skill, not counted + ]; + const r = computeDoctorReport(checks); + expect(r.brain_checks_score).toBe(100); + }); + + test('brain_checks_score reflects ONLY brain-category failures', () => { + const checks: Check[] = [ + check('embedding_provider', 'fail'), // brain -20 + check('graph_coverage', 'warn'), // brain -5 + check('connection', 'fail'), // ops, not counted + check('schema_version', 'warn'), // meta, not counted + ]; + const r = computeDoctorReport(checks); + expect(r.brain_checks_score).toBe(75); + }); + + test('the OpenClaw 504-warning scenario: brain ~100, skill ~0, overall low', () => { + const checks: Check[] = [ + check('brain_score', 'ok'), + check('embedding_provider', 'ok'), + check('sync_freshness', 'ok'), + ]; + // 30 skill warnings (the canonical pollution scenario, scaled down for test brevity) + for (let i = 0; i < 30; i++) checks.push(check(`resolver_health`, 'warn')); + // Categorization re-tags each `resolver_health` push as skill, so all 30 warns sit in skill. + const r = computeDoctorReport(checks); + expect(r.brain_checks_score).toBe(100); + expect(r.category_scores.skill).toBe(0); // 30 * -5 = -150, floored at 0 + expect(r.health_score).toBe(0); // overall is dragged to 0 (the problem!) + }); +}); + +describe('computeDoctorReport — category_scores', () => { + test('every category gets its own 100-floor penalty score', () => { + const checks: Check[] = [ + check('brain_score', 'warn'), + check('resolver_health', 'fail'), + check('connection', 'warn'), + check('schema_version', 'fail'), + ]; + const r = computeDoctorReport(checks); + expect(r.category_scores.brain).toBe(95); // 1 warn + expect(r.category_scores.skill).toBe(80); // 1 fail + expect(r.category_scores.ops).toBe(95); // 1 warn + expect(r.category_scores.meta).toBe(80); // 1 fail + }); + + test('empty category gets 100 (vacuous truth)', () => { + const checks: Check[] = [check('brain_score', 'ok')]; + const r = computeDoctorReport(checks); + expect(r.category_scores.brain).toBe(100); + expect(r.category_scores.skill).toBe(100); // no skill checks ran + expect(r.category_scores.ops).toBe(100); + expect(r.category_scores.meta).toBe(100); + }); +}); + +describe('computeDoctorReport — categorization fall-through', () => { + test('checks without category get tagged via categorizeCheck(name)', () => { + const r = computeDoctorReport([ + check('embedding_provider', 'ok'), + check('resolver_health', 'ok'), + check('connection', 'ok'), + check('schema_version', 'ok'), + ]); + expect(r.checks.find((c) => c.name === 'embedding_provider')?.category).toBe('brain'); + expect(r.checks.find((c) => c.name === 'resolver_health')?.category).toBe('skill'); + expect(r.checks.find((c) => c.name === 'connection')?.category).toBe('ops'); + expect(r.checks.find((c) => c.name === 'schema_version')?.category).toBe('meta'); + }); + + test('pre-tagged category survives compute (no override)', () => { + const r = computeDoctorReport([ + { name: 'made_up_unknown_name', status: 'ok', message: '', category: 'brain' }, + ]); + expect(r.checks[0].category).toBe('brain'); + expect(r.brain_checks_score).toBe(100); + }); +}); + +describe('computeDoctorReport — renaming regression (codex MAJOR-8)', () => { + test('NO field named brain_health_score ships in the JSON envelope', () => { + const r = computeDoctorReport([check('brain_score', 'ok')]); + const jsonShape = JSON.parse(JSON.stringify(r)); + expect(jsonShape).not.toHaveProperty('brain_health_score'); + expect(jsonShape).toHaveProperty('brain_checks_score'); + }); + + test('the existing weighted BrainHealth.brain_score is NOT replaced — it stays in the checks list', () => { + // The brain_score check surfaces engine.getHealth().brain_score (weighted + // 35/25/15/15/10). It's orthogonal to brain_checks_score. Make sure the + // check name is still resolvable as a `brain` category entry. + const r = computeDoctorReport([check('brain_score', 'ok', 'Brain score 92/100')]); + expect(r.checks[0].category).toBe('brain'); + expect(r.checks[0].message).toContain('92'); + }); +}); diff --git a/test/doctor-categories.test.ts b/test/doctor-categories.test.ts new file mode 100644 index 000000000..b6bbf3b8f --- /dev/null +++ b/test/doctor-categories.test.ts @@ -0,0 +1,161 @@ +/** + * Drift guard for src/core/doctor-categories.ts. + * + * Reads src/commands/doctor.ts source via a literal-string scan, enumerates + * every `name: '<...>'` Check name, and asserts each appears in exactly ONE + * category set. The union of the four sets must equal the discovered names + * exactly — no orphans, no extras. + * + * This is the structural failure the v0.41.19.0 plan-eng-review caught: + * doctor.ts grows new checks regularly; without this guard, the + * categorization map silently goes stale and unknown checks degrade to + * 'meta' without anyone noticing. + */ + +import { describe, test, expect, beforeEach } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + BRAIN_CHECK_NAMES, + SKILL_CHECK_NAMES, + OPS_CHECK_NAMES, + META_CHECK_NAMES, + categorizeCheck, + _resetUnknownCheckWarningsForTest, +} from '../src/core/doctor-categories.ts'; + +const DOCTOR_TS_PATH = join(import.meta.dir, '..', 'src', 'commands', 'doctor.ts'); + +function enumerateCheckNames(): Set { + const source = readFileSync(DOCTOR_TS_PATH, 'utf-8'); + const names = new Set(); + // 1) Inline object-literal form: `{ name: 'foo', ... }`. + for (const m of source.matchAll(/name:\s*['"]([a-z][a-z0-9_]+)['"]/g)) { + names.add(m[1]); + } + // 2) Helper-function form: `const name = 'foo';` inside a check helper. + // Catches checks like `nightly_quality_probe_health` and + // `conversation_facts_backlog` that build the Check from a captured + // name constant. + for (const m of source.matchAll(/const\s+name\s*=\s*['"]([a-z][a-z0-9_]+)['"]/g)) { + names.add(m[1]); + } + return names; +} + +describe('doctor-categories drift guard', () => { + test('every check name in doctor.ts source belongs to exactly one category set', () => { + const discovered = enumerateCheckNames(); + const allCategorized = new Set([ + ...BRAIN_CHECK_NAMES, + ...SKILL_CHECK_NAMES, + ...OPS_CHECK_NAMES, + ...META_CHECK_NAMES, + ]); + + const missing: string[] = []; + for (const name of discovered) { + if (!allCategorized.has(name)) missing.push(name); + } + if (missing.length > 0) { + throw new Error( + `These check names appear in doctor.ts but are not categorized in ` + + `src/core/doctor-categories.ts: ${missing.sort().join(', ')}. ` + + `Add each to BRAIN/SKILL/OPS/META_CHECK_NAMES.`, + ); + } + }); + + test('no check name appears in more than one category set', () => { + const counts = new Map(); + const tag = (s: ReadonlySet, label: string) => { + for (const n of s) { + if (!counts.has(n)) counts.set(n, []); + counts.get(n)!.push(label); + } + }; + tag(BRAIN_CHECK_NAMES, 'brain'); + tag(SKILL_CHECK_NAMES, 'skill'); + tag(OPS_CHECK_NAMES, 'ops'); + tag(META_CHECK_NAMES, 'meta'); + + const dupes: string[] = []; + for (const [name, cats] of counts) { + if (cats.length > 1) dupes.push(`${name} in [${cats.join(', ')}]`); + } + expect(dupes).toEqual([]); + }); + + test('every categorized name is currently used in doctor.ts source (no stale entries)', () => { + const discovered = enumerateCheckNames(); + const allCategorized = new Set([ + ...BRAIN_CHECK_NAMES, + ...SKILL_CHECK_NAMES, + ...OPS_CHECK_NAMES, + ...META_CHECK_NAMES, + ]); + + const stale: string[] = []; + for (const name of allCategorized) { + if (!discovered.has(name)) stale.push(name); + } + // Stale entries are warnings, not hard errors — a check may be temporarily + // removed during refactor. But the build should still flag them so we + // catch the drift quickly. Use a soft assertion via console hint and a + // strict expectation that the count is small (<=2). Adjust if real + // refactors require more headroom. + if (stale.length > 2) { + throw new Error( + `These categorized names no longer appear in doctor.ts: ${stale.sort().join(', ')}. ` + + `Remove them from src/core/doctor-categories.ts.`, + ); + } + }); +}); + +describe('categorizeCheck', () => { + beforeEach(() => { + _resetUnknownCheckWarningsForTest(); + }); + + test('returns the right category for a known brain name', () => { + expect(categorizeCheck('embedding_provider')).toBe('brain'); + expect(categorizeCheck('graph_coverage')).toBe('brain'); + expect(categorizeCheck('sync_freshness')).toBe('brain'); + }); + + test('returns the right category for a known skill name', () => { + expect(categorizeCheck('resolver_health')).toBe('skill'); + expect(categorizeCheck('skill_conformance')).toBe('skill'); + }); + + test('returns the right category for a known ops name', () => { + expect(categorizeCheck('connection')).toBe('ops'); + expect(categorizeCheck('rls')).toBe('ops'); + expect(categorizeCheck('supervisor')).toBe('ops'); + }); + + test('returns the right category for a known meta name', () => { + expect(categorizeCheck('schema_version')).toBe('meta'); + expect(categorizeCheck('upgrade_errors')).toBe('meta'); + }); + + test('unknown check name falls through to meta with a stderr warn (once per process)', () => { + const originalWrite = process.stderr.write.bind(process.stderr); + const captured: string[] = []; + (process.stderr as { write: typeof process.stderr.write }).write = (( + chunk: string | Uint8Array, + ) => { + captured.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString()); + return true; + }) as typeof process.stderr.write; + try { + expect(categorizeCheck('made_up_check_name_not_in_any_set')).toBe('meta'); + expect(categorizeCheck('made_up_check_name_not_in_any_set')).toBe('meta'); + const warns = captured.filter((c) => c.includes('made_up_check_name_not_in_any_set')); + expect(warns.length).toBe(1); + } finally { + (process.stderr as { write: typeof process.stderr.write }).write = originalWrite; + } + }); +}); diff --git a/test/doctor-home-dir-in-worktree.test.ts b/test/doctor-home-dir-in-worktree.test.ts index e5f77a5d8..2c35cfefe 100644 --- a/test/doctor-home-dir-in-worktree.test.ts +++ b/test/doctor-home-dir-in-worktree.test.ts @@ -64,10 +64,20 @@ async function getCheck(name: string, env: Record) { process.exit = origExit; } const text = captured.join(''); - // The doctor's JSON envelope is the last `{` block containing `"checks":`. - const idx = text.lastIndexOf('"checks"'); - const objStart = idx >= 0 ? text.lastIndexOf('{', idx) : text.lastIndexOf('{'); - const jsonStr = objStart >= 0 ? text.slice(objStart) : text; + // The doctor's JSON envelope is the LAST line that starts with + // `{"schema_version"`. v0.41.19.0 added nested objects to the envelope + // (category_scores), so a "find the `{` before `\"checks\"`" heuristic + // no longer works — it walks back to category_scores's `{` instead of + // the outer one. Anchor on the canonical envelope prefix instead. + const lines = text.split('\n'); + let jsonStr = ''; + for (let i = lines.length - 1; i >= 0; i--) { + const trimmed = lines[i].trim(); + if (trimmed.startsWith('{"schema_version"')) { + jsonStr = trimmed; + break; + } + } let parsed: { checks: { name: string; status: string; message: string }[] }; try { parsed = JSON.parse(jsonStr); diff --git a/test/doctor-scope-filter.test.ts b/test/doctor-scope-filter.test.ts new file mode 100644 index 000000000..ff5244e6b --- /dev/null +++ b/test/doctor-scope-filter.test.ts @@ -0,0 +1,94 @@ +/** + * v0.41.19.0 `--scope=brain` skip-computation contract. + * + * The plan-eng-review D4/D9 lock: `--scope=brain` must SKIP computation of + * the SKILL check group (resolver_health, skill_conformance, + * skill_brain_first, whoknows_health), not just filter the output. The + * observable contract: with `--scope=brain`, the returned checks list + * contains zero entries with `category: 'skill'`, AND the resolver walk is + * NOT performed. + * + * This is the "sub-second on a brain with thousands of skills" win. Hermetic + * test: passes `engine=null` so no DB or PGLite is needed; uses `--fast` to + * skip the DB check path; sets $GBRAIN_SKILLS_DIR to a tmpdir to control the + * resolver walk's input cheaply. + */ + +import { describe, test, expect } from 'bun:test'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { withEnv } from './helpers/with-env.ts'; +import { buildChecks } from '../src/commands/doctor.ts'; + +function makeSkillsTree(): string { + const root = mkdtempSync(join(tmpdir(), 'gbrain-scope-test-')); + mkdirSync(join(root, 'foo'), { recursive: true }); + // A minimal SKILL.md so checkResolvable / skill_conformance / skill_brain_first + // all have content to scan when scope=all. The body doesn't need to be + // valid in detail; we only care about whether the function INVOKES the + // resolver walk. + writeFileSync( + join(root, 'foo', 'SKILL.md'), + '---\nname: foo\ntriggers:\n - "test trigger"\nbrain_first: exempt\n---\n\n# foo\n\nA test skill.\n', + ); + writeFileSync( + join(root, 'RESOLVER.md'), + '| Skill | Triggers |\n|---|---|\n| **foo**: test trigger |\n', + ); + return root; +} + +describe('buildChecks --scope=brain skip-computation contract', () => { + test('SKILL group is absent from the checks list under --scope=brain', async () => { + const skillsDir = makeSkillsTree(); + await withEnv({ GBRAIN_SKILLS_DIR: skillsDir, GBRAIN_NO_BANNER: '1' }, async () => { + const checks = await buildChecks(null, ['--scope=brain', '--fast']); + const skillChecks = checks.filter( + (c) => + c.name === 'resolver_health' || + c.name === 'skill_conformance' || + c.name === 'skill_brain_first' || + c.name === 'whoknows_health', + ); + expect(skillChecks).toEqual([]); + }); + }); + + test('SKILL group IS present under default scope (--fast alone)', async () => { + const skillsDir = makeSkillsTree(); + await withEnv({ GBRAIN_SKILLS_DIR: skillsDir, GBRAIN_NO_BANNER: '1' }, async () => { + const checks = await buildChecks(null, ['--fast']); + const names = new Set(checks.map((c) => c.name)); + expect(names.has('resolver_health')).toBe(true); + // skill_conformance + skill_brain_first only run if skillsDir is detected + // AND has SKILL.md files — the makeSkillsTree fixture provides one. + expect(names.has('skill_conformance')).toBe(true); + expect(names.has('skill_brain_first')).toBe(true); + }); + }); + + test('--scope=brain still emits non-skill checks (the brain figure is meaningful)', async () => { + const skillsDir = makeSkillsTree(); + await withEnv({ GBRAIN_SKILLS_DIR: skillsDir, GBRAIN_NO_BANNER: '1' }, async () => { + const checks = await buildChecks(null, ['--scope=brain', '--fast']); + // At least one non-skill check must be present (e.g. the migration + // health/meta checks that always run in the FS phase, or schema_version + // which is the canonical META check). + expect(checks.length).toBeGreaterThan(0); + const cats = new Set(checks.map((c) => c.category)); + // No skill-category checks at all. + expect(cats.has('skill')).toBe(false); + }); + }); + + test('--scope=brain does NOT emit a "Could not find skills directory" warn (we deliberately skipped, not failed)', async () => { + await withEnv({ GBRAIN_SKILLS_DIR: '/nonexistent/path/should/not/exist', GBRAIN_NO_BANNER: '1' }, async () => { + const checks = await buildChecks(null, ['--scope=brain', '--fast']); + const resolverHealth = checks.find((c) => c.name === 'resolver_health'); + // Under scope=brain, we don't even attempt to find the skills dir, so + // the "Could not find skills directory" branch should NOT fire. + expect(resolverHealth).toBeUndefined(); + }); + }); +}); diff --git a/test/e2e/status-pglite.test.ts b/test/e2e/status-pglite.test.ts new file mode 100644 index 000000000..e9ba80dd6 --- /dev/null +++ b/test/e2e/status-pglite.test.ts @@ -0,0 +1,203 @@ +/** + * 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; +}) { + 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(); + }); +}); diff --git a/test/get-status-snapshot-op.test.ts b/test/get-status-snapshot-op.test.ts new file mode 100644 index 000000000..38fd3c6ae --- /dev/null +++ b/test/get-status-snapshot-op.test.ts @@ -0,0 +1,72 @@ +/** + * `get_status_snapshot` MCP op contract. + * + * Pins the v0.41.19.0 wave's per-op decisions: + * - scope: 'admin' (codex MAJOR-9 / D10 — prevents read-scoped clients + * from seeing brain-host operational state). + * - localOnly: false (remote thin-client `gbrain status` callers need + * it via HTTP MCP — that's the whole point). + * - payload returns ONLY {schema_version: 1, sync, cycle}. Locks / + * Workers / Queue / Autopilot are deliberately omitted from the + * remote shape; the local CLI's `gbrain status` renders them as + * "N/A on remote brain" instead. + * + * Hermetic — stubs the engine. The cycle / sync helpers degrade + * gracefully when their queries throw, so a stubbed `executeRaw` that + * returns `[]` is enough to exercise the shape. + */ + +import { describe, test, expect } from 'bun:test'; +import { operations, operationsByName } from '../src/core/operations.ts'; + +describe('get_status_snapshot op definition', () => { + test('exists and is registered in the operations array', () => { + expect(operationsByName.get_status_snapshot).toBeDefined(); + expect(operations.find((o) => o.name === 'get_status_snapshot')).toBeDefined(); + }); + + test('scope is admin', () => { + const op = operationsByName.get_status_snapshot; + expect(op.scope).toBe('admin'); + }); + + test('localOnly is false (must be remote-callable for thin-client status)', () => { + const op = operationsByName.get_status_snapshot; + expect(op.localOnly).toBe(false); + }); + + test('takes no params', () => { + const op = operationsByName.get_status_snapshot; + expect(op.params).toEqual({}); + }); +}); + +describe('get_status_snapshot handler shape', () => { + test('returns only {schema_version, sync, cycle} keys (no Locks/Workers/Queue/Autopilot)', async () => { + const op = operationsByName.get_status_snapshot; + // Stub engine that returns empty rows for any executeRaw and a minimal + // BrainEngine shape. The sync helper degrades to an empty sources list + // and a synthetic SyncStatusReport; the cycle helper degrades to + // {last_full: null, last_targeted: null}. + const stubEngine: any = { + kind: 'pglite', + executeRaw: async () => [], + getConfig: async () => null, + }; + const ctx: any = { + engine: stubEngine, + config: {}, + logger: { info: () => {}, warn: () => {}, error: () => {} }, + dryRun: false, + remote: true, + }; + const result = (await op.handler(ctx, {})) as Record; + expect(result.schema_version).toBe(1); + expect(result).toHaveProperty('sync'); + expect(result).toHaveProperty('cycle'); + expect(result).not.toHaveProperty('locks'); + expect(result).not.toHaveProperty('workers'); + expect(result).not.toHaveProperty('queue'); + expect(result).not.toHaveProperty('autopilot'); + }); +}); diff --git a/test/status-sections.test.ts b/test/status-sections.test.ts new file mode 100644 index 000000000..5de48dbac --- /dev/null +++ b/test/status-sections.test.ts @@ -0,0 +1,76 @@ +/** + * Pure-function unit tests for `gbrain status` orchestrator helpers. + * + * Hermetic — no PGLite, no DB. Drives the exported helpers (parseSectionFlag, + * runStatus with engine=null in thin-client-disabled mode) and asserts: + * - JSON envelope shape stability (schema_version: 1) + * - --section filter validation (unknown → exit 2) + * - exit code policy (0 success/degraded, 1 snapshot failure, 2 usage) + * - thin-client local-only-N/A render for Locks/Workers/Queue/Autopilot + * (we exercise this via a stubbed cfg that mimics thin-client mode) + * + * The E2E test at test/e2e/status-pglite.test.ts covers the full PGLite + + * fake-minion_jobs + fake-supervisor-audit path. + */ + +import { describe, test, expect } from 'bun:test'; +import { parseSectionFlag, runStatus } from '../src/commands/status.ts'; + +describe('parseSectionFlag', () => { + test('no --section flag → undefined (all sections)', () => { + expect(parseSectionFlag([])).toBeUndefined(); + expect(parseSectionFlag(['--json'])).toBeUndefined(); + }); + + test('--section form returns the set', () => { + const r = parseSectionFlag(['--section', 'sync']); + expect(r).toBeInstanceOf(Set); + expect((r as Set).has('sync')).toBe(true); + }); + + test('--section= form returns the set', () => { + const r = parseSectionFlag(['--section=cycle']); + expect(r).toBeInstanceOf(Set); + expect((r as Set).has('cycle')).toBe(true); + }); + + test('unknown section returns usage_error', () => { + expect(parseSectionFlag(['--section', 'bogus'])).toBe('usage_error'); + expect(parseSectionFlag(['--section=nonsense'])).toBe('usage_error'); + }); + + test('every valid section is accepted', () => { + for (const s of ['sync', 'cycle', 'locks', 'workers', 'queue', 'autopilot']) { + const r = parseSectionFlag(['--section', s]); + expect(r).toBeInstanceOf(Set); + expect((r as Set).has(s)).toBe(true); + } + }); +}); + +describe('runStatus exit codes', () => { + test('--section invalid → exit 2 (usage error)', async () => { + let captured = ''; + const r = await runStatus(null, ['--section', 'bogus'], { + stdout: () => {}, + stderr: (s: string) => { + captured += s; + }, + }); + expect(r.exitCode).toBe(2); + expect(captured).toContain('invalid --section'); + }); + + test('local mode with engine=null → exit 1 (snapshot failure)', async () => { + let captured = ''; + const r = await runStatus(null, [], { + stdout: () => {}, + stderr: (s: string) => { + captured += s; + }, + }); + // Without a config + engine, status can't build the local snapshot. + expect(r.exitCode).toBe(1); + expect(captured).toMatch(/snapshot failed|no engine connected/); + }); +});