diff --git a/lib/gbrain-local-status.ts b/lib/gbrain-local-status.ts new file mode 100644 index 000000000..3fd22919b --- /dev/null +++ b/lib/gbrain-local-status.ts @@ -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; +} + diff --git a/test/gbrain-local-status.test.ts b/test/gbrain-local-status.test.ts new file mode 100644 index 000000000..272a99289 --- /dev/null +++ b/test/gbrain-local-status.test.ts @@ -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(); + } + }); +});