mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
* fix(setup-gbrain): judge doctor health from brain checks only (#3790) Step 5 and gstack-gbrain-detect's gbrain_doctor_ok read gbrain doctor's overall status, so a failing skill check against a skills dir gbrain auto-detected (~/.openclaw/workspace) stopped setup on a working brain. Both now run doctor with --scope=brain and ignore category:skill failures from gbrain versions that predate the flag. gbrain's 'healthy' status is accepted too; only 'ok' and 'warnings' were before. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(gbrain-detect): spawn doctor through spawnGbrain, fail closed with a cause Review pass 1 batch: route the doctor spawn through lib/gbrain-exec so the Windows gbrain.cmd shim works, report why doctor printed no JSON, raise --doctor's timeout to 300 s, count skill failures only when doctor says unhealthy, and cover the fail-closed paths and exit codes in tests. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(gbrain-detect): a doctor spawn that throws fails closed instead of crashing detect A JSON-null ~/.gbrain/config.json made spawnGbrain throw, which crashed detect before Step 1.5's broken-config remediation could run. Step 5 prose now says skill checks never count, matching the code. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(gbrain-detect): ignore doctor stderr so a chatty doctor cannot overflow the spawn buffer stderr over spawnSync's 1 MB maxBuffer made the call fail with ENOBUFS and dropped the stdout report, turning a healthy brain into a Step 5 STOP. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(gbrain-detect): let --doctor's report drain before exit process.exit() cut a large verdict off mid-pipe, so Step 5's jq read truncated JSON and stopped on a healthy brain. Set exitCode instead. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(gbrain-detect): say what gbrain_doctor_ok proves under --fast Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: setup-gbrain doctor gate judges brain checks only (#3790) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(gbrain): document gstack-gbrain-detect --doctor and gbrain doctor's status values Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
380 lines
16 KiB
TypeScript
Executable File
380 lines
16 KiB
TypeScript
Executable File
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
/**
|
|
* gstack-gbrain-detect — emit current gbrain/gstack-brain state as JSON.
|
|
*
|
|
* Rewritten from bash to TypeScript in v{X.Y.Z.0} to share the engine-status
|
|
* classifier with bin/gstack-gbrain-sync.ts. Single source of truth via
|
|
* lib/gbrain-local-status.ts. Filename and exec semantics unchanged: callers
|
|
* just shell out to the file path; the bun shebang resolves at runtime.
|
|
*
|
|
* Output (always valid JSON, even when every check is false):
|
|
* {
|
|
* "gbrain_on_path": true|false,
|
|
* "gbrain_version": "0.18.2" | null,
|
|
* "gbrain_config_exists": true|false,
|
|
* "gbrain_engine": "pglite"|"postgres" | null,
|
|
* "gbrain_doctor_ok": true|false,
|
|
* "gbrain_mcp_mode": "local-stdio"|"remote-http"|"none",
|
|
* "gstack_brain_sync_mode": "off"|"artifacts-only"|"full",
|
|
* "gstack_brain_git": true|false,
|
|
* "gstack_artifacts_remote": "https://..." | "",
|
|
* "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db"|"engine-locked"|"timeout"|"db-unreachable"|"thin-client",
|
|
* "gbrain_pooler_mode": "transaction"|"session"|null
|
|
* }
|
|
*
|
|
* Backward compatibility (per plan codex #5): the 9 pre-existing fields stay
|
|
* identical in name + type + value semantics. One new field added:
|
|
* 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,
|
|
* error?};
|
|
* 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 { existsSync, readFileSync } from "fs";
|
|
import { homedir } from "os";
|
|
import { join } from "path";
|
|
|
|
import {
|
|
localEngineStatus,
|
|
localEngineStatusDetail,
|
|
resolveGbrainBin,
|
|
readGbrainVersion,
|
|
} from "../lib/gbrain-local-status";
|
|
import { gbrainConfigDir, isTransactionModePooler, spawnGbrain } from "../lib/gbrain-exec";
|
|
import { classifyGbrainMcpEntry, classifyGbrainMcpListLine, type GbrainMcpEntry } from "../lib/gbrain-mcp-locality";
|
|
import { resolveStateRoot } from "../lib/state-root";
|
|
|
|
const STATE_DIR = resolveStateRoot();
|
|
const SCRIPT_DIR = __dirname;
|
|
const CONFIG_BIN = join(SCRIPT_DIR, "gstack-config");
|
|
// Honors GBRAIN_HOME with gbrain's own configDir() semantics (#2521:
|
|
// GBRAIN_HOME is a parent dir, `.gbrain` is appended) — must stay consistent
|
|
// with lib/gbrain-local-status's config resolution, or the detect JSON
|
|
// reports gbrain_local_status "ok" alongside gbrain_config_exists false for
|
|
// relocated-home users. Both route through gbrainConfigDir.
|
|
const GBRAIN_CONFIG = join(gbrainConfigDir(), "config.json");
|
|
const CLAUDE_JSON = join(userHome(), ".claude.json");
|
|
|
|
function userHome(): string {
|
|
return process.env.HOME || homedir();
|
|
}
|
|
|
|
function tryExec(cmd: string, args: string[], timeoutMs = 5_000): string | null {
|
|
try {
|
|
return execFileSync(cmd, args, {
|
|
encoding: "utf-8",
|
|
timeout: timeoutMs,
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
}).trim();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function tryReadJSON(path: string): unknown | null {
|
|
if (!existsSync(path)) return null;
|
|
try {
|
|
return JSON.parse(readFileSync(path, "utf-8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// --- gbrain binary presence + version ---
|
|
// Uses the shared memoized resolvers from lib/gbrain-local-status.ts so
|
|
// detect and the classifier share probe results within one process.
|
|
function detectGbrain(): { onPath: boolean; version: string | null } {
|
|
const bin = resolveGbrainBin();
|
|
if (!bin) return { onPath: false, version: null };
|
|
const verRaw = readGbrainVersion();
|
|
if (!verRaw) return { onPath: true, version: null };
|
|
// Match bash behavior: head -1 | tr -d '[:space:]'
|
|
const version = verRaw.split("\n")[0].replace(/\s+/g, "") || null;
|
|
return { onPath: true, version };
|
|
}
|
|
|
|
// --- gbrain config existence + engine kind ---
|
|
function detectConfig(): { exists: boolean; engine: "pglite" | "postgres" | null } {
|
|
if (!existsSync(GBRAIN_CONFIG)) return { exists: false, engine: null };
|
|
const parsed = tryReadJSON(GBRAIN_CONFIG) as { engine?: string } | null;
|
|
if (!parsed) return { exists: true, engine: null };
|
|
if (parsed.engine === "pglite" || parsed.engine === "postgres") {
|
|
return { exists: true, engine: parsed.engine };
|
|
}
|
|
return { exists: true, engine: null };
|
|
}
|
|
|
|
// --- pooler mode detection (#1435) ---
|
|
//
|
|
// Reads DATABASE_URL from ~/.gbrain/config.json and checks whether it targets
|
|
// a PgBouncer transaction-mode pooler (port 6543). Surfaced so /sync-gbrain
|
|
// and /setup-gbrain can advise users when search may require GBRAIN_PREPARE.
|
|
function detectPoolerMode(): "transaction" | "session" | "unknown" | null {
|
|
const parsed = tryReadJSON(GBRAIN_CONFIG) as { database_url?: string } | null;
|
|
if (!parsed?.database_url) return null;
|
|
return isTransactionModePooler(parsed.database_url) ? "transaction" : "session";
|
|
}
|
|
|
|
// --- 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;
|
|
error?: string;
|
|
}
|
|
|
|
function runDoctor(extraArgs: string[], timeoutMs: number): DoctorVerdict {
|
|
let r: ReturnType<typeof spawnGbrain>;
|
|
try {
|
|
r = spawnGbrain(["doctor", "--json", ...extraArgs, "--scope=brain"], {
|
|
timeout: timeoutMs,
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
} catch (e) {
|
|
const error = `gbrain doctor could not start: ${e instanceof Error ? e.message : String(e)}`;
|
|
return { brain_ok: false, status: null, brain_failures: [], skill_failures: [], report: null, error };
|
|
}
|
|
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") {
|
|
const cause = r.error ? r.error.message : r.signal ? `killed by ${r.signal}` : `exit ${r.status}`;
|
|
const error = `gbrain doctor printed no JSON report (${cause})`;
|
|
return { brain_ok: false, status: null, brain_failures: [], skill_failures: [], report: null, error };
|
|
}
|
|
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 || (status === "unhealthy" && skill_failures.length > 0)),
|
|
status,
|
|
brain_failures,
|
|
skill_failures,
|
|
report,
|
|
};
|
|
}
|
|
|
|
// Uses --fast to avoid hanging on a dead DB. --fast skips every DB check, so
|
|
// gbrain_doctor_ok only says doctor ran and its filesystem checks passed; it
|
|
// proves nothing about the brain's data. Step 5 runs the full --doctor gate.
|
|
// Engine reachability is gbrain_local_status (it probes `gbrain sources list`).
|
|
function detectDoctor(onPath: boolean): boolean {
|
|
if (!onPath) return false;
|
|
return runDoctor(["--fast"], 3_000).brain_ok;
|
|
}
|
|
|
|
// --- artifacts sync mode ---
|
|
function detectSyncMode(): "off" | "artifacts-only" | "full" {
|
|
if (!existsSync(CONFIG_BIN)) return "off";
|
|
const out = tryExec(CONFIG_BIN, ["get", "artifacts_sync_mode"], 2_000);
|
|
if (out === "off" || out === "artifacts-only" || out === "full") return out;
|
|
return "off";
|
|
}
|
|
|
|
// --- gstack-brain git repo present? ---
|
|
function detectBrainGit(): boolean {
|
|
return existsSync(join(STATE_DIR, ".git"));
|
|
}
|
|
|
|
// --- MCP mode: local-stdio | remote-http | none ---
|
|
//
|
|
// Defense-in-depth fallback chain (same ordering as the bash version):
|
|
// 1. `claude mcp get gbrain --json` — public CLI surface, structured output
|
|
// 2. `claude mcp list` text-grep — older claude versions without --json
|
|
// 3. `~/.claude.json` jq read — last resort if `claude` isn't on PATH
|
|
//
|
|
// #3539: an HTTP entry on a loopback host whose local gbrain config has an
|
|
// engine and no remote_mcp marker is a resident `gbrain serve --http` of this
|
|
// machine's own brain. It reports "local-stdio" (the local-engine mode every
|
|
// consumer already understands: memory ingest imports, sync-gbrain skips the
|
|
// remote trust-policy question) rather than a new value.
|
|
type McpMode = "local-stdio" | "remote-http";
|
|
function classify(entry: GbrainMcpEntry): McpMode | null {
|
|
const c = classifyGbrainMcpEntry(entry);
|
|
return c === "remote" ? "remote-http" : c === "local" ? "local-stdio" : null;
|
|
}
|
|
|
|
function detectMcpMode(): McpMode | "none" {
|
|
const claudeOnPath = tryExec("sh", ["-c", "command -v claude"], 1_000) !== null;
|
|
if (claudeOnPath) {
|
|
// Tier 1: `claude mcp get gbrain --json`
|
|
const get = tryExec("claude", ["mcp", "get", "gbrain", "--json"], 3_000);
|
|
if (get) {
|
|
try {
|
|
const parsed = JSON.parse(get) as {
|
|
type?: string;
|
|
transport?: string;
|
|
command?: string;
|
|
url?: string;
|
|
};
|
|
const c = classify(parsed);
|
|
if (c) return c;
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
// Tier 2: `claude mcp list` text-grep. Name-pattern generalized (#2051):
|
|
// a gbrain server registered as e.g. "gbrain-remote" or "gbrain_work"
|
|
// still counts. Anchored to the gbrain token so unrelated servers can't
|
|
// false-positive. The exact `gbrain:` line wins over a variant, the same
|
|
// order as tier 3 (and memory-ingest, which reads mcpServers.gbrain):
|
|
// {gbrain-a: loopback, gbrain: remote} is remote-http.
|
|
const list = tryExec("claude", ["mcp", "list"], 3_000);
|
|
if (list) {
|
|
const lines = list.split("\n");
|
|
const line =
|
|
lines.find((l) => l.startsWith("gbrain:")) ?? lines.find((l) => /^gbrain[-_][\w-]*:/.test(l));
|
|
if (line) {
|
|
// The list line carries the URL for HTTP and SSE servers
|
|
// ("gbrain: http://127.0.0.1:3131/mcp (HTTP) - ✓ Connected"), so the
|
|
// loopback rule (#3539) applies here too. The whole URL token is
|
|
// classified, never cut at ")"; an HTTP or SSE line without a usable
|
|
// URL keeps the old remote-http answer.
|
|
return classifyGbrainMcpListLine(line) === "local" ? "local-stdio" : "remote-http";
|
|
}
|
|
}
|
|
}
|
|
// Tier 3: read ~/.claude.json directly
|
|
const cj = tryReadJSON(CLAUDE_JSON) as
|
|
| { mcpServers?: Record<string, GbrainMcpEntry> }
|
|
| null;
|
|
const servers = cj?.mcpServers || {};
|
|
const exact = servers["gbrain"];
|
|
if (exact) {
|
|
const c = classify(exact);
|
|
if (c) return c;
|
|
}
|
|
// #2051 generalization, deterministic identifiers first:
|
|
// (a) a server whose url matches the config's remote_mcp.mcp_url is THE
|
|
// thin-client brain regardless of its registered name (URL-path
|
|
// heuristics are impossible — gbrain mounts at the generic /mcp);
|
|
// (b) name pattern gbrain[-_]* ;
|
|
// (c) a stdio server whose command mentions gbrain.
|
|
const remoteMcpUrl = readRemoteMcpUrl();
|
|
for (const [name, entry] of Object.entries(servers)) {
|
|
if (remoteMcpUrl && entry.url && entry.url === remoteMcpUrl) return "remote-http";
|
|
if (/^gbrain([-_][\w-]*)?$/.test(name)) {
|
|
const c = classify(entry);
|
|
if (c) return c;
|
|
}
|
|
if (entry.command && /\bgbrain\b/.test(entry.command)) return "local-stdio";
|
|
}
|
|
return "none";
|
|
}
|
|
|
|
/** remote_mcp.mcp_url from gbrain's own config (thin-client marker, #2051). */
|
|
function readRemoteMcpUrl(): string {
|
|
const cfg = tryReadJSON(join(gbrainConfigDir(), "config.json")) as
|
|
| { remote_mcp?: { mcp_url?: string } }
|
|
| null;
|
|
return cfg?.remote_mcp?.mcp_url || "";
|
|
}
|
|
|
|
// --- artifacts remote URL with brain-* fallback during the rename migration window ---
|
|
function detectArtifactsRemote(): string {
|
|
const newPath = join(userHome(), ".gstack-artifacts-remote.txt");
|
|
const oldPath = join(userHome(), ".gstack-brain-remote.txt");
|
|
for (const p of [newPath, oldPath]) {
|
|
if (existsSync(p)) {
|
|
try {
|
|
return readFileSync(p, "utf-8").split("\n")[0].trim();
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function main(): void {
|
|
const gbrain = detectGbrain();
|
|
const config = detectConfig();
|
|
const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1";
|
|
|
|
// Order MATCHES the bash version's jq output for callers that visually grep
|
|
// (key order doesn't affect JSON parsers, but minimizes review noise).
|
|
const out = {
|
|
gbrain_on_path: gbrain.onPath,
|
|
gbrain_version: gbrain.version,
|
|
gbrain_config_exists: config.exists,
|
|
gbrain_engine: config.engine,
|
|
gbrain_doctor_ok: detectDoctor(gbrain.onPath),
|
|
gbrain_mcp_mode: detectMcpMode(),
|
|
gstack_brain_sync_mode: detectSyncMode(),
|
|
gstack_brain_git: detectBrainGit(),
|
|
gstack_artifacts_remote: detectArtifactsRemote(),
|
|
gbrain_local_status: localEngineStatus({ noCache }),
|
|
gbrain_pooler_mode: detectPoolerMode(),
|
|
};
|
|
|
|
// #2051 honesty marker: on a thin client the classifier verified the CONFIG
|
|
// (remote_mcp present), not the remote's reachability — that is checked at
|
|
// use time, where gbrain calls degrade gracefully.
|
|
const withThinClient =
|
|
out.gbrain_local_status === "thin-client"
|
|
? { ...out, gbrain_thin_client: { probed: false } }
|
|
: out.gbrain_local_status === "db-unreachable"
|
|
? { ...out, gbrain_local_status_detail: localEngineStatusDetail() }
|
|
: out;
|
|
|
|
process.stdout.write(JSON.stringify(withThinClient, null, 2) + "\n");
|
|
}
|
|
|
|
// --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok";
|
|
// "timeout" — a slow-but-healthy engine, #1964; "db-unreachable" — a network
|
|
// error reaching a configured DB, transient like timeout; "thin-client" — remote-HTTP
|
|
// MCP brain with no local engine by design, #2051; or "engine-locked" —
|
|
// PGLite is single-writer, so a live `gbrain serve` (typically an MCP
|
|
// server) holds the embedded DB, #2456 — gbrain is installed and healthy in
|
|
// all four; none must silently suppress brain features), 1 otherwise. Runs
|
|
// detection live (never reads the possibly-stale gbrain-detection.json), so
|
|
// 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.
|
|
if (process.argv.includes("--is-ok")) {
|
|
const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1";
|
|
const status = localEngineStatus({ noCache });
|
|
process.exit(
|
|
status === "ok" || status === "timeout" || status === "db-unreachable" ||
|
|
status === "thin-client" || status === "engine-locked"
|
|
? 0
|
|
: 1,
|
|
);
|
|
}
|
|
|
|
// --doctor: the full brain-health gate /setup-gbrain Step 5 runs. Prints the
|
|
// DoctorVerdict as JSON and exits 0 iff brain_ok. exitCode, not process.exit:
|
|
// exit() cuts off a large report still draining into a pipe.
|
|
if (process.argv.includes("--doctor")) {
|
|
const verdict = runDoctor([], 300_000);
|
|
process.stdout.write(JSON.stringify(verdict, null, 2) + "\n");
|
|
process.exitCode = verdict.brain_ok ? 0 : 1;
|
|
} else {
|
|
main();
|
|
}
|