This commit is contained in:
Jayesh Betala 2026-06-03 07:36:46 +02:00 committed by GitHub
commit 3e7b30fdc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 102 additions and 6 deletions

View File

@ -213,6 +213,50 @@ function writeCache(status: LocalEngineStatus, key: CacheEntry["key"]): void {
} }
} }
/**
* Confirm a healthy thin-client brain via `gbrain doctor --json --fast`.
*
* Thin-client mode (remote MCP, no local DB) intentionally refuses the
* `gbrain sources list` probe `sources` is a local-DB-only command. The probe
* therefore throws with a "not routable" stderr that matches neither the
* broken-db nor broken-config pattern, so freshClassify would mislabel a
* perfectly healthy thin-client as broken-config and suppress every brain block
* (#1792). Doctor is the mode-aware signal: it reports `mode` + `status` for
* thin-clients. We only call it on the not-routable failure path, so the cheap
* ~80ms sources-list probe still owns the healthy local-DB hot path.
*
* Returns true only when doctor confirms thin-client mode AND a healthy status
* (`ok`/`warnings`). Any parse failure or unhealthy status falls through to the
* caller's defensive classification we never upgrade an unconfirmed brain.
*/
function isHealthyThinClient(env?: NodeJS.ProcessEnv): boolean {
let parsed: Record<string, unknown> | null = null;
try {
const out = execFileSync("gbrain", ["doctor", "--json", "--fast"], {
encoding: "utf-8",
timeout: PROBE_TIMEOUT_MS,
stdio: ["ignore", "pipe", "ignore"],
env: buildGbrainEnv({ baseEnv: env ?? process.env }),
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
});
parsed = JSON.parse(out);
} catch (err) {
// doctor exits 1 whenever health_score < 100 (normal on warnings), but
// still prints the JSON to stdout. Recover it before giving up. See #1415.
try {
const stdout = (err as { stdout?: Buffer | string })?.stdout ?? "";
const stdoutStr = typeof stdout === "string" ? stdout : stdout.toString("utf-8");
if (stdoutStr) parsed = JSON.parse(stdoutStr);
} catch {
return false;
}
}
if (!parsed) return false;
const mode = parsed.mode;
const status = parsed.status;
return mode === "thin-client" && (status === "ok" || status === "warnings");
}
/** /**
* Probe via `gbrain sources list --json`. Classify the outcome. * Probe via `gbrain sources list --json`. Classify the outcome.
* *
@ -253,6 +297,12 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
// ENOENT can happen if gbrain disappeared between resolveGbrainBin and now. // ENOENT can happen if gbrain disappeared between resolveGbrainBin and now.
if (e.code === "ENOENT") return "no-cli"; if (e.code === "ENOENT") return "no-cli";
// Thin-client mode (remote MCP, no local DB) refuses `sources list` with a
// "not routable" error — `sources` is local-DB-only. This is NOT a broken
// brain: confirm health via the mode-aware doctor before classifying, so a
// healthy thin-client reports "ok" and keeps its brain blocks (#1792).
if (stderr.includes("not routable") && isHealthyThinClient(env)) return "ok";
// Pattern match against gbrain's known error strings. Order matters: // Pattern match against gbrain's known error strings. Order matters:
// "Cannot connect to database" is the more specific DB-unreachable signal. // "Cannot connect to database" is the more specific DB-unreachable signal.
if (stderr.includes("Cannot connect to database")) return "broken-db"; if (stderr.includes("Cannot connect to database")) return "broken-db";

View File

@ -53,9 +53,17 @@ interface FakeEnv {
* The classifier reads HOME via os.homedir() which reads process.env.HOME, so * The classifier reads HOME via os.homedir() which reads process.env.HOME, so
* we mutate process.env ambiently in each test (restored in afterEach). * we mutate process.env ambiently in each test (restored in afterEach).
*/ */
type GbrainBehavior =
| "ok"
| "broken-db"
| "broken-config"
| "throws"
| "thin-client-ok"
| "thin-client-unhealthy";
function makeEnv(opts: { function makeEnv(opts: {
withGbrain?: boolean; withGbrain?: boolean;
gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "throws"; gbrainBehavior?: GbrainBehavior;
withConfig?: boolean; withConfig?: boolean;
}): FakeEnv { }): FakeEnv {
const tmp = mkdtempSync(join(tmpdir(), "gbrain-local-status-test-")); const tmp = mkdtempSync(join(tmpdir(), "gbrain-local-status-test-"));
@ -95,9 +103,9 @@ function makeEnv(opts: {
}; };
} }
function makeFakeGbrainScript( function makeFakeGbrainScript(behavior: GbrainBehavior): string {
behavior: "ok" | "broken-db" | "broken-config" | "throws", const isThinClient =
): string { behavior === "thin-client-ok" || behavior === "thin-client-unhealthy";
const stderrLine = const stderrLine =
behavior === "broken-db" behavior === "broken-db"
? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2' ? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2'
@ -105,13 +113,27 @@ function makeFakeGbrainScript(
? 'echo "Error: malformed config.json at ~/.gbrain/config.json" >&2' ? 'echo "Error: malformed config.json at ~/.gbrain/config.json" >&2'
: behavior === "throws" : behavior === "throws"
? 'echo "unexpected gbrain failure" >&2' ? 'echo "unexpected gbrain failure" >&2'
: isThinClient
? 'echo "\\`gbrain sources\\` is not routable. sources commands manage local DB + config rows. Per-subcommand thin-client routing lands in v0.31.x." >&2'
: ""; : "";
const exitCode = behavior === "ok" ? 0 : 1; const exitCode = behavior === "ok" ? 0 : 1;
// Thin-client doctor: mode-aware health signal the classifier falls back to
// when `sources list` is refused. doctor exits 1 when health_score < 100 but
// still prints JSON to stdout (matches real gbrain + #1415 recovery path).
const doctorStatus = behavior === "thin-client-unhealthy" ? "error" : "ok";
const doctorExit = behavior === "thin-client-unhealthy" ? "1" : "0";
const doctorBlock = isThinClient
? `if [ "$1 $2 $3" = "doctor --json --fast" ]; then
echo '{"mode":"thin-client","status":"${doctorStatus}"}'
exit ${doctorExit}
fi`
: "";
return `#!/bin/sh return `#!/bin/sh
if [ "$1" = "--version" ]; then if [ "$1" = "--version" ]; then
echo "gbrain 0.33.1.0" echo "gbrain 0.41.28.0"
exit 0 exit 0
fi fi
${doctorBlock}
if [ "$1 $2" = "sources list" ]; then if [ "$1 $2" = "sources list" ]; then
if [ ${exitCode} -eq 0 ]; then if [ ${exitCode} -eq 0 ]; then
echo '{"sources":[]}' echo '{"sources":[]}'
@ -206,6 +228,30 @@ describe("lib/gbrain-local-status — five status cases", () => {
restoreEnv = applyEnv(env); restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("ok"); expect(localEngineStatus({ noCache: true })).toBe("ok");
}); });
it("returns 'ok' for a healthy thin-client whose 'sources list' is not routable (#1792)", () => {
// Thin-client refuses the local-DB-only sources probe; doctor confirms
// mode=thin-client + status=ok. Must NOT be mislabeled broken-config.
env = makeEnv({
withGbrain: true,
gbrainBehavior: "thin-client-ok",
withConfig: true,
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("ok");
});
it("stays defensive (broken-config) when a not-routable brain is unhealthy per doctor (#1792)", () => {
// "not routable" alone must not upgrade an unconfirmed brain: doctor reports
// status=error, so we fall through to the defensive default.
env = makeEnv({
withGbrain: true,
gbrainBehavior: "thin-client-unhealthy",
withConfig: true,
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("broken-config");
});
}); });
describe("lib/gbrain-local-status — cache behavior", () => { describe("lib/gbrain-local-status — cache behavior", () => {