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