Files
gbrain/test/doctor-brain-checks-score.test.ts
T
a74e5d90fc v0.41.20.0 feat: gbrain status + doctor --scope=brain (fix wave 2: items #6 + #7) (#1544)
* 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>
2026-05-26 23:47:05 -07:00

182 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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');
});
});