1use std::ops::ControlFlow;
14use std::sync::Arc;
15
16use bytes::Bytes;
17use sayiir_core::codec::sealed;
18use sayiir_core::codec::{Codec, EnvelopeCodec};
19use sayiir_core::context::WorkflowContext;
20use sayiir_core::error::WorkflowError;
21use sayiir_core::snapshot::{ExecutionPosition, TaskHint, WorkflowSnapshot};
22use sayiir_core::workflow::{ConflictPolicy, Workflow, WorkflowContinuation, WorkflowStatus};
23use sayiir_persistence::PersistentBackend;
24
25use crate::error::RuntimeError;
26use crate::execution::control_flow::{
27 ParkReason, StepOutcome, StepResult, compute_signal_timeout, compute_wake_at,
28 save_branch_park_checkpoint, save_park_checkpoint,
29};
30use crate::execution::loop_runner::{
31 CheckpointingLoopHooks, LoopConfig, LoopExit, LoopNext, resolve_loop_iteration, run_loop_async,
32};
33use crate::execution::{
34 ForkBranchOutcome, JoinResolution, ResumeParkedPosition, branch_execute_or_skip_task,
35 check_guards, collect_cached_branches, execute_or_skip_task, finalize_execution,
36 get_resume_input, resolve_join, retry_with_checkpoint, set_deadline_if_needed,
37 settle_fork_outcome,
38};
39
40pub struct CheckpointingRunner<B> {
77 backend: Arc<B>,
78 conflict_policy: ConflictPolicy,
79}
80
81impl<B> CheckpointingRunner<B>
82where
83 B: PersistentBackend,
84{
85 pub fn new(backend: B) -> Self {
87 Self {
88 backend: Arc::new(backend),
89 conflict_policy: ConflictPolicy::default(),
90 }
91 }
92
93 pub fn from_shared(backend: Arc<B>) -> Self {
97 Self {
98 backend,
99 conflict_policy: ConflictPolicy::default(),
100 }
101 }
102
103 #[must_use]
105 pub fn with_conflict_policy(mut self, policy: ConflictPolicy) -> Self {
106 self.conflict_policy = policy;
107 self
108 }
109
110 #[must_use]
112 pub fn backend(&self) -> &Arc<B> {
113 &self.backend
114 }
115}
116
117impl<B> CheckpointingRunner<B>
118where
119 B: PersistentBackend + 'static,
120{
121 pub async fn run<C, Input, M>(
132 &self,
133 workflow: &Workflow<C, Input, M>,
134 instance_id: impl Into<String>,
135 input: Input,
136 ) -> Result<WorkflowStatus, RuntimeError>
137 where
138 Input: Send + 'static,
139 M: Send + Sync + 'static,
140 C: Codec
141 + EnvelopeCodec
142 + sealed::EncodeValue<Input>
143 + sealed::DecodeValue<Input>
144 + 'static,
145 {
146 use crate::{PrepareRunOutcome, check_existing_instance, prepare_run};
147
148 let instance_id = instance_id.into();
149 let definition_hash = *workflow.definition_hash();
150 let conflict_policy = self.conflict_policy;
151
152 if let Some((status, _output)) = check_existing_instance(
154 &instance_id,
155 &definition_hash,
156 self.backend.as_ref(),
157 conflict_policy,
158 )
159 .await?
160 {
161 return Ok(status);
162 }
163
164 let input_bytes = workflow.context().codec.encode(&input)?;
166 let first_task = workflow.continuation().first_task_hint();
167
168 let mut snapshot = match prepare_run(
169 &instance_id,
170 definition_hash,
171 input_bytes.clone(),
172 first_task,
173 self.backend.as_ref(),
174 conflict_policy,
175 )
176 .await?
177 {
178 PrepareRunOutcome::Fresh(s) => *s,
179 PrepareRunOutcome::ExistingStatus(status, _output) => {
180 return Ok(status);
181 }
182 };
183
184 let context = workflow.context().clone();
186 let continuation = workflow.continuation();
187 let task_index = workflow.task_index();
188 let backend = Arc::clone(&self.backend);
189
190 let result = Self::execute_with_checkpointing(
191 continuation,
192 task_index,
193 input_bytes,
194 &mut snapshot,
195 Arc::clone(&backend),
196 context,
197 )
198 .await;
199
200 let (status, _output) = finalize_execution(result, &mut snapshot, backend.as_ref()).await?;
201 Ok(status)
202 }
203
204 #[allow(clippy::needless_lifetimes)]
216 pub async fn resume<'w, C, Input, M>(
217 &self,
218 workflow: &'w Workflow<C, Input, M>,
219 instance_id: &str,
220 ) -> Result<WorkflowStatus, RuntimeError>
221 where
222 Input: Send + 'static,
223 M: Send + Sync + 'static,
224 C: Codec
225 + EnvelopeCodec
226 + sealed::DecodeValue<Input>
227 + sealed::EncodeValue<Input>
228 + 'static,
229 {
230 let mut snapshot = self.backend.load_snapshot(instance_id).await?;
232
233 if snapshot.definition_hash != *workflow.definition_hash() {
235 return Err(WorkflowError::DefinitionMismatch {
236 expected: *workflow.definition_hash(),
237 found: snapshot.definition_hash,
238 }
239 .into());
240 }
241
242 if let Some(status) = snapshot.state.as_terminal_status() {
244 return Ok(status);
245 }
246
247 let parked = ResumeParkedPosition::extract(&snapshot);
249 if let Some(status) = parked
250 .resolve(&mut snapshot, instance_id, self.backend.as_ref())
251 .await?
252 {
253 return Ok(status);
254 }
255
256 let context = workflow.context().clone();
258 let continuation = workflow.continuation();
259 let task_index = workflow.task_index();
260 let backend = Arc::clone(&self.backend);
261
262 let input_bytes = get_resume_input(&snapshot)?;
264
265 let result = Self::execute_with_checkpointing(
266 continuation,
267 task_index,
268 input_bytes,
269 &mut snapshot,
270 Arc::clone(&backend),
271 context,
272 )
273 .await;
274
275 let (status, _output) = finalize_execution(result, &mut snapshot, backend.as_ref()).await?;
276 Ok(status)
277 }
278
279 #[allow(clippy::manual_async_fn, clippy::too_many_lines)]
281 async fn execute_with_checkpointing<'a, C, M>(
282 continuation: &'a WorkflowContinuation,
283 task_index: &'a sayiir_core::TaskIndex,
284 input: Bytes,
285 snapshot: &'a mut WorkflowSnapshot,
286 backend: Arc<B>,
287 context: WorkflowContext<C, M>,
288 ) -> Result<Bytes, RuntimeError>
289 where
290 B: 'static,
291 C: Codec + EnvelopeCodec + 'static,
292 M: Send + Sync + 'static,
293 {
294 let mut current = continuation;
295 let mut current_input = input;
296
297 loop {
298 let step: StepResult = match current {
299 WorkflowContinuation::Task {
300 id,
301 func: Some(func),
302 timeout,
303 retry_policy,
304 ..
305 } => {
306 check_guards(
307 backend.as_ref(),
308 &snapshot.instance_id,
309 Some(sayiir_core::TaskId::from(id)),
310 )
311 .await?;
312 set_deadline_if_needed(id, timeout.as_ref(), snapshot, backend.as_ref())
313 .await?;
314
315 let output = retry_with_checkpoint(
316 id,
317 retry_policy.as_ref(),
318 timeout.as_ref(),
319 snapshot,
320 Some(backend.as_ref()),
321 async |snap| {
322 execute_or_skip_task(id, current_input.clone(), |i| func.run(i), snap)
323 .await
324 },
325 )
326 .await?;
327
328 if let Some(next_cont) = current.get_next() {
329 let next_name = next_cont.first_task_id().to_string();
330 let next_hash = sayiir_core::TaskId::from(next_name.as_str());
331 snapshot.set_task_hint(&TaskHint::new(
332 &next_name,
333 task_index.priority(&next_hash),
334 task_index.tags(&next_hash),
335 ));
336 snapshot.update_position(ExecutionPosition::AtTask { task_id: next_hash });
337 }
338 backend.save_snapshot(snapshot).await?;
339 check_guards(backend.as_ref(), &snapshot.instance_id, None).await?;
340
341 Ok(ControlFlow::Continue(output))
342 }
343 WorkflowContinuation::Task { func: None, id, .. } => {
344 return Err(WorkflowError::TaskNotImplemented(id.clone()).into());
345 }
346 WorkflowContinuation::Delay { id, duration, next } => {
347 check_guards(
348 backend.as_ref(),
349 &snapshot.instance_id,
350 Some(sayiir_core::TaskId::from(id)),
351 )
352 .await?;
353
354 if snapshot
355 .get_task_result(&sayiir_core::TaskId::from(id))
356 .is_some()
357 {
358 Ok(ControlFlow::Continue(current_input.clone()))
359 } else {
360 let wake_at = compute_wake_at(duration)?;
361 Ok(ControlFlow::Break(StepOutcome::Park(ParkReason::Delay {
362 delay_id: sayiir_core::TaskId::from(id.as_str()),
363 wake_at,
364 next_task: next.as_deref().map(WorkflowContinuation::first_task_hint),
365 passthrough: current_input.clone(),
366 })))
367 }
368 }
369 WorkflowContinuation::AwaitSignal {
370 id,
371 signal_name,
372 timeout,
373 next,
374 } => {
375 check_guards(
376 backend.as_ref(),
377 &snapshot.instance_id,
378 Some(sayiir_core::TaskId::from(id)),
379 )
380 .await?;
381
382 if snapshot
383 .get_task_result(&sayiir_core::TaskId::from(id))
384 .is_some()
385 {
386 let payload = snapshot
387 .get_task_result_bytes(&sayiir_core::TaskId::from(id))
388 .unwrap_or(current_input.clone());
389 Ok(ControlFlow::Continue(payload))
390 } else {
391 match backend
392 .consume_event(&snapshot.instance_id, signal_name)
393 .await
394 {
395 Ok(Some(payload)) => {
396 snapshot
397 .mark_task_completed(sayiir_core::TaskId::from(id), payload);
398 if let Some(next_cont) = next.as_deref() {
399 let next_name = next_cont.first_task_id().to_string();
400 let next_hash = sayiir_core::TaskId::from(next_name.as_str());
401 snapshot.set_task_hint(&TaskHint::new(
402 &next_name,
403 task_index.priority(&next_hash),
404 task_index.tags(&next_hash),
405 ));
406 snapshot.update_position(ExecutionPosition::AtTask {
407 task_id: next_hash,
408 });
409 }
410 backend.save_snapshot(snapshot).await?;
411 let output = snapshot
412 .get_task_result_bytes(&sayiir_core::TaskId::from(id))
413 .unwrap_or(current_input.clone());
414 Ok(ControlFlow::Continue(output))
415 }
416 Ok(None) => Ok(ControlFlow::Break(StepOutcome::Park(
417 ParkReason::AwaitingSignal {
418 signal_id: sayiir_core::TaskId::from(id.as_str()),
419 signal_name: signal_name.clone(),
420 timeout: compute_signal_timeout(timeout.as_ref()),
421 next_task: next
422 .as_deref()
423 .map(WorkflowContinuation::first_task_hint),
424 },
425 ))),
426 Err(e) => Err(RuntimeError::from(e)),
427 }
428 }
429 }
430 WorkflowContinuation::Fork {
431 id: fork_id,
432 branches,
433 join,
434 } => {
435 check_guards(backend.as_ref(), &snapshot.instance_id, None).await?;
436
437 let branch_results =
438 if let Some(cached) = collect_cached_branches(branches, snapshot) {
439 cached
440 } else {
441 let outcome = Self::execute_fork_branches_parallel(
442 branches,
443 ¤t_input,
444 snapshot,
445 &backend,
446 &context,
447 )
448 .await?;
449 settle_fork_outcome(
450 fork_id,
451 outcome,
452 join.as_deref(),
453 snapshot,
454 backend.as_ref(),
455 )
456 .await?
457 };
458
459 match resolve_join(join.as_deref(), &branch_results, context.codec.as_ref())? {
460 JoinResolution::Continue { input, .. } => Ok(ControlFlow::Continue(input)),
461 JoinResolution::Done(output) => {
462 Ok(ControlFlow::Break(StepOutcome::Done(output)))
463 }
464 }
465 }
466 WorkflowContinuation::Branch {
467 id,
468 key_fn: Some(key_fn),
469 branches,
470 default,
471 ..
472 } => {
473 check_guards(
474 backend.as_ref(),
475 &snapshot.instance_id,
476 Some(sayiir_core::TaskId::from(id)),
477 )
478 .await?;
479
480 if let Some(result) = snapshot.get_task_result(&sayiir_core::TaskId::from(id)) {
481 Ok(ControlFlow::Continue(result.output.clone()))
482 } else {
483 let key_bytes = key_fn
484 .run(current_input.clone())
485 .await
486 .map_err(RuntimeError::from)?;
487 let key: String = context
488 .codec
489 .decode_string(&key_bytes)
490 .map_err(RuntimeError::from)?;
491
492 let chosen = branches.get(&key).or(default.as_ref()).ok_or_else(|| {
493 WorkflowError::BranchKeyNotFound {
494 branch_id: id.clone(),
495 key: key.clone(),
496 }
497 })?;
498
499 let branch_output = Self::execute_branch_with_checkpoint(
500 chosen,
501 current_input.clone(),
502 Arc::clone(&backend),
503 snapshot.instance_id.clone(),
504 context.clone(),
505 )
506 .await?;
507
508 let envelope_bytes = context
509 .codec
510 .encode_branch_envelope(&key, &branch_output)
511 .map_err(RuntimeError::from)?;
512
513 snapshot.mark_task_completed(
514 sayiir_core::TaskId::from(id),
515 envelope_bytes.clone(),
516 );
517 backend.save_snapshot(snapshot).await?;
518
519 Ok(ControlFlow::Continue(envelope_bytes))
520 }
521 }
522 WorkflowContinuation::Branch {
523 key_fn: None, id, ..
524 } => {
525 return Err(WorkflowError::TaskNotImplemented(
526 sayiir_core::workflow::key_fn_id(id),
527 )
528 .into());
529 }
530 WorkflowContinuation::Loop {
531 id,
532 body,
533 max_iterations,
534 on_max,
535 ..
536 } => {
537 check_guards(
538 backend.as_ref(),
539 &snapshot.instance_id,
540 Some(sayiir_core::TaskId::from(id)),
541 )
542 .await?;
543
544 if let Some(result) = snapshot.get_task_result(&sayiir_core::TaskId::from(id)) {
545 Ok(ControlFlow::Continue(result.output.clone()))
546 } else {
547 let cfg = LoopConfig {
548 id,
549 body,
550 max_iterations: *max_iterations,
551 on_max: *on_max,
552 start_iteration: snapshot
553 .loop_iteration(&sayiir_core::TaskId::from(id)),
554 };
555 let mut loop_input = current_input.clone();
556 let mut final_output = None;
557
558 for iteration in cfg.start_iteration..cfg.max_iterations {
559 let output = Box::pin(Self::execute_with_checkpointing(
560 body,
561 task_index,
562 loop_input.clone(),
563 snapshot,
564 Arc::clone(&backend),
565 context.clone(),
566 ))
567 .await?;
568
569 let body_ser = body.to_serializable();
570 for tid in &body_ser.task_ids() {
571 snapshot.remove_task_result(&sayiir_core::TaskId::from(*tid));
572 }
573
574 match resolve_loop_iteration(&output, iteration, &cfg)? {
575 ControlFlow::Break(LoopExit(inner)) => {
576 snapshot.clear_loop_iteration(&sayiir_core::TaskId::from(id));
577 snapshot.mark_task_completed(
578 sayiir_core::TaskId::from(id),
579 inner.clone(),
580 );
581 backend.save_snapshot(snapshot).await?;
582 final_output = Some(inner);
583 break;
584 }
585 ControlFlow::Continue(LoopNext(inner)) => {
586 snapshot.set_loop_iteration(
587 sayiir_core::TaskId::from(id),
588 iteration + 1,
589 );
590 snapshot.update_position(ExecutionPosition::InLoop {
591 loop_id: sayiir_core::TaskId::from(id),
592 iteration: iteration + 1,
593 next_task_id: Some(sayiir_core::TaskId::from(
594 body.first_task_id(),
595 )),
596 });
597 backend.save_snapshot(snapshot).await?;
598 loop_input = inner;
599 }
600 }
601 }
602
603 match final_output {
604 Some(output) => Ok(ControlFlow::Continue(output)),
605 None => Err(RuntimeError::from(WorkflowError::MaxIterationsExceeded {
606 loop_id: sayiir_core::TaskId::from(id),
607 max_iterations: *max_iterations,
608 })),
609 }
610 }
611 }
612 WorkflowContinuation::ChildWorkflow { id, child, .. } => {
613 check_guards(
614 backend.as_ref(),
615 &snapshot.instance_id,
616 Some(sayiir_core::TaskId::from(id)),
617 )
618 .await?;
619
620 if let Some(result) = snapshot.get_task_result(&sayiir_core::TaskId::from(id)) {
621 Ok(ControlFlow::Continue(result.output.clone()))
622 } else {
623 let output = Box::pin(Self::execute_with_checkpointing(
624 child,
625 task_index,
626 current_input.clone(),
627 snapshot,
628 Arc::clone(&backend),
629 context.clone(),
630 ))
631 .await?;
632
633 snapshot.mark_task_completed(sayiir_core::TaskId::from(id), output.clone());
634 backend.save_snapshot(snapshot).await?;
635
636 Ok(ControlFlow::Continue(output))
637 }
638 }
639 };
640
641 match step? {
642 ControlFlow::Continue(output) => match current.get_next() {
643 Some(next) => {
644 current = next;
645 current_input = output;
646 }
647 None => return Ok(output),
648 },
649 ControlFlow::Break(StepOutcome::Done(output)) => return Ok(output),
650 ControlFlow::Break(StepOutcome::Park(reason)) => {
651 return Err(save_park_checkpoint(reason, snapshot, backend.as_ref()).await);
652 }
653 }
654 }
655 }
656
657 async fn execute_fork_branches_parallel<C, M>(
659 branches: &[Arc<WorkflowContinuation>],
660 input: &Bytes,
661 snapshot: &WorkflowSnapshot,
662 backend: &Arc<B>,
663 context: &WorkflowContext<C, M>,
664 ) -> Result<ForkBranchOutcome, RuntimeError>
665 where
666 B: 'static,
667 C: Codec + EnvelopeCodec + 'static,
668 M: Send + Sync + 'static,
669 {
670 let mut branch_results = Vec::with_capacity(branches.len());
671 let mut set = tokio::task::JoinSet::new();
672 let instance_id = snapshot.instance_id.clone();
673
674 for branch in branches {
675 let branch_name = branch.id().to_string();
676 let branch_hash = sayiir_core::TaskId::from(branch_name.as_str());
677
678 if let Some(result) = snapshot.get_task_result(&branch_hash) {
679 branch_results.push((branch_name, result.output.clone()));
680 } else {
681 let branch = Arc::clone(branch);
682 let branch_input = input.clone();
683 let branch_backend = Arc::clone(backend);
684 let branch_instance_id = instance_id.clone();
685 let ctx_for_work = context.clone();
686
687 set.spawn(async move {
688 let result = Self::execute_branch_with_checkpoint(
689 &branch,
690 branch_input,
691 branch_backend,
692 branch_instance_id,
693 ctx_for_work,
694 )
695 .await?;
696 Ok((branch_name, result))
697 });
698 }
699 }
700
701 let mut max_wake_at: Option<chrono::DateTime<chrono::Utc>> = None;
702
703 while let Some(result) = set.join_next().await {
704 match result {
705 Ok(Ok((branch_id, output))) => {
706 branch_results.push((branch_id, output));
707 }
708 Ok(Err(RuntimeError::Workflow(WorkflowError::Waiting { wake_at }))) => {
709 max_wake_at = Some(match max_wake_at {
710 Some(existing) => existing.max(wake_at),
711 None => wake_at,
712 });
713 }
714 Ok(Err(e)) => return Err(e),
715 Err(join_err) => return Err(RuntimeError::from(join_err)),
716 }
717 }
718
719 Ok(ForkBranchOutcome {
720 results: branch_results,
721 max_wake_at,
722 })
723 }
724
725 async fn execute_nested_fork_branches<C, M>(
730 branches: &[Arc<WorkflowContinuation>],
731 input: &Bytes,
732 backend: &Arc<B>,
733 instance_id: &str,
734 context: &WorkflowContext<C, M>,
735 ) -> Result<Vec<(String, Bytes)>, RuntimeError>
736 where
737 B: 'static,
738 C: Codec + EnvelopeCodec + 'static,
739 M: Send + Sync + 'static,
740 {
741 let mut set: tokio::task::JoinSet<Result<(String, Bytes), RuntimeError>> =
742 tokio::task::JoinSet::new();
743 let shared_instance_id: Arc<str> = Arc::from(instance_id);
744 for branch in branches {
745 let id = branch.id().to_string();
746 let branch = Arc::clone(branch);
747 let branch_input = input.clone();
748 let branch_backend = Arc::clone(backend);
749 let branch_instance_id = Arc::clone(&shared_instance_id);
750 let ctx_for_work = context.clone();
751
752 set.spawn(async move {
753 let result = Self::execute_branch_with_checkpoint(
754 &branch,
755 branch_input,
756 branch_backend,
757 branch_instance_id,
758 ctx_for_work,
759 )
760 .await?;
761 Ok((id, result))
762 });
763 }
764
765 let mut branch_results: Vec<(String, Bytes)> = Vec::with_capacity(set.len());
766 while let Some(res) = set.join_next().await {
767 branch_results.push(res??);
768 }
769 Ok(branch_results)
770 }
771
772 #[allow(clippy::manual_async_fn, clippy::too_many_lines)]
781 fn execute_branch_with_checkpoint<C, M>(
782 continuation: &WorkflowContinuation,
783 input: Bytes,
784 backend: Arc<B>,
785 instance_id: Arc<str>,
786 context: WorkflowContext<C, M>,
787 ) -> impl std::future::Future<Output = Result<Bytes, RuntimeError>> + Send + '_
788 where
789 B: 'static,
790 C: Codec + EnvelopeCodec + 'static,
791 M: Send + Sync + 'static,
792 {
793 async move {
794 let mut snapshot = backend.load_snapshot(&instance_id).await?;
795
796 let mut current = continuation;
797 let mut current_input = input;
798
799 loop {
800 let step: StepResult = match current {
801 WorkflowContinuation::Task {
802 id,
803 func,
804 timeout,
805 retry_policy,
806 ..
807 } => {
808 let func = func
809 .as_ref()
810 .ok_or_else(|| WorkflowError::TaskNotImplemented(id.clone()))?;
811
812 let output = loop {
813 match branch_execute_or_skip_task(
814 id,
815 current_input.clone(),
816 |i| func.run(i),
817 timeout.as_ref(),
818 &mut snapshot,
819 &instance_id,
820 backend.as_ref(),
821 )
822 .await
823 {
824 Ok(output) => {
825 snapshot.clear_retry_state(&sayiir_core::TaskId::from(id));
826 break output;
827 }
828 Err(e) => {
829 if let Some(rp) = retry_policy
830 && !snapshot
831 .retries_exhausted(&sayiir_core::TaskId::from(id))
832 {
833 let next_retry_at = snapshot.record_retry(
834 sayiir_core::TaskId::from(id),
835 rp,
836 &e.to_string(),
837 None,
838 );
839 snapshot.clear_task_deadline();
840 tracing::info!(
841 task_id = %id,
842 attempt = snapshot.get_retry_state(&sayiir_core::TaskId::from(id)).map_or(0, |rs| rs.attempts),
843 max_retries = rp.max_retries,
844 %next_retry_at,
845 error = %e,
846 "Retrying task (branch)"
847 );
848 let delay = (next_retry_at - chrono::Utc::now())
849 .to_std()
850 .unwrap_or_default();
851 tokio::time::sleep(delay).await;
852 continue;
853 }
854 return Err(e);
855 }
856 }
857 };
858 Ok(ControlFlow::Continue(output))
859 }
860 WorkflowContinuation::Delay { id, duration, .. } => {
861 if let Some(result) =
862 snapshot.get_task_result(&sayiir_core::TaskId::from(id))
863 {
864 tracing::debug!(delay_id = %id, "delay already completed in branch, skipping");
865 Ok(ControlFlow::Continue(result.output.clone()))
866 } else {
867 let wake_at = compute_wake_at(duration)?;
868 Ok(ControlFlow::Break(StepOutcome::Park(ParkReason::Delay {
869 delay_id: sayiir_core::TaskId::from(id),
870 wake_at,
871 next_task: None,
872 passthrough: current_input.clone(),
873 })))
874 }
875 }
876 WorkflowContinuation::AwaitSignal {
877 id,
878 signal_name,
879 timeout,
880 ..
881 } => {
882 if let Some(result) =
883 snapshot.get_task_result(&sayiir_core::TaskId::from(id))
884 {
885 tracing::debug!(signal_id = %id, %signal_name, "signal already consumed in branch, skipping");
886 Ok(ControlFlow::Continue(result.output.clone()))
887 } else {
888 let wake_at = compute_signal_timeout(timeout.as_ref());
889 Ok(ControlFlow::Break(StepOutcome::Park(
890 ParkReason::AwaitingSignal {
891 signal_id: sayiir_core::TaskId::from(id),
892 signal_name: signal_name.clone(),
893 timeout: wake_at,
894 next_task: None,
895 },
896 )))
897 }
898 }
899 WorkflowContinuation::Fork { branches, join, .. } => {
900 let branch_results = Self::execute_nested_fork_branches(
901 branches,
902 ¤t_input,
903 &backend,
904 &instance_id,
905 &context,
906 )
907 .await?;
908
909 match resolve_join(
910 join.as_deref(),
911 &branch_results,
912 context.codec.as_ref(),
913 )? {
914 JoinResolution::Continue { input, .. } => {
915 Ok(ControlFlow::Continue(input))
916 }
917 JoinResolution::Done(output) => {
918 Ok(ControlFlow::Break(StepOutcome::Done(output)))
919 }
920 }
921 }
922 WorkflowContinuation::Branch {
923 id,
924 key_fn: Some(key_fn),
925 branches,
926 default,
927 ..
928 } => {
929 if let Some(result) =
930 snapshot.get_task_result(&sayiir_core::TaskId::from(id))
931 {
932 Ok(ControlFlow::Continue(result.output.clone()))
933 } else {
934 let key_bytes = key_fn
935 .run(current_input.clone())
936 .await
937 .map_err(RuntimeError::from)?;
938 let key: String = context
939 .codec
940 .decode_string(&key_bytes)
941 .map_err(RuntimeError::from)?;
942
943 let chosen =
944 branches.get(&key).or(default.as_ref()).ok_or_else(|| {
945 WorkflowError::BranchKeyNotFound {
946 branch_id: id.clone(),
947 key: key.clone(),
948 }
949 })?;
950
951 let branch_output = Box::pin(Self::execute_branch_with_checkpoint(
952 chosen,
953 current_input.clone(),
954 Arc::clone(&backend),
955 instance_id.clone(),
956 context.clone(),
957 ))
958 .await?;
959
960 let envelope_bytes = context
961 .codec
962 .encode_branch_envelope(&key, &branch_output)
963 .map_err(RuntimeError::from)?;
964
965 snapshot.mark_task_completed(
966 sayiir_core::TaskId::from(id),
967 envelope_bytes.clone(),
968 );
969 backend.save_snapshot(&mut snapshot).await?;
970
971 Ok(ControlFlow::Continue(envelope_bytes))
972 }
973 }
974 WorkflowContinuation::Branch {
975 key_fn: None, id, ..
976 } => {
977 return Err(WorkflowError::TaskNotImplemented(
978 sayiir_core::workflow::key_fn_id(id),
979 )
980 .into());
981 }
982 WorkflowContinuation::Loop {
983 id,
984 body,
985 max_iterations,
986 on_max,
987 ..
988 } => {
989 if let Some(result) =
990 snapshot.get_task_result(&sayiir_core::TaskId::from(id))
991 {
992 Ok(ControlFlow::Continue(result.output.clone()))
993 } else {
994 let cfg = LoopConfig {
995 id,
996 body,
997 max_iterations: *max_iterations,
998 on_max: *on_max,
999 start_iteration: snapshot
1000 .loop_iteration(&sayiir_core::TaskId::from(id)),
1001 };
1002 let mut hooks = CheckpointingLoopHooks {
1003 snapshot: &mut snapshot,
1004 backend: backend.as_ref(),
1005 track_position: false,
1006 };
1007 let output = run_loop_async(
1008 &cfg,
1009 current_input.clone(),
1010 |input| {
1011 Box::pin(Self::execute_branch_with_checkpoint(
1012 body,
1013 input,
1014 Arc::clone(&backend),
1015 instance_id.clone(),
1016 context.clone(),
1017 ))
1018 },
1019 &mut hooks,
1020 )
1021 .await?;
1022 Ok(ControlFlow::Continue(output))
1023 }
1024 }
1025 WorkflowContinuation::ChildWorkflow { id, child, .. } => {
1026 if let Some(result) =
1027 snapshot.get_task_result(&sayiir_core::TaskId::from(id))
1028 {
1029 Ok(ControlFlow::Continue(result.output.clone()))
1030 } else {
1031 let output = Box::pin(Self::execute_branch_with_checkpoint(
1032 child,
1033 current_input.clone(),
1034 Arc::clone(&backend),
1035 instance_id.clone(),
1036 context.clone(),
1037 ))
1038 .await?;
1039
1040 snapshot
1041 .mark_task_completed(sayiir_core::TaskId::from(id), output.clone());
1042 backend.save_snapshot(&mut snapshot).await?;
1043
1044 Ok(ControlFlow::Continue(output))
1045 }
1046 }
1047 };
1048
1049 match step? {
1050 ControlFlow::Continue(output) => match current.get_next() {
1051 Some(next) => {
1052 current = next;
1053 current_input = output;
1054 }
1055 None => return Ok(output),
1056 },
1057 ControlFlow::Break(StepOutcome::Done(output)) => return Ok(output),
1058 ControlFlow::Break(StepOutcome::Park(reason)) => {
1059 return Err(save_branch_park_checkpoint(
1060 reason,
1061 &instance_id,
1062 backend.as_ref(),
1063 )
1064 .await);
1065 }
1066 }
1067 }
1068 }
1069 }
1070}
1071
1072#[cfg(test)]
1073#[allow(
1074 clippy::unwrap_used,
1075 clippy::expect_used,
1076 clippy::panic,
1077 clippy::indexing_slicing,
1078 clippy::too_many_lines,
1079 clippy::manual_let_else
1080)]
1081mod tests {
1082 use super::*;
1083 use crate::serialization::JsonCodec;
1084 use sayiir_core::codec::Encoder;
1085 use sayiir_core::context::WorkflowContext;
1086 use sayiir_core::error::BoxError;
1087 use sayiir_core::snapshot::SignalKind;
1088 use sayiir_core::snapshot::WorkflowSnapshotState;
1089 use sayiir_core::task::BranchOutputs;
1090 use sayiir_core::workflow::WorkflowBuilder;
1091 use sayiir_macros::BranchKey;
1092 use sayiir_persistence::InMemoryBackend;
1093 use sayiir_persistence::{SignalStore, SnapshotStore};
1094
1095 #[derive(BranchKey)]
1096 enum RouteKey {
1097 Billing,
1098 Tech,
1099 }
1100
1101 #[derive(BranchKey)]
1102 enum AbKey {
1103 A,
1104 B,
1105 }
1106
1107 fn ctx() -> WorkflowContext<JsonCodec, ()> {
1108 WorkflowContext::new("test-workflow", Arc::new(JsonCodec), Arc::new(()))
1109 }
1110
1111 #[tokio::test]
1116 async fn test_run_single_task() {
1117 let backend = InMemoryBackend::new();
1118 let runner = CheckpointingRunner::new(backend);
1119
1120 let workflow = WorkflowBuilder::new(ctx())
1121 .then("add_one", |i: u32| async move { Ok(i + 1) })
1122 .build()
1123 .unwrap();
1124
1125 let status = runner.run(&workflow, "inst-1", 5u32).await.unwrap();
1126 assert!(matches!(status, WorkflowStatus::Completed));
1127
1128 let snapshot = runner.backend().load_snapshot("inst-1").await.unwrap();
1130 assert!(snapshot.state.is_completed());
1131 }
1132
1133 #[tokio::test]
1134 async fn test_run_chained_tasks() {
1135 let backend = InMemoryBackend::new();
1136 let runner = CheckpointingRunner::new(backend);
1137
1138 let workflow = WorkflowBuilder::new(ctx())
1139 .then("add_one", |i: u32| async move { Ok(i + 1) })
1140 .then("double", |i: u32| async move { Ok(i * 2) })
1141 .build()
1142 .unwrap();
1143
1144 let status = runner.run(&workflow, "inst-1", 10u32).await.unwrap();
1145 assert!(matches!(status, WorkflowStatus::Completed));
1146
1147 let snapshot = runner.backend().load_snapshot("inst-1").await.unwrap();
1148 assert!(snapshot.state.is_completed());
1149 }
1150
1151 #[tokio::test]
1152 async fn test_run_three_task_chain() {
1153 let backend = InMemoryBackend::new();
1154 let runner = CheckpointingRunner::new(backend);
1155
1156 let workflow = WorkflowBuilder::new(ctx())
1157 .then("step1", |i: u32| async move { Ok(i + 1) })
1158 .then("step2", |i: u32| async move { Ok(i * 3) })
1159 .then("step3", |i: u32| async move { Ok(i - 2) })
1160 .build()
1161 .unwrap();
1162
1163 let status = runner.run(&workflow, "inst-1", 5u32).await.unwrap();
1164 assert!(matches!(status, WorkflowStatus::Completed));
1166 }
1167
1168 #[tokio::test]
1169 async fn test_run_task_failure() {
1170 let backend = InMemoryBackend::new();
1171 let runner = CheckpointingRunner::new(backend);
1172
1173 let workflow = WorkflowBuilder::new(ctx())
1174 .then("fail", |_i: u32| async move {
1175 Err::<u32, BoxError>("intentional failure".into())
1176 })
1177 .build()
1178 .unwrap();
1179
1180 let status = runner.run(&workflow, "inst-1", 1u32).await.unwrap();
1181 match status {
1182 WorkflowStatus::Failed(e) => {
1183 assert!(e.contains("intentional failure"));
1184 }
1185 _ => panic!("Expected Failed status"),
1186 }
1187
1188 let snapshot = runner.backend().load_snapshot("inst-1").await.unwrap();
1190 assert!(snapshot.state.is_failed());
1191 }
1192
1193 #[tokio::test]
1194 async fn test_run_fork_join() {
1195 let backend = InMemoryBackend::new();
1196 let runner = CheckpointingRunner::new(backend);
1197
1198 let workflow = WorkflowBuilder::new(ctx())
1199 .then("prepare", |i: u32| async move { Ok(i) })
1200 .branches(|b| {
1201 b.add("double", |i: u32| async move { Ok(i * 2) });
1202 b.add("add_ten", |i: u32| async move { Ok(i + 10) });
1203 })
1204 .join("combine", |outputs: BranchOutputs<JsonCodec>| async move {
1205 let doubled: u32 = outputs.get_by_id("double")?;
1206 let added: u32 = outputs.get_by_id("add_ten")?;
1207 Ok(doubled + added)
1208 })
1209 .build()
1210 .unwrap();
1211
1212 let status = runner.run(&workflow, "inst-1", 5u32).await.unwrap();
1213 assert!(matches!(status, WorkflowStatus::Completed));
1214
1215 let snapshot = runner.backend().load_snapshot("inst-1").await.unwrap();
1216 assert!(snapshot.state.is_completed());
1217 }
1218
1219 #[tokio::test]
1220 async fn test_run_checkpoints_intermediate_tasks() {
1221 let backend = InMemoryBackend::new();
1222 let runner = CheckpointingRunner::new(backend);
1223
1224 let workflow = WorkflowBuilder::new(ctx())
1225 .then("step1", |i: u32| async move { Ok(i + 1) })
1226 .then("step2", |i: u32| async move { Ok(i * 2) })
1227 .build()
1228 .unwrap();
1229
1230 let status = runner.run(&workflow, "inst-1", 10u32).await.unwrap();
1231 assert!(matches!(status, WorkflowStatus::Completed));
1232
1233 let snapshot = runner.backend().load_snapshot("inst-1").await.unwrap();
1236 assert!(snapshot.state.is_completed());
1237 }
1238
1239 #[tokio::test]
1244 async fn test_resume_completed_workflow() {
1245 let backend = InMemoryBackend::new();
1246 let runner = CheckpointingRunner::new(backend);
1247
1248 let workflow = WorkflowBuilder::new(ctx())
1249 .then("step1", |i: u32| async move { Ok(i + 1) })
1250 .build()
1251 .unwrap();
1252
1253 runner.run(&workflow, "inst-1", 5u32).await.unwrap();
1255
1256 let status = runner.resume(&workflow, "inst-1").await.unwrap();
1258 assert!(matches!(status, WorkflowStatus::Completed));
1259 }
1260
1261 #[tokio::test]
1262 async fn test_resume_failed_workflow() {
1263 let backend = InMemoryBackend::new();
1264 let runner = CheckpointingRunner::new(backend);
1265
1266 let workflow = WorkflowBuilder::new(ctx())
1267 .then("fail", |_i: u32| async move {
1268 Err::<u32, BoxError>("failure".into())
1269 })
1270 .build()
1271 .unwrap();
1272
1273 runner.run(&workflow, "inst-1", 1u32).await.unwrap();
1274
1275 let status = runner.resume(&workflow, "inst-1").await.unwrap();
1276 match status {
1277 WorkflowStatus::Failed(_) => {}
1278 _ => panic!("Expected Failed status"),
1279 }
1280 }
1281
1282 #[tokio::test]
1283 async fn test_resume_definition_hash_mismatch() {
1284 let backend = InMemoryBackend::new();
1285 let runner = CheckpointingRunner::new(backend);
1286
1287 let workflow1 = WorkflowBuilder::new(ctx())
1288 .then("step1", |i: u32| async move { Ok(i + 1) })
1289 .build()
1290 .unwrap();
1291
1292 runner.run(&workflow1, "inst-1", 5u32).await.unwrap();
1294
1295 let mut snapshot = WorkflowSnapshot::with_initial_input(
1297 "inst-2",
1298 *workflow1.definition_hash(),
1299 Bytes::from(serde_json::to_vec(&5u32).unwrap()),
1300 );
1301 snapshot.update_position(ExecutionPosition::AtTask {
1302 task_id: sayiir_core::TaskId::from("step1"),
1303 });
1304 runner.backend().save_snapshot(&mut snapshot).await.unwrap();
1305
1306 let workflow2 = WorkflowBuilder::new(ctx())
1308 .then("step1", |i: u32| async move { Ok(i + 1) })
1309 .then("step2", |i: u32| async move { Ok(i * 2) })
1310 .build()
1311 .unwrap();
1312
1313 let result = runner.resume(&workflow2, "inst-2").await;
1315 assert!(result.is_err());
1316 assert!(result.unwrap_err().to_string().contains("mismatch"));
1317 }
1318
1319 #[tokio::test]
1324 async fn test_cancel_running_workflow() {
1325 let backend = InMemoryBackend::new();
1326 let runner = CheckpointingRunner::new(backend);
1327
1328 let workflow = WorkflowBuilder::new(ctx())
1330 .then("slow_task", |i: u32| async move {
1331 tokio::time::sleep(std::time::Duration::from_secs(10)).await;
1332 Ok(i)
1333 })
1334 .build()
1335 .unwrap();
1336
1337 let input_bytes = Arc::new(JsonCodec).encode(&1u32).unwrap();
1339 let mut snapshot = WorkflowSnapshot::with_initial_input(
1340 "inst-cancel",
1341 *workflow.definition_hash(),
1342 input_bytes,
1343 );
1344 snapshot.update_position(ExecutionPosition::AtTask {
1345 task_id: sayiir_core::TaskId::from("slow_task"),
1346 });
1347 runner.backend().save_snapshot(&mut snapshot).await.unwrap();
1348
1349 let client = crate::WorkflowClient::from_shared(Arc::clone(runner.backend()));
1351 client
1352 .cancel(
1353 "inst-cancel",
1354 Some("testing".into()),
1355 Some("test-suite".into()),
1356 )
1357 .await
1358 .unwrap();
1359
1360 let req = runner
1362 .backend()
1363 .get_signal("inst-cancel", SignalKind::Cancel)
1364 .await
1365 .unwrap();
1366 assert!(req.is_some());
1367 assert_eq!(req.unwrap().reason, Some("testing".into()));
1368 }
1369
1370 #[tokio::test]
1371 async fn test_run_with_pre_cancellation() {
1372 let backend = InMemoryBackend::new();
1373 let runner = CheckpointingRunner::new(backend);
1374
1375 let workflow = WorkflowBuilder::new(ctx())
1376 .then("task1", |i: u32| async move { Ok(i + 1) })
1377 .then("task2", |i: u32| async move { Ok(i * 2) })
1378 .build()
1379 .unwrap();
1380
1381 let input_bytes = Arc::new(JsonCodec).encode(&1u32).unwrap();
1383 let mut snapshot = WorkflowSnapshot::with_initial_input(
1384 "inst-precancel",
1385 *workflow.definition_hash(),
1386 input_bytes,
1387 );
1388 snapshot.update_position(ExecutionPosition::AtTask {
1389 task_id: sayiir_core::TaskId::from("task1"),
1390 });
1391 runner.backend().save_snapshot(&mut snapshot).await.unwrap();
1392
1393 let client = crate::WorkflowClient::from_shared(Arc::clone(runner.backend()));
1394 client
1395 .cancel("inst-precancel", Some("pre-cancel".into()), None)
1396 .await
1397 .unwrap();
1398
1399 let status = runner.resume(&workflow, "inst-precancel").await.unwrap();
1401 match status {
1402 WorkflowStatus::Cancelled { reason, .. } => {
1403 assert_eq!(reason, Some("pre-cancel".into()));
1404 }
1405 _ => panic!("Expected Cancelled status, got: {status:?}"),
1406 }
1407 }
1408
1409 #[tokio::test]
1414 async fn test_resume_nonexistent_instance() {
1415 let backend = InMemoryBackend::new();
1416 let runner = CheckpointingRunner::new(backend);
1417
1418 let workflow = WorkflowBuilder::new(ctx())
1419 .then("task", |i: u32| async move { Ok(i) })
1420 .build()
1421 .unwrap();
1422
1423 let result = runner.resume(&workflow, "nonexistent").await;
1424 assert!(result.is_err());
1425 }
1426
1427 #[tokio::test]
1428 async fn test_run_failure_in_chain_saves_snapshot() {
1429 let backend = InMemoryBackend::new();
1430 let runner = CheckpointingRunner::new(backend);
1431
1432 let workflow = WorkflowBuilder::new(ctx())
1433 .then("step1", |i: u32| async move { Ok(i + 1) })
1434 .then("fail_step", |_i: u32| async move {
1435 Err::<u32, BoxError>("mid-chain failure".into())
1436 })
1437 .then("step3", |i: u32| async move { Ok(i * 2) })
1438 .build()
1439 .unwrap();
1440
1441 let status = runner.run(&workflow, "inst-1", 10u32).await.unwrap();
1442 match status {
1443 WorkflowStatus::Failed(e) => {
1444 assert!(e.contains("mid-chain failure"));
1445 }
1446 _ => panic!("Expected Failed"),
1447 }
1448
1449 let snapshot = runner.backend().load_snapshot("inst-1").await.unwrap();
1451 assert!(snapshot.state.is_failed());
1452 }
1453
1454 #[tokio::test]
1459 async fn test_run_workflow_with_delay_returns_waiting() {
1460 let backend = InMemoryBackend::new();
1461 let runner = CheckpointingRunner::new(backend);
1462
1463 let workflow = WorkflowBuilder::new(ctx())
1464 .then("step1", |i: u32| async move { Ok(i + 1) })
1465 .delay("wait_1h", std::time::Duration::from_hours(1))
1466 .then("step2", |i: u32| async move { Ok(i * 2) })
1467 .build()
1468 .unwrap();
1469
1470 let status = runner.run(&workflow, "inst-1", 10u32).await.unwrap();
1471
1472 match &status {
1474 WorkflowStatus::Waiting { delay_id, .. } => {
1475 assert_eq!(*delay_id, sayiir_core::TaskId::from("wait_1h"));
1476 }
1477 _ => panic!("Expected Waiting status, got {status:?}"),
1478 }
1479
1480 let snapshot = runner.backend().load_snapshot("inst-1").await.unwrap();
1482 assert!(snapshot.state.is_in_progress());
1483 match &snapshot.state {
1484 WorkflowSnapshotState::InProgress { position, .. } => match position {
1485 ExecutionPosition::AtDelay {
1486 delay_id,
1487 next_task_id,
1488 ..
1489 } => {
1490 assert_eq!(*delay_id, sayiir_core::TaskId::from("wait_1h"));
1491 assert_eq!(next_task_id, &Some(sayiir_core::TaskId::from("step2")));
1492 }
1493 other => panic!("Expected AtDelay, got {other:?}"),
1494 },
1495 _ => panic!("Expected InProgress"),
1496 }
1497
1498 assert!(
1500 snapshot
1501 .get_task_result(&sayiir_core::TaskId::from("step1"))
1502 .is_some()
1503 );
1504 assert!(
1506 snapshot
1507 .get_task_result(&sayiir_core::TaskId::from("wait_1h"))
1508 .is_some()
1509 );
1510 }
1511
1512 #[tokio::test]
1513 async fn test_resume_before_delay_expires_returns_waiting() {
1514 let backend = InMemoryBackend::new();
1515 let runner = CheckpointingRunner::new(backend);
1516
1517 let workflow = WorkflowBuilder::new(ctx())
1518 .then("step1", |i: u32| async move { Ok(i + 1) })
1519 .delay("wait_1h", std::time::Duration::from_hours(1))
1520 .then("step2", |i: u32| async move { Ok(i * 2) })
1521 .build()
1522 .unwrap();
1523
1524 let status = runner.run(&workflow, "inst-1", 10u32).await.unwrap();
1526 assert!(matches!(status, WorkflowStatus::Waiting { .. }));
1527
1528 let status = runner.resume(&workflow, "inst-1").await.unwrap();
1530 match &status {
1531 WorkflowStatus::Waiting { delay_id, .. } => {
1532 assert_eq!(*delay_id, sayiir_core::TaskId::from("wait_1h"));
1533 }
1534 _ => panic!("Expected Waiting on resume, got {status:?}"),
1535 }
1536 }
1537
1538 #[tokio::test]
1539 async fn test_resume_after_delay_expires_completes() {
1540 let backend = InMemoryBackend::new();
1541 let runner = CheckpointingRunner::new(backend);
1542
1543 let workflow = WorkflowBuilder::new(ctx())
1545 .then("step1", |i: u32| async move { Ok(i + 1) })
1546 .delay("wait_short", std::time::Duration::from_millis(1))
1547 .then("step2", |i: u32| async move { Ok(i * 2) })
1548 .build()
1549 .unwrap();
1550
1551 let status = runner.run(&workflow, "inst-1", 10u32).await.unwrap();
1553 assert!(matches!(status, WorkflowStatus::Waiting { .. }));
1554
1555 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1557
1558 let status = runner.resume(&workflow, "inst-1").await.unwrap();
1560 assert!(
1561 matches!(status, WorkflowStatus::Completed),
1562 "Expected Completed after delay expired, got {status:?}"
1563 );
1564
1565 let snapshot = runner.backend().load_snapshot("inst-1").await.unwrap();
1567 assert!(snapshot.state.is_completed());
1568 }
1569
1570 #[tokio::test]
1571 async fn test_cancel_during_delay() {
1572 let backend = InMemoryBackend::new();
1573 let runner = CheckpointingRunner::new(backend);
1574
1575 let workflow = WorkflowBuilder::new(ctx())
1576 .then("step1", |i: u32| async move { Ok(i + 1) })
1577 .delay("wait_1h", std::time::Duration::from_hours(1))
1578 .then("step2", |i: u32| async move { Ok(i * 2) })
1579 .build()
1580 .unwrap();
1581
1582 let status = runner.run(&workflow, "inst-1", 10u32).await.unwrap();
1584 assert!(matches!(status, WorkflowStatus::Waiting { .. }));
1585
1586 let client = crate::WorkflowClient::from_shared(Arc::clone(runner.backend()));
1588 client
1589 .cancel(
1590 "inst-1",
1591 Some("no longer needed".into()),
1592 Some("admin".into()),
1593 )
1594 .await
1595 .unwrap();
1596
1597 let status = runner.resume(&workflow, "inst-1").await.unwrap();
1599 match status {
1600 WorkflowStatus::Cancelled {
1601 reason,
1602 cancelled_by,
1603 } => {
1604 assert_eq!(reason, Some("no longer needed".into()));
1605 assert_eq!(cancelled_by, Some("admin".into()));
1606 }
1607 _ => panic!("Expected Cancelled status, got {status:?}"),
1608 }
1609 }
1610
1611 #[tokio::test]
1612 async fn test_delay_as_last_node() {
1613 let backend = InMemoryBackend::new();
1614 let runner = CheckpointingRunner::new(backend);
1615
1616 let workflow = WorkflowBuilder::new(ctx())
1617 .then("step1", |i: u32| async move { Ok(i + 1) })
1618 .delay("final_wait", std::time::Duration::from_millis(1))
1619 .build()
1620 .unwrap();
1621
1622 let status = runner.run(&workflow, "inst-1", 10u32).await.unwrap();
1624 assert!(matches!(status, WorkflowStatus::Waiting { .. }));
1625
1626 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1628
1629 let status = runner.resume(&workflow, "inst-1").await.unwrap();
1631 assert!(
1632 matches!(status, WorkflowStatus::Completed),
1633 "Expected Completed when delay is last node, got {status:?}"
1634 );
1635 }
1636
1637 #[tokio::test]
1638 async fn test_delay_data_passthrough() {
1639 let backend = InMemoryBackend::new();
1640 let runner = CheckpointingRunner::new(backend);
1641
1642 let workflow = WorkflowBuilder::new(ctx())
1644 .then("step1", |i: u32| async move { Ok(i + 1) })
1645 .delay("wait", std::time::Duration::from_millis(1))
1646 .then("step2", |i: u32| async move {
1647 assert_eq!(i, 11);
1649 Ok(i * 2)
1650 })
1651 .build()
1652 .unwrap();
1653
1654 runner.run(&workflow, "inst-1", 10u32).await.unwrap();
1656
1657 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1659 let status = runner.resume(&workflow, "inst-1").await.unwrap();
1660 assert!(matches!(status, WorkflowStatus::Completed));
1661 }
1662
1663 #[tokio::test]
1668 async fn test_run_task_timeout_fails_workflow() {
1669 use sayiir_core::task::TaskMetadata;
1670
1671 let backend = InMemoryBackend::new();
1672 let runner = CheckpointingRunner::new(backend);
1673
1674 let workflow = WorkflowBuilder::new(ctx())
1675 .with_registry()
1676 .then("slow_task", |i: u32| async move {
1677 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1678 Ok(i)
1679 })
1680 .with_metadata(TaskMetadata {
1681 timeout: Some(std::time::Duration::from_millis(5)),
1682 ..Default::default()
1683 })
1684 .build()
1685 .unwrap();
1686
1687 let status = runner
1688 .run(workflow.workflow(), "inst-timeout", 5u32)
1689 .await
1690 .unwrap();
1691 match status {
1692 WorkflowStatus::Failed(msg) => {
1693 assert!(
1694 msg.contains("timed out"),
1695 "Expected timeout error, got: {msg}"
1696 );
1697 let slow_hash = sayiir_core::TaskId::from("slow_task").to_hex();
1698 assert!(
1699 msg.contains(&slow_hash),
1700 "Expected task id hash in error, got: {msg}"
1701 );
1702 }
1703 other => panic!("Expected Failed status, got {other:?}"),
1704 }
1705 }
1706
1707 #[tokio::test]
1708 async fn test_run_task_within_timeout_succeeds() {
1709 use sayiir_core::task::TaskMetadata;
1710
1711 let backend = InMemoryBackend::new();
1712 let runner = CheckpointingRunner::new(backend);
1713
1714 let workflow = WorkflowBuilder::new(ctx())
1715 .with_registry()
1716 .then("fast_task", |i: u32| async move { Ok(i + 1) })
1717 .with_metadata(TaskMetadata {
1718 timeout: Some(std::time::Duration::from_secs(5)),
1719 ..Default::default()
1720 })
1721 .build()
1722 .unwrap();
1723
1724 let status = runner
1725 .run(workflow.workflow(), "inst-fast", 5u32)
1726 .await
1727 .unwrap();
1728 assert!(matches!(status, WorkflowStatus::Completed));
1729 }
1730
1731 #[tokio::test]
1732 async fn test_route_selects_correct_branch() {
1733 let backend = InMemoryBackend::new();
1734 let runner = CheckpointingRunner::new(backend.clone());
1735
1736 let workflow = WorkflowBuilder::new(ctx())
1737 .then("classify", |input: String| async move {
1738 Ok(serde_json::json!({ "intent": input }))
1739 })
1740 .route::<u32, RouteKey, _, _>(|data: serde_json::Value| async move {
1741 match data["intent"].as_str().unwrap_or("unknown") {
1742 "billing" => Ok(RouteKey::Billing),
1743 "tech" => Ok(RouteKey::Tech),
1744 other => Err(format!("unknown intent: {other}").into()),
1745 }
1746 })
1747 .branch(RouteKey::Billing, |sub| {
1748 sub.then("handle_billing", |_data: serde_json::Value| async move {
1749 Ok(100u32)
1750 })
1751 })
1752 .branch(RouteKey::Tech, |sub| {
1753 sub.then("handle_tech", |_data: serde_json::Value| async move {
1754 Ok(200u32)
1755 })
1756 })
1757 .done()
1758 .build()
1759 .unwrap();
1760
1761 let status = runner
1763 .run(&workflow, "inst-branch-1", "billing".to_string())
1764 .await
1765 .unwrap();
1766 assert!(matches!(status, WorkflowStatus::Completed));
1767
1768 let snapshot = backend.load_snapshot("inst-branch-1").await.unwrap();
1769 match &snapshot.state {
1772 WorkflowSnapshotState::Completed { final_output } => {
1773 let envelope: serde_json::Value = serde_json::from_slice(final_output).unwrap();
1774 assert_eq!(envelope["branch"], "billing");
1775 assert_eq!(envelope["result"], 100);
1776 }
1777 other => panic!("Expected Completed, got: {other:?}"),
1778 }
1779 }
1780
1781 #[tokio::test]
1782 async fn test_route_with_default() {
1783 let backend = InMemoryBackend::new();
1784 let runner = CheckpointingRunner::new(backend.clone());
1785
1786 let workflow = WorkflowBuilder::new(ctx())
1790 .route::<String, AbKey, _, _>(|input: String| async move {
1791 match input.as_str() {
1792 "a" => Ok(AbKey::A),
1793 "b" => Ok(AbKey::B),
1794 other => Err(format!("unknown: {other}").into()),
1795 }
1796 })
1797 .branch(AbKey::A, |sub| {
1798 sub.then("handle_a", |_data: String| async move {
1799 Ok("matched".to_string())
1800 })
1801 })
1802 .default_branch(|sub| {
1803 sub.then("handle_fallback", |_data: String| async move {
1804 Ok("fallback".to_string())
1805 })
1806 })
1807 .done()
1808 .build()
1809 .unwrap();
1810
1811 let status = runner
1813 .run(&workflow, "inst-branch-default", "b".to_string())
1814 .await
1815 .unwrap();
1816 assert!(matches!(status, WorkflowStatus::Completed));
1817
1818 let snapshot = backend.load_snapshot("inst-branch-default").await.unwrap();
1819 match &snapshot.state {
1820 WorkflowSnapshotState::Completed { final_output } => {
1821 let envelope: serde_json::Value = serde_json::from_slice(final_output).unwrap();
1822 assert_eq!(envelope["branch"], "b");
1823 assert_eq!(envelope["result"], "fallback");
1824 }
1825 other => panic!("Expected Completed, got: {other:?}"),
1826 }
1827 }
1828
1829 #[tokio::test]
1830 async fn test_route_missing_branches_detected() {
1831 let result = WorkflowBuilder::new(ctx())
1834 .route::<String, RouteKey, _, _>(|input: String| async move {
1835 match input.as_str() {
1836 "billing" => Ok(RouteKey::Billing),
1837 _ => Ok(RouteKey::Tech),
1838 }
1839 })
1840 .branch(RouteKey::Billing, |sub| {
1841 sub.then("handle_billing", |_data: String| async move {
1842 Ok("ok".to_string())
1843 })
1844 })
1845 .done()
1846 .build();
1847
1848 let errors = match result {
1849 Err(e) => e,
1850 Ok(_) => panic!("expected build error"),
1851 };
1852 let has_missing = errors.iter().any(|e| {
1853 matches!(
1854 e,
1855 sayiir_core::error::BuildError::MissingBranches {
1856 branch_id,
1857 missing_keys,
1858 } if branch_id == "branch_1" && missing_keys.contains(&"tech".to_string())
1859 )
1860 });
1861 assert!(has_missing, "Expected MissingBranches error in: {errors:?}");
1862 }
1863
1864 #[tokio::test]
1865 async fn test_route_then_next_step() {
1866 use sayiir_core::task::BranchEnvelope;
1867
1868 let backend = InMemoryBackend::new();
1869 let runner = CheckpointingRunner::new(backend.clone());
1870
1871 let workflow = WorkflowBuilder::new(ctx())
1872 .route::<u32, AbKey, _, _>(|input: String| async move {
1873 match input.as_str() {
1874 "a" => Ok(AbKey::A),
1875 "b" => Ok(AbKey::B),
1876 other => Err(format!("unknown: {other}").into()),
1877 }
1878 })
1879 .branch(AbKey::A, |sub| {
1880 sub.then("handle_a", |_data: String| async move { Ok(10u32) })
1881 })
1882 .branch(AbKey::B, |sub| {
1883 sub.then("handle_b", |_data: String| async move { Ok(20u32) })
1884 })
1885 .done()
1886 .then("finalize", |env: BranchEnvelope<u32>| async move {
1887 Ok(env.result + 1)
1888 })
1889 .build()
1890 .unwrap();
1891
1892 let status = runner
1893 .run(&workflow, "inst-branch-next", "a".to_string())
1894 .await
1895 .unwrap();
1896 assert!(matches!(status, WorkflowStatus::Completed));
1897
1898 let snapshot = backend.load_snapshot("inst-branch-next").await.unwrap();
1899 match &snapshot.state {
1900 WorkflowSnapshotState::Completed { final_output } => {
1901 let val: u32 = serde_json::from_slice(final_output).unwrap();
1902 assert_eq!(val, 11); }
1904 other => panic!("Expected Completed, got: {other:?}"),
1905 }
1906 }
1907}