mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 12:20:48 +02:00
327 lines
15 KiB
TypeScript
327 lines
15 KiB
TypeScript
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 { pathToFileURL } from "node:url";
|
|
import { main } from "../runtime/cli.js";
|
|
import {
|
|
beginRun,
|
|
completeRun,
|
|
identityFromPaths,
|
|
initializeProject,
|
|
inspectRun,
|
|
resumeRun,
|
|
runExternalEffect,
|
|
updateRunWorkflow,
|
|
} from "../runtime/index.js";
|
|
|
|
const temporaryRoots: string[] = [];
|
|
|
|
function sink() {
|
|
let value = "";
|
|
return {
|
|
write(chunk: unknown) { value += Buffer.from(chunk as any).toString("utf8"); },
|
|
value() { return value; },
|
|
};
|
|
}
|
|
|
|
async function fixture(label = "gstack workflow state ", initialize = true) {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), label));
|
|
temporaryRoots.push(root);
|
|
const home = path.join(root, "home");
|
|
const cwd = path.join(root, "project");
|
|
await fs.mkdir(cwd);
|
|
const identity = identityFromPaths({
|
|
worktreeRoot: cwd,
|
|
commonDir: path.join(cwd, ".git"),
|
|
gitDir: path.join(cwd, ".git"),
|
|
});
|
|
if (initialize) await initializeProject(home, identity);
|
|
return { root, home, cwd, identity, env: { ...process.env, GSTACK_HOME: home } };
|
|
}
|
|
|
|
afterEach(async () => {
|
|
for (const root of temporaryRoots.splice(0)) {
|
|
await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
}
|
|
});
|
|
|
|
describe("GStack 2 authoritative workflow state", () => {
|
|
test("begin, update, inspect, and resume reconstruct the complete workflow contract", async () => {
|
|
const { home, identity } = await fixture();
|
|
const started = await beginRun(home, identity.projectId, "plan", {
|
|
runId: "run_authoritative",
|
|
originalGoal: "Ship GStack 2 without weakening specialist judgment",
|
|
currentPlanPointer: "plans/gstack-2.md",
|
|
currentWorkflowStage: "engineering",
|
|
selectedDepth: "deep",
|
|
mutationAuthority: "plan-only",
|
|
activeModules: ["plan-eng-review", "plan-devex-review"],
|
|
now: () => new Date("2026-07-16T10:00:00.000Z"),
|
|
});
|
|
expect(started.reconstruction).toMatchObject({
|
|
currentPlan: { runId: "run_authoritative", pointer: "plans/gstack-2.md" },
|
|
originalGoal: "Ship GStack 2 without weakening specialist judgment",
|
|
currentGoal: "Ship GStack 2 without weakening specialist judgment",
|
|
detourStack: [],
|
|
currentWorkflowStage: "engineering",
|
|
selectedDepth: "deep",
|
|
mutationAuthority: "plan-only",
|
|
activeModules: ["plan-eng-review", "plan-devex-review"],
|
|
evidenceFreshness: { status: "unknown", assessedAt: null },
|
|
evidenceProvenance: [],
|
|
pendingApprovalGates: [],
|
|
});
|
|
|
|
await updateRunWorkflow(home, identity.projectId, started.run.id, {
|
|
currentWorkflowStage: "debugging",
|
|
mutationAuthority: "investigate-only",
|
|
activeModules: ["investigate"],
|
|
pushDetour: "Prove the crash-resume failure before changing code",
|
|
addEvidenceProvenance: {
|
|
source: "local-test",
|
|
reference: "test/gstack2-runtime-workflow-state.test.ts",
|
|
capturedAt: "2026-07-16T10:04:00.000Z",
|
|
},
|
|
evidenceFreshness: "fresh",
|
|
addApprovalGate: {
|
|
id: "approve-fix",
|
|
summary: "User must authorize product-code mutation",
|
|
},
|
|
}, { now: () => new Date("2026-07-16T10:05:00.000Z") });
|
|
|
|
const inspected = await inspectRun(home, identity.projectId, started.run.id);
|
|
expect(inspected.reconstruction).toMatchObject({
|
|
currentPlan: { runId: "run_authoritative", pointer: "plans/gstack-2.md" },
|
|
originalGoal: "Ship GStack 2 without weakening specialist judgment",
|
|
currentGoal: "Prove the crash-resume failure before changing code",
|
|
currentWorkflowStage: "debugging",
|
|
selectedDepth: "deep",
|
|
mutationAuthority: "investigate-only",
|
|
activeModules: ["investigate"],
|
|
evidenceFreshness: { status: "fresh", assessedAt: "2026-07-16T10:05:00.000Z" },
|
|
pendingApprovalGates: [{ id: "approve-fix", summary: "User must authorize product-code mutation" }],
|
|
});
|
|
expect(inspected.reconstruction.detourStack).toEqual([{
|
|
goal: "Prove the crash-resume failure before changing code",
|
|
fromStage: "engineering",
|
|
enteredAt: "2026-07-16T10:05:00.000Z",
|
|
}]);
|
|
expect(inspected.reconstruction.evidenceProvenance).toEqual([{
|
|
source: "local-test",
|
|
reference: "test/gstack2-runtime-workflow-state.test.ts",
|
|
capturedAt: "2026-07-16T10:04:00.000Z",
|
|
recordedAt: "2026-07-16T10:05:00.000Z",
|
|
}]);
|
|
|
|
await expect(runExternalEffect(home, identity.projectId, started.run.id, "git.push", async () => "pushed"))
|
|
.rejects.toMatchObject({ code: "APPROVAL_REQUIRED" });
|
|
await expect(completeRun(home, identity.projectId, started.run.id))
|
|
.rejects.toMatchObject({ code: "APPROVAL_GATES_PENDING" });
|
|
|
|
await updateRunWorkflow(home, identity.projectId, started.run.id, {
|
|
resolveApprovalGate: "approve-fix",
|
|
popDetour: true,
|
|
currentWorkflowStage: "implementation",
|
|
mutationAuthority: "fix-safe",
|
|
activeModules: ["investigate", "review"],
|
|
});
|
|
const other = await beginRun(home, identity.projectId, "qa", {
|
|
runId: "run_other",
|
|
currentPlanPointer: "plans/other.md",
|
|
});
|
|
expect(other.state.currentPlan).toMatchObject({ runId: "run_other", pointer: "plans/other.md" });
|
|
await expect(updateRunWorkflow(home, identity.projectId, started.run.id, { currentWorkflowStage: "review" }))
|
|
.rejects.toMatchObject({ code: "RUN_NOT_ACTIVE" });
|
|
|
|
const resumed = await resumeRun(home, identity.projectId, started.run.id);
|
|
expect(resumed.reconstruction).toMatchObject({
|
|
isActive: true,
|
|
currentPlan: { runId: "run_authoritative", pointer: "plans/gstack-2.md" },
|
|
originalGoal: "Ship GStack 2 without weakening specialist judgment",
|
|
detourStack: [],
|
|
currentWorkflowStage: "implementation",
|
|
mutationAuthority: "fix-safe",
|
|
activeModules: ["investigate", "review"],
|
|
pendingApprovalGates: [],
|
|
});
|
|
});
|
|
|
|
test("all workflow mutations are locked and concurrent evidence writes are not lost", async () => {
|
|
const { home, identity } = await fixture();
|
|
await beginRun(home, identity.projectId, "qa", { runId: "run_concurrent" });
|
|
const updates = await Promise.allSettled(Array.from({ length: 24 }, (_, index) =>
|
|
updateRunWorkflow(home, identity.projectId, "run_concurrent", {
|
|
addEvidenceProvenance: {
|
|
source: "local-test",
|
|
reference: `evidence/case-${index}.json`,
|
|
capturedAt: `2026-07-16T10:${String(index).padStart(2, "0")}:00.000Z`,
|
|
},
|
|
})));
|
|
expect(updates.filter((result) => result.status === "rejected")).toEqual([]);
|
|
const inspected = await inspectRun(home, identity.projectId, "run_concurrent");
|
|
expect(inspected.reconstruction.evidenceProvenance).toHaveLength(24);
|
|
expect(new Set(inspected.reconstruction.evidenceProvenance.map((entry: any) => entry.reference)).size).toBe(24);
|
|
});
|
|
|
|
test("validation rejects schema confusion, prototype keys, and unsupported freshness claims", async () => {
|
|
const { home, identity } = await fixture();
|
|
await expect(beginRun(home, identity.projectId, "qa", {
|
|
runId: "run_bad_depth",
|
|
selectedDepth: "maximum",
|
|
})).rejects.toThrow("Invalid selected depth");
|
|
await expect(beginRun(home, identity.projectId, "qa", {
|
|
runId: "run_bad_module",
|
|
activeModules: ["constructor"],
|
|
})).rejects.toThrow("Invalid active module");
|
|
await expect(beginRun(home, identity.projectId, "qa", {
|
|
runId: "run_bad_authority",
|
|
mutationAuthority: "anything-goes",
|
|
})).rejects.toThrow("Unsupported mutation authority");
|
|
await beginRun(home, identity.projectId, "qa", { runId: "run_validation" });
|
|
|
|
const inherited = Object.create({ currentWorkflowStage: "review" });
|
|
await expect(updateRunWorkflow(home, identity.projectId, "run_validation", inherited))
|
|
.rejects.toThrow("Invalid workflow transition");
|
|
const prototypeKey = JSON.parse('{"__proto__":{"polluted":true}}');
|
|
await expect(updateRunWorkflow(home, identity.projectId, "run_validation", prototypeKey))
|
|
.rejects.toThrow("Unknown workflow transition field");
|
|
await expect(updateRunWorkflow(home, identity.projectId, "run_validation", {
|
|
originalGoal: "replace the immutable goal",
|
|
} as any)).rejects.toThrow("Unknown workflow transition field");
|
|
await expect(updateRunWorkflow(home, identity.projectId, "run_validation", {
|
|
evidenceFreshness: "fresh",
|
|
})).rejects.toThrow("Fresh evidence requires provenance");
|
|
|
|
const inspected = await inspectRun(home, identity.projectId, "run_validation");
|
|
expect(inspected.reconstruction.originalGoal).toBe("qa");
|
|
expect(inspected.reconstruction.evidenceFreshness).toEqual({ status: "unknown", assessedAt: null });
|
|
expect(({} as any).polluted).toBeUndefined();
|
|
|
|
await beginRun(home, identity.projectId, "review", {
|
|
runId: "run_report_only",
|
|
mutationAuthority: "report-only",
|
|
});
|
|
await expect(runExternalEffect(home, identity.projectId, "run_report_only", "git.push", async () => "pushed"))
|
|
.rejects.toMatchObject({ code: "MUTATION_NOT_AUTHORIZED" });
|
|
});
|
|
|
|
test("the CLI persists and reconstructs every workflow field across independent invocations", async () => {
|
|
const { cwd, env } = await fixture("gstack workflow CLI ", false);
|
|
const beginOut = sink();
|
|
const beginErr = sink();
|
|
expect(await main([
|
|
"state", "begin", "review", "--run-id", "run_cli_state", "--json",
|
|
"--goal", "Review the release boundary", "--plan", "plans/release.md",
|
|
"--stage", "triage", "--depth", "deep", "--mutation", "report-only",
|
|
"--modules", "review,cso",
|
|
], { cwd, env, stdout: beginOut, stderr: beginErr })).toBe(0);
|
|
expect(JSON.parse(beginOut.value()).reconstruction.originalGoal).toBe("Review the release boundary");
|
|
|
|
const updateOut = sink();
|
|
expect(await main([
|
|
"state", "update", "run_cli_state",
|
|
"--stage", "security-review", "--mutation", "investigate-only", "--modules", "cso",
|
|
"--push-detour", "Audit the credential boundary",
|
|
"--evidence-source", "local-test", "--evidence-reference", "evidence/security.json",
|
|
"--evidence-captured-at", "2026-07-16T12:00:00.000Z", "--evidence-freshness", "fresh",
|
|
"--add-approval", "approve-remediation", "--approval-summary", "Authorize remediation",
|
|
], { cwd, env, stdout: updateOut, stderr: sink() })).toBe(0);
|
|
expect(JSON.parse(updateOut.value()).reconstruction.pendingApprovalGates[0].id).toBe("approve-remediation");
|
|
|
|
const inspectOut = sink();
|
|
expect(await main(["state", "inspect", "run_cli_state", "--json"], {
|
|
cwd, env, stdout: inspectOut, stderr: sink(),
|
|
})).toBe(0);
|
|
const reconstructed = JSON.parse(inspectOut.value()).reconstruction;
|
|
expect(reconstructed).toMatchObject({
|
|
currentPlan: { runId: "run_cli_state", pointer: "plans/release.md" },
|
|
originalGoal: "Review the release boundary",
|
|
currentWorkflowStage: "security-review",
|
|
selectedDepth: "deep",
|
|
mutationAuthority: "investigate-only",
|
|
activeModules: ["cso"],
|
|
evidenceFreshness: { status: "fresh" },
|
|
pendingApprovalGates: [{ id: "approve-remediation", summary: "Authorize remediation" }],
|
|
});
|
|
expect(reconstructed.detourStack[0].goal).toBe("Audit the credential boundary");
|
|
expect(reconstructed.evidenceProvenance[0].reference).toBe("evidence/security.json");
|
|
|
|
const resumeOut = sink();
|
|
expect(await main(["state", "resume", "run_cli_state", "--json"], {
|
|
cwd, env, stdout: resumeOut, stderr: sink(),
|
|
})).toBe(0);
|
|
const resumed = JSON.parse(resumeOut.value()).reconstruction;
|
|
expect(resumed).toMatchObject({
|
|
originalGoal: reconstructed.originalGoal,
|
|
currentWorkflowStage: reconstructed.currentWorkflowStage,
|
|
selectedDepth: reconstructed.selectedDepth,
|
|
mutationAuthority: reconstructed.mutationAuthority,
|
|
activeModules: reconstructed.activeModules,
|
|
detourStack: reconstructed.detourStack,
|
|
evidenceFreshness: reconstructed.evidenceFreshness,
|
|
evidenceProvenance: reconstructed.evidenceProvenance,
|
|
pendingApprovalGates: reconstructed.pendingApprovalGates,
|
|
});
|
|
expect(resumed.currentPlan).toMatchObject({ runId: "run_cli_state", pointer: "plans/release.md" });
|
|
});
|
|
|
|
test("a completed transition survives abrupt process exit and old runs reconstruct safely", async () => {
|
|
const { home, cwd, identity } = await fixture("gstack workflow crash ");
|
|
const runtimeUrl = pathToFileURL(path.resolve(import.meta.dir, "../runtime/index.js")).href;
|
|
const script = `
|
|
const runtime = await import(${JSON.stringify(runtimeUrl)});
|
|
const identity = runtime.identityFromPaths(${JSON.stringify({
|
|
worktreeRoot: cwd,
|
|
commonDir: path.join(cwd, ".git"),
|
|
gitDir: path.join(cwd, ".git"),
|
|
})});
|
|
await runtime.beginRun(${JSON.stringify(home)}, identity.projectId, "ship", {
|
|
runId: "run_crash_metadata",
|
|
originalGoal: "Land only after approval",
|
|
currentPlanPointer: "plans/ship.md",
|
|
currentWorkflowStage: "preflight",
|
|
selectedDepth: "deep",
|
|
mutationAuthority: "commit-push-pr",
|
|
activeModules: ["ship"]
|
|
});
|
|
await runtime.updateRunWorkflow(${JSON.stringify(home)}, identity.projectId, "run_crash_metadata", {
|
|
addApprovalGate: { id: "approve-push", summary: "Approve the push" },
|
|
currentWorkflowStage: "awaiting-approval"
|
|
});
|
|
process.exit(23);
|
|
`;
|
|
const child = spawnSync(process.execPath, ["-e", script], { encoding: "utf8" });
|
|
expect(child.status).toBe(23);
|
|
const afterCrash = await inspectRun(home, identity.projectId, "run_crash_metadata");
|
|
expect(afterCrash.reconstruction).toMatchObject({
|
|
currentPlan: { pointer: "plans/ship.md" },
|
|
originalGoal: "Land only after approval",
|
|
currentWorkflowStage: "awaiting-approval",
|
|
selectedDepth: "deep",
|
|
mutationAuthority: "commit-push-pr",
|
|
activeModules: ["ship"],
|
|
pendingApprovalGates: [{ id: "approve-push", summary: "Approve the push" }],
|
|
});
|
|
|
|
const stateFile = afterCrash.paths.state;
|
|
const raw = JSON.parse(await fs.readFile(stateFile, "utf8"));
|
|
delete raw.runs.run_crash_metadata.workflow;
|
|
delete raw.currentPlan;
|
|
await fs.writeFile(stateFile, `${JSON.stringify(raw, null, 2)}\n`);
|
|
const legacy = await inspectRun(home, identity.projectId, "run_crash_metadata");
|
|
expect(legacy.reconstruction).toMatchObject({
|
|
originalGoal: "ship",
|
|
currentWorkflowStage: "initialized",
|
|
selectedDepth: "standard",
|
|
mutationAuthority: "source-defined",
|
|
evidenceFreshness: { status: "unknown", assessedAt: null },
|
|
pendingApprovalGates: [],
|
|
});
|
|
await resumeRun(home, identity.projectId, "run_crash_metadata");
|
|
const persisted = JSON.parse(await fs.readFile(stateFile, "utf8"));
|
|
expect(persisted.runs.run_crash_metadata.workflow.originalGoal).toBe("ship");
|
|
});
|
|
});
|