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}
27
28impl WorkflowStatus {
29 #[must_use]
31 pub const fn is_terminal(self) -> bool {
32 match self {
33 Self::Running => false,
34 Self::Completed
35 | Self::Failed
36 | Self::Cancelled
37 | Self::TimedOut
38 | Self::ContinuedAsNew => true,
39 }
40 }
41}
42
43#[must_use]
51pub fn status_from_events(events: &[Event]) -> WorkflowStatus {
52 events
53 .iter()
54 .rev()
55 .find_map(|event| match event {
56 Event::WorkflowStarted { .. } | Event::WorkflowReopened { .. } => {
60 Some(WorkflowStatus::Running)
61 }
62 Event::WorkflowCompleted { .. } => Some(WorkflowStatus::Completed),
63 Event::WorkflowFailed { .. } => Some(WorkflowStatus::Failed),
64 Event::WorkflowCancelled { .. } => Some(WorkflowStatus::Cancelled),
65 Event::WorkflowTimedOut { .. } => Some(WorkflowStatus::TimedOut),
66 Event::WorkflowContinuedAsNew { .. } => Some(WorkflowStatus::ContinuedAsNew),
67 Event::SearchAttributesUpdated { .. }
68 | Event::ActivityScheduled { .. }
69 | Event::ActivityStarted { .. }
70 | Event::ActivityCompleted { .. }
71 | Event::ActivityFailed { .. }
72 | Event::ActivityCancelled { .. }
73 | Event::TimerStarted { .. }
74 | Event::TimerFired { .. }
75 | Event::TimerCancelled { .. }
76 | Event::WithTimeoutCompleted { .. }
77 | Event::SignalReceived { .. }
78 | Event::SignalSent { .. }
79 | Event::ChildWorkflowStarted { .. }
80 | Event::ChildWorkflowCompleted { .. }
81 | Event::ChildWorkflowFailed { .. }
82 | Event::ChildWorkflowCancelled { .. }
83 | Event::ScheduleCreated { .. }
84 | Event::ScheduleUpdated { .. }
85 | Event::SchedulePaused { .. }
86 | Event::ScheduleResumed { .. }
87 | Event::ScheduleDeleted { .. }
88 | Event::ScheduleTriggered { .. } => None,
89 })
90 .unwrap_or(WorkflowStatus::Running)
91}
92
93#[must_use]
104pub fn current_lease_terminal(events: &[Event]) -> Option<&Event> {
105 events
106 .iter()
107 .rev()
108 .find_map(|event| match event {
109 Event::WorkflowCompleted { .. }
110 | Event::WorkflowFailed { .. }
111 | Event::WorkflowCancelled { .. }
112 | Event::WorkflowTimedOut { .. }
113 | Event::WorkflowContinuedAsNew { .. } => Some(Some(event)),
114 Event::WorkflowStarted { .. } | Event::WorkflowReopened { .. } => Some(None),
116 Event::SearchAttributesUpdated { .. }
117 | Event::ActivityScheduled { .. }
118 | Event::ActivityStarted { .. }
119 | Event::ActivityCompleted { .. }
120 | Event::ActivityFailed { .. }
121 | Event::ActivityCancelled { .. }
122 | Event::TimerStarted { .. }
123 | Event::TimerFired { .. }
124 | Event::TimerCancelled { .. }
125 | Event::WithTimeoutCompleted { .. }
126 | Event::SignalReceived { .. }
127 | Event::SignalSent { .. }
128 | Event::ChildWorkflowStarted { .. }
129 | Event::ChildWorkflowCompleted { .. }
130 | Event::ChildWorkflowFailed { .. }
131 | Event::ChildWorkflowCancelled { .. }
132 | Event::ScheduleCreated { .. }
133 | Event::ScheduleUpdated { .. }
134 | Event::SchedulePaused { .. }
135 | Event::ScheduleResumed { .. }
136 | Event::ScheduleDeleted { .. }
137 | Event::ScheduleTriggered { .. } => None,
138 })
139 .flatten()
140}
141
142#[must_use]
150pub fn run_segment<'a>(events: &'a [Event], run_id: &RunId) -> &'a [Event] {
151 let Some(start) = events.iter().position(
152 |event| matches!(event, Event::WorkflowStarted { run_id: id, .. } if id == run_id),
153 ) else {
154 return &[];
155 };
156 let end = events[start + 1..]
157 .iter()
158 .position(|event| matches!(event, Event::WorkflowStarted { .. }))
159 .map_or(events.len(), |offset| start + 1 + offset);
160 &events[start..end]
161}
162
163#[cfg(test)]
164mod tests {
165 use std::collections::HashMap;
166
167 use chrono::{DateTime, Utc};
168 use serde_json::json;
169
170 use super::{WorkflowStatus, current_lease_terminal, run_segment, status_from_events};
171 use crate::{
172 ActivityId, Event, EventEnvelope, Payload, RunId, ScheduleId, SearchAttributeValue,
173 WorkflowError, WorkflowId,
174 };
175
176 fn recorded_at(offset: i64) -> DateTime<Utc> {
177 DateTime::from_timestamp(1_700_000_000 + offset, 0).unwrap_or_default()
178 }
179
180 fn envelope(seq: u64) -> EventEnvelope {
181 EventEnvelope {
182 seq,
183 recorded_at: recorded_at(i64::try_from(seq).unwrap_or(0)),
184 workflow_id: WorkflowId::new(uuid::Uuid::nil()),
185 }
186 }
187
188 fn payload(label: &str) -> Result<Payload, crate::PayloadError> {
189 Payload::from_json(&json!({ "label": label }))
190 }
191
192 fn workflow_started(seq: u64) -> Result<Event, crate::PayloadError> {
193 Ok(Event::WorkflowStarted {
194 envelope: envelope(seq),
195 workflow_type: String::from("checkout"),
196 input: payload("input")?,
197 run_id: RunId::new(uuid::Uuid::from_u128(1)),
198 parent_run_id: None,
199 package_version: crate::PackageVersion::new("a".repeat(64)),
200 })
201 }
202
203 fn workflow_error(message: &str) -> WorkflowError {
204 WorkflowError {
205 message: String::from(message),
206 details: None,
207 }
208 }
209
210 #[test]
211 fn empty_history_projects_to_running() {
212 assert_eq!(status_from_events(&[]), WorkflowStatus::Running);
213 }
214
215 #[test]
216 fn replacement_start_projects_continue_as_new_chain_running() -> Result<(), crate::PayloadError>
217 {
218 let parent_run_id = RunId::new(uuid::Uuid::from_u128(7));
219 let events = vec![
220 workflow_started(1)?,
221 Event::WorkflowContinuedAsNew {
222 envelope: envelope(2),
223 input: payload("replacement")?,
224 workflow_type: None,
225 parent_run_id: parent_run_id.clone(),
226 },
227 Event::WorkflowStarted {
228 envelope: envelope(3),
229 workflow_type: String::from("checkout"),
230 input: payload("replacement")?,
231 run_id: RunId::new(uuid::Uuid::from_u128(1)),
232 parent_run_id: Some(parent_run_id),
233 package_version: crate::PackageVersion::new("a".repeat(64)),
234 },
235 ];
236
237 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
238 Ok(())
239 }
240
241 #[test]
242 fn completed_terminal_event_projects_to_completed() -> Result<(), Box<dyn std::error::Error>> {
243 let events = vec![
244 workflow_started(1)?,
245 Event::WorkflowCompleted {
246 envelope: envelope(2),
247 result: payload("result")?,
248 },
249 ];
250
251 assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
252 Ok(())
253 }
254
255 #[test]
256 fn failed_terminal_event_projects_to_failed() -> Result<(), Box<dyn std::error::Error>> {
257 let events = vec![
258 workflow_started(1)?,
259 Event::WorkflowFailed {
260 envelope: envelope(2),
261 error: workflow_error("failed"),
262 },
263 ];
264
265 assert_eq!(status_from_events(&events), WorkflowStatus::Failed);
266 Ok(())
267 }
268
269 #[test]
270 fn cancelled_terminal_event_projects_to_cancelled() -> Result<(), Box<dyn std::error::Error>> {
271 let events = vec![
272 workflow_started(1)?,
273 Event::WorkflowCancelled {
274 envelope: envelope(2),
275 reason: String::from("caller requested cancellation"),
276 },
277 ];
278
279 assert_eq!(status_from_events(&events), WorkflowStatus::Cancelled);
280 Ok(())
281 }
282
283 #[test]
284 fn timed_out_terminal_event_projects_to_timed_out() -> Result<(), Box<dyn std::error::Error>> {
285 let events = vec![
286 workflow_started(1)?,
287 Event::WorkflowTimedOut {
288 envelope: envelope(2),
289 timeout: String::from("execution"),
290 },
291 ];
292
293 assert_eq!(status_from_events(&events), WorkflowStatus::TimedOut);
294 Ok(())
295 }
296
297 #[test]
298 fn continued_as_new_projects_status() -> Result<(), Box<dyn std::error::Error>> {
299 let events = vec![
300 workflow_started(1)?,
301 Event::WorkflowContinuedAsNew {
302 envelope: envelope(2),
303 input: payload("continued-input")?,
304 workflow_type: Some(String::from("checkout-v2")),
305 parent_run_id: RunId::new(uuid::Uuid::from_u128(2)),
306 },
307 ];
308
309 assert_eq!(status_from_events(&events), WorkflowStatus::ContinuedAsNew);
310 Ok(())
311 }
312
313 #[test]
314 fn workflow_status_terminality_classifies_running_and_terminal_statuses() {
315 assert!(!WorkflowStatus::Running.is_terminal());
316 assert!(WorkflowStatus::Completed.is_terminal());
317 assert!(WorkflowStatus::Failed.is_terminal());
318 assert!(WorkflowStatus::Cancelled.is_terminal());
319 assert!(WorkflowStatus::TimedOut.is_terminal());
320 assert!(WorkflowStatus::ContinuedAsNew.is_terminal());
321 }
322
323 #[test]
324 fn started_then_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: None,
331 parent_run_id: RunId::new(uuid::Uuid::from_u128(3)),
332 },
333 ];
334
335 assert_eq!(status_from_events(&events), WorkflowStatus::ContinuedAsNew);
336 Ok(())
337 }
338
339 #[test]
340 fn non_terminal_history_projects_to_running() -> Result<(), Box<dyn std::error::Error>> {
341 let events = vec![
342 workflow_started(1)?,
343 Event::SearchAttributesUpdated {
344 envelope: envelope(2),
345 workflow_id: WorkflowId::new(uuid::Uuid::nil()),
346 attributes: HashMap::from([(
347 String::from("customer_id"),
348 SearchAttributeValue::String(String::from("customer-123")),
349 )]),
350 },
351 Event::ActivityScheduled {
352 envelope: envelope(3),
353 activity_id: ActivityId::from_sequence_position(3),
354 activity_type: String::from("charge-card"),
355 input: payload("activity-input")?,
356 task_queue: String::from("default"),
357 node: None,
358 },
359 ];
360
361 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
362 Ok(())
363 }
364
365 #[test]
366 fn schedule_events_do_not_change_workflow_status() -> Result<(), Box<dyn std::error::Error>> {
367 let events = vec![
368 workflow_started(1)?,
369 Event::SchedulePaused {
370 envelope: envelope(2),
371 schedule_id: ScheduleId::new(uuid::Uuid::from_u128(2)),
372 },
373 ];
374
375 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
376 Ok(())
377 }
378
379 #[test]
380 fn projection_is_deterministic() -> Result<(), Box<dyn std::error::Error>> {
381 let events = vec![
382 workflow_started(1)?,
383 Event::WorkflowCompleted {
384 envelope: envelope(2),
385 result: payload("result")?,
386 },
387 ];
388
389 let first = status_from_events(&events);
390 let second = status_from_events(&events);
391
392 assert_eq!(first, second);
393 Ok(())
394 }
395
396 #[test]
397 fn last_terminal_lifecycle_event_determines_status() -> Result<(), Box<dyn std::error::Error>> {
398 let events = vec![
399 workflow_started(1)?,
400 Event::WorkflowCompleted {
401 envelope: envelope(2),
402 result: payload("result")?,
403 },
404 Event::WorkflowTimedOut {
405 envelope: envelope(3),
406 timeout: String::from("execution"),
407 },
408 ];
409
410 assert_eq!(status_from_events(&events), WorkflowStatus::TimedOut);
411 Ok(())
412 }
413
414 fn run_id() -> RunId {
415 RunId::new(uuid::Uuid::from_u128(1))
416 }
417
418 fn workflow_reopened(seq: u64, reopened: Vec<ActivityId>) -> Event {
419 Event::WorkflowReopened {
420 envelope: envelope(seq),
421 run_id: run_id(),
422 reopened,
423 }
424 }
425
426 #[test]
427 fn reopen_after_failure_projects_running() -> Result<(), Box<dyn std::error::Error>> {
428 let events = vec![
429 workflow_started(1)?,
430 Event::WorkflowFailed {
431 envelope: envelope(2),
432 error: workflow_error("transient"),
433 },
434 workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
435 ];
436
437 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
438 Ok(())
439 }
440
441 #[test]
442 fn reopen_then_new_terminal_projects_that_terminal() -> Result<(), Box<dyn std::error::Error>> {
443 let events = vec![
444 workflow_started(1)?,
445 Event::WorkflowFailed {
446 envelope: envelope(2),
447 error: workflow_error("transient"),
448 },
449 workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
450 Event::WorkflowCompleted {
451 envelope: envelope(4),
452 result: payload("result")?,
453 },
454 ];
455
456 assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
457 Ok(())
458 }
459
460 #[test]
461 fn current_lease_terminal_is_none_for_running_and_reopened()
462 -> Result<(), Box<dyn std::error::Error>> {
463 let running = vec![workflow_started(1)?];
464 assert!(current_lease_terminal(&running).is_none());
465
466 let failed = vec![
467 workflow_started(1)?,
468 Event::WorkflowFailed {
469 envelope: envelope(2),
470 error: workflow_error("boom"),
471 },
472 ];
473 assert!(matches!(
474 current_lease_terminal(&failed),
475 Some(Event::WorkflowFailed { .. })
476 ));
477
478 let mut reopened = failed.clone();
479 reopened.push(workflow_reopened(
480 3,
481 vec![ActivityId::from_sequence_position(2)],
482 ));
483 assert!(
484 current_lease_terminal(&reopened).is_none(),
485 "a reopened run has no current-lease terminal"
486 );
487 Ok(())
488 }
489
490 #[test]
491 fn current_lease_terminal_returns_terminal_after_reopen_and_retermination()
492 -> Result<(), Box<dyn std::error::Error>> {
493 let events = vec![
494 workflow_started(1)?,
495 Event::WorkflowFailed {
496 envelope: envelope(2),
497 error: workflow_error("boom"),
498 },
499 workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
500 Event::WorkflowCompleted {
501 envelope: envelope(4),
502 result: payload("result")?,
503 },
504 ];
505
506 assert!(matches!(
507 current_lease_terminal(&events),
508 Some(Event::WorkflowCompleted { .. })
509 ));
510 Ok(())
511 }
512
513 #[test]
514 fn run_segment_scopes_to_the_named_run() -> Result<(), Box<dyn std::error::Error>> {
515 let first_run = RunId::new(uuid::Uuid::from_u128(10));
516 let second_run = RunId::new(uuid::Uuid::from_u128(20));
517 let events = vec![
518 Event::WorkflowStarted {
519 envelope: envelope(1),
520 workflow_type: String::from("checkout"),
521 input: payload("input")?,
522 run_id: first_run.clone(),
523 parent_run_id: None,
524 package_version: crate::PackageVersion::new("a".repeat(64)),
525 },
526 Event::WorkflowContinuedAsNew {
527 envelope: envelope(2),
528 input: payload("again")?,
529 workflow_type: None,
530 parent_run_id: first_run.clone(),
531 },
532 Event::WorkflowStarted {
533 envelope: envelope(3),
534 workflow_type: String::from("checkout"),
535 input: payload("input")?,
536 run_id: second_run.clone(),
537 parent_run_id: Some(first_run.clone()),
538 package_version: crate::PackageVersion::new("a".repeat(64)),
539 },
540 Event::WorkflowCompleted {
541 envelope: envelope(4),
542 result: payload("result")?,
543 },
544 ];
545
546 let first = run_segment(&events, &first_run);
547 assert_eq!(first.len(), 2, "first run spans its start through its CAN");
548 assert!(matches!(
549 current_lease_terminal(first),
550 Some(Event::WorkflowContinuedAsNew { .. })
551 ));
552
553 let second = run_segment(&events, &second_run);
554 assert_eq!(
555 second.len(),
556 2,
557 "second run spans its start through completion"
558 );
559 assert!(matches!(
560 current_lease_terminal(second),
561 Some(Event::WorkflowCompleted { .. })
562 ));
563
564 assert!(
565 run_segment(&events, &RunId::new(uuid::Uuid::from_u128(99))).is_empty(),
566 "absent run yields an empty segment"
567 );
568 Ok(())
569 }
570}