Skip to main content

ironflow_engine/fsm/
run_fsm.rs

1//! [`RunFsm`] — Finite state machine for the run lifecycle.
2//!
3//! Events drive transitions; the FSM rejects invalid ones and keeps
4//! a full history of state changes.
5
6use std::fmt;
7
8use chrono::Utc;
9use ironflow_store::entities::RunStatus;
10use serde::{Deserialize, Serialize};
11
12use super::{Transition, TransitionError};
13
14/// Events that drive [`RunFsm`] transitions.
15///
16/// Each event represents something that happened during execution.
17///
18/// # Examples
19///
20/// ```
21/// use ironflow_engine::fsm::RunEvent;
22///
23/// let event = RunEvent::PickedUp;
24/// assert_eq!(event.to_string(), "picked_up");
25/// ```
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum RunEvent {
29    /// Worker or inline executor picked up the run.
30    PickedUp,
31    /// All steps completed successfully.
32    AllStepsCompleted,
33    /// A step failed and the error is not retryable, or max retries exhausted.
34    StepFailed,
35    /// A step failed but a retry is possible.
36    StepFailedRetryable,
37    /// Retry attempt started.
38    RetryStarted,
39    /// Maximum retries exhausted after a retryable failure.
40    MaxRetriesExceeded,
41    /// User or API requested cancellation.
42    CancelRequested,
43    /// A step requires human approval before continuing.
44    ApprovalRequested,
45    /// Human approved the run to continue.
46    Approved,
47    /// Human rejected the run.
48    Rejected,
49}
50
51impl fmt::Display for RunEvent {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            RunEvent::PickedUp => f.write_str("picked_up"),
55            RunEvent::AllStepsCompleted => f.write_str("all_steps_completed"),
56            RunEvent::StepFailed => f.write_str("step_failed"),
57            RunEvent::StepFailedRetryable => f.write_str("step_failed_retryable"),
58            RunEvent::RetryStarted => f.write_str("retry_started"),
59            RunEvent::MaxRetriesExceeded => f.write_str("max_retries_exceeded"),
60            RunEvent::CancelRequested => f.write_str("cancel_requested"),
61            RunEvent::ApprovalRequested => f.write_str("approval_requested"),
62            RunEvent::Approved => f.write_str("approved"),
63            RunEvent::Rejected => f.write_str("rejected"),
64        }
65    }
66}
67
68/// Finite state machine for a workflow run.
69///
70/// Wraps a [`RunStatus`] and enforces valid transitions via typed
71/// [`RunEvent`]s. Records every transition in a history log.
72///
73/// # Transition table
74///
75/// | From | Event | To |
76/// |------|-------|----|
77/// | Pending | PickedUp | Running |
78/// | Pending | CancelRequested | Cancelled |
79/// | Running | AllStepsCompleted | Completed |
80/// | Running | StepFailed | Failed |
81/// | Running | StepFailedRetryable | Retrying |
82/// | Running | CancelRequested | Cancelled |
83/// | Retrying | RetryStarted | Running |
84/// | Retrying | MaxRetriesExceeded | Failed |
85/// | Retrying | CancelRequested | Cancelled |
86/// | Running | ApprovalRequested | AwaitingApproval |
87/// | AwaitingApproval | Approved | Running |
88/// | AwaitingApproval | Rejected | Failed |
89/// | AwaitingApproval | CancelRequested | Cancelled |
90///
91/// # Examples
92///
93/// ```
94/// use ironflow_engine::fsm::{RunFsm, RunEvent};
95/// use ironflow_store::entities::RunStatus;
96///
97/// let mut fsm = RunFsm::new();
98/// assert_eq!(fsm.state(), RunStatus::Pending);
99///
100/// fsm.apply(RunEvent::PickedUp).unwrap();
101/// assert_eq!(fsm.state(), RunStatus::Running);
102///
103/// fsm.apply(RunEvent::AllStepsCompleted).unwrap();
104/// assert_eq!(fsm.state(), RunStatus::Completed);
105/// assert_eq!(fsm.history().len(), 2);
106/// ```
107#[derive(Debug, Clone)]
108pub struct RunFsm {
109    state: RunStatus,
110    history: Vec<Transition<RunStatus, RunEvent>>,
111}
112
113impl RunFsm {
114    /// Create a new FSM in `Pending` state.
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use ironflow_engine::fsm::RunFsm;
120    /// use ironflow_store::entities::RunStatus;
121    ///
122    /// let fsm = RunFsm::new();
123    /// assert_eq!(fsm.state(), RunStatus::Pending);
124    /// ```
125    pub fn new() -> Self {
126        Self {
127            state: RunStatus::Pending,
128            history: Vec::new(),
129        }
130    }
131
132    /// Create a FSM from an existing state (e.g. loaded from DB).
133    ///
134    /// # Examples
135    ///
136    /// ```
137    /// use ironflow_engine::fsm::RunFsm;
138    /// use ironflow_store::entities::RunStatus;
139    ///
140    /// let fsm = RunFsm::from_state(RunStatus::Running);
141    /// assert_eq!(fsm.state(), RunStatus::Running);
142    /// ```
143    pub fn from_state(state: RunStatus) -> Self {
144        Self {
145            state,
146            history: Vec::new(),
147        }
148    }
149
150    /// Returns the current state.
151    pub fn state(&self) -> RunStatus {
152        self.state
153    }
154
155    /// Returns the full transition history.
156    pub fn history(&self) -> &[Transition<RunStatus, RunEvent>] {
157        &self.history
158    }
159
160    /// Returns `true` if the FSM is in a terminal state.
161    pub fn is_terminal(&self) -> bool {
162        self.state.is_terminal()
163    }
164
165    /// Apply an event, transitioning to a new state if valid.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`TransitionError`] if the event is not allowed in the current state.
170    ///
171    /// # Examples
172    ///
173    /// ```
174    /// use ironflow_engine::fsm::{RunFsm, RunEvent};
175    /// use ironflow_store::entities::RunStatus;
176    ///
177    /// let mut fsm = RunFsm::new();
178    ///
179    /// // Valid transition
180    /// assert!(fsm.apply(RunEvent::PickedUp).is_ok());
181    ///
182    /// // Invalid: can't pick up a running run
183    /// assert!(fsm.apply(RunEvent::PickedUp).is_err());
184    /// ```
185    pub fn apply(
186        &mut self,
187        event: RunEvent,
188    ) -> Result<RunStatus, TransitionError<RunStatus, RunEvent>> {
189        let next = next_state(self.state, event).ok_or(TransitionError {
190            from: self.state,
191            event,
192        })?;
193
194        let transition = Transition {
195            from: self.state,
196            to: next,
197            event,
198            at: Utc::now(),
199        };
200
201        self.history.push(transition);
202        self.state = next;
203        Ok(next)
204    }
205
206    /// Check if an event would be accepted without applying it.
207    ///
208    /// # Examples
209    ///
210    /// ```
211    /// use ironflow_engine::fsm::{RunFsm, RunEvent};
212    ///
213    /// let fsm = RunFsm::new();
214    /// assert!(fsm.can_apply(RunEvent::PickedUp));
215    /// assert!(!fsm.can_apply(RunEvent::AllStepsCompleted));
216    /// ```
217    pub fn can_apply(&self, event: RunEvent) -> bool {
218        next_state(self.state, event).is_some()
219    }
220}
221
222impl Default for RunFsm {
223    fn default() -> Self {
224        Self::new()
225    }
226}
227
228/// Pure transition function — returns the next state for a given (state, event)
229/// pair, or `None` if the transition is invalid.
230fn next_state(from: RunStatus, event: RunEvent) -> Option<RunStatus> {
231    match (from, event) {
232        // Pending
233        (RunStatus::Pending, RunEvent::PickedUp) => Some(RunStatus::Running),
234        (RunStatus::Pending, RunEvent::CancelRequested) => Some(RunStatus::Cancelled),
235
236        // Running
237        (RunStatus::Running, RunEvent::AllStepsCompleted) => Some(RunStatus::Completed),
238        (RunStatus::Running, RunEvent::StepFailed) => Some(RunStatus::Failed),
239        (RunStatus::Running, RunEvent::StepFailedRetryable) => Some(RunStatus::Retrying),
240        (RunStatus::Running, RunEvent::CancelRequested) => Some(RunStatus::Cancelled),
241
242        // Retrying
243        (RunStatus::Retrying, RunEvent::RetryStarted) => Some(RunStatus::Running),
244        (RunStatus::Retrying, RunEvent::MaxRetriesExceeded) => Some(RunStatus::Failed),
245        (RunStatus::Retrying, RunEvent::CancelRequested) => Some(RunStatus::Cancelled),
246
247        // Approval
248        (RunStatus::Running, RunEvent::ApprovalRequested) => Some(RunStatus::AwaitingApproval),
249        (RunStatus::AwaitingApproval, RunEvent::Approved) => Some(RunStatus::Running),
250        (RunStatus::AwaitingApproval, RunEvent::Rejected) => Some(RunStatus::Failed),
251        (RunStatus::AwaitingApproval, RunEvent::CancelRequested) => Some(RunStatus::Cancelled),
252
253        // Terminal states and all other combos → invalid
254        _ => None,
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    // ---- Happy paths ----
263
264    #[test]
265    fn pending_to_running() {
266        let mut fsm = RunFsm::new();
267        let result = fsm.apply(RunEvent::PickedUp);
268        assert!(result.is_ok());
269        assert_eq!(fsm.state(), RunStatus::Running);
270    }
271
272    #[test]
273    fn full_success_path() {
274        let mut fsm = RunFsm::new();
275        fsm.apply(RunEvent::PickedUp).unwrap();
276        fsm.apply(RunEvent::AllStepsCompleted).unwrap();
277        assert_eq!(fsm.state(), RunStatus::Completed);
278        assert!(fsm.is_terminal());
279        assert_eq!(fsm.history().len(), 2);
280    }
281
282    #[test]
283    fn full_failure_path() {
284        let mut fsm = RunFsm::new();
285        fsm.apply(RunEvent::PickedUp).unwrap();
286        fsm.apply(RunEvent::StepFailed).unwrap();
287        assert_eq!(fsm.state(), RunStatus::Failed);
288        assert!(fsm.is_terminal());
289    }
290
291    #[test]
292    fn retry_then_success() {
293        let mut fsm = RunFsm::new();
294        fsm.apply(RunEvent::PickedUp).unwrap();
295        fsm.apply(RunEvent::StepFailedRetryable).unwrap();
296        assert_eq!(fsm.state(), RunStatus::Retrying);
297
298        fsm.apply(RunEvent::RetryStarted).unwrap();
299        assert_eq!(fsm.state(), RunStatus::Running);
300
301        fsm.apply(RunEvent::AllStepsCompleted).unwrap();
302        assert_eq!(fsm.state(), RunStatus::Completed);
303        assert_eq!(fsm.history().len(), 4);
304    }
305
306    #[test]
307    fn retry_then_max_retries_exceeded() {
308        let mut fsm = RunFsm::new();
309        fsm.apply(RunEvent::PickedUp).unwrap();
310        fsm.apply(RunEvent::StepFailedRetryable).unwrap();
311        fsm.apply(RunEvent::MaxRetriesExceeded).unwrap();
312        assert_eq!(fsm.state(), RunStatus::Failed);
313    }
314
315    #[test]
316    fn cancel_from_pending() {
317        let mut fsm = RunFsm::new();
318        fsm.apply(RunEvent::CancelRequested).unwrap();
319        assert_eq!(fsm.state(), RunStatus::Cancelled);
320        assert!(fsm.is_terminal());
321    }
322
323    #[test]
324    fn cancel_from_running() {
325        let mut fsm = RunFsm::new();
326        fsm.apply(RunEvent::PickedUp).unwrap();
327        fsm.apply(RunEvent::CancelRequested).unwrap();
328        assert_eq!(fsm.state(), RunStatus::Cancelled);
329    }
330
331    #[test]
332    fn cancel_from_retrying() {
333        let mut fsm = RunFsm::new();
334        fsm.apply(RunEvent::PickedUp).unwrap();
335        fsm.apply(RunEvent::StepFailedRetryable).unwrap();
336        fsm.apply(RunEvent::CancelRequested).unwrap();
337        assert_eq!(fsm.state(), RunStatus::Cancelled);
338    }
339
340    // ---- Invalid transitions ----
341
342    #[test]
343    fn cannot_complete_from_pending() {
344        let mut fsm = RunFsm::new();
345        let result = fsm.apply(RunEvent::AllStepsCompleted);
346        assert!(result.is_err());
347        assert_eq!(fsm.state(), RunStatus::Pending);
348    }
349
350    #[test]
351    fn cannot_pick_up_running() {
352        let mut fsm = RunFsm::new();
353        fsm.apply(RunEvent::PickedUp).unwrap();
354        let result = fsm.apply(RunEvent::PickedUp);
355        assert!(result.is_err());
356    }
357
358    #[test]
359    fn cannot_transition_from_terminal() {
360        let mut fsm = RunFsm::new();
361        fsm.apply(RunEvent::PickedUp).unwrap();
362        fsm.apply(RunEvent::AllStepsCompleted).unwrap();
363
364        assert!(fsm.apply(RunEvent::PickedUp).is_err());
365        assert!(fsm.apply(RunEvent::CancelRequested).is_err());
366        assert!(fsm.apply(RunEvent::StepFailed).is_err());
367    }
368
369    // ---- can_apply ----
370
371    #[test]
372    fn can_apply_checks_without_mutation() {
373        let fsm = RunFsm::new();
374        assert!(fsm.can_apply(RunEvent::PickedUp));
375        assert!(fsm.can_apply(RunEvent::CancelRequested));
376        assert!(!fsm.can_apply(RunEvent::AllStepsCompleted));
377        assert!(!fsm.can_apply(RunEvent::StepFailed));
378        assert_eq!(fsm.state(), RunStatus::Pending);
379    }
380
381    // ---- from_state ----
382
383    #[test]
384    fn from_state_resumes_at_given_state() {
385        let mut fsm = RunFsm::from_state(RunStatus::Running);
386        assert_eq!(fsm.state(), RunStatus::Running);
387        assert!(fsm.history().is_empty());
388
389        fsm.apply(RunEvent::AllStepsCompleted).unwrap();
390        assert_eq!(fsm.state(), RunStatus::Completed);
391    }
392
393    // ---- History ----
394
395    #[test]
396    fn history_records_transitions() {
397        let mut fsm = RunFsm::new();
398        fsm.apply(RunEvent::PickedUp).unwrap();
399        fsm.apply(RunEvent::StepFailedRetryable).unwrap();
400        fsm.apply(RunEvent::RetryStarted).unwrap();
401
402        let history = fsm.history();
403        assert_eq!(history.len(), 3);
404
405        assert_eq!(history[0].from, RunStatus::Pending);
406        assert_eq!(history[0].to, RunStatus::Running);
407        assert_eq!(history[0].event, RunEvent::PickedUp);
408
409        assert_eq!(history[1].from, RunStatus::Running);
410        assert_eq!(history[1].to, RunStatus::Retrying);
411        assert_eq!(history[1].event, RunEvent::StepFailedRetryable);
412
413        assert_eq!(history[2].from, RunStatus::Retrying);
414        assert_eq!(history[2].to, RunStatus::Running);
415        assert_eq!(history[2].event, RunEvent::RetryStarted);
416    }
417
418    // ---- Approval transitions ----
419
420    #[test]
421    fn running_to_awaiting_approval() {
422        let mut fsm = RunFsm::new();
423        fsm.apply(RunEvent::PickedUp).unwrap();
424        fsm.apply(RunEvent::ApprovalRequested).unwrap();
425        assert_eq!(fsm.state(), RunStatus::AwaitingApproval);
426        assert!(!fsm.is_terminal());
427    }
428
429    #[test]
430    fn awaiting_approval_approved_resumes_running() {
431        let mut fsm = RunFsm::new();
432        fsm.apply(RunEvent::PickedUp).unwrap();
433        fsm.apply(RunEvent::ApprovalRequested).unwrap();
434        fsm.apply(RunEvent::Approved).unwrap();
435        assert_eq!(fsm.state(), RunStatus::Running);
436    }
437
438    #[test]
439    fn awaiting_approval_rejected_fails() {
440        let mut fsm = RunFsm::new();
441        fsm.apply(RunEvent::PickedUp).unwrap();
442        fsm.apply(RunEvent::ApprovalRequested).unwrap();
443        fsm.apply(RunEvent::Rejected).unwrap();
444        assert_eq!(fsm.state(), RunStatus::Failed);
445        assert!(fsm.is_terminal());
446    }
447
448    #[test]
449    fn awaiting_approval_cancel() {
450        let mut fsm = RunFsm::new();
451        fsm.apply(RunEvent::PickedUp).unwrap();
452        fsm.apply(RunEvent::ApprovalRequested).unwrap();
453        fsm.apply(RunEvent::CancelRequested).unwrap();
454        assert_eq!(fsm.state(), RunStatus::Cancelled);
455        assert!(fsm.is_terminal());
456    }
457
458    #[test]
459    fn cannot_approve_from_pending() {
460        let mut fsm = RunFsm::new();
461        assert!(fsm.apply(RunEvent::Approved).is_err());
462    }
463
464    #[test]
465    fn approval_then_complete() {
466        let mut fsm = RunFsm::new();
467        fsm.apply(RunEvent::PickedUp).unwrap();
468        fsm.apply(RunEvent::ApprovalRequested).unwrap();
469        fsm.apply(RunEvent::Approved).unwrap();
470        fsm.apply(RunEvent::AllStepsCompleted).unwrap();
471        assert_eq!(fsm.state(), RunStatus::Completed);
472        assert_eq!(fsm.history().len(), 4);
473    }
474
475    // ---- TransitionError Display ----
476
477    #[test]
478    fn transition_error_display() {
479        let mut fsm = RunFsm::new();
480        let err = fsm.apply(RunEvent::AllStepsCompleted).unwrap_err();
481        let msg = err.to_string();
482        assert!(msg.contains("all_steps_completed"));
483        assert!(msg.contains("Pending"));
484    }
485}