mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-11 08:40:22 +02:00
* fix(careful): warn on chained rm even when the last target is safe The safe-exception block whitelisted rm -rf of build artifacts by extracting targets with a single greedy match (.*rm ...), which only ever inspects the LAST rm in the command. A chain like 'rm -rf /; rm -rf node_modules' was therefore judged solely by its trailing safe target and allowed without warning, waving through the destructive 'rm -rf /'. Gate the shortcut to single rm invocations: when any shell separator (; | & newline, incl. JSON-escaped \n/\r from the grep extraction path) is present, fall through to the destructive-pattern check, which warns on any recursive rm. Single-command artifact cleanups still allow. Adds 3 regression tests covering semicolon and && chains in both orders. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * harden(careful): substitution separators + capital -R recursive flag (#2039) Two residual fail-opens in the same guard PR #2040 hardened, both verified by executing the script pre-fix: - rm -rf $(./wipe-all)/node_modules silently allowed: the substitution token ends in a whitelisted suffix and the safe-exception early exit skipped ALL downstream checks. $( and backtick now count as chain separators; plain $VAR expansion stays allowed. - rm -R / silently allowed: both greps required a lowercase r in the flag cluster; capital -R is the documented BSD/macOS recursive flag. Both greps now match -[a-zA-Z]*[rR]. Six new tests: substitution x2 -> ask, capital-R x2 -> ask, rm -Rf node_modules single-command -> still allowed, escaped-newline branch (existing code, previously untested), and a pinned deliberate FP (cd app && rm -rf node_modules -> ask) documenting the fail-closed direction on chains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(context-restore): prefer the current branch's own checkpoint (#2052) All worktrees of a repo share one origin-derived slug, so they share one `~/.gstack/projects/<slug>/checkpoints/` dir. `/context-restore` loaded the newest checkpoint across the whole dir, so in one worktree it could silently restore a *sibling worktree's* newer checkpoint. Step 1 now orders candidates current-branch-first (read from each file's `branch:` frontmatter), keeping other branches as a fallback. A branch is checked out in at most one worktree, so this stops cross-worktree contamination while preserving Conductor cross-branch handoff: when the current branch has no checkpoint of its own, the full newest-first set is still used. - scan the 200 newest before partitioning so a current-branch checkpoint sitting below a burst of sibling saves is still found; output still capped at 20 - non-git / detached HEAD / branchless legacy saves fall back to the old newest-first behavior (back-compat) - +5 regression tests in context-save-hardening.test.ts (the #2052 bug case fails on the old pipeline); regenerated SKILL.md + proactive-suggestions.json Fixes #2052 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(gbrain): pass --confirm-destructive on drift re-register (#1985) ensureSourceRegistered() handles match-but-different-path by removing the old source then re-adding it at the new path. The remove was issued as `gbrain sources remove <id> --yes`, but gbrain >= 0.42 gates `sources remove` behind `--confirm-destructive` (`--yes` alone no longer suppresses the data-loss prompt). The remove therefore fails with "To proceed, pass --confirm-destructive", which ensureSourceRegistered surfaces as "source registration failed" — aborting the entire /sync-gbrain code stage for any already-registered source whose path has drifted. The memory and brain-sync stages still pass, so the code index silently stops refreshing. The orchestrator's own safeSourcesRemove() already passes --confirm-destructive; this brings the lib helper in line with that convention. Keeps --yes for older gbrain. Tests: extend the fake gbrain shim in gbrain-sources.test.ts to simulate the gbrain >= 0.42 guard (remove without --confirm-destructive exits 1), update the drift re-register assertion, and add a regression test that proves the drift path no longer throws. Both fail on main with the exact "To proceed, pass --confirm-destructive" error and pass with the fix. Fixes #1985 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * harden(gbrain-sources): route drift remove through #1734 guards + realpath drift check Absorbing #2031 un-blocked a destructive remove that bypassed the #1734 data-loss guards: ensureSourceRegistered's drift path issued `gbrain sources remove` directly, without the detectAutopilot + decideSourceRemove checks every other remove routes through via safeSourcesRemove. gbrain >= 0.42's own prompt was accidentally blocking that path; with --confirm-destructive passed it is live again. - Drift remove now refuses LOUDLY (throws, actionable message) while an autopilot is active or when decideSourceRemove disallows; a silent changed=false would hide the drifted registration. - decideSourceRemove's extraArgs (--keep-storage when supported) propagate to the remove call, matching safeSourcesRemove. - Drift is realpath-normalized before being declared: a symlink alias of the same directory (macOS /tmp -> /private/tmp) is a match, not drift — the probable cause of #1985's reporter hitting the remove on an unmoved repo. - Drift fires a loud stderr line (old -> new path); perpetual drift in logs is the trigger for promoting #1985's reindex-in-place design. Tests: autopilot-active refusal (no remove in call log), fail-closed refusal on unreadable sources list, --keep-storage propagation, symlink-alias no-drift; existing drift tests pin the guard probes so a live autopilot on the dev machine can't flip them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(developer-profile): exclude mode:resources rows from SESSION_COUNT, TIER, NUDGE_ELIGIBLE (#2067) Every /office-hours run appends a mode:"resources" bookkeeping row alongside the real session row, so --read double-counted sessions (~2x): tiers promoted early and the builder-to-founder nudge armed prematurely. The file already filtered resources rows for LAST_*/CROSS_PROJECT; the same realSessions filter now feeds SESSION_COUNT/TIER, and the nudge predicate is the faithful allowlist (mode === 'builder') so a future mode #4 fails closed instead of re-opening this bug. 8 regression tests: count vs resources noise, tier boundaries both sides, nudge false-with-noise / true-at-3-builders, cross-project trailing row. Absorbed from PR #1991 by @mvann (fix + tests commits; the PR's version-bump commit is superseded by this wave's consolidated release commit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(hooks): passThrough() two-branch contract — never emit permissionDecision:'defer' (#2035, #2006) Every AskUserQuestion died with "Tool result missing due to internal error" on current Claude Code builds (Desktop 1.14271.0, CC 2.1.177). Root cause: the question-preference-hook emitted permissionDecision:'defer' on every pass-through path. 'defer' is a real PreToolUse value, but since CC v2.1.89 its semantics are "pause this tool call for external resumption" (headless resume) — never "abstain". Interactive sessions have nothing to resume the paused call, so the tool orphaned. Pre-2.1.89 builds ignored the unknown value, which is why the hook worked when it shipped and broke later. The fix is the two-branch pass-through contract: - no context -> exit 0 with EXACTLY empty stdout - memory nuggets present -> hookSpecificOutput with hookEventName + additionalContext ONLY (the documented shape; plan-tune Layer 8 memory injection ships through this branch and keeps working) defer() is renamed passThrough() so the function says what it does, and docs/spikes/claude-code-hook-mutation.md's protocol contract (cited by the hook header) is corrected in the same commit — it taught '"defer" — let permission flow continue' and was the reintroduction vector. Test contract rewritten in the same commit (13 assertions across 3 files, verified fail-first against the unfixed hook): pass-through paths assert exact-empty stdout (a garbage/partial write cannot slip past an optional-chained parse), the nugget path asserts permissionDecision is ABSENT while additionalContext survives, and a new tripwire asserts no non-deny path ever puts the string "permissionDecision" on stdout. The deny (auto-decide) and Conductor prose-redirect paths are unchanged. Deployment: no migration needed — settings.json points at the absolute bash shim which execs the .ts live; /gstack-upgrade delivers the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(one-way-doors): unify credential noun net + wire it into the runtime (#2024) Library fix: revoke/reset/rotate now share ONE noun alternation (api key, token, secret, credential, access key, password) with optional plural s?. Pre-fix leaks: "reset my secret", "reset my access key", "revoke my secret" (mismatched per-verb lists) and every plural form ("rotate the credentials", "revoke all tokens" — \b(...)\b cannot match a trailing s). Runtime wiring — the regexes could never fire in production before: - gstack-question-preference --check gains --summary-stdin: the question text pipes via stdin (never argv — summaries carry quotes/newlines/shell metacharacters) and feeds isOneWayDoor alongside the id, so an ad-hoc destructive question with a stored never-ask preference now forces ASK_NORMALLY. Empty/absent stdin keeps exact id-only semantics. - question-preference-hook falls back to classifyQuestion(question text) when the registry lookup misses, so unregistered destructive questions pass through to a human instead of auto-deciding. - question-tuning resolver prose shows the piped form (SKILL.md regen lands in the wave's release commit). Tripwires (verified fail-first): full verbs x nouns x singular/plural matrix with the #2024 repro rows, benign-summary no-over-match rows, stdin transport survival (quotes/newlines), empty-stdin fail-safe, and hook fallback both directions (destructive -> pass-through, benign -> deny). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(design): loud integer-flag contract for --count/--retry/--timeout (#2032) design variants --count abc silently generated ZERO variants and exited 0: parseInt(NaN) flowed through Math.min into the generation loop bound. The same NaN class was live on the two sibling flags in the same file: --retry abc made generate() a silent no-op (attempt <= NaN never true, null output, exit 0) and --timeout abc killed the serve board ~immediately (setTimeout(NaN)). New design/src/flag-utils.ts: parseIntFlag (pure, unit-testable) + normalizeIntFlag (CLI wrapper). Contract matches the --viewports precedent (error loudly on nonsense — these commands spend real image-API money, a silent fixup hides typos from calling agents): undefined -> default; bare flag/empty/non-integer ("3.7" rejected, not truncated)/below-min -> exit 1 with usage hint; above-max -> clamp with stderr warning. --count normalizes at the variants() consumption site so programmatic callers are covered, with the ceiling derived from STYLE_VARIATIONS.length instead of a magic 7; the CLI passes the raw flag through (a pre-parseInt would truncate "3.7"). Tripwires live in test/design-flag-utils.test.ts — deliberately under test/, not design/test/, which is invisible to the bun test glob, TEST_ROOTS, and every workflow (wiring design/test/ into CI is a captured TODO). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gbrain): thin-client state — remote-MCP brains no longer classify as broken-config (#2051) A thin client (remote-HTTP MCP brain, no local engine by design) probed `gbrain sources list`, which gbrain's dispatch guard REFUSES on thin clients (exit 1, no recognized error string), so the classifier fell to its defensive broken-config default and every suppression gate silently hid brain-aware blocks from exactly the users on a shared team brain. New 'thin-client' state, detected PRE-probe from gbrain's own remote_mcp config marker via the existing gbrainConfigPath() helper (mirrors gbrain's isThinClient(); honors GBRAIN_HOME; zero network, immune to error-string drift), with a /thin[- ]client/ stderr backstop in the probe catch. Remote reachability is deliberately NOT probed by the classifier — that is the #1964 pathology; gbrain calls degrade gracefully at use time, and the detect JSON says so honestly (gbrain_thin_client: {probed: false}). The state is admitted at every suppression gate — gstack-gbrain-detect --is-ok (drives setup + gbrain-refresh), gen-skill-docs' detection override, gstack-config gbrain-refresh — while the sync stages (code/memory/dream) SKIP with an accurate reason: code indexing runs on the brain server, memory syncs via the remote brain's artifacts pull. The two consumer classes need opposite answers, which is why this is a distinct state and not a skip-the-probe special case. sync-gbrain Step 1.5 and setup-gbrain prose route thin-client to proceed, never into broken-config remediation. detectMcpMode secondary generalization: url-match against the config's remote_mcp.mcp_url (deterministic — gbrain mounts at the generic /mcp path) -> name pattern gbrain[-_]* -> stdio command token; gbrain_mcp_mode stays a 3-value enum. Tripwires: end-to-end --is-ok exits 0 on a thin-client fixture AND still exits 1 on broken-config (the gate didn't widen); pre-probe + stderr-fallback classifier paths; 4 detectMcpMode identification cases incl. a non-matching url that must NOT false-positive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * release: v1.60.0.0 — regen SKILL.md, VERSION, CHANGELOG, TODOS follow-ups - Regenerate all SKILL.md from templates (question-tuning --summary-stdin prose from #2024, context-restore branch preference from PR #2054, sync-gbrain/setup-gbrain thin-client prose from #2051) + llms.txt. - VERSION + package.json -> 1.60.0.0 (bin/gstack-next-version, queue-aware: #1815 claims 1.59.0.0, #2213 claims 1.59.1.0). - CHANGELOG release summary + itemized entry crediting @jbetala7 (x3) and @mvann. - TODOS.md: three eng-review follow-ups (design/test CI wiring + documented pre-existing retry-after flake, /context-save worktree identity, gbrain reindex-in-place conditional on the new drift log). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(resolvers): compress --summary-stdin preamble prose to fit parity budget; re-bless ship goldens The v1.57.7.0 parity suite caps investigate's generated size at 1.09x baseline; the #2024 question-tuning prose (duplicated into every tier->=2 skill) tipped it to 1.092. Compressed to a single inline command + short pointer (the full rationale lives in bin/gstack-question-preference's header and the one-way-doors module docs). Ship goldens re-blessed against the final resolver text (conscious template-change acknowledgment, per the golden-file regression contract). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(e2e): office-hours-spec-review turn budget fits the carved skill layout (#2473) The test failed deterministically with error_max_turns at 9 turns on main and this branch alike (CI attempt logs + local main repro). Root cause from the failing transcript: the Spec Review Loop content is carved out of office-hours/SKILL.md into office-hours/sections/, so the agent needs discovery hops (grep SKILL.md -> ls sections/ -> read the section) before it can write — 8 tool turns + the closing text turn = 9 > the 8-turn budget, which predates the carve. Observed failures wrote a CORRECT summary on tool turn 8 and died on the closing turn. maxTurns 8 -> 12. Verified: PASS locally post-fix (7 turns this run — the extra headroom absorbs discovery-path nondeterminism). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(e2e): review-dashboard-via session budget survives runner contention (#2473) The test failed on CI (and its baseline run) with the timeout signature: 0 turns, $0.00, exactly 183s, 3/3 attempts — the spawned claude -p session never emitted a single stream event before the 180s inner timeout. The file's tests run concurrently on one runner; session startup queues behind sibling sessions, and this test had the tightest budget in the file (the 240s-budget tests in the same job passed). A clean local run takes 270s wall for 4 turns, confirming 180s was too tight even without contention. Inner timeout 180s -> 300s; outer bun timeout 240s -> 360s to keep headroom over the inner budget. Verified: PASS locally post-fix (4 turns, 270s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(e2e): retro-base-branch session budget survives runner contention (#2473) Same class as review-dashboard-via, one test over in the same file: /retro is a long multi-step flow whose clean pass measures 225-239s — a coin flip against the 240s inner budget. First CI run passed at 225s; the rerun timed out at the 240s line on all 3 attempts (exitReason "timeout"); the local verification run passed at 239s, ONE second under the old cap. Inner timeout 240s -> 360s; outer bun timeout 300s -> 480s for headroom. Verified: PASS locally post-fix (17 turns, 239s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Michael Vann <9221873+mvann@users.noreply.github.com>
380 lines
14 KiB
TypeScript
380 lines
14 KiB
TypeScript
/**
|
|
* gbrain-local-status — classify the local gbrain engine into 6 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, gbrain_home, path_hash, gbrain_bin_path, gbrain_version,
|
|
* config_mtime, probe_timeout_ms}.
|
|
* - --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, config.json absent (honors GBRAIN_HOME).
|
|
* 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.
|
|
* Engine-locked → PGLite probe hit gbrain's own connect timeout, usually
|
|
* because another `gbrain serve` process owns the embedded DB.
|
|
* Timeout → probe exceeded GSTACK_GBRAIN_PROBE_TIMEOUT_MS (default 15s) with no
|
|
* recognized error — engine is likely healthy but slow (e.g. a cold
|
|
* pooler connection, #1964). Consumers treat this as usable.
|
|
* Thin-client → config carries gbrain's remote_mcp marker (#2051): NO local
|
|
* engine by design; queries go to a remote-HTTP MCP brain. Usable
|
|
* for brain-aware prose gates; sync stages that need a LOCAL engine
|
|
* (code/memory/dream) skip. Remote reachability is verified at USE
|
|
* time (gbrain calls degrade gracefully), never by a classifier
|
|
* network probe — that's the #1964 pathology.
|
|
* 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";
|
|
import { buildGbrainEnv, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
|
|
|
|
export type LocalEngineStatus =
|
|
| "ok"
|
|
| "no-cli"
|
|
| "missing-config"
|
|
| "broken-config"
|
|
| "broken-db"
|
|
| "engine-locked"
|
|
| "timeout"
|
|
| "thin-client";
|
|
|
|
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 {
|
|
// Local-cache schema version, controlled by gstack. Not to be confused
|
|
// with `gbrain doctor --json` output schema_version (gbrain v0.25+ emits
|
|
// schema_version: 2). Doctor-output parsing lives in
|
|
// lib/gstack-memory-helpers.ts:freshDetectEngineTier and accepts both
|
|
// doctor-output versions. This cache stays strictly at version 1 — a
|
|
// future shape change here requires an explicit migration.
|
|
schema_version: 1;
|
|
status: LocalEngineStatus;
|
|
cached_at: number;
|
|
/** Cache invariants — entry is invalidated if any of these change between writes. */
|
|
key: {
|
|
home: string;
|
|
gbrain_home: string; // honors GBRAIN_HOME (#1964 / codex D11)
|
|
path_hash: string;
|
|
gbrain_bin_path: string;
|
|
gbrain_version: string;
|
|
config_mtime: number; // 0 when config absent
|
|
config_size: number; // 0 when config absent
|
|
probe_timeout_ms: number; // raising the timeout invalidates a cached "timeout"
|
|
};
|
|
}
|
|
|
|
export const CACHE_TTL_MS = 60_000;
|
|
export const DEFAULT_PROBE_TIMEOUT_MS = 15_000;
|
|
|
|
/**
|
|
* Effective probe timeout. `GSTACK_GBRAIN_PROBE_TIMEOUT_MS` overrides the
|
|
* 15s default (tests set it low; users with slow poolers raise it).
|
|
* Non-numeric or non-positive values fall back to the default.
|
|
*/
|
|
export function probeTimeoutMs(env?: NodeJS.ProcessEnv): number {
|
|
const raw = (env ?? process.env).GSTACK_GBRAIN_PROBE_TIMEOUT_MS;
|
|
if (!raw) return DEFAULT_PROBE_TIMEOUT_MS;
|
|
const parsed = Number(raw);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_PROBE_TIMEOUT_MS;
|
|
// Floor of 1ms: Math.floor(0.5) would yield 0, and execFileSync treats
|
|
// timeout: 0 as NO timeout — the probe that exists to bound hangs would
|
|
// itself hang forever (adversarial review finding 2).
|
|
return Math.max(1, Math.floor(parsed));
|
|
}
|
|
|
|
/** Effective user home — respects HOME env override (used by tests). */
|
|
function userHome(env?: NodeJS.ProcessEnv): string {
|
|
return (env ?? 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",
|
|
);
|
|
}
|
|
|
|
/** Honors GBRAIN_HOME (codex D11) — same resolution as buildGbrainEnv. */
|
|
function gbrainConfigPath(env?: NodeJS.ProcessEnv): string {
|
|
const e = env ?? process.env;
|
|
const gbrainHome = e.GBRAIN_HOME || join(userHome(e), ".gbrain");
|
|
return join(gbrainHome, "config.json");
|
|
}
|
|
|
|
function configuredEngine(env?: NodeJS.ProcessEnv): "pglite" | "postgres" | null {
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(gbrainConfigPath(env), "utf-8")) as { engine?: string };
|
|
return parsed.engine === "pglite" || parsed.engine === "postgres" ? parsed.engine : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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.
|
|
* Memoized per-process keyed on PATH so detect's call and the classifier's
|
|
* call share one fork-exec (~200ms saved per skill preamble).
|
|
*/
|
|
const _gbrainBinCache = new Map<string, string | null>();
|
|
export function resolveGbrainBin(env?: NodeJS.ProcessEnv): string | null {
|
|
const e = env ?? process.env;
|
|
const key = e.PATH || "";
|
|
if (_gbrainBinCache.has(key)) return _gbrainBinCache.get(key)!;
|
|
let result: string | null = null;
|
|
try {
|
|
execFileSync("gbrain", ["--version"], {
|
|
encoding: "utf-8",
|
|
timeout: 2_000,
|
|
stdio: ["ignore", "ignore", "ignore"],
|
|
env: e,
|
|
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
|
});
|
|
result = "gbrain";
|
|
} catch {
|
|
result = null;
|
|
}
|
|
_gbrainBinCache.set(key, result);
|
|
return result;
|
|
}
|
|
|
|
/** Memoized per-process. */
|
|
const _gbrainVersionCache = new Map<string, string>();
|
|
export function readGbrainVersion(env?: NodeJS.ProcessEnv): string {
|
|
const e = env ?? process.env;
|
|
const key = `${e.PATH || ""}|${resolveGbrainBin(e) || ""}`;
|
|
if (_gbrainVersionCache.has(key)) return _gbrainVersionCache.get(key)!;
|
|
let result = "";
|
|
try {
|
|
const out = execFileSync("gbrain", ["--version"], {
|
|
encoding: "utf-8",
|
|
timeout: 2_000,
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
env: e,
|
|
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
|
});
|
|
result = out.trim().split("\n")[0] || "";
|
|
} catch {
|
|
result = "";
|
|
}
|
|
_gbrainVersionCache.set(key, result);
|
|
return result;
|
|
}
|
|
|
|
function configFingerprint(env?: NodeJS.ProcessEnv): { mtime: number; size: number } {
|
|
try {
|
|
const st = statSync(gbrainConfigPath(env));
|
|
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(e);
|
|
return {
|
|
home: e.HOME || "",
|
|
gbrain_home: e.GBRAIN_HOME || "",
|
|
path_hash: hashPath(e.PATH || ""),
|
|
gbrain_bin_path: gbrainBin || "",
|
|
gbrain_version: gbrainVersion,
|
|
config_mtime: config.mtime,
|
|
config_size: config.size,
|
|
probe_timeout_ms: probeTimeoutMs(e),
|
|
};
|
|
}
|
|
|
|
function keysEqual(a: CacheEntry["key"], b: CacheEntry["key"]): boolean {
|
|
return (
|
|
a.home === b.home &&
|
|
a.gbrain_home === b.gbrain_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 &&
|
|
a.probe_timeout_ms === b.probe_timeout_ms
|
|
);
|
|
}
|
|
|
|
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(env))) return "missing-config";
|
|
|
|
// 2.5 Thin client? gbrain's own marker (mirrors gbrain isThinClient():
|
|
// truthy remote_mcp in config). A thin client has NO local engine — gbrain
|
|
// REFUSES `sources` commands on it (THIN_CLIENT_REFUSED_COMMANDS, exit 1
|
|
// with no recognized error string), so the probe below would fall to the
|
|
// defensive broken-config default and silently suppress brain-aware blocks
|
|
// (#2051). Detected PRE-probe from the config file: zero network cost,
|
|
// immune to gbrain error-string drift. Remote reachability is deliberately
|
|
// NOT probed here — a classifier network probe is the #1964 pathology.
|
|
try {
|
|
const cfg = JSON.parse(readFileSync(gbrainConfigPath(env), "utf-8")) as {
|
|
remote_mcp?: unknown;
|
|
};
|
|
if (cfg && typeof cfg === "object" && cfg.remote_mcp) {
|
|
return "thin-client";
|
|
}
|
|
} catch {
|
|
// Unparseable config: fall through to the probe, whose stderr
|
|
// classification surfaces broken-config with the raw error upstream.
|
|
}
|
|
|
|
// 3. Probe gbrain sources list.
|
|
//
|
|
// Seed DATABASE_URL from ~/.gbrain/config.json (via buildGbrainEnv, the
|
|
// same helper the sync orchestrator uses in lib/gbrain-exec.ts). Without
|
|
// this, Bun autoloads a project's .env when the probe runs inside a repo
|
|
// that defines its own DATABASE_URL (e.g. an app DB on a different port),
|
|
// gbrain connects to the wrong DB, and the classifier falsely reports
|
|
// broken-db. This also makes the result cwd-independent, so the 60s cache
|
|
// can no longer propagate a poisoned negative to clean directories.
|
|
try {
|
|
execFileSync("gbrain", ["sources", "list", "--json"], {
|
|
encoding: "utf-8",
|
|
timeout: probeTimeoutMs(env),
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: buildGbrainEnv({ baseEnv: env ?? process.env }),
|
|
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
|
});
|
|
return "ok";
|
|
} catch (err) {
|
|
const e = err as NodeJS.ErrnoException & {
|
|
stderr?: Buffer | string;
|
|
killed?: boolean;
|
|
signal?: NodeJS.Signals | null;
|
|
status?: number | null;
|
|
};
|
|
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:
|
|
// thin-client refusal first (backstop for a config the pre-probe check
|
|
// couldn't read — gbrain's dispatch guard says e.g. "`gbrain sources` is
|
|
// not routable ... (thin-client of <url>)"), then the more specific
|
|
// DB-unreachable signal.
|
|
if (/thin[- ]client/i.test(stderr)) return "thin-client";
|
|
if (stderr.includes("Cannot connect to database")) return "broken-db";
|
|
if (stderr.includes("config.json")) return "broken-config";
|
|
|
|
// PGLite is single-process. A long-lived `gbrain serve` can own the
|
|
// embedded database, causing the CLI to finish with its own exit 124 and
|
|
// "connect timed out" message. This is neither our watchdog timeout nor
|
|
// evidence that the valid config is malformed (#2194).
|
|
if (stderr.includes("connect timed out") || e.status === 124) {
|
|
return configuredEngine(env) === "pglite" ? "engine-locked" : "broken-db";
|
|
}
|
|
|
|
// Probe killed by the timeout with no recognized error: the engine is
|
|
// most likely healthy but slow (cold pooler connections measured at
|
|
// 6.9-10.7s in #1964). Don't tell the user their config is malformed.
|
|
if (e.killed === true || e.signal === "SIGTERM" || e.code === "ETIMEDOUT") {
|
|
return "timeout";
|
|
}
|
|
|
|
// 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;
|
|
}
|