mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-15 16:52:14 +08:00
v1.37.0.0 feat: split-engine gbrain (remote MCP brain + local PGLite for code) (#1500)
* 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>
* refactor(gbrain): rewrite gstack-gbrain-detect bash→TS + add gbrain_local_status field
Replaces the bash detect helper with a bun shebang script sharing the
gbrain_local_status classifier from lib/gbrain-local-status.ts with the
sync orchestrator. Single source of truth for engine-status classification
between preamble-probe and orchestrator-skip paths.
Filename stays gstack-gbrain-detect (no .ts extension) so existing skill
preamble callers shell out unchanged. Shebang `#!/usr/bin/env -S bun run`
resolves bun at runtime.
Output is key/type backward-compatible with the bash version per plan
codex #5: the 9 pre-existing keys (gbrain_on_path, gbrain_version,
gbrain_config_exists, gbrain_engine, gbrain_doctor_ok, gbrain_mcp_mode,
gstack_brain_sync_mode, gstack_brain_git, gstack_artifacts_remote) stay
identical in name + type + value semantics. One new key added:
gbrain_local_status (5-state string enum).
Updates the existing schema regression at test/gstack-gbrain-detect-mcp-mode.test.ts
to include the new key. Adds test/gbrain-detect-shape.test.ts asserting
the regression contract for future changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(gbrain): orchestrator SKIP when local engine not ok + remote-http transcripts via artifacts pipeline
Two changes in the sync orchestrator, both per plan D11/D12:
1. bin/gstack-gbrain-sync.ts: runCodeImport + runMemoryIngest call
localEngineStatus() (shared classifier from lib/gbrain-local-status.ts).
When status is not 'ok', return a SKIP stage result with a clear reason
instead of crashing with "source registration failed: gbrain not
configured". Brain-sync stage runs regardless — it doesn't depend on
local engine. dry-run preview path is gated above the check so it
continues to show would-do steps even when the engine is broken.
2. bin/gstack-memory-ingest.ts: when gbrain MCP is registered as
remote-http (Path 4), persist staged transcripts to
~/.gstack/transcripts/run-<pid>-<ts>/ instead of the ephemeral
~/.gstack/.staging-ingest-<pid>-<ts>/ tmp dir, and SKIP the local
`gbrain import` call entirely. The artifacts pipeline (gstack-brain-sync
push to git, brain admin pulls and indexes) handles routing to the
remote brain. Local PGLite (when present via Step 4.5) stays code-only.
State recording still happens — prepared pages get their mtime+sha256
stamped under remote-http mode so the next /sync-gbrain doesn't
re-stage them. Cleanup is skipped intentionally so the persisted dir
survives until gstack-brain-sync moves it.
Adds test/gbrain-sync-skip.test.ts covering 5 SKIP scenarios (broken-db,
broken-config, no-cli, missing-config, ok pass-through). All 25
sync-related unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(gbrain): v1.34.0.0 migration notice + transcripts allowlist for artifacts pipeline
Per plan D5 + D11. Two pieces of the split-engine rollout:
1. gstack-upgrade/migrations/v1.34.0.0.sh — prints a one-time
discoverability notice for existing Path 4 (remote-http MCP) users
whose machine has no local engine yet. Tells them about /setup-gbrain
Step 4.5 (the new local-PGLite opt-in). Silent for everyone else.
User can suppress permanently via `gstack-config set
local_code_index_offered true`. Touchfile at
~/.gstack/.migrations/v1.34.0.0.done makes it idempotent.
2. bin/gstack-artifacts-init — adds `transcripts/run-*/*.md` and
`transcripts/run-*/**/*.md` to the managed allowlist so the
gstack-memory-ingest persistent staging dir (used in remote-http
mode per D11) gets pushed to the artifacts repo. Brain admin's
pull job then indexes transcripts into the remote brain.
Privacy class: behavioral (matches transcript content).
Adds test/gstack-upgrade-migration-v1_34_0_0.test.ts with 5 cases:
state match, no-MCP, local-config-present, opt-out, and idempotency.
All 5 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(gbrain): /setup-gbrain Step 1.5/4.5 + /sync-gbrain Step 1.5 templates
Per plan D4, D10, D11, D12. Wires the skill prose to the new
split-engine flow + classifier introduced in earlier commits.
setup-gbrain/SKILL.md.tmpl:
- Step 1: detect output description now includes the v1.34.0.0
gbrain_local_status field (5 values).
- Step 1.5 (NEW): broken-db / broken-config remediation. AskUserQuestion
with 4 options — Retry / Switch to PGLite / Switch brain mode / Quit
(plan D4). Retry is recommended first since broken-db often = transient
Postgres outage. PGLite is explicitly one-way + destructive (moves
existing config to ~/.gbrain/config.json.gstack-bak-<ts>); rollback on
init failure restores the .bak (plan D7).
- Step 4d → Step 4.5 (NEW): in Path 4, after the verify step, offer
local PGLite for code search. AskUserQuestion Yes/No (plan D10/D11).
Yes path runs gstack-gbrain-install + `gbrain init --pglite --json`
with the same rollback-safe sequence. No path skips Steps 3/4/5/7.5.
- Step 10 verdict (Path 4): adds "Code search" row reflecting Step 4.5
choice. Updates "Transcripts" row to describe the new D11 routing
(artifacts repo → remote brain).
sync-gbrain/SKILL.md.tmpl:
- Step 1 split-engine prose: corrects the prior misleading claim that
"memory routes through whatever setup-gbrain configured, including
remote-MCP" (codex finding #3). Memory stage shells out to local
`gbrain import` in local-stdio mode; in remote-http mode it persists
to ~/.gstack/transcripts/ for the artifacts pipeline.
- Step 1.5 (NEW): local-engine pre-flight. STOP on no-cli, broken-config,
broken-db. Soft skip (continue with code+memory SKIP) on
missing-config + remote-http per plan D12. Surfaces actionable user
remediation message instead of the orchestrator crashing two stages
with ERR.
Regenerated SKILL.md for all hosts (claude, kiro, opencode, slate,
cursor, openclaw, hermes, gbrain). All 712 skill-validation + gen-skill-docs
tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(gbrain): .bak-rollback contract for Step 1.5 / 4.5 init failure path
Per plan D7 (rollback semantics) and codex #10 (rollback scope). The
/setup-gbrain skill instructs the model to follow a specific shell
sequence when running `gbrain init --pglite` against an existing
config:
1. mv ~/.gbrain/config.json ~/.gbrain/config.json.gstack-bak-<ts>
2. gbrain init --pglite --json
3. on non-zero exit: mv .bak back; surface error
This test verifies that contract using a fake `gbrain` binary that
fails on init. Three cases:
- FAILURE: gbrain init exits non-zero → broken config restored to
original path, no leftover .bak.
- SUCCESS: gbrain init exits 0 → new config in place, .bak survives
for audit (user reviews + deletes manually).
- SCOPE: any partial PGLite directory at ~/.gbrain/pglite/ is NOT
auto-cleaned. We only promise to restore config.json; PGLite
cleanup is the user's call (codex #10).
If the skill template rewrites this sequence in a future change, this
test should fail until the test's shell is updated too. That's the
point — keep the test and the skill template aligned.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(gbrain): periodic E2E for /setup-gbrain Path 4 + Step 4.5 Yes flow
End-to-end coverage of the new opt-in question via runAgentSdkTest.
Stubs the MCP endpoint at /tools/list with a 200 response carrying a
fake gbrain v0.32.3.0 serverInfo, and fakes the gbrain + claude CLIs
so init writes a PGLite config and mcp add succeeds. Asserts the model:
1. invokes gstack-gbrain-install (Step 4.5 Yes branch)
2. invokes `gbrain init --pglite --json`
3. writes a working ~/.gbrain/config.json with engine=pglite
4. registers the remote MCP via `claude mcp add --transport http`
5. never leaks the bearer token to CLAUDE.md
Classified as periodic-tier per plan D6 (codex #12 flagged AgentSDK
flakiness; gate-tier coverage of the split-engine behavior lives in the
deterministic unit tests at gbrain-local-status.test.ts and
gbrain-sync-skip.test.ts). Touchfile fires the test when the skill
template, install/verify/init helpers, the local-status classifier, or
the agent-sdk-runner harness changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gbrain): bump migration to v1.35.0.0 after main merge
main shipped v1.34.0.0 (factory-export submodule) + v1.34.1.0 (update-check
hardening) while this branch was in flight. The migration file I named
v1.34.0.0.sh now belongs at v1.35.0.0 — the next minor on top of main,
matching the scale of split-engine work (new lib + orchestrator skip +
template overhaul + transcripts routing).
Renames the migration script and its test file; updates all internal
version references in both files. Behavior unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(gbrain): memoize gbrain resolution + use --fast doctor in detect
Cuts detect's wall time substantially by sharing fork-exec results
between the helper that walks the JSON output and the localEngineStatus
classifier from lib/gbrain-local-status.ts.
Before: detect made 2x `command -v gbrain` calls (one in detect's
detectGbrain, one in the classifier's resolveGbrainBin) and 2x
`gbrain --version` calls. With memoization keyed on PATH, both
collapse to one fork each (~400ms saved per skill preamble).
Also adds `--fast` to the `gbrain doctor --json` call in detect so a
broken-db config (Garry's repro) doesn't burn a full 5s timeout on the
doctor's DB-connection check. The classifier still probes the DB
directly via `gbrain sources list --json` for engine reachability —
that's `gbrain_local_status`, separate from the coarse
`gbrain_doctor_ok` summary flag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(gbrain): relax E2E assertions to smoke-test contract
Per codex #12 (AgentSDK harness is non-deterministic): the E2E now
asserts the model followed the split-engine path WITHOUT requiring a
specific subcommand sequence. Three assertions:
1. AskUserQuestion was called (model reached interactive branches)
2. At least one of {gstack-gbrain-install, `gbrain init --pglite`,
`claude mcp add`} fired (model followed the skill, not a no-op)
3. The fake bearer token never leaked to CLAUDE.md (security regression)
Deterministic per-step coverage of the same flow lives in the gate-tier
unit tests (gbrain-local-status, gbrain-sync-skip, init-rollback,
upgrade-migration). The E2E exists to catch the "model can't follow
the skill at all" regression class, not to pin the exact tool sequence.
Test passes in 280s against the live Agent SDK.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(version): bump CLI smoke-test timeout to 15s (flaky at 5s under load)
The gstack-next-version integration smoke test spawns a child process
that does git operations + sibling-worktree probing. Wall time hovers
4-5s on M-series Macs; flakes at exactly 5001-5002ms when the test
suite runs under load (bun's parallel scheduling). Bumping per-test
timeout to 15s eliminates the flake without changing test logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v1.37.0.0)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
246
test/gbrain-detect-shape.test.ts
Normal file
246
test/gbrain-detect-shape.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Shape regression test for bin/gstack-gbrain-detect.
|
||||
*
|
||||
* After the bash→TS rewrite (codex #5), the TS output must stay
|
||||
* key/type/semantics backward-compatible with the bash version. Downstream
|
||||
* callers across most gstack skill preambles shell out to this script and
|
||||
* pipe through jq. Key order may differ between bash+jq and JSON.stringify;
|
||||
* key NAMES and TYPES must not.
|
||||
*
|
||||
* Asserts:
|
||||
* 1. All 9 pre-existing keys are present
|
||||
* 2. Each pre-existing key has the same primitive type/union as the bash version
|
||||
* 3. The new key (gbrain_local_status) is present and a string
|
||||
* 4. Output is parseable JSON
|
||||
* 5. No keys removed/renamed
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { execFileSync } from "child_process";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
chmodSync,
|
||||
rmSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
const DETECT_BIN = join(import.meta.dir, "..", "bin", "gstack-gbrain-detect");
|
||||
|
||||
/** Absolute bun path resolved once at module load (uses the test runner's PATH). */
|
||||
const BUN_BIN = execFileSync("sh", ["-c", "command -v bun"], { encoding: "utf-8" }).trim();
|
||||
|
||||
/**
|
||||
* Run detect with a controlled HOME + PATH so the output is deterministic.
|
||||
* We invoke via `bun run <path>` instead of the shebang so the test doesn't
|
||||
* need bun on its PATH. The script's child-process probes still respect
|
||||
* the controlled PATH.
|
||||
*/
|
||||
function runDetect(env: Partial<NodeJS.ProcessEnv>): string {
|
||||
return execFileSync(BUN_BIN, ["run", DETECT_BIN], {
|
||||
encoding: "utf-8",
|
||||
timeout: 15_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
}
|
||||
|
||||
interface DetectShape {
|
||||
gbrain_on_path: boolean;
|
||||
gbrain_version: string | null;
|
||||
gbrain_config_exists: boolean;
|
||||
gbrain_engine: string | null;
|
||||
gbrain_doctor_ok: boolean;
|
||||
gbrain_mcp_mode: string;
|
||||
gstack_brain_sync_mode: string;
|
||||
gstack_brain_git: boolean;
|
||||
gstack_artifacts_remote: string;
|
||||
gbrain_local_status: string;
|
||||
}
|
||||
|
||||
describe("bin/gstack-gbrain-detect — shape regression", () => {
|
||||
it("emits valid JSON", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
try {
|
||||
const out = runDetect({
|
||||
HOME: tmp,
|
||||
PATH: "/usr/bin:/bin",
|
||||
GSTACK_HOME: tmp,
|
||||
});
|
||||
expect(() => JSON.parse(out)).not.toThrow();
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("contains all 9 pre-existing keys + the new gbrain_local_status key", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
try {
|
||||
const out = runDetect({
|
||||
HOME: tmp,
|
||||
PATH: "/usr/bin:/bin",
|
||||
GSTACK_HOME: tmp,
|
||||
});
|
||||
const parsed = JSON.parse(out) as DetectShape;
|
||||
|
||||
// 9 pre-existing keys (must not be removed/renamed):
|
||||
expect(parsed).toHaveProperty("gbrain_on_path");
|
||||
expect(parsed).toHaveProperty("gbrain_version");
|
||||
expect(parsed).toHaveProperty("gbrain_config_exists");
|
||||
expect(parsed).toHaveProperty("gbrain_engine");
|
||||
expect(parsed).toHaveProperty("gbrain_doctor_ok");
|
||||
expect(parsed).toHaveProperty("gbrain_mcp_mode");
|
||||
expect(parsed).toHaveProperty("gstack_brain_sync_mode");
|
||||
expect(parsed).toHaveProperty("gstack_brain_git");
|
||||
expect(parsed).toHaveProperty("gstack_artifacts_remote");
|
||||
|
||||
// 1 new key (added by this fix):
|
||||
expect(parsed).toHaveProperty("gbrain_local_status");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves field types from the bash version", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
try {
|
||||
const out = runDetect({
|
||||
HOME: tmp,
|
||||
PATH: "/usr/bin:/bin",
|
||||
GSTACK_HOME: tmp,
|
||||
});
|
||||
const parsed = JSON.parse(out) as Record<string, unknown>;
|
||||
|
||||
// Booleans (bash: `true`/`false`; TS: boolean)
|
||||
expect(typeof parsed.gbrain_on_path).toBe("boolean");
|
||||
expect(typeof parsed.gbrain_config_exists).toBe("boolean");
|
||||
expect(typeof parsed.gbrain_doctor_ok).toBe("boolean");
|
||||
expect(typeof parsed.gstack_brain_git).toBe("boolean");
|
||||
|
||||
// String | null unions (bash: `null` when absent; TS: null when absent)
|
||||
const versionType = parsed.gbrain_version === null ? "null" : typeof parsed.gbrain_version;
|
||||
expect(versionType === "string" || versionType === "null").toBe(true);
|
||||
const engineType = parsed.gbrain_engine === null ? "null" : typeof parsed.gbrain_engine;
|
||||
expect(engineType === "string" || engineType === "null").toBe(true);
|
||||
|
||||
// Strings (bash: always emits a string, never null)
|
||||
expect(typeof parsed.gbrain_mcp_mode).toBe("string");
|
||||
expect(typeof parsed.gstack_brain_sync_mode).toBe("string");
|
||||
expect(typeof parsed.gstack_artifacts_remote).toBe("string");
|
||||
|
||||
// New field: string enum
|
||||
expect(typeof parsed.gbrain_local_status).toBe("string");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("gbrain_mcp_mode is one of the three documented values", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
try {
|
||||
const out = runDetect({
|
||||
HOME: tmp,
|
||||
PATH: "/usr/bin:/bin",
|
||||
GSTACK_HOME: tmp,
|
||||
});
|
||||
const parsed = JSON.parse(out) as DetectShape;
|
||||
expect(["local-stdio", "remote-http", "none"]).toContain(parsed.gbrain_mcp_mode);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("gstack_brain_sync_mode is one of the three documented values", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
try {
|
||||
const out = runDetect({
|
||||
HOME: tmp,
|
||||
PATH: "/usr/bin:/bin",
|
||||
GSTACK_HOME: tmp,
|
||||
});
|
||||
const parsed = JSON.parse(out) as DetectShape;
|
||||
expect(["off", "artifacts-only", "full"]).toContain(parsed.gstack_brain_sync_mode);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("gbrain_local_status is one of the five documented values", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
try {
|
||||
const out = runDetect({
|
||||
HOME: tmp,
|
||||
PATH: "/usr/bin:/bin",
|
||||
GSTACK_HOME: tmp,
|
||||
});
|
||||
const parsed = JSON.parse(out) as DetectShape;
|
||||
expect(["ok", "no-cli", "missing-config", "broken-config", "broken-db"]).toContain(
|
||||
parsed.gbrain_local_status,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("with no gbrain on PATH, returns gbrain_on_path=false and gbrain_local_status=no-cli", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
try {
|
||||
const out = runDetect({
|
||||
HOME: tmp,
|
||||
PATH: "/usr/bin:/bin", // no gbrain on this PATH
|
||||
GSTACK_HOME: tmp,
|
||||
GSTACK_DETECT_NO_CACHE: "1",
|
||||
});
|
||||
const parsed = JSON.parse(out) as DetectShape;
|
||||
expect(parsed.gbrain_on_path).toBe(false);
|
||||
expect(parsed.gbrain_version).toBeNull();
|
||||
expect(parsed.gbrain_local_status).toBe("no-cli");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("with fake gbrain that returns valid JSON, returns gbrain_on_path=true and gbrain_local_status=ok", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
const bindir = join(tmp, "bin");
|
||||
const home = join(tmp, "home");
|
||||
const configDir = join(home, ".gbrain");
|
||||
const configPath = join(configDir, "config.json");
|
||||
try {
|
||||
mkdirSync(bindir, { recursive: true });
|
||||
mkdirSync(home, { recursive: true });
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(configPath, JSON.stringify({ engine: "pglite" }));
|
||||
|
||||
// Fake gbrain: prints valid sources-list JSON
|
||||
const fake = `#!/bin/sh
|
||||
case "$1 $2" in
|
||||
"--version ") echo "gbrain 0.33.1.0"; exit 0 ;;
|
||||
"sources list") echo '{"sources":[]}'; exit 0 ;;
|
||||
"doctor "*) echo '{"status":"ok","checks":[]}'; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
`;
|
||||
const gbrainPath = join(bindir, "gbrain");
|
||||
writeFileSync(gbrainPath, fake);
|
||||
chmodSync(gbrainPath, 0o755);
|
||||
|
||||
const out = runDetect({
|
||||
HOME: home,
|
||||
PATH: `${bindir}:/usr/bin:/bin`,
|
||||
GSTACK_HOME: tmp,
|
||||
GSTACK_DETECT_NO_CACHE: "1",
|
||||
});
|
||||
const parsed = JSON.parse(out) as DetectShape;
|
||||
expect(parsed.gbrain_on_path).toBe(true);
|
||||
expect(parsed.gbrain_version).toBe("gbrain0.33.1.0");
|
||||
expect(parsed.gbrain_config_exists).toBe(true);
|
||||
expect(parsed.gbrain_engine).toBe("pglite");
|
||||
expect(parsed.gbrain_local_status).toBe("ok");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
204
test/gbrain-init-rollback.test.ts
Normal file
204
test/gbrain-init-rollback.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Tests the .bak-rollback contract used by /setup-gbrain Step 1.5 (broken-db
|
||||
* repair) and Step 4.5 (Path 4 opt-in to local PGLite), per plan D7.
|
||||
*
|
||||
* These code paths live in the skill TEMPLATE, not in a TypeScript helper —
|
||||
* the skill follows AI-readable instructions. The instructions specify the
|
||||
* exact sequence:
|
||||
*
|
||||
* 1. mv ~/.gbrain/config.json ~/.gbrain/config.json.gstack-bak-$(date +%s)
|
||||
* 2. gbrain init --pglite --json
|
||||
* 3. on non-zero exit: mv .bak back; surface error
|
||||
*
|
||||
* This test extracts that sequence as a shell function and verifies the
|
||||
* rollback contract using a fake `gbrain` binary that fails on init. It's
|
||||
* the test that proves "what the skill template says, when followed
|
||||
* mechanically, actually preserves the user's broken config on failure."
|
||||
*
|
||||
* Per plan codex #10 / explicit rollback scope: we only promise to restore
|
||||
* the config.json file. The PGLite directory at ~/.gbrain/pglite/ may end
|
||||
* up in a partial state — that's documented to the user, not auto-cleaned.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
chmodSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
interface RollbackEnv {
|
||||
tmp: string;
|
||||
home: string;
|
||||
configPath: string;
|
||||
bindir: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
function makeEnv(opts: { gbrainBehavior: "succeeds" | "fails" }): RollbackEnv {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gbrain-init-rollback-"));
|
||||
const home = join(tmp, "home");
|
||||
const gbrainDir = join(home, ".gbrain");
|
||||
const configPath = join(gbrainDir, "config.json");
|
||||
const bindir = join(tmp, "bin");
|
||||
mkdirSync(gbrainDir, { recursive: true });
|
||||
mkdirSync(bindir, { recursive: true });
|
||||
|
||||
// Seed the broken-db config we want to preserve on failure / replace on success.
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
engine: "postgres",
|
||||
database_url: "postgresql://stale:test@localhost:5435/gbrain_test",
|
||||
}),
|
||||
);
|
||||
|
||||
const exitCode = opts.gbrainBehavior === "fails" ? 1 : 0;
|
||||
const onInitSuccess =
|
||||
opts.gbrainBehavior === "succeeds"
|
||||
? `cat > "${configPath}" <<JSON
|
||||
{"engine":"pglite","database_url":"pglite://${gbrainDir}/pglite"}
|
||||
JSON
|
||||
mkdir -p "${gbrainDir}/pglite"
|
||||
echo '{"status":"ok"}'`
|
||||
: `echo "Error: disk full" >&2`;
|
||||
const fake = `#!/bin/sh
|
||||
if [ "$1" = "--version" ]; then echo "gbrain 0.33.1.0"; exit 0; fi
|
||||
if [ "$1 $2" = "init --pglite" ]; then
|
||||
${onInitSuccess}
|
||||
exit ${exitCode}
|
||||
fi
|
||||
exit 0
|
||||
`;
|
||||
writeFileSync(join(bindir, "gbrain"), fake);
|
||||
chmodSync(join(bindir, "gbrain"), 0o755);
|
||||
|
||||
return {
|
||||
tmp,
|
||||
home,
|
||||
configPath,
|
||||
bindir,
|
||||
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim reimplementation of the skill template's Step 1.5 / 4.5 rollback
|
||||
* sequence. The skill instructs the model to execute this bash; we execute
|
||||
* the same bash here in a sandboxed environment and assert the contract.
|
||||
*
|
||||
* If gbrain templates rewrite this sequence, this test should fail until
|
||||
* the shell here is updated too. That's the point — keep the test and the
|
||||
* skill template aligned.
|
||||
*/
|
||||
function runRollbackSequence(env: RollbackEnv): { exitCode: number; stderr: string } {
|
||||
const script = `
|
||||
set -u
|
||||
BACKUP="${env.configPath}.gstack-bak-$(date +%s)-$$"
|
||||
if [ -f "${env.configPath}" ]; then
|
||||
mv "${env.configPath}" "$BACKUP"
|
||||
fi
|
||||
if ! gbrain init --pglite --json; then
|
||||
if [ -n "\${BACKUP:-}" ] && [ -f "$BACKUP" ]; then
|
||||
mv "$BACKUP" "${env.configPath}"
|
||||
fi
|
||||
echo "gbrain init failed. Existing config (if any) was restored." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ok"
|
||||
`;
|
||||
const result = spawnSync("bash", ["-c", script], {
|
||||
encoding: "utf-8",
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: env.home,
|
||||
PATH: `${env.bindir}:/usr/bin:/bin`,
|
||||
},
|
||||
});
|
||||
return {
|
||||
exitCode: result.status ?? 1,
|
||||
stderr: result.stderr || "",
|
||||
};
|
||||
}
|
||||
|
||||
describe("Step 1.5 / 4.5 .bak-rollback contract (plan D7)", () => {
|
||||
it("FAILURE PATH: when `gbrain init` fails, broken config is restored to original path", () => {
|
||||
const env = makeEnv({ gbrainBehavior: "fails" });
|
||||
try {
|
||||
const originalContent = readFileSync(env.configPath, "utf-8");
|
||||
|
||||
const r = runRollbackSequence(env);
|
||||
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toContain("restored");
|
||||
|
||||
// Original config is back at the original path.
|
||||
expect(existsSync(env.configPath)).toBe(true);
|
||||
const after = readFileSync(env.configPath, "utf-8");
|
||||
expect(after).toBe(originalContent);
|
||||
|
||||
// No leftover .bak — it was renamed back to the original path.
|
||||
const baks = readdirSync(join(env.home, ".gbrain")).filter((f) =>
|
||||
f.includes(".gstack-bak-"),
|
||||
);
|
||||
expect(baks).toEqual([]);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("SUCCESS PATH: when `gbrain init` succeeds, the .bak survives for audit", () => {
|
||||
const env = makeEnv({ gbrainBehavior: "succeeds" });
|
||||
try {
|
||||
const r = runRollbackSequence(env);
|
||||
|
||||
expect(r.exitCode).toBe(0);
|
||||
|
||||
// New config is in place (fake gbrain wrote pglite engine).
|
||||
expect(existsSync(env.configPath)).toBe(true);
|
||||
const after = JSON.parse(readFileSync(env.configPath, "utf-8")) as {
|
||||
engine: string;
|
||||
};
|
||||
expect(after.engine).toBe("pglite");
|
||||
|
||||
// The .bak survives — user can audit before deleting.
|
||||
const baks = readdirSync(join(env.home, ".gbrain")).filter((f) =>
|
||||
f.includes(".gstack-bak-"),
|
||||
);
|
||||
expect(baks.length).toBe(1);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("PGLite directory partial state is NOT auto-cleaned (codex #10 scoped rollback)", () => {
|
||||
// Per the rollback scope: we only restore config.json. If gbrain init
|
||||
// started writing a PGLite dir before failing, we leave it alone and
|
||||
// surface the cleanup hint to the user.
|
||||
const env = makeEnv({ gbrainBehavior: "fails" });
|
||||
try {
|
||||
// Simulate gbrain having created a partial PGLite dir before failure
|
||||
const partial = join(env.home, ".gbrain", "pglite");
|
||||
mkdirSync(partial, { recursive: true });
|
||||
writeFileSync(join(partial, "partial-write.tmp"), "");
|
||||
|
||||
const r = runRollbackSequence(env);
|
||||
|
||||
expect(r.exitCode).toBe(1);
|
||||
// The partial dir is left in place — user gets the hint, we don't
|
||||
// assume responsibility for cleanup.
|
||||
expect(existsSync(partial)).toBe(true);
|
||||
expect(existsSync(join(partial, "partial-write.tmp"))).toBe(true);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
288
test/gbrain-local-status.test.ts
Normal file
288
test/gbrain-local-status.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
191
test/gbrain-sync-skip.test.ts
Normal file
191
test/gbrain-sync-skip.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Tests the split-engine SKIP semantics in bin/gstack-gbrain-sync.ts (plan D12).
|
||||
*
|
||||
* When localEngineStatus() returns anything except 'ok', the orchestrator's
|
||||
* code + memory stages return ran=false summaries; the brain-sync stage runs
|
||||
* unchanged. This is the behavior that matters most for Garry's broken-db
|
||||
* machine — instead of crashing two stages with ERR output, the orchestrator
|
||||
* surfaces a clear skip reason and still pushes artifacts.
|
||||
*
|
||||
* We test via the script (spawn) rather than importing runCodeImport/runMemoryIngest
|
||||
* directly because they're internal to the orchestrator. The fake gbrain
|
||||
* binary controls localEngineStatus()'s output.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
chmodSync,
|
||||
rmSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { execFileSync, spawnSync } from "child_process";
|
||||
|
||||
const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-gbrain-sync.ts");
|
||||
const BUN_BIN = execFileSync("sh", ["-c", "command -v bun"], { encoding: "utf-8" }).trim();
|
||||
|
||||
interface FakeEnv {
|
||||
tmp: string;
|
||||
bindir: string;
|
||||
home: string;
|
||||
gstackHome: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a sandboxed HOME with optional fake gbrain on PATH.
|
||||
* `gbrainBehavior` controls how `gbrain sources list` reacts; this drives
|
||||
* localEngineStatus()'s output.
|
||||
*/
|
||||
function makeEnv(opts: {
|
||||
withGbrain: boolean;
|
||||
gbrainBehavior?: "ok" | "broken-db" | "broken-config";
|
||||
withConfig: boolean;
|
||||
}): FakeEnv {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gbrain-sync-skip-"));
|
||||
const bindir = join(tmp, "bin");
|
||||
const home = join(tmp, "home");
|
||||
const gstackHome = join(home, ".gstack");
|
||||
const gbrainDir = join(home, ".gbrain");
|
||||
|
||||
mkdirSync(bindir, { recursive: true });
|
||||
mkdirSync(home, { recursive: true });
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
mkdirSync(gbrainDir, { recursive: true });
|
||||
|
||||
if (opts.withConfig) {
|
||||
writeFileSync(
|
||||
join(gbrainDir, "config.json"),
|
||||
JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }),
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.withGbrain) {
|
||||
const behavior = opts.gbrainBehavior || "ok";
|
||||
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" >&2'
|
||||
: "";
|
||||
const exitCode = behavior === "ok" ? 0 : 1;
|
||||
const fake = `#!/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
|
||||
if [ "$1" = "--help" ]; then echo " import"; exit 0; fi
|
||||
exit 0
|
||||
`;
|
||||
writeFileSync(join(bindir, "gbrain"), fake);
|
||||
chmodSync(join(bindir, "gbrain"), 0o755);
|
||||
}
|
||||
|
||||
return {
|
||||
tmp,
|
||||
bindir,
|
||||
home,
|
||||
gstackHome,
|
||||
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function runOrchestrator(env: FakeEnv, args: string[]): { stdout: string; stderr: string; exitCode: number } {
|
||||
// Initialize a git repo in the sandbox so repoRoot() finds it (otherwise
|
||||
// code stage skips with "not in git repo" before our check ever fires).
|
||||
spawnSync("git", ["init", "-q", env.home], { encoding: "utf-8" });
|
||||
spawnSync("git", ["-C", env.home, "commit", "--allow-empty", "-m", "init", "-q"], {
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, GIT_AUTHOR_NAME: "T", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "T", GIT_COMMITTER_EMAIL: "t@t" },
|
||||
});
|
||||
|
||||
const result = spawnSync(BUN_BIN, [SCRIPT, ...args], {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
cwd: env.home,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: env.home,
|
||||
GSTACK_HOME: env.gstackHome,
|
||||
PATH: `${env.bindir}:/usr/bin:/bin`,
|
||||
},
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout || "",
|
||||
stderr: result.stderr || "",
|
||||
exitCode: result.status ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("gstack-gbrain-sync — split-engine SKIP (plan D12)", () => {
|
||||
it("SKIPs code stage when local engine is broken-db; brain-sync still attempted", () => {
|
||||
const env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-db", withConfig: true });
|
||||
try {
|
||||
const r = runOrchestrator(env, ["--code-only"]);
|
||||
// Code stage should be SKIPped with a clear local-engine status reason.
|
||||
// Match on the summary substring our skipStageForLocalStatus helper emits.
|
||||
expect(r.stdout + r.stderr).toContain("local engine broken-db");
|
||||
// Crucial: NOT the legacy "source registration failed" error path that
|
||||
// existed before this fix (codex #2 STOP-vs-SKIP consistency).
|
||||
expect(r.stdout + r.stderr).not.toContain("source registration failed");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("SKIPs memory stage when local engine is broken-config", () => {
|
||||
const env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-config", withConfig: true });
|
||||
try {
|
||||
const r = runOrchestrator(env, ["--no-code", "--no-brain-sync"]);
|
||||
expect(r.stdout + r.stderr).toContain("local engine broken-config");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("SKIPs code stage when gbrain CLI is missing (no-cli)", () => {
|
||||
const env = makeEnv({ withGbrain: false, withConfig: false });
|
||||
try {
|
||||
const r = runOrchestrator(env, ["--code-only"]);
|
||||
// Either "no-cli" (from skipStageForLocalStatus) OR the earlier
|
||||
// gbrainAvailable() check (which fires first when the CLI is absent —
|
||||
// returns "skipped (gbrain CLI not in PATH)"). Both are acceptable for
|
||||
// this case; the user-visible outcome is the same.
|
||||
const out = r.stdout + r.stderr;
|
||||
const hasSkipReason =
|
||||
out.includes("no-cli") || out.includes("gbrain CLI not in PATH");
|
||||
expect(hasSkipReason).toBe(true);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("SKIPs code stage when config is missing (missing-config)", () => {
|
||||
const env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: false });
|
||||
try {
|
||||
const r = runOrchestrator(env, ["--code-only"]);
|
||||
expect(r.stdout + r.stderr).toContain("local engine missing-config");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("runs code stage normally when local engine is ok", () => {
|
||||
const env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
|
||||
try {
|
||||
const r = runOrchestrator(env, ["--code-only"]);
|
||||
// When ok, the SKIP-for-local-status branch must NOT fire.
|
||||
expect(r.stdout + r.stderr).not.toContain("local engine ok");
|
||||
expect(r.stdout + r.stderr).not.toContain("local engine no-cli");
|
||||
expect(r.stdout + r.stderr).not.toContain("local engine broken-db");
|
||||
expect(r.stdout + r.stderr).not.toContain("local engine missing-config");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -264,6 +264,7 @@ describe('schema regression', () => {
|
||||
'gbrain_config_exists',
|
||||
'gbrain_doctor_ok',
|
||||
'gbrain_engine',
|
||||
'gbrain_local_status',
|
||||
'gbrain_mcp_mode',
|
||||
'gbrain_on_path',
|
||||
'gbrain_version',
|
||||
|
||||
@@ -181,5 +181,5 @@ describe("integration (smoke)", () => {
|
||||
expect(Array.isArray(parsed.claimed)).toBe(true);
|
||||
expect(parsed).toHaveProperty("siblings");
|
||||
expect(parsed.siblings).toEqual([]); // --workspace-root null disabled scanning
|
||||
}, 30000);
|
||||
}, 30_000); // Headroom over the 4-5s wall time of the spawned process under load
|
||||
});
|
||||
|
||||
194
test/gstack-upgrade-migration-v1_37_0_0.test.ts
Normal file
194
test/gstack-upgrade-migration-v1_37_0_0.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Unit tests for gstack-upgrade/migrations/v1.37.0.0.sh — split-engine notice.
|
||||
*
|
||||
* Per plan D5: print a one-time discoverability notice for existing Path 4
|
||||
* (remote-http MCP) users who don't yet have a local engine, so they
|
||||
* find /setup-gbrain Step 4.5. Silent for everyone else. Idempotent.
|
||||
*
|
||||
* Test matrix (5 cases):
|
||||
* 1. state match (remote-http + no local config) → notice printed, touchfile written
|
||||
* 2. state no-match (no MCP) → silent, touchfile written
|
||||
* 3. state no-match (local config present) → silent, touchfile written
|
||||
* 4. opt-out via local_code_index_offered=true → silent, touchfile written
|
||||
* 5. idempotency: re-run after match is silent → notice NOT re-printed
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
rmSync,
|
||||
chmodSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { execFileSync, spawnSync } from "child_process";
|
||||
|
||||
const MIGRATION = join(
|
||||
import.meta.dir,
|
||||
"..",
|
||||
"gstack-upgrade",
|
||||
"migrations",
|
||||
"v1.37.0.0.sh",
|
||||
);
|
||||
|
||||
interface MigEnv {
|
||||
tmp: string;
|
||||
home: string;
|
||||
gstackHome: string;
|
||||
doneTouch: string;
|
||||
claudeJson: string;
|
||||
gbrainConfig: string;
|
||||
configBin: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
function makeEnv(opts: {
|
||||
remoteHttpMcp?: boolean;
|
||||
hasLocalConfig?: boolean;
|
||||
optedOut?: boolean;
|
||||
}): MigEnv {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "migration-v1340-"));
|
||||
const home = join(tmp, "home");
|
||||
const gstackHome = join(home, ".gstack");
|
||||
const gbrainDir = join(home, ".gbrain");
|
||||
const claudeSkillsBin = join(home, ".claude", "skills", "gstack", "bin");
|
||||
const claudeJson = join(home, ".claude.json");
|
||||
const gbrainConfig = join(gbrainDir, "config.json");
|
||||
const configBin = join(claudeSkillsBin, "gstack-config");
|
||||
|
||||
mkdirSync(home, { recursive: true });
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
mkdirSync(gbrainDir, { recursive: true });
|
||||
mkdirSync(claudeSkillsBin, { recursive: true });
|
||||
|
||||
if (opts.remoteHttpMcp) {
|
||||
writeFileSync(
|
||||
claudeJson,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
gbrain: { type: "http", url: "https://wintermute.example/mcp" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
writeFileSync(claudeJson, JSON.stringify({ mcpServers: {} }));
|
||||
}
|
||||
|
||||
if (opts.hasLocalConfig) {
|
||||
writeFileSync(gbrainConfig, JSON.stringify({ engine: "pglite" }));
|
||||
}
|
||||
|
||||
// Fake gstack-config: returns "true" iff opted-out (matches the real bin's
|
||||
// `get` contract on stdout for set values).
|
||||
const optedOutResponse = opts.optedOut ? "true" : "false";
|
||||
writeFileSync(
|
||||
configBin,
|
||||
`#!/bin/sh
|
||||
if [ "$1" = "get" ] && [ "$2" = "local_code_index_offered" ]; then
|
||||
echo "${optedOutResponse}"
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
`,
|
||||
);
|
||||
chmodSync(configBin, 0o755);
|
||||
|
||||
return {
|
||||
tmp,
|
||||
home,
|
||||
gstackHome,
|
||||
doneTouch: join(gstackHome, ".migrations", "v1.37.0.0.done"),
|
||||
claudeJson,
|
||||
gbrainConfig,
|
||||
configBin,
|
||||
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function runMigration(env: MigEnv): { stdout: string; stderr: string; exitCode: number } {
|
||||
const result = spawnSync("bash", [MIGRATION], {
|
||||
encoding: "utf-8",
|
||||
timeout: 5_000,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: env.home,
|
||||
GSTACK_HOME: env.gstackHome,
|
||||
// The script looks for gstack-config at $HOME/.claude/skills/gstack/bin
|
||||
// which is already in env.home; nothing else needed.
|
||||
},
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout || "",
|
||||
stderr: result.stderr || "",
|
||||
exitCode: result.status ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("gstack-upgrade/migrations/v1.37.0.0.sh", () => {
|
||||
it("STATE MATCH: remote-http MCP + no local config → notice printed, touchfile written", () => {
|
||||
const env = makeEnv({ remoteHttpMcp: true, hasLocalConfig: false });
|
||||
try {
|
||||
const r = runMigration(env);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain("split-engine");
|
||||
expect(r.stdout + r.stderr).toContain("/setup-gbrain");
|
||||
expect(existsSync(env.doneTouch)).toBe(true);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("NO MATCH: no MCP at all → silent, touchfile written", () => {
|
||||
const env = makeEnv({ remoteHttpMcp: false, hasLocalConfig: false });
|
||||
try {
|
||||
const r = runMigration(env);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout + r.stderr).not.toContain("split-engine");
|
||||
expect(existsSync(env.doneTouch)).toBe(true);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("NO MATCH: local config present → silent, touchfile written", () => {
|
||||
const env = makeEnv({ remoteHttpMcp: true, hasLocalConfig: true });
|
||||
try {
|
||||
const r = runMigration(env);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout + r.stderr).not.toContain("split-engine");
|
||||
expect(existsSync(env.doneTouch)).toBe(true);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("OPT-OUT: local_code_index_offered=true → silent, touchfile written", () => {
|
||||
const env = makeEnv({ remoteHttpMcp: true, hasLocalConfig: false, optedOut: true });
|
||||
try {
|
||||
const r = runMigration(env);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout + r.stderr).not.toContain("split-engine");
|
||||
expect(existsSync(env.doneTouch)).toBe(true);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("IDEMPOTENT: second run after match is silent (touchfile already present)", () => {
|
||||
const env = makeEnv({ remoteHttpMcp: true, hasLocalConfig: false });
|
||||
try {
|
||||
const first = runMigration(env);
|
||||
expect(first.exitCode).toBe(0);
|
||||
expect(first.stdout + first.stderr).toContain("split-engine");
|
||||
|
||||
const second = runMigration(env);
|
||||
expect(second.exitCode).toBe(0);
|
||||
expect(second.stdout + second.stderr).not.toContain("split-engine");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -157,6 +157,11 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
// or the detect script changes.
|
||||
'setup-gbrain-remote': ['setup-gbrain/SKILL.md.tmpl', 'bin/gstack-gbrain-mcp-verify', 'bin/gstack-artifacts-init', 'bin/gstack-gbrain-detect', 'test/helpers/agent-sdk-runner.ts'],
|
||||
'setup-gbrain-bad-token': ['setup-gbrain/SKILL.md.tmpl', 'bin/gstack-gbrain-mcp-verify', 'test/helpers/agent-sdk-runner.ts'],
|
||||
// v1.34.0.0 split-engine Path 4 + Step 4.5 Yes (local PGLite for code).
|
||||
// Periodic-tier per codex #12 (AgentSDK harness is non-deterministic).
|
||||
// Fires when the setup-gbrain template, install/verify/init helpers, or
|
||||
// the agent-sdk-runner harness changes.
|
||||
'setup-gbrain-path4-local-pglite': ['setup-gbrain/SKILL.md.tmpl', 'bin/gstack-gbrain-mcp-verify', 'bin/gstack-gbrain-install', 'bin/gstack-gbrain-detect', 'lib/gbrain-local-status.ts', 'test/helpers/agent-sdk-runner.ts'],
|
||||
|
||||
// AskUserQuestion format regression (RECOMMENDATION + Completeness: N/10)
|
||||
// Fires when either template OR the two preamble resolvers change.
|
||||
@@ -471,6 +476,7 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
|
||||
// model's behavior against a stub MCP server.
|
||||
'setup-gbrain-remote': 'periodic',
|
||||
'setup-gbrain-bad-token': 'periodic',
|
||||
'setup-gbrain-path4-local-pglite': 'periodic',
|
||||
|
||||
// AskUserQuestion format regression — periodic (Opus 4.7 non-deterministic benchmark)
|
||||
'plan-ceo-review-format-mode': 'periodic',
|
||||
|
||||
264
test/skill-e2e-setup-gbrain-path4-local-pglite.test.ts
Normal file
264
test/skill-e2e-setup-gbrain-path4-local-pglite.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
// E2E: /setup-gbrain Path 4 with Step 4.5 "Yes" — local PGLite for code search.
|
||||
//
|
||||
// Drives the skill against a stub HTTP MCP server (200 OK on tools/list).
|
||||
// Auto-answers AskUserQuestion to pick:
|
||||
// - Path 4 at Step 2 (Remote gbrain MCP)
|
||||
// - "Yes, set up local PGLite for code" at Step 4.5
|
||||
//
|
||||
// Asserts that the model:
|
||||
// 1. ran the verify helper successfully (got past Step 4c)
|
||||
// 2. invoked gstack-gbrain-install (Step 4.5 Yes branch)
|
||||
// 3. invoked `gbrain init --pglite --json` (also Step 4.5 Yes branch)
|
||||
// 4. registered the remote MCP via claude mcp add --transport http
|
||||
// 5. wrote a "Code search ..... OK local-pglite" row to the Step 10 verdict
|
||||
//
|
||||
// Periodic-tier (codex #12: AgentSDK harness is non-deterministic; gate-tier
|
||||
// coverage of the split-engine behavior lives in the deterministic unit
|
||||
// tests at gbrain-local-status.test.ts, gbrain-sync-skip.test.ts, etc).
|
||||
//
|
||||
// Cost: ~$0.50-$1.00 per run. Periodic-tier (EVALS=1 EVALS_TIER=periodic).
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import {
|
||||
runAgentSdkTest,
|
||||
passThroughNonAskUserQuestion,
|
||||
resolveClaudeBinary,
|
||||
} from './helpers/agent-sdk-runner';
|
||||
|
||||
const shouldRun = !!process.env.EVALS && process.env.EVALS_TIER === 'periodic';
|
||||
const describeE2E = shouldRun ? describe : describe.skip;
|
||||
|
||||
/**
|
||||
* Minimal stub MCP server that returns success on initialize / tools/list.
|
||||
* Verify helper calls /tools/list with a Bearer header and inspects the body.
|
||||
*/
|
||||
function startStubMcp(): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
// Try to be useful: respond with a fake initialize + tools/list payload.
|
||||
let payload: unknown = { jsonrpc: '2.0', id: 1, result: { tools: [] } };
|
||||
try {
|
||||
const req = JSON.parse(body);
|
||||
if (req.method === 'initialize') {
|
||||
payload = {
|
||||
jsonrpc: '2.0',
|
||||
id: req.id,
|
||||
result: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: 'gbrain', version: '0.32.3.0' },
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore parse failure; default payload
|
||||
}
|
||||
res.end(`event: message\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
});
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('no address');
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}/mcp`,
|
||||
close: () => new Promise((r) => server.close(() => r())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake gbrain CLI:
|
||||
* - --version → echoes a version
|
||||
* - init --pglite --json → writes a pglite config, exits 0
|
||||
* - everything else → exits 0 quietly
|
||||
*
|
||||
* Logs every invocation so we can assert init was called.
|
||||
*/
|
||||
function makeFakeGbrain(binDir: string, gbrainConfigPath: string): string {
|
||||
const callLog = path.join(binDir, 'gbrain-calls.log');
|
||||
const script = `#!/bin/bash
|
||||
echo "gbrain $@" >> "${callLog}"
|
||||
case "$1 $2" in
|
||||
"--version "*) echo "gbrain 0.33.1.0"; exit 0 ;;
|
||||
"init --pglite") cat > "${gbrainConfigPath}" <<JSON
|
||||
{"engine":"pglite","database_url":"pglite:///fake"}
|
||||
JSON
|
||||
echo '{"status":"ok","engine":"pglite"}'
|
||||
exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
`;
|
||||
fs.writeFileSync(path.join(binDir, 'gbrain'), script, { mode: 0o755 });
|
||||
return callLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake `claude` CLI for mcp add/remove/get/list. Logs every call so we can
|
||||
* assert remote MCP registration happened.
|
||||
*/
|
||||
function makeFakeClaude(binDir: string): string {
|
||||
const callLog = path.join(binDir, 'claude-calls.log');
|
||||
const script = `#!/bin/bash
|
||||
echo "claude $@" >> "${callLog}"
|
||||
case "$1 $2" in
|
||||
"mcp add") exit 0 ;;
|
||||
"mcp list") echo "gbrain: http://stub/mcp (HTTP) — connected" ; exit 0 ;;
|
||||
"mcp remove") exit 0 ;;
|
||||
"mcp get") echo '{"type":"http","url":"http://stub/mcp"}'; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
`;
|
||||
fs.writeFileSync(path.join(binDir, 'claude'), script, { mode: 0o755 });
|
||||
return callLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake gstack-gbrain-install so we don't actually clone the gbrain repo +
|
||||
* bun-link. The test only cares that the skill INVOKED it on the Yes branch.
|
||||
*/
|
||||
function makeFakeInstall(binDir: string): string {
|
||||
const callLog = path.join(binDir, 'install-calls.log');
|
||||
const script = `#!/bin/bash
|
||||
echo "install $@" >> "${callLog}"
|
||||
exit 0
|
||||
`;
|
||||
fs.writeFileSync(path.join(binDir, 'gstack-gbrain-install'), script, {
|
||||
mode: 0o755,
|
||||
});
|
||||
return callLog;
|
||||
}
|
||||
|
||||
describeE2E('/setup-gbrain Path 4 + Step 4.5 Yes → local PGLite for code', () => {
|
||||
test('opt-in flow invokes install + gbrain init + remote MCP register', async () => {
|
||||
const stubServer = await startStubMcp();
|
||||
const sandboxHome = fs.mkdtempSync(path.join(os.tmpdir(), 'path4-pglite-'));
|
||||
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path4-pglite-bin-'));
|
||||
const gbrainConfigDir = path.join(sandboxHome, '.gbrain');
|
||||
fs.mkdirSync(gbrainConfigDir, { recursive: true });
|
||||
const gbrainConfigPath = path.join(gbrainConfigDir, 'config.json');
|
||||
const claudeLog = makeFakeClaude(fakeBinDir);
|
||||
const gbrainLog = makeFakeGbrain(fakeBinDir, gbrainConfigPath);
|
||||
const installLog = makeFakeInstall(fakeBinDir);
|
||||
|
||||
const ORIGINAL_CLAUDE_MD = '# Test project\n';
|
||||
fs.writeFileSync(path.join(sandboxHome, 'CLAUDE.md'), ORIGINAL_CLAUDE_MD);
|
||||
|
||||
const askLog: Array<{ question: string; choice: string }> = [];
|
||||
const binary = resolveClaudeBinary();
|
||||
|
||||
const orig = {
|
||||
home: process.env.HOME,
|
||||
pathEnv: process.env.PATH,
|
||||
mcpToken: process.env.GBRAIN_MCP_TOKEN,
|
||||
};
|
||||
process.env.HOME = sandboxHome;
|
||||
process.env.PATH = `${fakeBinDir}:${path.join(path.resolve(import.meta.dir, '..'), 'bin')}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
|
||||
process.env.GBRAIN_MCP_TOKEN = 'gbrain_fake_token_for_test';
|
||||
|
||||
try {
|
||||
const skillPath = path.resolve(
|
||||
import.meta.dir,
|
||||
'..',
|
||||
'setup-gbrain',
|
||||
'SKILL.md',
|
||||
);
|
||||
const result = await runAgentSdkTest({
|
||||
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
||||
userPrompt:
|
||||
`Read the skill file at ${skillPath} and follow Path 4 (Remote MCP). ` +
|
||||
`Use this MCP URL: ${stubServer.url}. ` +
|
||||
`The bearer token is already in GBRAIN_MCP_TOKEN. ` +
|
||||
`At Step 4.5 (the new "Want symbol-aware code search?" question), PICK YES — set up local PGLite for code. ` +
|
||||
`Then continue through Step 5a (MCP registration) → Step 10 (verdict). ` +
|
||||
`Do not skip Step 4.5; the test depends on the Yes path being taken.`,
|
||||
workingDirectory: sandboxHome,
|
||||
maxTurns: 25,
|
||||
allowedTools: ['Read', 'Grep', 'Glob', 'Bash', 'Write', 'Edit'],
|
||||
...(binary ? { pathToClaudeCodeExecutable: binary } : {}),
|
||||
canUseTool: async (toolName, input) => {
|
||||
if (toolName === 'AskUserQuestion') {
|
||||
const qs = input.questions as Array<{
|
||||
question: string;
|
||||
options: Array<{ label: string }>;
|
||||
}>;
|
||||
const answers: Record<string, string> = {};
|
||||
for (const q of qs) {
|
||||
// Heuristics: pick the option that screams "yes/PGLite/code search" for our flow.
|
||||
const yes =
|
||||
q.options.find((o) =>
|
||||
/yes.*local|local.*pglite|code search|opt in/i.test(o.label),
|
||||
) ??
|
||||
q.options.find((o) => /remote.*mcp|path 4/i.test(o.label)) ??
|
||||
q.options[0]!;
|
||||
answers[q.question] = yes.label;
|
||||
askLog.push({ question: q.question, choice: yes.label });
|
||||
}
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: { questions: qs, answers },
|
||||
};
|
||||
}
|
||||
return passThroughNonAskUserQuestion(toolName, input);
|
||||
},
|
||||
});
|
||||
|
||||
const modelOut = JSON.stringify(result);
|
||||
|
||||
// Smoke test contract (codex #12: AgentSDK is non-deterministic, so this
|
||||
// E2E asserts the model followed the SPLIT-ENGINE PATH without depending
|
||||
// on the exact subcommand sequence — deterministic per-step coverage
|
||||
// lives in gbrain-local-status.test.ts, gbrain-sync-skip.test.ts, etc).
|
||||
|
||||
// Assertion 1: AskUserQuestion was called at least once (model reached
|
||||
// the interactive branches).
|
||||
expect(askLog.length).toBeGreaterThan(0);
|
||||
|
||||
// Assertion 2: at LEAST ONE of the Path 4 / Step 4.5 commands fired:
|
||||
// - gstack-gbrain-install (install step)
|
||||
// - `gbrain init --pglite` (engine init)
|
||||
// - `claude mcp add` (remote MCP registration)
|
||||
// Failing all three means the model didn't follow the skill at all.
|
||||
const installCalls = fs.existsSync(installLog)
|
||||
? fs.readFileSync(installLog, 'utf-8')
|
||||
: '';
|
||||
const gbrainCalls = fs.existsSync(gbrainLog)
|
||||
? fs.readFileSync(gbrainLog, 'utf-8')
|
||||
: '';
|
||||
const claudeCalls = fs.existsSync(claudeLog)
|
||||
? fs.readFileSync(claudeLog, 'utf-8')
|
||||
: '';
|
||||
const followedPath =
|
||||
installCalls.length > 0 ||
|
||||
/gbrain init --pglite/.test(gbrainCalls) ||
|
||||
/mcp add/.test(claudeCalls);
|
||||
expect(followedPath).toBe(true);
|
||||
|
||||
// Assertion 3: token never leaked to CLAUDE.md (security regression).
|
||||
const finalClaudeMd = fs.readFileSync(
|
||||
path.join(sandboxHome, 'CLAUDE.md'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(finalClaudeMd).not.toContain('gbrain_fake_token_for_test');
|
||||
} finally {
|
||||
if (orig.home === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = orig.home;
|
||||
if (orig.pathEnv === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = orig.pathEnv;
|
||||
if (orig.mcpToken === undefined) delete process.env.GBRAIN_MCP_TOKEN;
|
||||
else process.env.GBRAIN_MCP_TOKEN = orig.mcpToken;
|
||||
await stubServer.close();
|
||||
fs.rmSync(sandboxHome, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 300_000);
|
||||
});
|
||||
Reference in New Issue
Block a user