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 package_version: crate::PackageVersion::new("a".repeat(64)),
226 })
227 }
228
229 fn workflow_error(message: &str) -> WorkflowError {
230 WorkflowError {
231 message: String::from(message),
232 details: None,
233 }
234 }
235
236 #[test]
237 fn empty_history_projects_to_running() {
238 assert_eq!(status_from_events(&[]), WorkflowStatus::Running);
239 }
240
241 #[test]
242 fn replacement_start_projects_continue_as_new_chain_running() -> Result<(), crate::PayloadError>
243 {
244 let parent_run_id = RunId::new(uuid::Uuid::from_u128(7));
245 let events = vec![
246 workflow_started(1)?,
247 Event::WorkflowContinuedAsNew {
248 envelope: envelope(2),
249 input: payload("replacement")?,
250 workflow_type: None,
251 parent_run_id: parent_run_id.clone(),
252 },
253 Event::WorkflowStarted {
254 envelope: envelope(3),
255 workflow_type: String::from("checkout"),
256 input: payload("replacement")?,
257 run_id: RunId::new(uuid::Uuid::from_u128(1)),
258 parent_run_id: Some(parent_run_id),
259 package_version: crate::PackageVersion::new("a".repeat(64)),
260 },
261 ];
262
263 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
264 Ok(())
265 }
266
267 #[test]
268 fn completed_terminal_event_projects_to_completed() -> Result<(), Box<dyn std::error::Error>> {
269 let events = vec![
270 workflow_started(1)?,
271 Event::WorkflowCompleted {
272 envelope: envelope(2),
273 result: payload("result")?,
274 },
275 ];
276
277 assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
278 Ok(())
279 }
280
281 #[test]
282 fn failed_terminal_event_projects_to_failed() -> Result<(), Box<dyn std::error::Error>> {
283 let events = vec![
284 workflow_started(1)?,
285 Event::WorkflowFailed {
286 envelope: envelope(2),
287 error: workflow_error("failed"),
288 },
289 ];
290
291 assert_eq!(status_from_events(&events), WorkflowStatus::Failed);
292 Ok(())
293 }
294
295 #[test]
296 fn cancelled_terminal_event_projects_to_cancelled() -> Result<(), Box<dyn std::error::Error>> {
297 let events = vec![
298 workflow_started(1)?,
299 Event::WorkflowCancelled {
300 envelope: envelope(2),
301 reason: String::from("caller requested cancellation"),
302 },
303 ];
304
305 assert_eq!(status_from_events(&events), WorkflowStatus::Cancelled);
306 Ok(())
307 }
308
309 #[test]
310 fn timed_out_terminal_event_projects_to_timed_out() -> Result<(), Box<dyn std::error::Error>> {
311 let events = vec![
312 workflow_started(1)?,
313 Event::WorkflowTimedOut {
314 envelope: envelope(2),
315 timeout: String::from("execution"),
316 },
317 ];
318
319 assert_eq!(status_from_events(&events), WorkflowStatus::TimedOut);
320 Ok(())
321 }
322
323 #[test]
324 fn continued_as_new_projects_status() -> Result<(), Box<dyn std::error::Error>> {
325 let events = vec![
326 workflow_started(1)?,
327 Event::WorkflowContinuedAsNew {
328 envelope: envelope(2),
329 input: payload("continued-input")?,
330 workflow_type: Some(String::from("checkout-v2")),
331 parent_run_id: RunId::new(uuid::Uuid::from_u128(2)),
332 },
333 ];
334
335 assert_eq!(status_from_events(&events), WorkflowStatus::ContinuedAsNew);
336 Ok(())
337 }
338
339 #[test]
340 fn workflow_status_terminality_classifies_running_and_terminal_statuses() {
341 assert!(!WorkflowStatus::Running.is_terminal());
342 assert!(WorkflowStatus::Completed.is_terminal());
343 assert!(WorkflowStatus::Failed.is_terminal());
344 assert!(WorkflowStatus::Cancelled.is_terminal());
345 assert!(WorkflowStatus::TimedOut.is_terminal());
346 assert!(WorkflowStatus::ContinuedAsNew.is_terminal());
347 }
348
349 #[test]
350 fn started_then_continued_as_new_projects_status() -> Result<(), Box<dyn std::error::Error>> {
351 let events = vec![
352 workflow_started(1)?,
353 Event::WorkflowContinuedAsNew {
354 envelope: envelope(2),
355 input: payload("continued-input")?,
356 workflow_type: None,
357 parent_run_id: RunId::new(uuid::Uuid::from_u128(3)),
358 },
359 ];
360
361 assert_eq!(status_from_events(&events), WorkflowStatus::ContinuedAsNew);
362 Ok(())
363 }
364
365 #[test]
366 fn non_terminal_history_projects_to_running() -> Result<(), Box<dyn std::error::Error>> {
367 let events = vec![
368 workflow_started(1)?,
369 Event::SearchAttributesUpdated {
370 envelope: envelope(2),
371 workflow_id: WorkflowId::new(uuid::Uuid::nil()),
372 attributes: HashMap::from([(
373 String::from("customer_id"),
374 SearchAttributeValue::String(String::from("customer-123")),
375 )]),
376 },
377 Event::ActivityScheduled {
378 envelope: envelope(3),
379 activity_id: ActivityId::from_sequence_position(3),
380 activity_type: String::from("charge-card"),
381 input: payload("activity-input")?,
382 task_queue: String::from("default"),
383 node: None,
384 },
385 ];
386
387 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
388 Ok(())
389 }
390
391 #[test]
392 fn schedule_events_do_not_change_workflow_status() -> Result<(), Box<dyn std::error::Error>> {
393 let events = vec![
394 workflow_started(1)?,
395 Event::SchedulePaused {
396 envelope: envelope(2),
397 schedule_id: ScheduleId::new(uuid::Uuid::from_u128(2)),
398 },
399 ];
400
401 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
402 Ok(())
403 }
404
405 #[test]
406 fn projection_is_deterministic() -> Result<(), Box<dyn std::error::Error>> {
407 let events = vec![
408 workflow_started(1)?,
409 Event::WorkflowCompleted {
410 envelope: envelope(2),
411 result: payload("result")?,
412 },
413 ];
414
415 let first = status_from_events(&events);
416 let second = status_from_events(&events);
417
418 assert_eq!(first, second);
419 Ok(())
420 }
421
422 #[test]
423 fn last_terminal_lifecycle_event_determines_status() -> Result<(), Box<dyn std::error::Error>> {
424 let events = vec![
425 workflow_started(1)?,
426 Event::WorkflowCompleted {
427 envelope: envelope(2),
428 result: payload("result")?,
429 },
430 Event::WorkflowTimedOut {
431 envelope: envelope(3),
432 timeout: String::from("execution"),
433 },
434 ];
435
436 assert_eq!(status_from_events(&events), WorkflowStatus::TimedOut);
437 Ok(())
438 }
439
440 fn run_id() -> RunId {
441 RunId::new(uuid::Uuid::from_u128(1))
442 }
443
444 fn workflow_reopened(seq: u64, reopened: Vec<ActivityId>) -> Event {
445 Event::WorkflowReopened {
446 envelope: envelope(seq),
447 run_id: run_id(),
448 reopened,
449 }
450 }
451
452 #[test]
453 fn reopen_after_failure_projects_running() -> Result<(), Box<dyn std::error::Error>> {
454 let events = vec![
455 workflow_started(1)?,
456 Event::WorkflowFailed {
457 envelope: envelope(2),
458 error: workflow_error("transient"),
459 },
460 workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
461 ];
462
463 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
464 Ok(())
465 }
466
467 #[test]
468 fn reopen_then_new_terminal_projects_that_terminal() -> Result<(), Box<dyn std::error::Error>> {
469 let events = vec![
470 workflow_started(1)?,
471 Event::WorkflowFailed {
472 envelope: envelope(2),
473 error: workflow_error("transient"),
474 },
475 workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
476 Event::WorkflowCompleted {
477 envelope: envelope(4),
478 result: payload("result")?,
479 },
480 ];
481
482 assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
483 Ok(())
484 }
485
486 #[test]
487 fn current_lease_terminal_is_none_for_running_and_reopened()
488 -> Result<(), Box<dyn std::error::Error>> {
489 let running = vec![workflow_started(1)?];
490 assert!(current_lease_terminal(&running).is_none());
491
492 let failed = vec![
493 workflow_started(1)?,
494 Event::WorkflowFailed {
495 envelope: envelope(2),
496 error: workflow_error("boom"),
497 },
498 ];
499 assert!(matches!(
500 current_lease_terminal(&failed),
501 Some(Event::WorkflowFailed { .. })
502 ));
503
504 let mut reopened = failed.clone();
505 reopened.push(workflow_reopened(
506 3,
507 vec![ActivityId::from_sequence_position(2)],
508 ));
509 assert!(
510 current_lease_terminal(&reopened).is_none(),
511 "a reopened run has no current-lease terminal"
512 );
513 Ok(())
514 }
515
516 #[test]
517 fn current_lease_terminal_returns_terminal_after_reopen_and_retermination()
518 -> Result<(), Box<dyn std::error::Error>> {
519 let events = vec![
520 workflow_started(1)?,
521 Event::WorkflowFailed {
522 envelope: envelope(2),
523 error: workflow_error("boom"),
524 },
525 workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
526 Event::WorkflowCompleted {
527 envelope: envelope(4),
528 result: payload("result")?,
529 },
530 ];
531
532 assert!(matches!(
533 current_lease_terminal(&events),
534 Some(Event::WorkflowCompleted { .. })
535 ));
536 Ok(())
537 }
538
539 fn workflow_paused(seq: u64) -> Event {
540 Event::WorkflowPaused {
541 envelope: envelope(seq),
542 run_id: run_id(),
543 reason: None,
544 operator: None,
545 }
546 }
547
548 fn workflow_resumed(seq: u64) -> Event {
549 Event::WorkflowResumed {
550 envelope: envelope(seq),
551 run_id: run_id(),
552 operator: None,
553 }
554 }
555
556 #[test]
557 fn pause_projects_paused_and_is_non_terminal() -> Result<(), Box<dyn std::error::Error>> {
558 let events = vec![workflow_started(1)?, workflow_paused(2)];
559 assert_eq!(status_from_events(&events), WorkflowStatus::Paused);
560 assert!(!WorkflowStatus::Paused.is_terminal());
561 assert!(current_lease_terminal(&events).is_none());
564 Ok(())
565 }
566
567 #[test]
568 fn resume_after_pause_projects_running() -> Result<(), Box<dyn std::error::Error>> {
569 let events = vec![
570 workflow_started(1)?,
571 workflow_paused(2),
572 workflow_resumed(3),
573 ];
574 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
575 assert!(current_lease_terminal(&events).is_none());
576 Ok(())
577 }
578
579 #[test]
580 fn complete_after_resume_projects_completed() -> Result<(), Box<dyn std::error::Error>> {
581 let events = vec![
582 workflow_started(1)?,
583 workflow_paused(2),
584 workflow_resumed(3),
585 Event::WorkflowCompleted {
586 envelope: envelope(4),
587 result: payload("result")?,
588 },
589 ];
590 assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
591 assert!(matches!(
592 current_lease_terminal(&events),
593 Some(Event::WorkflowCompleted { .. })
594 ));
595 Ok(())
596 }
597
598 #[test]
599 fn complete_while_paused_still_projects_completed() -> Result<(), Box<dyn std::error::Error>> {
600 let events = vec![
603 workflow_started(1)?,
604 workflow_paused(2),
605 Event::WorkflowCompleted {
606 envelope: envelope(3),
607 result: payload("result")?,
608 },
609 ];
610 assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
611 Ok(())
612 }
613
614 #[test]
615 fn run_segment_scopes_to_the_named_run() -> Result<(), Box<dyn std::error::Error>> {
616 let first_run = RunId::new(uuid::Uuid::from_u128(10));
617 let second_run = RunId::new(uuid::Uuid::from_u128(20));
618 let events = vec![
619 Event::WorkflowStarted {
620 envelope: envelope(1),
621 workflow_type: String::from("checkout"),
622 input: payload("input")?,
623 run_id: first_run.clone(),
624 parent_run_id: None,
625 package_version: crate::PackageVersion::new("a".repeat(64)),
626 },
627 Event::WorkflowContinuedAsNew {
628 envelope: envelope(2),
629 input: payload("again")?,
630 workflow_type: None,
631 parent_run_id: first_run.clone(),
632 },
633 Event::WorkflowStarted {
634 envelope: envelope(3),
635 workflow_type: String::from("checkout"),
636 input: payload("input")?,
637 run_id: second_run.clone(),
638 parent_run_id: Some(first_run.clone()),
639 package_version: crate::PackageVersion::new("a".repeat(64)),
640 },
641 Event::WorkflowCompleted {
642 envelope: envelope(4),
643 result: payload("result")?,
644 },
645 ];
646
647 let first = run_segment(&events, &first_run);
648 assert_eq!(first.len(), 2, "first run spans its start through its CAN");
649 assert!(matches!(
650 current_lease_terminal(first),
651 Some(Event::WorkflowContinuedAsNew { .. })
652 ));
653
654 let second = run_segment(&events, &second_run);
655 assert_eq!(
656 second.len(),
657 2,
658 "second run spans its start through completion"
659 );
660 assert!(matches!(
661 current_lease_terminal(second),
662 Some(Event::WorkflowCompleted { .. })
663 ));
664
665 assert!(
666 run_segment(&events, &RunId::new(uuid::Uuid::from_u128(99))).is_empty(),
667 "absent run yields an empty segment"
668 );
669 Ok(())
670 }
671}