diff --git a/lib/gstack-memory-helpers.ts b/lib/gstack-memory-helpers.ts index 8b20f32031..472410e345 100644 --- a/lib/gstack-memory-helpers.ts +++ b/lib/gstack-memory-helpers.ts @@ -19,7 +19,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, appendFileSync } from "fs"; import { dirname, join } from "path"; -import { execSync, execFileSync } from "child_process"; +import { execSync, execFileSync, spawnSync } from "child_process"; import { homedir } from "os"; // ── Types ────────────────────────────────────────────────────────────────── @@ -247,9 +247,16 @@ export function detectEngineTier(): EngineDetect { function freshDetectEngineTier(): EngineDetect { const now = Date.now(); try { - const out = execSync("gbrain doctor --json --fast 2>/dev/null", { encoding: "utf-8", timeout: 5000 }); - const parsed = JSON.parse(out); - const engine: EngineTier = parsed?.engine === "supabase" ? "supabase" : parsed?.engine === "pglite" ? "pglite" : "unknown"; + const result = spawnSync("gbrain", ["doctor", "--json", "--fast"], { + encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"], env: process.env, + }); + if (result.error) return { engine: "unknown", detected_at: now, schema_version: 1 }; + + const parsed = JSON.parse(result.stdout); + const engine = detectEngineFromDoctorJson(parsed); + if (engine === "unknown" && process.env.GSTACK_DEBUG) { + process.stderr.write(`[gstack-memory-helpers] unable to detect gbrain engine from doctor JSON: ${JSON.stringify(parsed)}\n`); + } return { engine, supabase_url: parsed?.supabase_url || undefined, @@ -261,6 +268,23 @@ function freshDetectEngineTier(): EngineDetect { } } +function detectEngineFromDoctorJson(parsed: unknown): EngineTier { + const obj = (parsed && typeof parsed === "object" ? parsed : {}) as Record; + const direct = [obj.engine, obj.engine_kind, obj.backend, obj.engine_type].find(isEngineTier); + if (direct) return direct; + const check = Array.isArray(obj.checks) + ? obj.checks.find((c) => c && typeof c === "object" && /engine|backend|kind/i.test(String((c as Record).name))) + : null; + const nested = check + ? [check.value, check.engine, check.kind].find(isEngineTier) + : null; + return nested || "unknown"; +} + +function isEngineTier(value: unknown): value is EngineTier { + return value === "supabase" || value === "pglite"; +} + // ── Public: parseSkillManifest ──────────────────────────────────────────── /** diff --git a/test/gstack-memory-helpers.test.ts b/test/gstack-memory-helpers.test.ts index 864fde635c..41ca18f703 100644 --- a/test/gstack-memory-helpers.test.ts +++ b/test/gstack-memory-helpers.test.ts @@ -11,7 +11,7 @@ * Free-tier (~50ms total). Runs in `bun test`. */ -import { describe, it, expect, beforeEach, afterAll } from "bun:test"; +import { describe, it, expect, beforeEach, afterEach, afterAll } from "bun:test"; import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; @@ -272,19 +272,34 @@ describe("withErrorContext", () => { describe("detectEngineTier", () => { let savedHome: string | undefined; + let savedPath: string | undefined; let testHome: string; beforeEach(() => { savedHome = process.env.GSTACK_HOME; + savedPath = process.env.PATH; testHome = mkdtempSync(join(tmpdir(), "gstack-test-engine-")); process.env.GSTACK_HOME = testHome; + process.env.PATH = `${testHome}:${savedPath || ""}`; + writeFileSync(join(testHome, "gbrain"), "#!/bin/sh\nprintf '%s\\n' \"$GSTACK_TEST_GBRAIN_STDOUT\"\nexit \"$GSTACK_TEST_GBRAIN_STATUS\"\n", { mode: 0o755 }); + mockDoctor('{"engine":"pglite","status":"ok"}'); }); - afterAll(() => { + afterEach(() => { + if (savedPath === undefined) delete process.env.PATH; + else process.env.PATH = savedPath; if (savedHome === undefined) delete process.env.GSTACK_HOME; else process.env.GSTACK_HOME = savedHome; + delete process.env.GSTACK_TEST_GBRAIN_STDOUT; + delete process.env.GSTACK_TEST_GBRAIN_STATUS; + rmSync(testHome, { recursive: true, force: true }); }); + function mockDoctor(stdout: string, exitCode = 0) { + process.env.GSTACK_TEST_GBRAIN_STDOUT = stdout; + process.env.GSTACK_TEST_GBRAIN_STATUS = String(exitCode); + } + it("returns a valid EngineDetect shape (engine, detected_at, schema_version)", () => { const result = detectEngineTier(); expect(["pglite", "supabase", "unknown"]).toContain(result.engine); @@ -293,6 +308,20 @@ describe("detectEngineTier", () => { expect(result.detected_at).toBeGreaterThan(0); }); + for (const [name, stdout, exitCode, supabaseUrl] of [ + ["preserves schema_version 1 engine detection from doctor output", '{"engine":"supabase","supabase_url":"https://example.supabase.co","status":"ok"}', 0, "https://example.supabase.co"], + ["detects schema_version 2 engine from top-level doctor fields", '{"status":"ok","schema_version":2,"health_score":100,"engine_kind":"supabase","checks":[]}', 0, undefined], + ["detects schema_version 2 engine from checks even when doctor exits non-zero", '{"status":"warn","schema_version":2,"health_score":80,"checks":[{"name":"resolver_health","status":"warn"},{"name":"backend","value":"supabase"}]}', 1, undefined], + ] as const) { + it(name, () => { + mockDoctor(stdout, exitCode); + const result = detectEngineTier(); + expect(result.engine).toBe("supabase"); + if (supabaseUrl) expect(result.supabase_url).toBe(supabaseUrl); + expect(result.schema_version).toBe(1); + }); + } + it("writes a cache file at ~/.gstack/.gbrain-engine-cache.json", () => { detectEngineTier(); const cachePath = join(testHome, ".gbrain-engine-cache.json");