/*
Step runner for operation journal — executes OperationDefinitions with resume and skip semantics (RFC-0958).
- Do not implement mission-specific step logic — steps are defined in mission/steps/.
- Do not manage locks — the caller acquires and releases locks.
- RFC-1168: onTransition option on runOperation — per-record callback so callers can stream progress (stderr) without waiting for run completion.
- RFC-1168: ship stderr progress + leitstand.ship.status reader
- 2026-10-04 recovery fix: buildStepState keys done/crash tracking on step name instead of seq index — a resumed plan with filtered steps (e.g. buildShipPlan missionClosed drops 3 lifecycle steps) shifted every later seq and would skip unexecuted steps or re-run done ones; step names are stable across plan shapes. Also adds getActiveOpIds() — the in-process set of currently-running opIds — so mission.close's ADR-0084 sweep can exclude the journal of the very operation it is invoked from (observed: ship op self-abandoned mid-run, breaking --resume).
- RFC-1219: op-interrupted record appended on step failure — explicit non-terminal resumable marker; findIncompleteOperation scans all op-started newest→oldest (a completed later op no longer masks an earlier incomplete one).
- RFC-1190: restore mission context + evidence archival in release-mode deploys
RFC-1190: precedence-ordered resolveMissionContext (--mission → release build-identity → currentMission), MISSION_CONTEXT_UNRESOLVED/MISMATCH blocking, fail-closed EVIDENCE_SYNC_FAILED with --skip-evidence-sync as sole escape hatch, journaled evidence-sync-* ship steps after each certify-*, evidence.sync --site/--system flag.
2026-10-04 handoff fixes: shared 25min mission.check timeout (inner 5min race + 300s orchestrator raced cold ~7-11min captures), sweep excludeOpIds so mission.close no longer self-abandons the live ship journal (broke --resume), name-keyed journal resume state so closed-mission plan shifts can't skip unexecuted steps, wrangler dev documented as the required --base-url preview server.
RFC-0958, RFC-0962
*/
import { appendRecord, readJournal, findIncompleteOperation } from "./jsonl.ts";
import type {
JournalRecord,
OperationDefinition,
RunOperationResult,
StepResult,
} from "./types.ts";
export { readJournal, findIncompleteOperation };
export { appendRecord };
export { TornLineError } from "./jsonl.ts";
function nowIso(): string {
return new Date().toISOString();
}
function generateOpId(op: string): string {
const slug = op.replace(/[^a-z0-9]/gi, "-").toLowerCase();
const ts = new Date().toISOString().replace(/[:.]/g, "-");
return `${slug}-${ts}`;
}
interface StepJournalState {
doneSteps: Set;
crashStepName: string | null;
}
/**
* Reconstruct step state for resume — keyed on step NAME, not seq index.
* Plans may change shape between runs (e.g. buildShipPlan filters lifecycle
* steps for closed missions, shifting every later seq); names survive that.
* A step counts as crashed when its latest step-started has no following
* terminal record (step-done/step-failed/step-skipped) for the same name.
*/
function buildStepState(records: JournalRecord[], opId: string): StepJournalState {
const doneSteps = new Set();
const openStepNames: string[] = [];
for (const r of records) {
if (r.opId !== opId) continue;
if (r.kind === "step-started") {
openStepNames.push(r.step);
} else if (r.kind === "step-done" || r.kind === "step-failed" || r.kind === "step-skipped") {
if (r.kind === "step-done") doneSteps.add(r.step);
const i = openStepNames.lastIndexOf(r.step);
if (i !== -1) openStepNames.splice(i, 1);
}
}
return { doneSteps, crashStepName: openStepNames[openStepNames.length - 1] ?? null };
}
/**
* OpIds currently executing inside this process. The ADR-0084 mission sweep
* must skip these: a sweep invoked from inside a running operation (e.g.
* mission.close as a leitstand.ship step) must not append op-abandoned to the
* journal its caller is still writing to — that would mark the live op
* terminal and break --resume.
*/
const activeOpIds = new Set();
export function getActiveOpIds(): ReadonlySet {
return activeOpIds;
}
export async function runOperation(
journalPath: string,
def: OperationDefinition,
ctx: C,
opts?: {
resumeOpId?: string;
missionId?: string;
platformVersion?: string;
// RFC-1168: invoked after each record append — lets callers stream
// progress (e.g. stderr lines) while the operation is still running.
onTransition?: (record: JournalRecord) => void;
},
): Promise {
const onTransition = opts?.onTransition;
const append = async (record: JournalRecord): Promise => {
await appendRecord(journalPath, record);
try {
onTransition?.(record);
} catch {
// Progress hooks are informational — a failing emitter must not break
// the operation being journaled.
}
};
const existingRecords = await readJournal(journalPath);
const incomplete = findIncompleteOperation(existingRecords);
let opId: string;
let isResume = false;
let stepState: StepJournalState = { doneSteps: new Set(), crashStepName: null };
if (opts?.resumeOpId) {
opId = opts.resumeOpId;
isResume = true;
stepState = buildStepState(existingRecords, opId);
} else if (incomplete && incomplete.op === def.op) {
opId = incomplete.opId;
isResume = true;
stepState = buildStepState(existingRecords, opId);
} else {
opId = generateOpId(def.op);
await append({
kind: "op-started",
opId,
op: def.op,
missionId: opts?.missionId ?? "",
at: nowIso(),
platformVersion: opts?.platformVersion ?? "",
});
}
activeOpIds.add(opId);
const skipped: string[] = [];
const executed: string[] = [];
const stepResults: StepResult[] = [];
try {
for (let seq = 0; seq < def.steps.length; seq++) {
const step = def.steps[seq];
if (isResume && stepState.doneSteps.has(step.name)) {
skipped.push(step.name);
stepResults.push({ step: step.name, status: "skipped", durationMs: 0 });
continue;
}
if (isResume && stepState.crashStepName === step.name) {
if (step.verify) {
const verified = await step.verify(ctx);
if (verified) {
await append({
kind: "step-skipped",
opId,
step: step.name,
seq,
at: nowIso(),
reason: "resume",
});
skipped.push(step.name);
stepResults.push({ step: step.name, status: "skipped", durationMs: 0 });
continue;
}
}
} else if (!isResume && step.verify) {
const verified = await step.verify(ctx);
if (verified) {
await append({
kind: "step-skipped",
opId,
step: step.name,
seq,
at: nowIso(),
reason: "already-satisfied",
});
skipped.push(step.name);
stepResults.push({ step: step.name, status: "skipped", durationMs: 0 });
continue;
}
}
await append({
kind: "step-started",
opId,
step: step.name,
seq,
at: nowIso(),
});
const stepStart = Date.now();
let stepMeta: Record | undefined;
try {
const runResult = await step.run(ctx);
if (runResult && typeof runResult === "object") {
stepMeta = runResult;
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
const durationMs = Date.now() - stepStart;
await append({
kind: "step-failed",
opId,
step: step.name,
seq,
at: nowIso(),
error: errorMsg,
});
// RFC-1219: explicit interrupted marker — non-terminal, the op stays
// resumable; tooling (ship.status, journal.show) can distinguish a
// failed-resumable op from an in-flight/crashed one.
await append({
kind: "op-interrupted",
opId,
at: nowIso(),
failedStep: step.name,
});
stepResults.push({ step: step.name, status: "failed", durationMs });
return {
opId,
completed: false,
failedStep: step.name,
failedStepError: errorMsg,
skipped,
executed,
stepResults,
};
}
if (step.verify) {
const verified = await step.verify(ctx);
if (!verified) {
await append({
kind: "step-failed",
opId,
step: step.name,
seq,
at: nowIso(),
error: `verify returned false after run for step "${step.name}"`,
});
await append({
kind: "op-interrupted",
opId,
at: nowIso(),
failedStep: step.name,
});
stepResults.push({
step: step.name,
status: "failed",
durationMs: Date.now() - stepStart,
});
return {
opId,
completed: false,
failedStep: step.name,
failedStepError: `verify returned false after run for step "${step.name}"`,
skipped,
executed,
stepResults,
};
}
}
const durationMs = Date.now() - stepStart;
await append({
kind: "step-done",
opId,
step: step.name,
seq,
at: nowIso(),
...(stepMeta ? { meta: stepMeta } : {}),
});
executed.push(step.name);
stepResults.push({
step: step.name,
status: "done",
durationMs,
...(stepMeta ? { meta: stepMeta } : {}),
});
}
await append({
kind: "op-done",
opId,
at: nowIso(),
});
return {
opId,
completed: true,
skipped,
executed,
stepResults,
};
} finally {
activeOpIds.delete(opId);
}
}
export async function abandonOperation(
journalPath: string,
opId: string,
reason: string,
): Promise {
await appendRecord(journalPath, {
kind: "op-abandoned",
opId,
at: nowIso(),
reason,
});
}