feat(gbrain): add lib/gbrain-local-status classifier with 5-state engine status + 60s cache

Foundation for split-engine gbrain: shared classifier used by both
bin/gstack-gbrain-detect (preamble probe) and bin/gstack-gbrain-sync.ts
(orchestrator SKIP-when-not-ok). Single source of truth.

Probes via `gbrain sources list --json` and classifies stderr against the
same patterns lib/gbrain-sources.ts:66-67 already uses ("Cannot connect to
database", "config.json"). Returns one of: ok, no-cli, missing-config,
broken-config, broken-db. Defensive default: unrecognized failures
classify as broken-config so the raw stderr can be surfaced upstream.

Cache at ~/.gstack/.gbrain-local-status-cache.json keyed on
{home, path_hash, gbrain_bin_path, gbrain_version, config_mtime, config_size}
with 60s TTL. Cache invalidates on any invariant change. --no-cache option
busts the cache for callers that just mutated state (/setup-gbrain,
/sync-gbrain after init/migration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-13 11:00:02 -07:00
parent dc6252d1df
commit f59c5a7a60
2 changed files with 541 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
/**
* gbrain-local-status — classify the local gbrain engine into 5 states.
*
* Shared between bin/gstack-gbrain-detect (preamble probe on every skill start)
* and bin/gstack-gbrain-sync.ts (orchestrator SKIP-when-not-ok semantics).
* Single source of truth: same probe, same classification, same cache.
*
* Per the split-engine plan (D2 + D8):
* - Probe: `gbrain sources list --json`. Cheap (~80ms), actually hits the DB.
* Uses the same stderr patterns as lib/gbrain-sources.ts:66-67.
* - Cache: 60s TTL at ~/.gstack/.gbrain-local-status-cache.json, keyed on
* {home, path_hash, gbrain_bin_path, gbrain_version, config_mtime}.
* - --no-cache bypass: /setup-gbrain and /sync-gbrain pass it after any
* state-mutating operation so the next read sees fresh status.
*
* No-cli → gbrain not on PATH.
* Missing → CLI present, ~/.gbrain/config.json absent.
* Broken-config → config exists but `gbrain sources list` fails with config parse error
* (or any non-recognized error — defensive default per codex #8).
* Broken-db → config exists, DB unreachable per stderr classification.
* Ok → DB reachable, sources list returned valid JSON.
*/
import { execFileSync } from "child_process";
import {
createHash,
} from "crypto";
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
statSync,
writeFileSync,
} from "fs";
import { homedir } from "os";
import { dirname, join } from "path";
export type LocalEngineStatus =
| "ok"
| "no-cli"
| "missing-config"
| "broken-config"
| "broken-db";
export interface ClassifyOptions {
/** Bypass the 60s cache. Used after any state-mutating operation. */
noCache?: boolean;
/** Env override for the spawned `gbrain` (used by tests to point at a fake binary). */
env?: NodeJS.ProcessEnv;
}
interface CacheEntry {
schema_version: 1;
status: LocalEngineStatus;
cached_at: number;
/** Cache invariants — entry is invalidated if any of these change between writes. */
key: {
home: string;
path_hash: string;
gbrain_bin_path: string;
gbrain_version: string;
config_mtime: number; // 0 when config absent
config_size: number; // 0 when config absent
};
}
export const CACHE_TTL_MS = 60_000;
export const PROBE_TIMEOUT_MS = 5_000;
/** Effective user home — respects HOME env override (used by tests). */
function userHome(): string {
return process.env.HOME || homedir();
}
/** Cache path computed fresh on each call so tests can mutate GSTACK_HOME per case. */
export function cacheFilePath(): string {
return join(
process.env.GSTACK_HOME || join(userHome(), ".gstack"),
".gbrain-local-status-cache.json",
);
}
function gbrainConfigPath(): string {
return join(userHome(), ".gbrain", "config.json");
}
function hashPath(p: string): string {
return createHash("sha256").update(p).digest("hex").slice(0, 16);
}
/**
* Resolve the absolute path of `gbrain` on PATH. Returns null when missing.
* Uses `command -v` semantics via execFileSync.
*/
function resolveGbrainBin(env?: NodeJS.ProcessEnv): string | null {
try {
const out = execFileSync("sh", ["-c", "command -v gbrain"], {
encoding: "utf-8",
timeout: 2_000,
stdio: ["ignore", "pipe", "ignore"],
env: env ?? process.env,
});
return out.trim() || null;
} catch {
return null;
}
}
function readGbrainVersion(env?: NodeJS.ProcessEnv): string {
try {
const out = execFileSync("gbrain", ["--version"], {
encoding: "utf-8",
timeout: 2_000,
stdio: ["ignore", "pipe", "ignore"],
env: env ?? process.env,
});
return out.trim().split("\n")[0] || "";
} catch {
return "";
}
}
function configFingerprint(): { mtime: number; size: number } {
try {
const st = statSync(gbrainConfigPath());
return { mtime: Math.floor(st.mtimeMs), size: st.size };
} catch {
return { mtime: 0, size: 0 };
}
}
function buildCacheKey(
gbrainBin: string | null,
gbrainVersion: string,
env?: NodeJS.ProcessEnv,
): CacheEntry["key"] {
const e = env ?? process.env;
const config = configFingerprint();
return {
home: e.HOME || "",
path_hash: hashPath(e.PATH || ""),
gbrain_bin_path: gbrainBin || "",
gbrain_version: gbrainVersion,
config_mtime: config.mtime,
config_size: config.size,
};
}
function keysEqual(a: CacheEntry["key"], b: CacheEntry["key"]): boolean {
return (
a.home === b.home &&
a.path_hash === b.path_hash &&
a.gbrain_bin_path === b.gbrain_bin_path &&
a.gbrain_version === b.gbrain_version &&
a.config_mtime === b.config_mtime &&
a.config_size === b.config_size
);
}
function readCache(key: CacheEntry["key"]): LocalEngineStatus | null {
if (!existsSync(cacheFilePath())) return null;
try {
const raw = JSON.parse(readFileSync(cacheFilePath(), "utf-8")) as CacheEntry;
if (raw.schema_version !== 1) return null;
if (Date.now() - raw.cached_at > CACHE_TTL_MS) return null;
if (!keysEqual(raw.key, key)) return null;
return raw.status;
} catch {
return null;
}
}
function writeCache(status: LocalEngineStatus, key: CacheEntry["key"]): void {
const entry: CacheEntry = {
schema_version: 1,
status,
cached_at: Date.now(),
key,
};
try {
mkdirSync(dirname(cacheFilePath()), { recursive: true });
const tmp = cacheFilePath() + ".tmp." + process.pid;
writeFileSync(tmp, JSON.stringify(entry, null, 2), "utf-8");
renameSync(tmp, cacheFilePath());
} catch {
// Cache write failure is non-fatal — we re-probe next call.
}
}
/**
* Probe via `gbrain sources list --json`. Classify the outcome.
*
* Pattern strings ("Cannot connect to database", "config.json") are deliberately
* the same strings used in lib/gbrain-sources.ts:66-67. If gbrain reworks its
* error messages, classifier returns broken-config defensively (codex #8).
*/
function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
// 1. CLI on PATH?
const gbrainBin = resolveGbrainBin(env);
if (!gbrainBin) return "no-cli";
// 2. Config file present?
if (!existsSync(gbrainConfigPath())) return "missing-config";
// 3. Probe gbrain sources list.
try {
execFileSync("gbrain", ["sources", "list", "--json"], {
encoding: "utf-8",
timeout: PROBE_TIMEOUT_MS,
stdio: ["ignore", "pipe", "pipe"],
env: env ?? process.env,
});
return "ok";
} catch (err) {
const e = err as NodeJS.ErrnoException & { stderr?: Buffer | string };
const stderr = (e.stderr ? e.stderr.toString() : "") || "";
// ENOENT can happen if gbrain disappeared between resolveGbrainBin and now.
if (e.code === "ENOENT") return "no-cli";
// Pattern match against gbrain's known error strings. Order matters:
// "Cannot connect to database" is the more specific DB-unreachable signal.
if (stderr.includes("Cannot connect to database")) return "broken-db";
if (stderr.includes("config.json")) return "broken-config";
// Defensive default per codex #8: unrecognized failures classify as
// broken-config so the user sees the raw stderr surfaced upstream.
return "broken-config";
}
}
/**
* Classify the local gbrain engine status. Cached for 60s; bypassable.
*
* Returns one of 5 states. Never throws — failure modes are surfaced as states.
*/
export function localEngineStatus(opts: ClassifyOptions = {}): LocalEngineStatus {
const env = opts.env ?? process.env;
const gbrainBin = resolveGbrainBin(env);
const gbrainVersion = gbrainBin ? readGbrainVersion(env) : "";
const key = buildCacheKey(gbrainBin, gbrainVersion, env);
if (!opts.noCache) {
const cached = readCache(key);
if (cached) return cached;
}
const fresh = freshClassify(env);
writeCache(fresh, key);
return fresh;
}
+288
View File
@@ -0,0 +1,288 @@
/**
* Unit tests for lib/gbrain-local-status.ts.
*
* Per the eng-review D6 (gate-tier = mocked, codex #9): no real gbrain CLI, no
* real PGLite, no real Postgres. Each case builds a fake `gbrain` shell script
* on PATH that emits canned exit codes + stderr matching the patterns the
* classifier looks for.
*
* Five status cases:
* 1. no-cli — gbrain absent from PATH
* 2. missing-config — gbrain present, ~/.gbrain/config.json absent
* 3. broken-config — gbrain present, config exists, stderr contains "config.json"
* 4. broken-db — gbrain present, config exists, stderr contains "Cannot connect to database"
* 5. ok — gbrain present, config exists, sources list returns valid JSON
*
* Plus cache behavior: hit, TTL expiry, invariant invalidation (HOME change),
* --no-cache bypass.
*/
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import {
mkdtempSync,
writeFileSync,
mkdirSync,
rmSync,
chmodSync,
existsSync,
utimesSync,
} from "fs";
import { tmpdir } from "os";
import { join } from "path";
import {
localEngineStatus,
cacheFilePath,
CACHE_TTL_MS,
type LocalEngineStatus,
} from "../lib/gbrain-local-status";
interface FakeEnv {
tmp: string;
bindir: string;
home: string;
gstackHome: string;
configPath: string;
cleanup: () => void;
}
/**
* Build a tmp HOME + GSTACK_HOME + optional fake `gbrain` on PATH.
*
* The classifier reads HOME via os.homedir() which reads process.env.HOME, so
* we mutate process.env ambiently in each test (restored in afterEach).
*/
function makeEnv(opts: {
withGbrain?: boolean;
gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "throws";
withConfig?: boolean;
}): FakeEnv {
const tmp = mkdtempSync(join(tmpdir(), "gbrain-local-status-test-"));
const bindir = join(tmp, "bin");
const home = join(tmp, "home");
const gstackHome = join(home, ".gstack");
const configDir = join(home, ".gbrain");
const configPath = join(configDir, "config.json");
mkdirSync(bindir, { recursive: true });
mkdirSync(home, { recursive: true });
mkdirSync(gstackHome, { recursive: true });
mkdirSync(configDir, { recursive: true });
if (opts.withConfig) {
writeFileSync(
configPath,
JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }),
);
}
if (opts.withGbrain) {
const behavior = opts.gbrainBehavior || "ok";
const fake = makeFakeGbrainScript(behavior);
const gbrainPath = join(bindir, "gbrain");
writeFileSync(gbrainPath, fake);
chmodSync(gbrainPath, 0o755);
}
return {
tmp,
bindir,
home,
gstackHome,
configPath,
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
};
}
function makeFakeGbrainScript(
behavior: "ok" | "broken-db" | "broken-config" | "throws",
): string {
const stderrLine =
behavior === "broken-db"
? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2'
: behavior === "broken-config"
? 'echo "Error: malformed config.json at ~/.gbrain/config.json" >&2'
: behavior === "throws"
? 'echo "unexpected gbrain failure" >&2'
: "";
const exitCode = behavior === "ok" ? 0 : 1;
return `#!/bin/sh
if [ "$1" = "--version" ]; then
echo "gbrain 0.33.1.0"
exit 0
fi
if [ "$1 $2" = "sources list" ]; then
if [ ${exitCode} -eq 0 ]; then
echo '{"sources":[]}'
exit 0
fi
${stderrLine}
exit ${exitCode}
fi
exit 0
`;
}
/**
* Apply a FakeEnv to process.env. Returns a function that restores previous values.
*
* PATH is REPLACED (not prepended) so a real `gbrain` on the inherited PATH
* can't shadow the test's fake-or-absent binary. /usr/bin:/bin is kept so `sh`
* and `command` work.
*/
function applyEnv(env: FakeEnv): () => void {
const prev = {
HOME: process.env.HOME,
PATH: process.env.PATH,
GSTACK_HOME: process.env.GSTACK_HOME,
};
process.env.HOME = env.home;
process.env.PATH = `${env.bindir}:/usr/bin:/bin`;
process.env.GSTACK_HOME = env.gstackHome;
return () => {
if (prev.HOME === undefined) delete process.env.HOME;
else process.env.HOME = prev.HOME;
if (prev.PATH === undefined) delete process.env.PATH;
else process.env.PATH = prev.PATH;
if (prev.GSTACK_HOME === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = prev.GSTACK_HOME;
};
}
describe("lib/gbrain-local-status — five status cases", () => {
let env: FakeEnv | null = null;
let restoreEnv: (() => void) | null = null;
afterEach(() => {
if (restoreEnv) restoreEnv();
if (env) env.cleanup();
env = null;
restoreEnv = null;
});
it("returns 'no-cli' when gbrain is not on PATH", () => {
env = makeEnv({ withGbrain: false });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("no-cli");
});
it("returns 'missing-config' when CLI is present but ~/.gbrain/config.json absent", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: false });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
});
it("returns 'broken-db' when sources list emits 'Cannot connect to database'", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-db", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("broken-db");
});
it("returns 'broken-config' when sources list emits config.json error", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-config", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("broken-config");
});
it("returns 'broken-config' defensively when stderr matches neither pattern", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "throws", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("broken-config");
});
it("returns 'ok' when sources list succeeds", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("ok");
});
});
describe("lib/gbrain-local-status — cache behavior", () => {
let env: FakeEnv | null = null;
let restoreEnv: (() => void) | null = null;
afterEach(() => {
if (restoreEnv) restoreEnv();
if (env) env.cleanup();
env = null;
restoreEnv = null;
});
it("writes a cache entry on first call", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
localEngineStatus({ noCache: false });
expect(existsSync(cacheFilePath())).toBe(true);
});
it("returns cached value within TTL even if underlying state would change", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
const first = localEngineStatus({ noCache: false });
expect(first).toBe("ok");
// Make the fake gbrain emit broken-db now. Cache should still say ok.
writeFileSync(
join(env.bindir, "gbrain"),
makeFakeGbrainScript("broken-db"),
);
chmodSync(join(env.bindir, "gbrain"), 0o755);
const second = localEngineStatus({ noCache: false });
expect(second).toBe("ok"); // cache hit
});
it("re-probes when --no-cache is passed", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: false })).toBe("ok");
writeFileSync(
join(env.bindir, "gbrain"),
makeFakeGbrainScript("broken-db"),
);
chmodSync(join(env.bindir, "gbrain"), 0o755);
expect(localEngineStatus({ noCache: true })).toBe("broken-db");
});
it("invalidates cache when config_mtime changes (key invariant)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: false })).toBe("ok");
// Bump config mtime artificially (touch +10s) AND rewrite gbrain to broken-db.
const future = Math.floor(Date.now() / 1000) + 10;
utimesSync(env.configPath, future, future);
writeFileSync(
join(env.bindir, "gbrain"),
makeFakeGbrainScript("broken-db"),
);
chmodSync(join(env.bindir, "gbrain"), 0o755);
// Even with cache enabled, mtime mismatch forces re-probe.
expect(localEngineStatus({ noCache: false })).toBe("broken-db");
});
it("invalidates cache when HOME changes (key invariant)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: false })).toBe("ok");
// Switch to a new HOME (different user). Same gstack home (shared cache file).
const env2 = makeEnv({
withGbrain: true,
gbrainBehavior: "broken-db",
withConfig: true,
});
process.env.HOME = env2.home;
process.env.PATH = `${env2.bindir}:/usr/bin:/bin`;
// GSTACK_HOME stays pointing at env.gstackHome (the original cache file).
try {
expect(localEngineStatus({ noCache: false })).toBe("broken-db");
} finally {
env2.cleanup();
}
});
});