/* journal sweep — scan an operations directory for incomplete operations belonging to a mission and abandon them (ADR-0084). Do not implement step execution logic — that lives in runner.ts. Do not import mission-specific types — the sweep matches ops by a missionId string. ADR-0084: initial sweep — findIncompleteOperationsForMission, abandonIncompleteOperationsForMission. An op belongs to a mission when its op-started record carries the missionId, its opId contains the missionId, or the journal filename contains the missionId (filename inference covers records without mission attribution). RECOVERY-2026-10-04: excludeOpIds parameter — callers pass getActiveOpIds() so the sweep cannot abandon an operation that is still executing in this process (mission.close as a ship step was self-abandoning the ship journal, breaking --resume). 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. */ import path from "node:path"; import { readJournal } from "./jsonl.ts"; import { abandonOperation } from "./runner.ts"; import type { JournalRecord } from "./types.ts"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; export interface IncompleteOperationRef { opId: string; op: string; journalPath: string; } export interface OperationSweepResult { incomplete: IncompleteOperationRef[]; /** Basenames of journal files that could not be parsed (e.g. torn lines). */ unreadableFiles: string[]; } function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Boundary-aware containment check: `text` references `missionId` when the id * appears and is not immediately followed by an alphanumeric character — so * `m-006` matches `ship-m-006.jsonl` and `ship-m-006-retry.jsonl` but not * `ship-m-0060.jsonl`. */ export function referencesMissionId(text: string, missionId: string): boolean { return new RegExp(`${escapeRegExp(missionId)}(?![0-9A-Za-z])`).test(text); } /** * ADR-0084: find every incomplete operation in `operationsDir` that belongs to * `missionId`. An operation belongs to the mission when its op-started record * carries `missionId === missionId`, its opId contains the missionId, or the * journal filename contains the missionId (filename inference covers records * that lack mission attribution). */ export async function findIncompleteOperationsForMission( operationsDir: string, missionId: string, io: WorkspaceIO = createDefaultIO().io, excludeOpIds?: ReadonlySet, ): Promise { const result: OperationSweepResult = { incomplete: [], unreadableFiles: [] }; if (!(await io.exists(operationsDir))) { return result; } const files = (await io.readdir(operationsDir)).filter((f) => f.name.endsWith(".jsonl")); for (const file of files) { const journalPath = path.join(operationsDir, file.name); let records: JournalRecord[]; try { records = await readJournal(journalPath); } catch { result.unreadableFiles.push(file.name); continue; } const fileReferencesMission = referencesMissionId(file.name, missionId); const terminalOpIds = new Set( records.filter((r) => r.kind === "op-done" || r.kind === "op-abandoned").map((r) => r.opId), ); for (const rec of records) { if (rec.kind !== "op-started") continue; const belongsToMission = rec.missionId === missionId || referencesMissionId(rec.opId, missionId) || fileReferencesMission; if (!belongsToMission || terminalOpIds.has(rec.opId) || excludeOpIds?.has(rec.opId)) continue; result.incomplete.push({ opId: rec.opId, op: rec.op, journalPath }); } } return result; } /** * ADR-0084: append `op-abandoned` for every incomplete operation belonging to * `missionId` in `operationsDir`. Uses the same abandonOperation mechanism as a * manual abandon; the recorded reason is `mission closed: `. */ export async function abandonIncompleteOperationsForMission( operationsDir: string, missionId: string, excludeOpIds?: ReadonlySet, ): Promise<{ abandoned: IncompleteOperationRef[]; unreadableFiles: string[] }> { const scan = await findIncompleteOperationsForMission( operationsDir, missionId, undefined, excludeOpIds, ); for (const op of scan.incomplete) { await abandonOperation(op.journalPath, op.opId, `mission closed: ${missionId}`); } return { abandoned: scan.incomplete, unreadableFiles: scan.unreadableFiles }; }