Skip to main content

aion_core/
status.rs

1//! Workflow status projection from authoritative event history.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{Event, RunId};
6
7/// Projected lifecycle status for a workflow execution.
8///
9/// Status must be obtained only by projecting from event history with
10/// [`status_from_events`], never assigned directly or stored as an independent
11/// mutable field. Event history remains authoritative for every workflow state.
12#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
13pub enum WorkflowStatus {
14    /// The workflow has not recorded a terminal lifecycle event.
15    Running,
16    /// The workflow recorded a [`Event::WorkflowCompleted`] terminal event.
17    Completed,
18    /// The workflow recorded a [`Event::WorkflowFailed`] terminal event.
19    Failed,
20    /// The workflow recorded a [`Event::WorkflowCancelled`] terminal event.
21    Cancelled,
22    /// The workflow recorded a [`Event::WorkflowTimedOut`] terminal event.
23    TimedOut,
24    /// The workflow recorded a [`Event::WorkflowContinuedAsNew`] terminal event.
25    ContinuedAsNew,
26    /// The workflow recorded a [`Event::WorkflowPaused`] marker with no later
27    /// [`Event::WorkflowResumed`]. NON-terminal: the run has recorded no terminal
28    /// event and can still complete/fail/cancel or be resumed.
29    Paused,
30}
31
32impl WorkflowStatus {
33    /// Returns whether this status represents a terminal workflow execution state.
34    #[must_use]
35    pub const fn is_terminal(self) -> bool {
36        match self {
37            // Paused is non-terminal: complete/fail/cancel stay reachable, and a
38            // paused run is excluded from the active set without being terminal.
39            Self::Running | Self::Paused => false,
40            Self::Completed
41            | Self::Failed
42            | Self::Cancelled
43            | Self::TimedOut
44            | Self::ContinuedAsNew => true,
45        }
46    }
47}
48
49/// Projects workflow status from an event history.
50///
51/// The last terminal workflow lifecycle event determines the projected status.
52/// Histories without a terminal workflow event are considered running.
53/// When a history contains multiple runs for continue-as-new, a later
54/// [`Event::WorkflowStarted`] begins the current run and supersedes earlier
55/// terminal events from the previous run.
56#[must_use]
57pub fn status_from_events(events: &[Event]) -> WorkflowStatus {
58    events
59        .iter()
60        .rev()
61        .find_map(|event| match event {
62            // A run start and a reopen both put the run in Running. A reopen
63            // supersedes the run's prior terminal event under this same
64            // last-lifecycle-event-wins scan.
65            // A run start, a reopen, and a resume all put the run in Running. A
66            // resume supersedes the run's prior WorkflowPaused under this same
67            // last-lifecycle-event-wins scan.
68            Event::WorkflowStarted { .. }
69            | Event::WorkflowReopened { .. }
70            | Event::WorkflowResumed { .. } => Some(WorkflowStatus::Running),
71            Event::WorkflowCompleted { .. } => Some(WorkflowStatus::Completed),
72            Event::WorkflowFailed { .. } => Some(WorkflowStatus::Failed),
73            Event::WorkflowCancelled { .. } => Some(WorkflowStatus::Cancelled),
74            Event::WorkflowTimedOut { .. } => Some(WorkflowStatus::TimedOut),
75            Event::WorkflowContinuedAsNew { .. } => Some(WorkflowStatus::ContinuedAsNew),
76            // Paused is the one non-terminal lifecycle event that projects a
77            // distinct status; a later WorkflowResumed supersedes it above.
78            Event::WorkflowPaused { .. } => Some(WorkflowStatus::Paused),
79            Event::SearchAttributesUpdated { .. }
80            | Event::ActivityScheduled { .. }
81            | Event::ActivityStarted { .. }
82            | Event::ActivityCompleted { .. }
83            | Event::ActivityFailed { .. }
84            // The advisory warning says a SIDE CHANNEL failed; it says
85            // nothing about the run's outcome, so it projects no status.
86            | Event::ActivityAdvisoryExhausted { .. }
87            | Event::ActivityCancelled { .. }
88            | Event::TimerStarted { .. }
89            | Event::TimerFired { .. }
90            | Event::TimerCancelled { .. }
91            | Event::WithTimeoutCompleted { .. }
92            | Event::SignalReceived { .. }
93            | Event::SignalSent { .. }
94            | Event::ChildWorkflowStarted { .. }
95            | Event::ChildWorkflowCompleted { .. }
96            | Event::ChildWorkflowFailed { .. }
97            | Event::ChildWorkflowCancelled { .. }
98            | Event::ScheduleCreated { .. }
99            | Event::ScheduleUpdated { .. }
100            | Event::SchedulePaused { .. }
101            | Event::ScheduleResumed { .. }
102            | Event::ScheduleDeleted { .. }
103            | Event::ScheduleTriggered { .. } => None,
104        })
105        .unwrap_or(WorkflowStatus::Running)
106}
107
108/// Returns the terminal lifecycle event of the run's current lease, or `None`
109/// when the run is not currently terminal — either it never recorded a terminal
110/// event, or a later [`Event::WorkflowReopened`] reopened it.
111///
112/// This is the single reset-aware terminal predicate every site derives from:
113/// close-time is its `recorded_at`, the terminal outcome is a match on the
114/// returned event, and "is the run terminal now" is `is_some()`. Scanning back
115/// from the end it stops at the first reset point (a run start or a reopen), so
116/// terminality is scoped to "since the last reopen point" — a run holds exactly
117/// one terminal event per lease.
118#[must_use]
119pub fn current_lease_terminal(events: &[Event]) -> Option<&Event> {
120    events
121        .iter()
122        .rev()
123        .find_map(|event| match event {
124            Event::WorkflowCompleted { .. }
125            | Event::WorkflowFailed { .. }
126            | Event::WorkflowCancelled { .. }
127            | Event::WorkflowTimedOut { .. }
128            | Event::WorkflowContinuedAsNew { .. } => Some(Some(event)),
129            // Reset points: the current lease has no terminal before them.
130            Event::WorkflowStarted { .. } | Event::WorkflowReopened { .. } => Some(None),
131            // Pause/resume are neither a terminal nor a run-start reset — they
132            // fall through like SearchAttributesUpdated, so a paused (or resumed)
133            // run keeps whatever current-lease terminal state it otherwise has
134            // (None while live), leaving complete/fail/cancel reachable.
135            Event::WorkflowPaused { .. }
136            | Event::WorkflowResumed { .. }
137            | Event::SearchAttributesUpdated { .. }
138            | Event::ActivityScheduled { .. }
139            | Event::ActivityStarted { .. }
140            | Event::ActivityCompleted { .. }
141            | Event::ActivityFailed { .. }
142            // The advisory warning says a SIDE CHANNEL failed; it says
143            // nothing about the run's outcome, so it projects no status.
144            | Event::ActivityAdvisoryExhausted { .. }
145            | Event::ActivityCancelled { .. }
146            | Event::TimerStarted { .. }
147            | Event::TimerFired { .. }
148            | Event::TimerCancelled { .. }
149            | Event::WithTimeoutCompleted { .. }
150            | Event::SignalReceived { .. }
151            | Event::SignalSent { .. }
152            | Event::ChildWorkflowStarted { .. }
153            | Event::ChildWorkflowCompleted { .. }
154            | Event::ChildWorkflowFailed { .. }
155            | Event::ChildWorkflowCancelled { .. }
156            | Event::ScheduleCreated { .. }
157            | Event::ScheduleUpdated { .. }
158            | Event::SchedulePaused { .. }
159            | Event::ScheduleResumed { .. }
160            | Event::ScheduleDeleted { .. }
161            | Event::ScheduleTriggered { .. } => None,
162        })
163        .flatten()
164}
165
166/// Returns the slice of `events` belonging to the run identified by `run_id`:
167/// from that run's [`Event::WorkflowStarted`] up to (but excluding) the next
168/// run's start, or an empty slice when the run is absent.
169///
170/// Scoping [`current_lease_terminal`] to a `run_segment` answers "is this
171/// particular run currently terminal" without a separate bespoke scan per call
172/// site.
173#[must_use]
174pub fn run_segment<'a>(events: &'a [Event], run_id: &RunId) -> &'a [Event] {
175    let Some(start) = events.iter().position(
176        |event| matches!(event, Event::WorkflowStarted { run_id: id, .. } if id == run_id),
177    ) else {
178        return &[];
179    };
180    let end = events[start + 1..]
181        .iter()
182        .position(|event| matches!(event, Event::WorkflowStarted { .. }))
183        .map_or(events.len(), |offset| start + 1 + offset);
184    &events[start..end]
185}
186
187#[cfg(test)]
188mod tests {
189    use std::collections::HashMap;
190
191    use chrono::{DateTime, Utc};
192    use serde_json::json;
193
194    use super::{WorkflowStatus, current_lease_terminal, run_segment, status_from_events};
195    use crate::{
196        ActivityId, Event, EventEnvelope, Payload, RunId, ScheduleId, SearchAttributeValue,
197        WorkflowError, WorkflowId,
198    };
199
200    fn recorded_at(offset: i64) -> DateTime<Utc> {
201        DateTime::from_timestamp(1_700_000_000 + offset, 0).unwrap_or_default()
202    }
203
204    fn envelope(seq: u64) -> EventEnvelope {
205        EventEnvelope {
206            seq,
207            recorded_at: recorded_at(i64::try_from(seq).unwrap_or(0)),
208            workflow_id: WorkflowId::new(uuid::Uuid::nil()),
209        }
210    }
211
212    fn payload(label: &str) -> Result<Payload, crate::PayloadError> {
213        Payload::from_json(&json!({ "label": label }))
214    }
215
216    fn workflow_started(seq: u64) -> Result<Event, crate::PayloadError> {
217        Ok(Event::WorkflowStarted {
218            envelope: envelope(seq),
219            workflow_type: String::from("checkout"),
220            input: payload("input")?,
221            run_id: RunId::new(uuid::Uuid::from_u128(1)),
222            parent_run_id: None,
223            package_version: crate::PackageVersion::new("a".repeat(64)),
224        })
225    }
226
227    fn workflow_error(message: &str) -> WorkflowError {
228        WorkflowError {
229            message: String::from(message),
230            details: None,
231        }
232    }
233
234    #[test]
235    fn empty_history_projects_to_running() {
236        assert_eq!(status_from_events(&[]), WorkflowStatus::Running);
237    }
238
239    #[test]
240    fn replacement_start_projects_continue_as_new_chain_running() -> Result<(), crate::PayloadError>
241    {
242        let parent_run_id = RunId::new(uuid::Uuid::from_u128(7));
243        let events = vec![
244            workflow_started(1)?,
245            Event::WorkflowContinuedAsNew {
246                envelope: envelope(2),
247                input: payload("replacement")?,
248                workflow_type: None,
249                parent_run_id: parent_run_id.clone(),
250            },
251            Event::WorkflowStarted {
252                envelope: envelope(3),
253                workflow_type: String::from("checkout"),
254                input: payload("replacement")?,
255                run_id: RunId::new(uuid::Uuid::from_u128(1)),
256                parent_run_id: Some(parent_run_id),
257                package_version: crate::PackageVersion::new("a".repeat(64)),
258            },
259        ];
260
261        assert_eq!(status_from_events(&events), WorkflowStatus::Running);
262        Ok(())
263    }
264
265    #[test]
266    fn completed_terminal_event_projects_to_completed() -> Result<(), Box<dyn std::error::Error>> {
267        let events = vec![
268            workflow_started(1)?,
269            Event::WorkflowCompleted {
270                envelope: envelope(2),
271                result: payload("result")?,
272            },
273        ];
274
275        assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
276        Ok(())
277    }
278
279    #[test]
280    fn failed_terminal_event_projects_to_failed() -> Result<(), Box<dyn std::error::Error>> {
281        let events = vec![
282            workflow_started(1)?,
283            Event::WorkflowFailed {
284                envelope: envelope(2),
285                error: workflow_error("failed"),
286            },
287        ];
288
289        assert_eq!(status_from_events(&events), WorkflowStatus::Failed);
290        Ok(())
291    }
292
293    #[test]
294    fn cancelled_terminal_event_projects_to_cancelled() -> Result<(), Box<dyn std::error::Error>> {
295        let events = vec![
296            workflow_started(1)?,
297            Event::WorkflowCancelled {
298                envelope: envelope(2),
299                reason: String::from("caller requested cancellation"),
300            },
301        ];
302
303        assert_eq!(status_from_events(&events), WorkflowStatus::Cancelled);
304        Ok(())
305    }
306
307    #[test]
308    fn timed_out_terminal_event_projects_to_timed_out() -> Result<(), Box<dyn std::error::Error>> {
309        let events = vec![
310            workflow_started(1)?,
311            Event::WorkflowTimedOut {
312                envelope: envelope(2),
313                timeout: String::from("execution"),
314            },
315        ];
316
317        assert_eq!(status_from_events(&events), WorkflowStatus::TimedOut);
318        Ok(())
319    }
320
321    #[test]
322    fn continued_as_new_projects_status() -> Result<(), Box<dyn std::error::Error>> {
323        let events = vec![
324            workflow_started(1)?,
325            Event::WorkflowContinuedAsNew {
326                envelope: envelope(2),
327                input: payload("continued-input")?,
328                workflow_type: Some(String::from("checkout-v2")),
329                parent_run_id: RunId::new(uuid::Uuid::from_u128(2)),
330            },
331        ];
332
333        assert_eq!(status_from_events(&events), WorkflowStatus::ContinuedAsNew);
334        Ok(())
335    }
336
337    #[test]
338    fn workflow_status_terminality_classifies_running_and_terminal_statuses() {
339        assert!(!WorkflowStatus::Running.is_terminal());
340        assert!(WorkflowStatus::Completed.is_terminal());
341        assert!(WorkflowStatus::Failed.is_terminal());
342        assert!(WorkflowStatus::Cancelled.is_terminal());
343        assert!(WorkflowStatus::TimedOut.is_terminal());
344        assert!(WorkflowStatus::ContinuedAsNew.is_terminal());
345    }
346
347    #[test]
348    fn started_then_continued_as_new_projects_status() -> Result<(), Box<dyn std::error::Error>> {
349        let events = vec![
350            workflow_started(1)?,
351            Event::WorkflowContinuedAsNew {
352                envelope: envelope(2),
353                input: payload("continued-input")?,
354                workflow_type: None,
355                parent_run_id: RunId::new(uuid::Uuid::from_u128(3)),
356            },
357        ];
358
359        assert_eq!(status_from_events(&events), WorkflowStatus::ContinuedAsNew);
360        Ok(())
361    }
362
363    #[test]
364    fn non_terminal_history_projects_to_running() -> Result<(), Box<dyn std::error::Error>> {
365        let events = vec![
366            workflow_started(1)?,
367            Event::SearchAttributesUpdated {
368                envelope: envelope(2),
369                workflow_id: WorkflowId::new(uuid::Uuid::nil()),
370                attributes: HashMap::from([(
371                    String::from("customer_id"),
372                    SearchAttributeValue::String(String::from("customer-123")),
373                )]),
374            },
375            Event::ActivityScheduled {
376                envelope: envelope(3),
377                activity_id: ActivityId::from_sequence_position(3),
378                activity_type: String::from("charge-card"),
379                input: payload("activity-input")?,
380                task_queue: String::from("default"),
381                node: None,
382            },
383        ];
384
385        assert_eq!(status_from_events(&events), WorkflowStatus::Running);
386        Ok(())
387    }
388
389    #[test]
390    fn schedule_events_do_not_change_workflow_status() -> Result<(), Box<dyn std::error::Error>> {
391        let events = vec![
392            workflow_started(1)?,
393            Event::SchedulePaused {
394                envelope: envelope(2),
395                schedule_id: ScheduleId::new(uuid::Uuid::from_u128(2)),
396            },
397        ];
398
399        assert_eq!(status_from_events(&events), WorkflowStatus::Running);
400        Ok(())
401    }
402
403    #[test]
404    fn projection_is_deterministic() -> Result<(), Box<dyn std::error::Error>> {
405        let events = vec![
406            workflow_started(1)?,
407            Event::WorkflowCompleted {
408                envelope: envelope(2),
409                result: payload("result")?,
410            },
411        ];
412
413        let first = status_from_events(&events);
414        let second = status_from_events(&events);
415
416        assert_eq!(first, second);
417        Ok(())
418    }
419
420    #[test]
421    fn last_terminal_lifecycle_event_determines_status() -> Result<(), Box<dyn std::error::Error>> {
422        let events = vec![
423            workflow_started(1)?,
424            Event::WorkflowCompleted {
425                envelope: envelope(2),
426                result: payload("result")?,
427            },
428            Event::WorkflowTimedOut {
429                envelope: envelope(3),
430                timeout: String::from("execution"),
431            },
432        ];
433
434        assert_eq!(status_from_events(&events), WorkflowStatus::TimedOut);
435        Ok(())
436    }
437
438    fn run_id() -> RunId {
439        RunId::new(uuid::Uuid::from_u128(1))
440    }
441
442    fn workflow_reopened(seq: u64, reopened: Vec<ActivityId>) -> Event {
443        Event::WorkflowReopened {
444            envelope: envelope(seq),
445            run_id: run_id(),
446            reopened,
447        }
448    }
449
450    #[test]
451    fn reopen_after_failure_projects_running() -> Result<(), Box<dyn std::error::Error>> {
452        let events = vec![
453            workflow_started(1)?,
454            Event::WorkflowFailed {
455                envelope: envelope(2),
456                error: workflow_error("transient"),
457            },
458            workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
459        ];
460
461        assert_eq!(status_from_events(&events), WorkflowStatus::Running);
462        Ok(())
463    }
464
465    #[test]
466    fn reopen_then_new_terminal_projects_that_terminal() -> Result<(), Box<dyn std::error::Error>> {
467        let events = vec![
468            workflow_started(1)?,
469            Event::WorkflowFailed {
470                envelope: envelope(2),
471                error: workflow_error("transient"),
472            },
473            workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
474            Event::WorkflowCompleted {
475                envelope: envelope(4),
476                result: payload("result")?,
477            },
478        ];
479
480        assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
481        Ok(())
482    }
483
484    #[test]
485    fn current_lease_terminal_is_none_for_running_and_reopened()
486    -> Result<(), Box<dyn std::error::Error>> {
487        let running = vec![workflow_started(1)?];
488        assert!(current_lease_terminal(&running).is_none());
489
490        let failed = vec![
491            workflow_started(1)?,
492            Event::WorkflowFailed {
493                envelope: envelope(2),
494                error: workflow_error("boom"),
495            },
496        ];
497        assert!(matches!(
498            current_lease_terminal(&failed),
499            Some(Event::WorkflowFailed { .. })
500        ));
501
502        let mut reopened = failed.clone();
503        reopened.push(workflow_reopened(
504            3,
505            vec![ActivityId::from_sequence_position(2)],
506        ));
507        assert!(
508            current_lease_terminal(&reopened).is_none(),
509            "a reopened run has no current-lease terminal"
510        );
511        Ok(())
512    }
513
514    #[test]
515    fn current_lease_terminal_returns_terminal_after_reopen_and_retermination()
516    -> Result<(), Box<dyn std::error::Error>> {
517        let events = vec![
518            workflow_started(1)?,
519            Event::WorkflowFailed {
520                envelope: envelope(2),
521                error: workflow_error("boom"),
522            },
523            workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
524            Event::WorkflowCompleted {
525                envelope: envelope(4),
526                result: payload("result")?,
527            },
528        ];
529
530        assert!(matches!(
531            current_lease_terminal(&events),
532            Some(Event::WorkflowCompleted { .. })
533        ));
534        Ok(())
535    }
536
537    fn workflow_paused(seq: u64) -> Event {
538        Event::WorkflowPaused {
539            envelope: envelope(seq),
540            run_id: run_id(),
541            reason: None,
542            operator: None,
543        }
544    }
545
546    fn workflow_resumed(seq: u64) -> Event {
547        Event::WorkflowResumed {
548            envelope: envelope(seq),
549            run_id: run_id(),
550            operator: None,
551        }
552    }
553
554    #[test]
555    fn pause_projects_paused_and_is_non_terminal() -> Result<(), Box<dyn std::error::Error>> {
556        let events = vec![workflow_started(1)?, workflow_paused(2)];
557        assert_eq!(status_from_events(&events), WorkflowStatus::Paused);
558        assert!(!WorkflowStatus::Paused.is_terminal());
559        // Paused is non-terminal, so the run has no current-lease terminal and can
560        // still complete/fail/cancel.
561        assert!(current_lease_terminal(&events).is_none());
562        Ok(())
563    }
564
565    #[test]
566    fn resume_after_pause_projects_running() -> Result<(), Box<dyn std::error::Error>> {
567        let events = vec![
568            workflow_started(1)?,
569            workflow_paused(2),
570            workflow_resumed(3),
571        ];
572        assert_eq!(status_from_events(&events), WorkflowStatus::Running);
573        assert!(current_lease_terminal(&events).is_none());
574        Ok(())
575    }
576
577    #[test]
578    fn complete_after_resume_projects_completed() -> Result<(), Box<dyn std::error::Error>> {
579        let events = vec![
580            workflow_started(1)?,
581            workflow_paused(2),
582            workflow_resumed(3),
583            Event::WorkflowCompleted {
584                envelope: envelope(4),
585                result: payload("result")?,
586            },
587        ];
588        assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
589        assert!(matches!(
590            current_lease_terminal(&events),
591            Some(Event::WorkflowCompleted { .. })
592        ));
593        Ok(())
594    }
595
596    #[test]
597    fn complete_while_paused_still_projects_completed() -> Result<(), Box<dyn std::error::Error>> {
598        // The drain case: a paused run reaches a terminal via drained work.
599        // Last-lifecycle-event-wins projects the terminal, not Paused.
600        let events = vec![
601            workflow_started(1)?,
602            workflow_paused(2),
603            Event::WorkflowCompleted {
604                envelope: envelope(3),
605                result: payload("result")?,
606            },
607        ];
608        assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
609        Ok(())
610    }
611
612    #[test]
613    fn run_segment_scopes_to_the_named_run() -> Result<(), Box<dyn std::error::Error>> {
614        let first_run = RunId::new(uuid::Uuid::from_u128(10));
615        let second_run = RunId::new(uuid::Uuid::from_u128(20));
616        let events = vec![
617            Event::WorkflowStarted {
618                envelope: envelope(1),
619                workflow_type: String::from("checkout"),
620                input: payload("input")?,
621                run_id: first_run.clone(),
622                parent_run_id: None,
623                package_version: crate::PackageVersion::new("a".repeat(64)),
624            },
625            Event::WorkflowContinuedAsNew {
626                envelope: envelope(2),
627                input: payload("again")?,
628                workflow_type: None,
629                parent_run_id: first_run.clone(),
630            },
631            Event::WorkflowStarted {
632                envelope: envelope(3),
633                workflow_type: String::from("checkout"),
634                input: payload("input")?,
635                run_id: second_run.clone(),
636                parent_run_id: Some(first_run.clone()),
637                package_version: crate::PackageVersion::new("a".repeat(64)),
638            },
639            Event::WorkflowCompleted {
640                envelope: envelope(4),
641                result: payload("result")?,
642            },
643        ];
644
645        let first = run_segment(&events, &first_run);
646        assert_eq!(first.len(), 2, "first run spans its start through its CAN");
647        assert!(matches!(
648            current_lease_terminal(first),
649            Some(Event::WorkflowContinuedAsNew { .. })
650        ));
651
652        let second = run_segment(&events, &second_run);
653        assert_eq!(
654            second.len(),
655            2,
656            "second run spans its start through completion"
657        );
658        assert!(matches!(
659            current_lease_terminal(second),
660            Some(Event::WorkflowCompleted { .. })
661        ));
662
663        assert!(
664            run_segment(&events, &RunId::new(uuid::Uuid::from_u128(99))).is_empty(),
665            "absent run yields an empty segment"
666        );
667        Ok(())
668    }
669}