add gstack 2 parity and lifecycle gates

This commit is contained in:
Sinabina
2026-07-17 11:08:32 -07:00
parent b6572ebbb7
commit 9919c4cdd3
212 changed files with 29098 additions and 3845 deletions
+283
View File
@@ -0,0 +1,283 @@
import { afterEach, describe, expect, test } from "bun:test";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import {
beginRun,
appendDecision,
completeRun,
configSet,
discoverProjectIdentity,
identityFromPaths,
initializeProject,
inspectProject,
markEffectApplied,
markEffectNotApplied,
resumeRun,
resolveGstackHome,
runExternalEffect,
setupRuntime,
updateProjectState,
} from "../runtime/index.js";
const temporaryRoots: string[] = [];
async function temporaryRoot(label = "gstack2 runtime ") {
const root = await fs.mkdtemp(path.join(os.tmpdir(), label));
temporaryRoots.push(root);
return root;
}
afterEach(async () => {
await Promise.all(temporaryRoots.splice(0).map((root) =>
fs.chmod(root, 0o700).catch(() => {}).then(() => fs.rm(root, { recursive: true, force: true }))));
});
describe("gstack 2 host-neutral paths and state", () => {
test("GSTACK_HOME is the only override and shell-looking paths stay literal", async () => {
const root = await temporaryRoot();
const configured = "state with spaces $(touch should-not-run);$HOME";
const resolved = resolveGstackHome({
env: { GSTACK_HOME: configured },
cwd: root,
homeDir: path.join(root, "fake-home"),
});
expect(resolved).toBe(path.join(root, configured));
expect(resolveGstackHome({
env: { CLAUDE_PLUGIN_DATA: "/host-specific/path" },
cwd: root,
homeDir: path.join(root, "person"),
})).toBe(path.join(root, "person", ".gstack"));
expect(await fs.readdir(root)).toEqual([]);
});
test("setup creates the canonical project shape and private secrets", async () => {
const root = await temporaryRoot();
const home = path.join(root, "home with spaces");
const result = await setupRuntime({ home, cwd: root });
const project = path.join(home, "projects", result.identity.projectId);
for (const entry of [
"state.json", "timeline.jsonl", "decisions.jsonl", "evidence", "artifacts", "reviews", "checkpoints",
]) {
expect(await fs.stat(path.join(project, entry))).toBeTruthy();
}
if (process.platform !== "win32") {
expect((await fs.stat(path.join(home, "secrets.json"))).mode & 0o777).toBe(0o600);
}
});
test("public config rejects key, token, and credential-looking fields", async () => {
const root = await temporaryRoot();
const home = path.join(root, "state");
await setupRuntime({ home, cwd: root });
for (const key of ["context.apiKey", "context.api_key", "context.token", "service.accessToken", "service.credentials"]) {
let error: any;
try {
await configSet(home, key, "must-not-be-public");
} catch (caught) {
error = caught;
}
expect(error?.code).toBe("SECRET_IN_CONFIG");
}
});
test("locked concurrent updates do not lose writes", async () => {
const root = await temporaryRoot();
const home = path.join(root, "state");
const identity = identityFromPaths({
worktreeRoot: path.join(root, "repo"),
commonDir: path.join(root, "repo", ".git"),
gitDir: path.join(root, "repo", ".git"),
});
await initializeProject(home, identity);
await Promise.all(Array.from({ length: 60 }, () =>
updateProjectState(home, identity.projectId, (state) => {
state.concurrentCounter = Number(state.concurrentCounter ?? 0) + 1;
})));
const { state } = await inspectProject(home, identity);
expect(state.concurrentCounter).toBe(60);
expect(state.revision).toBe(60);
});
test("crash/resume never automatically repeats an uncertain external effect", async () => {
const root = await temporaryRoot();
const home = path.join(root, "state");
const identity = identityFromPaths({
worktreeRoot: path.join(root, "repo"),
commonDir: path.join(root, "repo", ".git"),
gitDir: path.join(root, "repo", ".git"),
});
await initializeProject(home, identity);
const { run } = await beginRun(home, identity.projectId, "ship", { runId: "run_crash_test" });
let externalCalls = 0;
let firstError: any;
try {
await runExternalEffect(home, identity.projectId, run.id, "publish.release", async () => {
externalCalls += 1;
// This models a connection drop after the remote service accepted the action.
throw new Error("connection dropped after accept");
});
} catch (error) {
firstError = error;
}
expect(firstError?.code).toBe("EXTERNAL_EFFECT_UNCERTAIN");
await resumeRun(home, identity.projectId, run.id);
const retried = await runExternalEffect(home, identity.projectId, run.id, "publish.release", async () => {
externalCalls += 1;
return "duplicated";
});
expect(retried.status).toBe("uncertain");
expect(externalCalls).toBe(1);
});
test("completed external effects are idempotent", async () => {
const root = await temporaryRoot();
const home = path.join(root, "state");
const identity = identityFromPaths({
worktreeRoot: root,
commonDir: path.join(root, ".git"),
gitDir: path.join(root, ".git"),
});
await initializeProject(home, identity);
const { run } = await beginRun(home, identity.projectId, "notify", { runId: "run_once" });
let calls = 0;
const execute = async () => ({ sequence: ++calls });
expect((await runExternalEffect(home, identity.projectId, run.id, "notify.once", execute)).result).toEqual({ sequence: 1 });
expect((await runExternalEffect(home, identity.projectId, run.id, "notify.once", execute)).result).toEqual({ sequence: 1 });
expect(calls).toBe(1);
});
test("ambiguous effects reconcile both outcomes without permitting incomplete runs", async () => {
const root = await temporaryRoot();
const home = path.join(root, "state");
const identity = identityFromPaths({
worktreeRoot: root,
commonDir: path.join(root, ".git"),
gitDir: path.join(root, ".git"),
});
await initializeProject(home, identity);
const appliedRun = (await beginRun(home, identity.projectId, "ship", { runId: "run_applied" })).run;
await expect(runExternalEffect(home, identity.projectId, appliedRun.id, "git.push", async () => {
throw new Error("connection dropped after remote accepted push");
})).rejects.toMatchObject({ code: "EXTERNAL_EFFECT_UNCERTAIN" });
await markEffectApplied(home, identity.projectId, appliedRun.id, "git.push", "origin/main contains commit abc123");
await expect(completeRun(home, identity.projectId, appliedRun.id)).resolves.toBeTruthy();
const retryRun = (await beginRun(home, identity.projectId, "ship", { runId: "run_not_applied" })).run;
await expect(runExternalEffect(home, identity.projectId, retryRun.id, "deploy.production", async () => {
throw new Error("preflight failed before request");
})).rejects.toMatchObject({ code: "EXTERNAL_EFFECT_UNCERTAIN" });
await markEffectNotApplied(home, identity.projectId, retryRun.id, "deploy.production");
await expect(completeRun(home, identity.projectId, retryRun.id))
.rejects.toMatchObject({ code: "EFFECTS_UNCERTAIN" });
await runExternalEffect(home, identity.projectId, retryRun.id, "deploy.production", async () => "deployed");
await expect(completeRun(home, identity.projectId, retryRun.id)).resolves.toBeTruthy();
});
test("state keys cannot collide with object prototypes and effect idempotency keys do not truncate", async () => {
const root = await temporaryRoot();
const home = path.join(root, "state");
const identity = identityFromPaths({
worktreeRoot: root,
commonDir: path.join(root, ".git"),
gitDir: path.join(root, ".git"),
});
await initializeProject(home, identity);
await expect(beginRun(home, identity.projectId, "ship", { runId: "constructor" })).rejects.toThrow("Invalid run id");
const { run } = await beginRun(home, identity.projectId, "ship", { runId: "run_keys" });
await expect(runExternalEffect(home, identity.projectId, run.id, "constructor", async () => null))
.rejects.toThrow("Invalid external effect key");
const commonPrefix = `publish.${"a".repeat(110)}`;
const first = await runExternalEffect(home, identity.projectId, run.id, `${commonPrefix}x`, async () => "first");
const second = await runExternalEffect(home, identity.projectId, run.id, `${commonPrefix}y`, async () => "second");
expect(first.idempotencyKey).toMatch(/^gstack_[0-9a-f]{64}$/);
expect(second.idempotencyKey).toMatch(/^gstack_[0-9a-f]{64}$/);
expect(first.idempotencyKey).not.toBe(second.idempotencyKey);
await expect(markEffectNotApplied(home, identity.projectId, run.id, `${commonPrefix}x`))
.rejects.toMatchObject({ code: "EFFECT_NOT_UNCERTAIN" });
});
test("decision provenance fields cannot be overwritten by caller input", async () => {
const root = await temporaryRoot();
const home = path.join(root, "state");
const identity = identityFromPaths({
worktreeRoot: root,
commonDir: path.join(root, ".git"),
gitDir: path.join(root, ".git"),
});
await initializeProject(home, identity);
const record = await appendDecision(home, identity.projectId, {
id: "forged",
at: "1900-01-01T00:00:00.000Z",
decision: "keep scope",
}, { now: () => new Date("2026-07-16T12:00:00.000Z") });
expect(record.id).not.toBe("forged");
expect(record.at).toBe("2026-07-16T12:00:00.000Z");
});
test("repo identity is shared while linked worktree identity is stable and distinct", async () => {
const root = await temporaryRoot("gstack2 worktree identity ");
const repo = path.join(root, "main repo");
const linked = path.join(root, "linked worktree");
await fs.mkdir(repo);
const git = (args: string[], cwd = repo) => spawnSync("git", args, { cwd, encoding: "utf8" });
expect(git(["init", "--quiet", "-b", "main"]).status).toBe(0);
git(["config", "user.email", "runtime@example.com"]);
git(["config", "user.name", "Runtime Test"]);
await fs.writeFile(path.join(repo, "README.md"), "runtime\n");
git(["add", "README.md"]);
expect(git(["commit", "--quiet", "-m", "initial"]).status).toBe(0);
expect(git(["worktree", "add", "--quiet", "-b", "linked", linked]).status).toBe(0);
const mainIdentity = await discoverProjectIdentity(repo);
const linkedIdentity = await discoverProjectIdentity(linked);
expect(linkedIdentity.repoId).toBe(mainIdentity.repoId);
expect(linkedIdentity.worktreeId).not.toBe(mainIdentity.worktreeId);
expect((await discoverProjectIdentity(linked)).worktreeId).toBe(linkedIdentity.worktreeId);
expect(linkedIdentity.projectId).not.toBe(mainIdentity.projectId);
});
test("linked-worktree identity survives a checkout move and git infrastructure failures do not become non-git", async () => {
const root = await temporaryRoot();
const common = path.join(root, "repo", ".git");
const gitDir = path.join(common, "worktrees", "feature");
const before = identityFromPaths({ worktreeRoot: path.join(root, "old checkout"), commonDir: common, gitDir });
const after = identityFromPaths({ worktreeRoot: path.join(root, "new checkout"), commonDir: common, gitDir });
expect(after.repoId).toBe(before.repoId);
expect(after.worktreeId).toBe(before.worktreeId);
expect(after.projectId).toBe(before.projectId);
const notGit = Object.assign(new Error("fatal: not a git repository"), {
code: 128,
stderr: "fatal: not a git repository",
});
const folder = await fs.mkdtemp(path.join(root, "plain "));
expect((await discoverProjectIdentity(folder, { git: async () => { throw notGit; } })).isGit).toBe(false);
const timeout = Object.assign(new Error("git timed out"), { code: "ETIMEDOUT" });
await expect(discoverProjectIdentity(folder, { git: async () => { throw timeout; } }))
.rejects.toMatchObject({ code: "ETIMEDOUT" });
});
test("setup reports a read-only destination where the platform enforces modes", async () => {
if (process.platform === "win32" || process.getuid?.() === 0) return;
const root = await temporaryRoot();
const readOnly = path.join(root, "read-only");
await fs.mkdir(readOnly, { mode: 0o500 });
await fs.chmod(readOnly, 0o500);
try {
let failed = false;
try {
await setupRuntime({ home: path.join(readOnly, "state"), cwd: root });
} catch (error: any) {
failed = ["EACCES", "EPERM", "EROFS"].includes(error?.code);
}
expect(failed).toBe(true);
} finally {
await fs.chmod(readOnly, 0o700);
}
});
});