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