mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
add gstack 2 parity and lifecycle gates
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user