mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 21:19:18 +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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a7b79b66d4
commit
a74e5d90fc
+155
@@ -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.**
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -140,5 +140,5 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.41.19.0"
|
||||
"version": "0.41.20.0"
|
||||
}
|
||||
|
||||
+26
-1
@@ -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');
|
||||
|
||||
+148
-41
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> | 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<string, number>;
|
||||
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<CycleSnapshot> {
|
||||
type Row = {
|
||||
finished_at: string | Date | null;
|
||||
name: string;
|
||||
status: string;
|
||||
started_at: string | Date | null;
|
||||
result: { partial?: unknown; status?: unknown; report?: { totals?: Record<string, unknown> } } | 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<Row>(
|
||||
`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<Row>(
|
||||
`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<LockRow[]> {
|
||||
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<Row>(
|
||||
`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<QueueCounts> {
|
||||
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<Row>(
|
||||
`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<string, number>)[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<string, number> = {};
|
||||
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<Section>;
|
||||
}
|
||||
|
||||
async function buildLocalReport(
|
||||
engine: BrainEngine,
|
||||
opts: BuildOpts,
|
||||
): Promise<StatusReport> {
|
||||
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<string, unknown> | 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<typeof loadConfig>,
|
||||
opts: BuildOpts,
|
||||
): Promise<StatusReport> {
|
||||
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 <name>` (and `--section=<name>` form) from args.
|
||||
* Returns:
|
||||
* - undefined → no filter (all sections)
|
||||
* - Set<Section> → only these sections
|
||||
* - 'usage_error' → bad section name (caller exits 2)
|
||||
*/
|
||||
export function parseSectionFlag(args: string[]): Set<Section> | 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<Section>([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<RunStatusResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<string> = 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<string> = 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<string> = 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<string> = 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<string>();
|
||||
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<string> = SKILL_CHECK_NAMES;
|
||||
@@ -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<string, unknown> }> = [];
|
||||
try {
|
||||
const rows = await ctx.engine.executeRaw<{
|
||||
id: string;
|
||||
name: string;
|
||||
local_path: string | null;
|
||||
config: Record<string, unknown> | 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<string, unknown> | 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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
const source = readFileSync(DOCTOR_TS_PATH, 'utf-8');
|
||||
const names = new Set<string>();
|
||||
// 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<string>([
|
||||
...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<string, string[]>();
|
||||
const tag = (s: ReadonlySet<string>, 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<string>([
|
||||
...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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -64,10 +64,20 @@ async function getCheck(name: string, env: Record<string, string | undefined>) {
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 <name> form returns the set', () => {
|
||||
const r = parseSectionFlag(['--section', 'sync']);
|
||||
expect(r).toBeInstanceOf(Set);
|
||||
expect((r as Set<string>).has('sync')).toBe(true);
|
||||
});
|
||||
|
||||
test('--section=<name> form returns the set', () => {
|
||||
const r = parseSectionFlag(['--section=cycle']);
|
||||
expect(r).toBeInstanceOf(Set);
|
||||
expect((r as Set<string>).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<string>).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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user