Skip to content
File

Blob: src/worker/durable/run-do/state.ts

typescript108 lines
1import { type RunStatus, type StepStatus } from "@/contracts";
2import { isTerminalStatus, type RunMetaState, type UpdateRunStateInput } from "@/worker/contracts";
3 
4export type RunStateTransitionConflictReason = "already_terminal" | "invalid_transition";
5 
6export class RunStateTransitionError extends Error {
7 readonly reason: RunStateTransitionConflictReason;
8 readonly current: RunStatus;
9 readonly next: RunStatus;
10 
11 constructor(reason: RunStateTransitionConflictReason, current: RunStatus, next: RunStatus) {
12 super(
13 reason === "already_terminal"
14 ? `Run is already terminal in status ${current}.`
15 : `Invalid run status transition ${current} -> ${next}.`,
16 );
17 
18 this.name = "RunStateTransitionError";
19 this.reason = reason;
20 this.current = current;
21 this.next = next;
22 }
23}
24 
25export const RUN_TRANSITIONS: Record<RunStatus, RunStatus[]> = {
26 queued: ["starting", "failed", "canceled"],
27 starting: ["running", "failed", "cancel_requested"],
28 running: ["passed", "failed", "cancel_requested"],
29 cancel_requested: ["canceling", "canceled"],
30 canceling: ["canceled", "failed"],
31 passed: [],
32 failed: [],
33 canceled: [],
34};
35 
36export const STEP_TRANSITIONS: Record<StepStatus, StepStatus[]> = {
37 queued: ["running", "failed"],
38 running: ["passed", "failed"],
39 passed: [],
40 failed: [],
41};
42 
43export interface ResolvedRunStateUpdate {
44 status: RunStatus;
45 currentStep: UpdateRunStateInput["currentStep"];
46 startedAt: RunMetaState["startedAt"];
47 finishedAt: RunMetaState["finishedAt"];
48 exitCode: UpdateRunStateInput["exitCode"];
49 errorMessage: UpdateRunStateInput["errorMessage"];
50}
51 
52export const assertRunTransition = (current: RunStatus, next: RunStatus): void => {
53 if (current === next) {
54 if (isTerminalStatus(current)) {
55 throw new RunStateTransitionError("already_terminal", current, next);
56 }
57 
58 return;
59 }
60 
61 if (!RUN_TRANSITIONS[current].includes(next)) {
62 throw new RunStateTransitionError("invalid_transition", current, next);
63 }
64};
65 
66export const assertStepTransition = (current: StepStatus, next: StepStatus): void => {
67 if (current === next) {
68 if (current === "passed" || current === "failed") {
69 throw new Error(`Step is already terminal in status ${current}.`);
70 }
71 
72 return;
73 }
74 
75 if (!STEP_TRANSITIONS[current].includes(next)) {
76 throw new Error(`Invalid step status transition ${current} -> ${next}.`);
77 }
78};
79 
80export const resolveRunStateUpdate = (current: RunMetaState, payload: UpdateRunStateInput): ResolvedRunStateUpdate => {
81 assertRunTransition(current.status, payload.status);
82 
83 const startedAt = payload.startedAt ?? current.startedAt;
84 const requiresStartedAt =
85 payload.status === "starting" ||
86 payload.status === "running" ||
87 payload.status === "cancel_requested" ||
88 payload.status === "canceling" ||
89 payload.status === "passed";
90 if (requiresStartedAt && startedAt === null) {
91 throw new Error(`Run ${payload.runId} cannot enter ${payload.status} without startedAt.`);
92 }
93 
94 const finishedAt = payload.finishedAt ?? current.finishedAt;
95 if (isTerminalStatus(payload.status) && finishedAt === null) {
96 throw new Error(`Run ${payload.runId} cannot enter terminal state ${payload.status} without finishedAt.`);
97 }
98 
99 return {
100 status: payload.status,
101 currentStep: payload.currentStep,
102 startedAt,
103 finishedAt,
104 exitCode: payload.exitCode,
105 errorMessage: payload.errorMessage,
106 };
107};