/*
RFC-0958 Step 8: Kill-test matrix for mission.close — verifies runOperation resume completes after simulated crash at each step.
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-0958: initial kill-test matrix for mission.close.
*/
import { test, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
import path from "node:path";
import os from "node:os";
vi.mock("../mission-io.ts", () => ({
readMissionManifest: vi.fn(),
writeMissionManifest: vi.fn(),
resolveMissionDir: vi.fn((workspaceRoot: string, missionId: string) =>
path.join(workspaceRoot, "missions", missionId),
),
}));
vi.mock("../../werkstatt/index.ts", () => ({
acquireLock: vi.fn(),
releaseLock: vi.fn(),
isLockStale: vi.fn().mockReturnValue(false),
removeStaleLock: vi.fn(),
readAllLocks: vi.fn().mockResolvedValue([]),
commitWerkstattSideEffects: vi.fn(),
}));
import { runOperation } from "../../journal/runner.ts";
import { appendRecord, readJournal, findIncompleteOperation } from "../../journal/jsonl.ts";
import type { OperationStep, OperationDefinition } from "../../journal/types.ts";
let tmpDir: string;
let missionDir: string;
let journalPath: string;
beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "kill-test-close-"));
missionDir = path.join(tmpDir, "missions", "m-kill");
journalPath = path.join(missionDir, "journal.jsonl");
mkdirSync(missionDir, { recursive: true });
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
vi.clearAllMocks();
});
/**
* Simulates a crash at a given step index by:
* 1. Running the operation up to (but not completing) the target step
* 2. Writing a step-started record without step-done (simulating SIGKILL)
* 3. Verifying findIncompleteOperation detects it
* 4. Calling runOperation again to resume
* 5. Verifying the operation completes successfully
*/
async function simulateCrashAndResume(
steps: OperationStep>[],
crashAtStep: number,
): Promise {
const ctx: Record = { calls: [] };
// Phase 1: Manually write journal records as if the operation ran and crashed
const opId = `op-kill-${crashAtStep}`;
await appendRecord(journalPath, {
kind: "op-started",
opId,
op: "mission.close",
missionId: "m-kill",
at: new Date().toISOString(),
platformVersion: "1.0.0",
});
// Complete all steps before the crash point
for (let i = 0; i < crashAtStep; i++) {
await appendRecord(journalPath, {
kind: "step-started",
opId,
step: steps[i].name,
seq: i,
at: new Date().toISOString(),
});
await appendRecord(journalPath, {
kind: "step-done",
opId,
step: steps[i].name,
seq: i,
at: new Date().toISOString(),
});
}
// Write step-started for the crash step (no step-done — simulates SIGKILL)
await appendRecord(journalPath, {
kind: "step-started",
opId,
step: steps[crashAtStep].name,
seq: crashAtStep,
at: new Date().toISOString(),
});
// Phase 2: Verify the journal shows the incomplete operation
const records = await readJournal(journalPath);
const incomplete = findIncompleteOperation(records);
expect(incomplete).not.toBeNull();
expect(incomplete!.op).toBe("mission.close");
expect(incomplete!.opId).toBe(opId);
// Phase 3: Resume the operation — runOperation should skip done steps and re-run crash step
const def: OperationDefinition> = { op: "mission.close", steps };
const result = await runOperation(journalPath, def, ctx, { resumeOpId: opId });
// Phase 4: Verify completion
expect(result.completed).toBe(true);
expect(result.failedStep).toBeUndefined();
expect(result.failedStepError).toBeUndefined();
// Crash step has verify=true, so it's skipped on resume (verify postcondition met)
// Steps after the crash point are executed
for (let i = crashAtStep + 1; i < steps.length; i++) {
expect(result.executed).toContain(steps[i].name);
}
// Crash step itself is skipped (verify returned true on resume)
expect(result.skipped).toContain(steps[crashAtStep].name);
}
// Build a set of mock steps that track execution in ctx.calls
function buildMockSteps(count: number): OperationStep>[] {
const steps: OperationStep>[] = [];
for (let i = 0; i < count; i++) {
steps.push({
name: `step-${i}`,
run: async (ctx: Record) => {
const calls = ctx.calls as string[];
calls.push(`step-${i}`);
},
verify: async () => true,
});
}
return steps;
}
test("kill-test: crash at step 0 (first step) — resume completes", async () => {
const steps = buildMockSteps(5);
await simulateCrashAndResume(steps, 0);
});
test("kill-test: crash at step 1 — resume skips step 0, completes remaining", async () => {
const steps = buildMockSteps(5);
await simulateCrashAndResume(steps, 1);
});
test("kill-test: crash at step 2 — resume skips done steps, completes remaining", async () => {
const steps = buildMockSteps(5);
await simulateCrashAndResume(steps, 2);
});
test("kill-test: crash at step 3 — resume skips done steps, completes remaining", async () => {
const steps = buildMockSteps(5);
await simulateCrashAndResume(steps, 3);
});
test("kill-test: crash at last step — resume skips all done, completes last", async () => {
const steps = buildMockSteps(5);
await simulateCrashAndResume(steps, 4);
});
test("kill-test: resume with no crash (all steps done) — skips all, completes", async () => {
const ctx: Record = { calls: [] };
const steps = buildMockSteps(3);
const opId = "op-nocrash";
await appendRecord(journalPath, {
kind: "op-started",
opId,
op: "mission.close",
missionId: "m-kill",
at: new Date().toISOString(),
platformVersion: "1.0.0",
});
for (let i = 0; i < steps.length; i++) {
await appendRecord(journalPath, {
kind: "step-started",
opId,
step: steps[i].name,
seq: i,
at: new Date().toISOString(),
});
await appendRecord(journalPath, {
kind: "step-done",
opId,
step: steps[i].name,
seq: i,
at: new Date().toISOString(),
});
}
// No op-done record — operation is still "incomplete"
const def: OperationDefinition> = { op: "mission.close", steps };
const result = await runOperation(journalPath, def, ctx, { resumeOpId: opId });
expect(result.completed).toBe(true);
expect(result.skipped).toHaveLength(3);
expect(result.executed).toHaveLength(0);
});
test("kill-test: crash step with verify=true on resume — skipped, not re-run", async () => {
const ctx: Record = { calls: [] };
const steps: OperationStep>[] = [
{
name: "step-0",
run: async (c) => {
(c.calls as string[]).push("step-0");
},
verify: async () => true,
},
{
name: "step-1",
run: async (c) => {
(c.calls as string[]).push("step-1");
},
verify: async () => true,
},
{
name: "step-2",
run: async (c) => {
(c.calls as string[]).push("step-2");
},
verify: async () => true,
},
];
const opId = "op-verify-skip";
await appendRecord(journalPath, {
kind: "op-started",
opId,
op: "mission.close",
missionId: "m-kill",
at: new Date().toISOString(),
platformVersion: "1.0.0",
});
// Step 0 done
await appendRecord(journalPath, { kind: "step-started", opId, step: "step-0", seq: 0, at: "t" });
await appendRecord(journalPath, { kind: "step-done", opId, step: "step-0", seq: 0, at: "t" });
// Step 1 started but not done (crash)
await appendRecord(journalPath, { kind: "step-started", opId, step: "step-1", seq: 1, at: "t" });
const def: OperationDefinition> = { op: "mission.close", steps };
const result = await runOperation(journalPath, def, ctx, { resumeOpId: opId });
expect(result.completed).toBe(true);
// step-0 skipped (done), step-1 skipped (verify=true on resume), step-2 executed
expect(result.skipped).toContain("step-0");
expect(result.skipped).toContain("step-1");
expect(result.executed).toContain("step-2");
});
test("kill-test: crash step with verify=false on resume — re-run", async () => {
const ctx: Record = { calls: [] };
const steps: OperationStep>[] = [
{
name: "step-0",
run: async (c) => {
(c.calls as string[]).push("step-0");
},
verify: async () => true,
},
{
name: "step-1",
run: async (c) => {
(c.calls as string[]).push("step-1");
},
// No verify — crash step must be re-run
},
{
name: "step-2",
run: async (c) => {
(c.calls as string[]).push("step-2");
},
verify: async () => true,
},
];
const opId = "op-verify-rerun";
await appendRecord(journalPath, {
kind: "op-started",
opId,
op: "mission.close",
missionId: "m-kill",
at: new Date().toISOString(),
platformVersion: "1.0.0",
});
await appendRecord(journalPath, { kind: "step-started", opId, step: "step-0", seq: 0, at: "t" });
await appendRecord(journalPath, { kind: "step-done", opId, step: "step-0", seq: 0, at: "t" });
await appendRecord(journalPath, { kind: "step-started", opId, step: "step-1", seq: 1, at: "t" });
const def: OperationDefinition> = { op: "mission.close", steps };
const result = await runOperation(journalPath, def, ctx, { resumeOpId: opId });
expect(result.completed).toBe(true);
expect(result.skipped).toContain("step-0");
expect(result.executed).toContain("step-1");
expect(result.executed).toContain("step-2");
});
test("kill-test: op-done after resume — no incomplete operation remains", async () => {
const ctx: Record = { calls: [] };
const steps = buildMockSteps(3);
const opId = "op-cleanup-check";
await appendRecord(journalPath, {
kind: "op-started",
opId,
op: "mission.close",
missionId: "m-kill",
at: new Date().toISOString(),
platformVersion: "1.0.0",
});
// Crash at step 1
await appendRecord(journalPath, { kind: "step-started", opId, step: "step-0", seq: 0, at: "t" });
await appendRecord(journalPath, { kind: "step-done", opId, step: "step-0", seq: 0, at: "t" });
await appendRecord(journalPath, { kind: "step-started", opId, step: "step-1", seq: 1, at: "t" });
const def: OperationDefinition> = { op: "mission.close", steps };
await runOperation(journalPath, def, ctx, { resumeOpId: opId });
// After resume, no incomplete operation should remain
const records = await readJournal(journalPath);
const incomplete = findIncompleteOperation(records);
expect(incomplete).toBeNull();
});