/** * apps/worker — automation run state machine (TASK-0113 §2). * * The ONLY place run-status transitions are validated. `canTransition` is the * pure predicate; `assertTransition` throws on an invalid move (a corrupt * update from a bug or a concurrent writer must be loud, not silent). * * Valid transitions: * PENDING → RUNNING (processor start, startedAt = now) * RUNNING → SUCCEEDED (terminal: every action done / skipped) * RUNNING → FAILED (fatal error, or a retryable error recorded * before the retry decision) * FAILED → PENDING (retry: same runId, retryCount + 1, < cap) * FAILED → DEAD_LETTER (terminal: retryCount reached the cap) * PENDING → FAILED (watchdog: stale at the retry cap) * DEAD_LETTER → PENDING (manual retry via the API, retryCount = 0) * * SUCCEEDED / DEAD_LETTER are terminal (no outgoing edges). */ import type { AutomationRunStatus } from "@prisma/client"; export const TERMINAL: readonly AutomationRunStatus[] = ["SUCCEEDED", "DEAD_LETTER"] as const; const VALID: Record = { PENDING: ["RUNNING", "FAILED"], RUNNING: ["SUCCEEDED", "FAILED", "DEAD_LETTER"], FAILED: ["PENDING", "DEAD_LETTER"], SUCCEEDED: [], DEAD_LETTER: ["PENDING"] }; /** Pure transition predicate. */ export function canTransition(from: AutomationRunStatus, to: AutomationRunStatus): boolean { return (VALID[from] ?? []).includes(to); } /** Throw on an invalid transition (the single enforcement point). */ export function assertTransition(from: AutomationRunStatus, to: AutomationRunStatus): void { if (!canTransition(from, to)) { throw new Error(`invalid automation run transition: ${from} → ${to}`); } } export function isTerminal(status: AutomationRunStatus): boolean { return (TERMINAL as readonly string[]).includes(status); }