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