Skip to content

Commit 7a920ff

Browse files
garrytanclaude
andcommitted
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>
1 parent 0668f92 commit 7a920ff

1 file changed

Lines changed: 204 additions & 0 deletions

File tree

test/gbrain-init-rollback.test.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/**
2+
* Tests the .bak-rollback contract used by /setup-gbrain Step 1.5 (broken-db
3+
* repair) and Step 4.5 (Path 4 opt-in to local PGLite), per plan D7.
4+
*
5+
* These code paths live in the skill TEMPLATE, not in a TypeScript helper —
6+
* the skill follows AI-readable instructions. The instructions specify the
7+
* exact sequence:
8+
*
9+
* 1. mv ~/.gbrain/config.json ~/.gbrain/config.json.gstack-bak-$(date +%s)
10+
* 2. gbrain init --pglite --json
11+
* 3. on non-zero exit: mv .bak back; surface error
12+
*
13+
* This test extracts that sequence as a shell function and verifies the
14+
* rollback contract using a fake `gbrain` binary that fails on init. It's
15+
* the test that proves "what the skill template says, when followed
16+
* mechanically, actually preserves the user's broken config on failure."
17+
*
18+
* Per plan codex #10 / explicit rollback scope: we only promise to restore
19+
* the config.json file. The PGLite directory at ~/.gbrain/pglite/ may end
20+
* up in a partial state — that's documented to the user, not auto-cleaned.
21+
*/
22+
23+
import { describe, it, expect } from "bun:test";
24+
import {
25+
mkdtempSync,
26+
mkdirSync,
27+
writeFileSync,
28+
readFileSync,
29+
existsSync,
30+
readdirSync,
31+
rmSync,
32+
chmodSync,
33+
} from "fs";
34+
import { tmpdir } from "os";
35+
import { join } from "path";
36+
import { spawnSync } from "child_process";
37+
38+
interface RollbackEnv {
39+
tmp: string;
40+
home: string;
41+
configPath: string;
42+
bindir: string;
43+
cleanup: () => void;
44+
}
45+
46+
function makeEnv(opts: { gbrainBehavior: "succeeds" | "fails" }): RollbackEnv {
47+
const tmp = mkdtempSync(join(tmpdir(), "gbrain-init-rollback-"));
48+
const home = join(tmp, "home");
49+
const gbrainDir = join(home, ".gbrain");
50+
const configPath = join(gbrainDir, "config.json");
51+
const bindir = join(tmp, "bin");
52+
mkdirSync(gbrainDir, { recursive: true });
53+
mkdirSync(bindir, { recursive: true });
54+
55+
// Seed the broken-db config we want to preserve on failure / replace on success.
56+
writeFileSync(
57+
configPath,
58+
JSON.stringify({
59+
engine: "postgres",
60+
database_url: "postgresql://stale:test@localhost:5435/gbrain_test",
61+
}),
62+
);
63+
64+
const exitCode = opts.gbrainBehavior === "fails" ? 1 : 0;
65+
const onInitSuccess =
66+
opts.gbrainBehavior === "succeeds"
67+
? `cat > "${configPath}" <<JSON
68+
{"engine":"pglite","database_url":"pglite://${gbrainDir}/pglite"}
69+
JSON
70+
mkdir -p "${gbrainDir}/pglite"
71+
echo '{"status":"ok"}'`
72+
: `echo "Error: disk full" >&2`;
73+
const fake = `#!/bin/sh
74+
if [ "$1" = "--version" ]; then echo "gbrain 0.33.1.0"; exit 0; fi
75+
if [ "$1 $2" = "init --pglite" ]; then
76+
${onInitSuccess}
77+
exit ${exitCode}
78+
fi
79+
exit 0
80+
`;
81+
writeFileSync(join(bindir, "gbrain"), fake);
82+
chmodSync(join(bindir, "gbrain"), 0o755);
83+
84+
return {
85+
tmp,
86+
home,
87+
configPath,
88+
bindir,
89+
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
90+
};
91+
}
92+
93+
/**
94+
* Verbatim reimplementation of the skill template's Step 1.5 / 4.5 rollback
95+
* sequence. The skill instructs the model to execute this bash; we execute
96+
* the same bash here in a sandboxed environment and assert the contract.
97+
*
98+
* If gbrain templates rewrite this sequence, this test should fail until
99+
* the shell here is updated too. That's the point — keep the test and the
100+
* skill template aligned.
101+
*/
102+
function runRollbackSequence(env: RollbackEnv): { exitCode: number; stderr: string } {
103+
const script = `
104+
set -u
105+
BACKUP="${env.configPath}.gstack-bak-$(date +%s)-$$"
106+
if [ -f "${env.configPath}" ]; then
107+
mv "${env.configPath}" "$BACKUP"
108+
fi
109+
if ! gbrain init --pglite --json; then
110+
if [ -n "\${BACKUP:-}" ] && [ -f "$BACKUP" ]; then
111+
mv "$BACKUP" "${env.configPath}"
112+
fi
113+
echo "gbrain init failed. Existing config (if any) was restored." >&2
114+
exit 1
115+
fi
116+
echo "ok"
117+
`;
118+
const result = spawnSync("bash", ["-c", script], {
119+
encoding: "utf-8",
120+
env: {
121+
...process.env,
122+
HOME: env.home,
123+
PATH: `${env.bindir}:/usr/bin:/bin`,
124+
},
125+
});
126+
return {
127+
exitCode: result.status ?? 1,
128+
stderr: result.stderr || "",
129+
};
130+
}
131+
132+
describe("Step 1.5 / 4.5 .bak-rollback contract (plan D7)", () => {
133+
it("FAILURE PATH: when `gbrain init` fails, broken config is restored to original path", () => {
134+
const env = makeEnv({ gbrainBehavior: "fails" });
135+
try {
136+
const originalContent = readFileSync(env.configPath, "utf-8");
137+
138+
const r = runRollbackSequence(env);
139+
140+
expect(r.exitCode).toBe(1);
141+
expect(r.stderr).toContain("restored");
142+
143+
// Original config is back at the original path.
144+
expect(existsSync(env.configPath)).toBe(true);
145+
const after = readFileSync(env.configPath, "utf-8");
146+
expect(after).toBe(originalContent);
147+
148+
// No leftover .bak — it was renamed back to the original path.
149+
const baks = readdirSync(join(env.home, ".gbrain")).filter((f) =>
150+
f.includes(".gstack-bak-"),
151+
);
152+
expect(baks).toEqual([]);
153+
} finally {
154+
env.cleanup();
155+
}
156+
});
157+
158+
it("SUCCESS PATH: when `gbrain init` succeeds, the .bak survives for audit", () => {
159+
const env = makeEnv({ gbrainBehavior: "succeeds" });
160+
try {
161+
const r = runRollbackSequence(env);
162+
163+
expect(r.exitCode).toBe(0);
164+
165+
// New config is in place (fake gbrain wrote pglite engine).
166+
expect(existsSync(env.configPath)).toBe(true);
167+
const after = JSON.parse(readFileSync(env.configPath, "utf-8")) as {
168+
engine: string;
169+
};
170+
expect(after.engine).toBe("pglite");
171+
172+
// The .bak survives — user can audit before deleting.
173+
const baks = readdirSync(join(env.home, ".gbrain")).filter((f) =>
174+
f.includes(".gstack-bak-"),
175+
);
176+
expect(baks.length).toBe(1);
177+
} finally {
178+
env.cleanup();
179+
}
180+
});
181+
182+
it("PGLite directory partial state is NOT auto-cleaned (codex #10 scoped rollback)", () => {
183+
// Per the rollback scope: we only restore config.json. If gbrain init
184+
// started writing a PGLite dir before failing, we leave it alone and
185+
// surface the cleanup hint to the user.
186+
const env = makeEnv({ gbrainBehavior: "fails" });
187+
try {
188+
// Simulate gbrain having created a partial PGLite dir before failure
189+
const partial = join(env.home, ".gbrain", "pglite");
190+
mkdirSync(partial, { recursive: true });
191+
writeFileSync(join(partial, "partial-write.tmp"), "");
192+
193+
const r = runRollbackSequence(env);
194+
195+
expect(r.exitCode).toBe(1);
196+
// The partial dir is left in place — user gets the hint, we don't
197+
// assume responsibility for cleanup.
198+
expect(existsSync(partial)).toBe(true);
199+
expect(existsSync(join(partial, "partial-write.tmp"))).toBe(true);
200+
} finally {
201+
env.cleanup();
202+
}
203+
});
204+
});

0 commit comments

Comments
 (0)