mirror of https://github.com/garrytan/gstack.git
Merge 77fbdea01e into c43c850cae
This commit is contained in:
commit
3e7b30fdc5
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue