/* Test RFC-1219: op-interrupted marker, scan-all findIncompleteOperation, failed-step re-execution on resume. 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 rfc 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, appendRecord } from "../runner.ts"; import { findIncompleteOperation } from "../jsonl.ts"; import type { JournalRecord, OperationDefinition } from "../types.ts"; let tmpDir: string; let journalPath: string; beforeEach(() => { tmpDir = mkdtempSync(path.join(os.tmpdir(), "rfc-1219-")); journalPath = path.join(tmpDir, "journal.jsonl"); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); const rec = (r: JournalRecord) => appendRecord(journalPath, r); // AC-2: a step failure leaves the journal open with an op-interrupted marker // naming the failed step — non-terminal, so the op stays resumable. test("failed step appends op-interrupted and the op stays resumable", async () => { const def: OperationDefinition<{ log: string[] }> = { op: "test.op", steps: [ { name: "s0", run: async (c) => void c.log.push("s0") }, { name: "s1", run: async () => { throw new Error("boom"); }, }, { name: "s2", run: async (c) => void c.log.push("s2") }, ], }; const result = await runOperation(journalPath, def, { log: [] }, { missionId: "m-1" }); expect(result.completed).toBe(false); expect(result.failedStep).toBe("s1"); const records = await readJournal(journalPath); const interrupted = records.filter((r) => r.kind === "op-interrupted"); expect(interrupted).toHaveLength(1); expect(interrupted[0]).toMatchObject({ opId: result.opId, failedStep: "s1" }); expect(records.some((r) => r.kind === "op-done" && r.opId === result.opId)).toBe(false); const incomplete = findIncompleteOperation(records); expect(incomplete).not.toBeNull(); expect(incomplete?.opId).toBe(result.opId); expect(incomplete?.status).toBe("interrupted"); expect(incomplete?.failedStep).toBe("s1"); }); // AC-4 + AC-3: resume skips done steps and re-executes the failed step. test("resume skips done steps and re-executes the failed step", async () => { const log: string[] = []; let failOnce = true; const def: OperationDefinition<{ log: string[] }> = { op: "test.op", steps: [ { name: "s0", run: async (c) => void c.log.push("s0") }, { name: "s1", run: async (c) => { if (failOnce) { failOnce = false; throw new Error("boom"); } c.log.push("s1"); }, }, { name: "s2", run: async (c) => void c.log.push("s2") }, ], }; const ctx = { log }; const first = await runOperation(journalPath, def, ctx, { missionId: "m-1" }); expect(first.completed).toBe(false); expect(log).toEqual(["s0"]); const second = await runOperation(journalPath, def, ctx, { missionId: "m-1", resumeOpId: first.opId, }); expect(second.completed).toBe(true); expect(second.skipped).toEqual(["s0"]); expect(second.executed).toEqual(["s1", "s2"]); // s0 must NOT have re-run. expect(log).toEqual(["s0", "s1", "s2"]); }); // AC-6: a completed later op must not mask an earlier incomplete one. test("findIncompleteOperation scans newest-first and returns the earlier incomplete op", async () => { await rec({ kind: "op-started", opId: "op-a", op: "mission.reconcile", missionId: "m-1", at: "t0", platformVersion: "", }); await rec({ kind: "step-started", opId: "op-a", step: "s0", seq: 0, at: "t1" }); await rec({ kind: "step-failed", opId: "op-a", step: "s0", seq: 0, at: "t2", error: "boom", }); // A later, fully completed op (e.g. a manual re-run) must not close it. await rec({ kind: "op-started", opId: "op-b", op: "mission.validate", missionId: "m-1", at: "t3", platformVersion: "", }); await rec({ kind: "step-started", opId: "op-b", step: "v0", seq: 0, at: "t4" }); await rec({ kind: "step-done", opId: "op-b", step: "v0", seq: 0, at: "t5" }); await rec({ kind: "op-done", opId: "op-b", at: "t6" }); const incomplete = findIncompleteOperation(await readJournal(journalPath)); expect(incomplete?.opId).toBe("op-a"); expect(incomplete?.status).toBe("interrupted"); expect(incomplete?.failedStep).toBe("s0"); }); // AC-6: running status — open op without failure markers is "running". test("open op without failure records reports status running", async () => { await rec({ kind: "op-started", opId: "op-1", op: "test.op", missionId: "m-1", at: "t0", platformVersion: "", }); await rec({ kind: "step-started", opId: "op-1", step: "s0", seq: 0, at: "t1" }); const incomplete = findIncompleteOperation(await readJournal(journalPath)); expect(incomplete?.opId).toBe("op-1"); expect(incomplete?.status).toBe("running"); expect(incomplete?.failedStep).toBeUndefined(); }); // AC-7: legacy journal without op-interrupted — a step-failed-only op is // still interrupted/resumable; a completed journal reports nothing. test("legacy journals without op-interrupted stay resumable; completed ops return null", async () => { // Legacy shape: op-started + step records + step-failed, no marker. await rec({ kind: "op-started", opId: "op-legacy", op: "leitstand.ship", missionId: "m-1", at: "t0", platformVersion: "", }); await rec({ kind: "step-started", opId: "op-legacy", step: "preflight", seq: 0, at: "t1" }); await rec({ kind: "step-done", opId: "op-legacy", step: "preflight", seq: 0, at: "t2" }); await rec({ kind: "step-started", opId: "op-legacy", step: "reconcile", seq: 1, at: "t3" }); await rec({ kind: "step-failed", opId: "op-legacy", step: "reconcile", seq: 1, at: "t4", error: "boom", }); const legacy = findIncompleteOperation(await readJournal(journalPath)); expect(legacy?.opId).toBe("op-legacy"); expect(legacy?.status).toBe("interrupted"); // Complete it — nothing resumable afterwards. await rec({ kind: "op-done", opId: "op-legacy", at: "t9" }); expect(findIncompleteOperation(await readJournal(journalPath))).toBeNull(); }); // AC-7: op-interrupted does not close the op for abandon semantics — the // sweep-side terminal check (op-done|op-abandoned) is unchanged; assert the // marker alone doesn't satisfy findIncompleteOperation's terminal check. test("op-interrupted without op-started does not create a resumable op", async () => { await rec({ kind: "op-interrupted", opId: "op-orphan", at: "t0", failedStep: "s1" }); expect(findIncompleteOperation(await readJournal(journalPath))).toBeNull(); });