diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect index e79f4d558..1f9d1140a 100755 --- a/bin/gstack-gbrain-detect +++ b/bin/gstack-gbrain-detect @@ -27,13 +27,19 @@ * gbrain_local_status. Key order may differ from the bash version's `jq -n` * output — downstream parsers must not depend on key order (none currently do). * + * Flags: + * --is-ok exit 0 iff the local engine is usable (see below). + * --doctor run `gbrain doctor --json --scope=brain` and print + * {brain_ok, status, brain_failures, skill_failures, report}; + * exit 0 iff brain_ok. Skill checks never decide brain_ok (#3790). + * * Env: * GSTACK_HOME — override ~/.gstack for state lookups (used by tests). * HOME — effective user home (drives ~/.gbrain/config.json path). * GSTACK_DETECT_NO_CACHE=1 — bypass the 60s local-status cache. */ -import { execFileSync } from "child_process"; +import { execFileSync, spawnSync } from "child_process"; import { existsSync, readFileSync } from "fs"; import { homedir } from "os"; import { join } from "path"; @@ -118,21 +124,57 @@ function detectPoolerMode(): "transaction" | "session" | "unknown" | null { return isTransactionModePooler(parsed.database_url) ? "transaction" : "session"; } -// --- gbrain doctor health (any nonzero exit or non-"ok"/"warnings" status → false) --- +// --- gbrain doctor health, judged from brain checks only (#3790) --- // +// gbrain doctor's skill checks (category "skill": resolver_health, +// skill_conformance, ...) grade whatever skills dir gbrain auto-detects, which +// can be another agent's workspace such as ~/.openclaw/workspace. They say +// nothing about the brain, so they never decide health here. `--scope=brain` +// (gbrain v0.41.20+) skips that group; older gbrain ignores the flag, so +// failed checks tagged "skill" are dropped as well. gbrain's status is +// "healthy"/"warnings"/"unhealthy"; "ok" is kept for older reports. doctor +// exits 1 when any check fails, so stdout is read whatever the exit code. +interface DoctorVerdict { + brain_ok: boolean; + status: string | null; + brain_failures: string[]; + skill_failures: string[]; + report: unknown; +} + +function runDoctor(extraArgs: string[], timeoutMs: number): DoctorVerdict { + const r = spawnSync("gbrain", ["doctor", "--json", ...extraArgs, "--scope=brain"], { + encoding: "utf-8", + timeout: timeoutMs, + stdio: ["ignore", "pipe", "ignore"], + }); + let report: { status?: string; checks?: { name?: string; status?: string; category?: string }[] } | null = null; + try { + report = JSON.parse(r.stdout || "null"); + } catch {} + if (!report || typeof report !== "object") { + return { brain_ok: false, status: null, brain_failures: [], skill_failures: [], report: null }; + } + const failed = (Array.isArray(report.checks) ? report.checks : []).filter((c) => c?.status === "fail"); + const skill_failures = failed.filter((c) => c.category === "skill").map((c) => c.name ?? "unnamed"); + const brain_failures = failed.filter((c) => c.category !== "skill").map((c) => c.name ?? "unnamed"); + const status = report.status ?? null; + const statusOk = status === "healthy" || status === "ok" || status === "warnings"; + return { + brain_ok: brain_failures.length === 0 && (statusOk || skill_failures.length > 0), + status, + brain_failures, + skill_failures, + report, + }; +} + // Uses --fast to avoid hanging on a dead DB. Per the local-status classifier // (which probes DB directly via `gbrain sources list`), gbrain_doctor_ok is a // coarse health summary, not engine-reachability — that's gbrain_local_status. function detectDoctor(onPath: boolean): boolean { if (!onPath) return false; - const out = tryExec("gbrain", ["doctor", "--json", "--fast"], 3_000); - if (!out) return false; - try { - const parsed = JSON.parse(out) as { status?: string }; - return parsed.status === "ok" || parsed.status === "warnings"; - } catch { - return false; - } + return runDoctor(["--fast"], 3_000).brain_ok; } // --- artifacts sync mode --- @@ -300,6 +342,14 @@ function main(): void { // callers — setup, bin/dev-setup, and `gstack-config gbrain-refresh` — can // decide whether to render the gbrain :user variant without duplicating the // JSON grep. Prints nothing on stdout. +// --doctor: the full brain-health gate /setup-gbrain Step 5 runs. Prints the +// DoctorVerdict as JSON and exits 0 iff brain_ok. +if (process.argv.includes("--doctor")) { + const verdict = runDoctor([], 120_000); + process.stdout.write(JSON.stringify(verdict, null, 2) + "\n"); + process.exit(verdict.brain_ok ? 0 : 1); +} + if (process.argv.includes("--is-ok")) { const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1"; const status = localEngineStatus({ noCache }); diff --git a/setup-gbrain/SKILL.md b/setup-gbrain/SKILL.md index 201c54af9..6f6d785ea 100644 --- a/setup-gbrain/SKILL.md +++ b/setup-gbrain/SKILL.md @@ -315,12 +315,14 @@ compatible MCP version. For Paths 1, 2a, 2b, 3, switch: ```bash -doctor=$(gbrain doctor --json) -status=$(echo "$doctor" | jq -r .status) +DOCTOR=$(~/.claude/skills/gstack/bin/gstack-gbrain-detect --doctor) +BRAIN_OK=$(echo "$DOCTOR" | jq -r .brain_ok) ``` -If status is `ok` or `warnings`, proceed. Anything else → surface the full -doctor output and STOP. +If `BRAIN_OK` is `true`, proceed; only brain checks count. Note any +`skill_failures`: gbrain graded a skills dir it found, maybe another agent's +(`~/.openclaw/workspace`). Never edit that workspace to pass. Otherwise show +`echo "$DOCTOR" | jq .report` and STOP. --- diff --git a/setup-gbrain/SKILL.md.tmpl b/setup-gbrain/SKILL.md.tmpl index 02c0f096a..4de37a6b9 100644 --- a/setup-gbrain/SKILL.md.tmpl +++ b/setup-gbrain/SKILL.md.tmpl @@ -230,12 +230,14 @@ compatible MCP version. For Paths 1, 2a, 2b, 3, switch: ```bash -doctor=$(gbrain doctor --json) -status=$(echo "$doctor" | jq -r .status) +DOCTOR=$(~/.claude/skills/gstack/bin/gstack-gbrain-detect --doctor) +BRAIN_OK=$(echo "$DOCTOR" | jq -r .brain_ok) ``` -If status is `ok` or `warnings`, proceed. Anything else → surface the full -doctor output and STOP. +If `BRAIN_OK` is `true`, proceed; only brain checks count. Note any +`skill_failures`: gbrain graded a skills dir it found, maybe another agent's +(`~/.openclaw/workspace`). Never edit that workspace to pass. Otherwise show +`echo "$DOCTOR" | jq .report` and STOP. --- diff --git a/test/setup-gbrain-doctor-gate.test.ts b/test/setup-gbrain-doctor-gate.test.ts new file mode 100644 index 000000000..8137cf1d4 --- /dev/null +++ b/test/setup-gbrain-doctor-gate.test.ts @@ -0,0 +1,131 @@ +/** + * /setup-gbrain Step 5 and gstack-gbrain-detect judge brain health from brain + * checks only (#3790). + * + * gbrain doctor's skill checks (category "skill": resolver_health, + * skill_conformance, ...) grade whatever skills dir gbrain auto-detects, which + * can be another agent's workspace such as ~/.openclaw/workspace. A failure + * there made a working brain read "unhealthy", Step 5 stopped, and the agent + * proposed editing the other agent's workspace. Real brain failures + * (connection, schema_version, pgvector) must still stop Step 5. + * + * A stub gbrain on PATH serves canned doctor JSON; no real gbrain runs. + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawnSync } from 'child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const DETECT = path.join(ROOT, 'bin', 'gstack-gbrain-detect'); +const SKILL_MD = path.join(ROOT, 'setup-gbrain', 'SKILL.md'); + +const check = (name: string, category: string, status = 'ok') => ({ name, category, status, message: '' }); +const BRAIN_OK = [ + check('connection', 'ops'), check('pgvector', 'ops'), check('schema_version', 'meta'), check('embeddings', 'brain'), +]; +const SKILL_FAIL = check('resolver_health', 'skill', 'fail'); +const report = (checks: ReturnType[]) => ({ + schema_version: 2, + status: checks.some((c) => c.status === 'fail') ? 'unhealthy' : checks.some((c) => c.status === 'warn') ? 'warnings' : 'healthy', + checks, +}); +const withFailure = (name: string) => BRAIN_OK.map((c) => (c.name === name ? { ...c, status: 'fail' } : c)); + +type Case = { full: object; scoped: object }; +const CASES: Record = { + // gbrain >= v0.41.20: --scope=brain drops the skill group. + 'skill-only failure': { + full: report([...BRAIN_OK, SKILL_FAIL]), + scoped: report([...BRAIN_OK, check('harness_wiring', 'ops', 'warn')]), + }, + // Older gbrain ignores --scope=brain and still runs the skill group. + 'skill-only failure, gbrain without --scope': { + full: report([...BRAIN_OK, SKILL_FAIL]), + scoped: report([...BRAIN_OK, SKILL_FAIL]), + }, + 'all checks pass': { full: report(BRAIN_OK), scoped: report(BRAIN_OK) }, + 'connection failure': { full: report([...withFailure('connection'), SKILL_FAIL]), scoped: report(withFailure('connection')) }, + 'schema_version failure': { full: report([...withFailure('schema_version'), SKILL_FAIL]), scoped: report(withFailure('schema_version')) }, + 'pgvector failure': { full: report([...withFailure('pgvector'), SKILL_FAIL]), scoped: report(withFailure('pgvector')) }, + 'pgvector failure, gbrain without --scope': { + full: report([...withFailure('pgvector'), SKILL_FAIL]), + scoped: report([...withFailure('pgvector'), SKILL_FAIL]), + }, +}; +const HEALTHY = ['skill-only failure', 'skill-only failure, gbrain without --scope', 'all checks pass']; + +let tmp: string; +const SAFE_PATH = '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin'; + +beforeAll(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-doctor-gate-')); + fs.mkdirSync(path.join(tmp, 'bin')); + fs.symlinkSync(process.execPath, path.join(tmp, 'bin', 'bun')); + // gbrain doctor exits 1 when any check fails and prints the report anyway. + fs.writeFileSync(path.join(tmp, 'bin', 'gbrain'), `#!/bin/sh +case "$1" in + --version) echo "gbrain 0.60.537.0"; exit 0 ;; + doctor) ;; + *) exit 2 ;; +esac +f="$FAKE_DOCTOR_DIR/full.json" +for a in "$@"; do [ "$a" = "--scope=brain" ] && f="$FAKE_DOCTOR_DIR/scoped.json"; done +cat "$f" +grep -q '"status":"unhealthy"' "$f" && exit 1 +exit 0 +`, { mode: 0o755 }); +}); + +afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); + +function caseEnv(name: string) { + const dir = path.join(tmp, name.replace(/[^a-z0-9]+/gi, '-')); + fs.mkdirSync(path.join(dir, 'home'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'full.json'), JSON.stringify(CASES[name]!.full)); + fs.writeFileSync(path.join(dir, 'scoped.json'), JSON.stringify(CASES[name]!.scoped)); + return { + ...process.env, + PATH: `${path.join(tmp, 'bin')}:${SAFE_PATH}`, + HOME: path.join(dir, 'home'), + GSTACK_HOME: path.join(dir, 'state'), + GSTACK_DETECT_NO_CACHE: '1', + FAKE_DOCTOR_DIR: dir, + }; +} + +function step5Fence(): string { + const md = fs.readFileSync(SKILL_MD, 'utf-8'); + const start = md.indexOf('## Step 5: Verify gbrain doctor'); + const end = md.indexOf('\n## ', start + 1); + expect(start).toBeGreaterThan(-1); + const fence = md.slice(start, end).match(/\`\`\`bash\n([\s\S]*?)\`\`\`/); + expect(fence).not.toBeNull(); + return fence![1]!.replaceAll('~/.claude/skills/gstack/bin/', `${ROOT}/bin/`); +} + +describe('/setup-gbrain Step 5 doctor gate', () => { + for (const name of Object.keys(CASES)) { + const healthy = HEALTHY.includes(name); + test(`${name}: Step 5 ${healthy ? 'proceeds' : 'stops'}`, () => { + const r = spawnSync('bash', ['-c', `${step5Fence()}\necho "BRAIN_OK=\${BRAIN_OK:-unset} status=\${status:-unset}"`], { + env: caseEnv(name), encoding: 'utf-8', timeout: 30_000, + }); + const verdict = r.stdout.trim().split('\n').pop(); + expect(verdict).toStartWith(`BRAIN_OK=${healthy}`); + }); + } +}); + +describe('gstack-gbrain-detect gbrain_doctor_ok', () => { + for (const name of Object.keys(CASES)) { + const healthy = HEALTHY.includes(name); + test(`${name}: gbrain_doctor_ok is ${healthy}`, () => { + const r = spawnSync(DETECT, [], { env: caseEnv(name), encoding: 'utf-8', timeout: 30_000 }); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout).gbrain_doctor_ok).toBe(healthy); + }); + } +});