/*
Test RFC-0958: runOperation step runner — fresh run, resume, skip, fail semantics.
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-1311: Compass header sweep — declares the runner suite's contract for test-file scaffold coverage.
*/
import { test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import {
runOperation,
readJournal,
abandonOperation,
appendRecord,
getActiveOpIds,
} from "../runner.ts";
import type { OperationDefinition } from "../types.ts";
interface TestCtx {
log: string[];
crashAtStep?: number;
crashTriggered: boolean;
}
function makeStep(name: string, ctx: TestCtx, opts?: { verify?: () => Promise }) {
return {
name,
async run(c: TestCtx) {
if (c.crashAtStep !== undefined && c.log.length === c.crashAtStep && !c.crashTriggered) {
c.crashTriggered = true;
throw new Error(`Simulated crash at step ${name}`);
}
c.log.push(name);
},
verify: opts?.verify,
};
}
let tmpDir: string;
let journalPath: string;
beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "journal-runner-"));
journalPath = path.join(tmpDir, "journal.jsonl");
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
test("fresh run completes all steps in order", async () => {
const ctx: TestCtx = { log: [], crashTriggered: false };
const def: OperationDefinition = {
op: "test.op",
steps: [makeStep("s0", ctx), makeStep("s1", ctx), makeStep("s2", ctx)],
};
const result = await runOperation(journalPath, def, ctx, { missionId: "m-001" });
expect(result.completed).toBe(true);
expect(result.executed).toEqual(["s0", "s1", "s2"]);
expect(result.skipped).toEqual([]);
expect(ctx.log).toEqual(["s0", "s1", "s2"]);
const records = await readJournal(journalPath);
const kinds = records.map((r) => r.kind);
expect(kinds).toContain("op-started");
expect(kinds).toContain("op-done");
});
test("fresh run skips steps where verify returns true (already-satisfied)", async () => {
const ctx: TestCtx = { log: [], crashTriggered: false };
const def: OperationDefinition = {
op: "test.op",
steps: [makeStep("s0", ctx, { verify: async () => true }), makeStep("s1", ctx)],
};
const result = await runOperation(journalPath, def, ctx);
expect(result.completed).toBe(true);
expect(result.skipped).toEqual(["s0"]);
expect(result.executed).toEqual(["s1"]);
expect(ctx.log).toEqual(["s1"]);
const records = await readJournal(journalPath);
const skipRecord = records.find((r) => r.kind === "step-skipped");
expect(skipRecord).toBeDefined();
if (skipRecord && skipRecord.kind === "step-skipped") {
expect(skipRecord.reason).toBe("already-satisfied");
}
});
test("step failure stops operation and records failedStep", async () => {
const ctx: TestCtx = { log: [], crashAtStep: 1, crashTriggered: false };
const def: OperationDefinition = {
op: "test.op",
steps: [makeStep("s0", ctx), makeStep("s1", ctx), makeStep("s2", ctx)],
};
const result = await runOperation(journalPath, def, ctx);
expect(result.completed).toBe(false);
expect(result.failedStep).toBe("s1");
expect(result.executed).toEqual(["s0"]);
expect(ctx.log).toEqual(["s0"]);
const records = await readJournal(journalPath);
const failRecord = records.find((r) => r.kind === "step-failed");
expect(failRecord).toBeDefined();
const doneRecords = records.filter((r) => r.kind === "op-done");
expect(doneRecords).toHaveLength(0);
});
test("resume skips done steps and re-runs crash-point step when verify returns false", async () => {
const ctx: TestCtx = { log: [], crashAtStep: 1, crashTriggered: false };
const def: OperationDefinition = {
op: "test.op",
steps: [makeStep("s0", ctx), makeStep("s1", ctx), makeStep("s2", ctx)],
};
const firstResult = await runOperation(journalPath, def, ctx);
expect(firstResult.completed).toBe(false);
expect(firstResult.failedStep).toBe("s1");
ctx.crashAtStep = undefined;
ctx.crashTriggered = false;
ctx.log = [];
const resumeResult = await runOperation(journalPath, def, ctx, {
resumeOpId: firstResult.opId,
});
expect(resumeResult.completed).toBe(true);
expect(resumeResult.skipped).toContain("s0");
expect(resumeResult.executed).toContain("s1");
expect(resumeResult.executed).toContain("s2");
});
test("resume skips crash-point step when verify returns true", async () => {
const ctx: TestCtx = { log: [], crashTriggered: false };
const def: OperationDefinition = {
op: "test.op",
steps: [
makeStep("s0", ctx),
makeStep("s1", ctx, { verify: async () => true }),
makeStep("s2", ctx),
],
};
const opId = "test-op-crash-verify";
await appendRecord(journalPath, {
kind: "op-started",
opId,
op: "test.op",
missionId: "m-001",
at: "2026-08-27T00:00:00.000Z",
platformVersion: "1.0.0",
});
await appendRecord(journalPath, { kind: "step-started", opId, step: "s0", seq: 0, at: "t1" });
await appendRecord(journalPath, { kind: "step-done", opId, step: "s0", seq: 0, at: "t2" });
await appendRecord(journalPath, { kind: "step-started", opId, step: "s1", seq: 1, at: "t3" });
const resumeResult = await runOperation(journalPath, def, ctx, { resumeOpId: opId });
expect(resumeResult.completed).toBe(true);
expect(resumeResult.skipped).toContain("s0");
expect(resumeResult.skipped).toContain("s1");
expect(resumeResult.executed).toContain("s2");
expect(ctx.log).toEqual(["s2"]);
});
test("resume with no incomplete operation starts fresh", async () => {
const ctx: TestCtx = { log: [], crashTriggered: false };
const def: OperationDefinition = {
op: "test.op",
steps: [makeStep("s0", ctx)],
};
const result = await runOperation(journalPath, def, ctx, { resumeOpId: "nonexistent-op" });
expect(result.completed).toBe(true);
expect(result.executed).toEqual(["s0"]);
});
test("verify after run failure records step-failed", async () => {
const ctx: TestCtx = { log: [], crashTriggered: false };
const def: OperationDefinition = {
op: "test.op",
steps: [
{
name: "s0",
async run(c: TestCtx) {
c.log.push("s0");
},
verify: async () => false,
},
],
};
const result = await runOperation(journalPath, def, ctx);
expect(result.completed).toBe(false);
expect(result.failedStep).toBe("s0");
const records = await readJournal(journalPath);
const failRecord = records.find((r) => r.kind === "step-failed");
expect(failRecord).toBeDefined();
if (failRecord && failRecord.kind === "step-failed") {
expect(failRecord.error).toContain("verify returned false");
}
});
test("abandonOperation appends op-abandoned record", async () => {
const ctx: TestCtx = { log: [], crashTriggered: false };
const def: OperationDefinition = {
op: "test.op",
steps: [makeStep("s0", ctx)],
};
const result = await runOperation(journalPath, def, ctx);
await abandonOperation(journalPath, result.opId, "manual abort");
const records = await readJournal(journalPath);
const abandoned = records.find((r) => r.kind === "op-abandoned");
expect(abandoned).toBeDefined();
if (abandoned && abandoned.kind === "op-abandoned") {
expect(abandoned.reason).toBe("manual abort");
}
});
test("resume matches done steps by name, surviving plan shape shifts (seq re-indexing)", async () => {
// Regression (2026-10-04): buildStepState keyed done state on numeric seq —
// a journal produced by a plan whose early steps were later filtered out
// (buildShipPlan drops 3 lifecycle steps for a closed mission) mapped done
// seqs onto different steps and would skip never-executed work (certify-dev
// at seq 7 became "done" because seq 7 in the shifted plan was a different
// step). Step names are stable across plan shapes.
const ctx: TestCtx = { log: [], crashAtStep: 4, crashTriggered: false };
const fullDef: OperationDefinition = {
op: "test.op",
steps: [
makeStep("preflight", ctx),
makeStep("mission-validate", ctx),
makeStep("mission-reconcile", ctx),
makeStep("mission-close", ctx),
makeStep("certify-dev", ctx),
],
};
const firstResult = await runOperation(journalPath, fullDef, ctx, { missionId: "m-001" });
expect(firstResult.completed).toBe(false);
expect(firstResult.failedStep).toBe("certify-dev");
ctx.crashAtStep = undefined;
ctx.crashTriggered = false;
ctx.log = [];
// Closed-mission plan shape: lifecycle steps filtered out — certify-dev
// shifted from seq 4 to seq 1, and seq-keyed "done" state would skip it.
const shiftedDef: OperationDefinition = {
op: "test.op",
steps: [makeStep("preflight", ctx), makeStep("certify-dev", ctx)],
};
const resumeResult = await runOperation(journalPath, shiftedDef, ctx, {
resumeOpId: firstResult.opId,
});
expect(resumeResult.completed).toBe(true);
expect(resumeResult.skipped).toEqual(["preflight"]);
expect(resumeResult.executed).toEqual(["certify-dev"]);
expect(ctx.log).toEqual(["certify-dev"]);
});
test("getActiveOpIds tracks the running op and clears it on completion", async () => {
let observed: string[] = [];
const ctx: TestCtx = { log: [], crashTriggered: false };
const def: OperationDefinition = {
op: "test.op",
steps: [
{
name: "s0",
async run(c: TestCtx) {
observed = [...getActiveOpIds()];
c.log.push("s0");
},
},
],
};
const result = await runOperation(journalPath, def, ctx);
expect(result.completed).toBe(true);
expect(observed).toEqual([result.opId]);
expect(getActiveOpIds().has(result.opId)).toBe(false);
});
test("getActiveOpIds clears the opId even when the run fails", async () => {
const ctx: TestCtx = { log: [], crashAtStep: 0, crashTriggered: false };
const def: OperationDefinition = {
op: "test.op",
steps: [makeStep("s0", ctx)],
};
const result = await runOperation(journalPath, def, ctx);
expect(result.completed).toBe(false);
expect(getActiveOpIds().has(result.opId)).toBe(false);
});
test("same-kind incomplete operation auto-resumes without explicit resumeOpId", async () => {
const ctx: TestCtx = { log: [], crashAtStep: 0, crashTriggered: false };
const def: OperationDefinition = {
op: "test.op",
steps: [makeStep("s0", ctx), makeStep("s1", ctx)],
};
const firstResult = await runOperation(journalPath, def, ctx);
expect(firstResult.completed).toBe(false);
ctx.crashAtStep = undefined;
ctx.crashTriggered = false;
ctx.log = [];
const secondResult = await runOperation(journalPath, def, ctx);
expect(secondResult.completed).toBe(true);
expect(secondResult.opId).toBe(firstResult.opId);
expect(secondResult.executed).toEqual(["s0", "s1"]);
});