Skip to main content

sayiir_runtime/runner/
distributed.rs

1//! Checkpointing workflow runner for single-process execution with persistence.
2//!
3//! This runner executes an entire workflow within a single process while saving
4//! snapshots after each task completion. This enables crash recovery and resumption
5//! without requiring multiple workers.
6//!
7//! **Use this when**: You want to run a workflow reliably on a single node with
8//! the ability to resume after crashes.
9//!
10//! **Use [`PooledWorker`](crate::worker::PooledWorker) instead when**: You need
11//! horizontal scaling with multiple workers collaborating on tasks.
12
13use 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
40/// A single-process workflow runner with checkpointing for crash recovery.
41///
42/// `CheckpointingRunner` executes an entire workflow within one process,
43/// saving snapshots after each task. Fork branches run concurrently as tokio tasks.
44/// If the process crashes, the workflow can be resumed from the last checkpoint.
45///
46/// # When to Use
47///
48/// - **Single-node execution**: One process runs the entire workflow
49/// - **Crash recovery**: Resume from the last completed task after restart
50/// - **Simple deployment**: No coordination between workers needed
51///
52/// For horizontal scaling with multiple workers, use [`PooledWorker`](crate::worker::PooledWorker).
53///
54/// # Example
55///
56/// ```rust,no_run
57/// # use sayiir_runtime::prelude::*;
58/// # use std::sync::Arc;
59/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
60/// let backend = InMemoryBackend::new();
61/// let runner = CheckpointingRunner::new(backend);
62///
63/// let ctx = WorkflowContext::new("my-workflow", Arc::new(JsonCodec), Arc::new(()));
64/// let workflow = WorkflowBuilder::new(ctx)
65///     .then("step1", |i: u32| async move { Ok(i + 1) })
66///     .build()?;
67///
68/// // Run workflow - snapshots are saved automatically
69/// let status = runner.run(&workflow, "instance-123", 1u32).await?;
70///
71/// // Resume from checkpoint if needed (e.g., after crash)
72/// let status = runner.resume(&workflow, "instance-123").await?;
73/// # Ok(())
74/// # }
75/// ```
76pub struct CheckpointingRunner<B> {
77    backend: Arc<B>,
78    conflict_policy: ConflictPolicy,
79}
80
81impl<B> CheckpointingRunner<B>
82where
83    B: PersistentBackend,
84{
85    /// Create a new checkpointing runner with the given backend.
86    pub fn new(backend: B) -> Self {
87        Self {
88            backend: Arc::new(backend),
89            conflict_policy: ConflictPolicy::default(),
90        }
91    }
92
93    /// Create a runner from a shared backend reference.
94    ///
95    /// Useful when the same backend is shared with a [`WorkflowClient`](crate::WorkflowClient).
96    pub fn from_shared(backend: Arc<B>) -> Self {
97        Self {
98            backend,
99            conflict_policy: ConflictPolicy::default(),
100        }
101    }
102
103    /// Set the conflict policy for duplicate instance IDs.
104    #[must_use]
105    pub fn with_conflict_policy(mut self, policy: ConflictPolicy) -> Self {
106        self.conflict_policy = policy;
107        self
108    }
109
110    /// Get a reference to the backend.
111    #[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    /// Run a workflow from the beginning, saving checkpoints after each task.
122    ///
123    /// The `instance_id` uniquely identifies this workflow execution instance.
124    /// The [`ConflictPolicy`] (set via [`with_conflict_policy`](Self::with_conflict_policy))
125    /// controls behaviour when a snapshot with this ID already exists.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if the workflow cannot be executed, if snapshot
130    /// operations fail, or if the conflict policy rejects a duplicate.
131    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        // Phase 1: check for existing instance before encoding input.
153        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        // Phase 2: encode input and prepare snapshot.
165        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        // Execute workflow with checkpointing
185        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    /// Resume a workflow from a saved snapshot.
205    ///
206    /// Loads the snapshot for the given instance ID and continues execution
207    /// from the last checkpoint.
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if:
212    /// - The snapshot is not found
213    /// - The workflow definition hash doesn't match (workflow definition changed)
214    /// - The workflow cannot be resumed
215    #[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        // Load snapshot
231        let mut snapshot = self.backend.load_snapshot(instance_id).await?;
232
233        // Validate definition hash
234        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        // Check if already in terminal state
243        if let Some(status) = snapshot.state.as_terminal_status() {
244            return Ok(status);
245        }
246
247        // Resolve any parked position (delay / fork) before resuming.
248        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        // Resume execution
257        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        // Get the last completed task's output or initial input
263        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    /// Execute continuation with checkpointing after each task (iterative, no boxing).
280    #[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                                &current_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    /// Execute fork branches in parallel using tokio tasks.
658    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    /// Execute nested fork branches in parallel within a branch.
726    ///
727    /// Spawns each branch as a tokio task, collects all results, and propagates
728    /// errors (including `JoinError`).
729    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    /// Execute branch continuation with per-task checkpointing (iterative, no boxing).
773    ///
774    /// Unlike `execute_with_checkpointing`, this doesn't update position tracking
775    /// (branches run independently). It saves each task result directly to the backend.
776    ///
777    /// On resume after `AtFork`, the backend snapshot contains sub-task results from
778    /// the previous execution. This function loads the snapshot to skip cached tasks
779    /// and parks at delays instead of sleeping through them.
780    #[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                            &current_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    // ========================================================================
1112    // Run (fresh execution)
1113    // ========================================================================
1114
1115    #[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        // Verify snapshot was saved as completed
1129        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        // 5+1=6, 6*3=18, 18-2=16
1165        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        // Snapshot should be marked as failed
1189        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        // The final snapshot should be completed, but we can verify the
1234        // instance was tracked throughout
1235        let snapshot = runner.backend().load_snapshot("inst-1").await.unwrap();
1236        assert!(snapshot.state.is_completed());
1237    }
1238
1239    // ========================================================================
1240    // Resume
1241    // ========================================================================
1242
1243    #[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        // Run to completion
1254        runner.run(&workflow, "inst-1", 5u32).await.unwrap();
1255
1256        // Resume should return Completed immediately
1257        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        // Run with workflow1
1293        runner.run(&workflow1, "inst-1", 5u32).await.unwrap();
1294
1295        // Manually create in-progress snapshot with workflow1's hash
1296        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        // Build a different workflow
1307        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        // Resume with different workflow definition should fail
1314        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    // ========================================================================
1320    // Cancellation
1321    // ========================================================================
1322
1323    #[tokio::test]
1324    async fn test_cancel_running_workflow() {
1325        let backend = InMemoryBackend::new();
1326        let runner = CheckpointingRunner::new(backend);
1327
1328        // Create a workflow with a slow task
1329        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        // Set up a snapshot as if it's in progress
1338        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        // Request cancellation via WorkflowClient
1350        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        // Verify cancellation request was stored
1361        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        // Save initial snapshot and request cancellation before running
1382        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        // Resume should detect cancellation
1400        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    // ========================================================================
1410    // Edge cases
1411    // ========================================================================
1412
1413    #[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        // Snapshot should be saved as failed
1450        let snapshot = runner.backend().load_snapshot("inst-1").await.unwrap();
1451        assert!(snapshot.state.is_failed());
1452    }
1453
1454    // ========================================================================
1455    // Delay tests
1456    // ========================================================================
1457
1458    #[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        // Should return Waiting (delay is 1 hour in the future)
1473        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        // Snapshot should be in-progress at AtDelay position
1481        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        // step1 should have been completed
1499        assert!(
1500            snapshot
1501                .get_task_result(&sayiir_core::TaskId::from("step1"))
1502                .is_some()
1503        );
1504        // delay pass-through should be stored
1505        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        // Run to delay
1525        let status = runner.run(&workflow, "inst-1", 10u32).await.unwrap();
1526        assert!(matches!(status, WorkflowStatus::Waiting { .. }));
1527
1528        // Resume immediately (delay hasn't expired)
1529        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        // Use a very short delay so it expires immediately
1544        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        // Run — delay is so short it should still park (snapshot is saved before checking time)
1552        let status = runner.run(&workflow, "inst-1", 10u32).await.unwrap();
1553        assert!(matches!(status, WorkflowStatus::Waiting { .. }));
1554
1555        // Wait a bit for the delay to definitely expire
1556        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1557
1558        // Resume — delay should have expired, execution continues
1559        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        // Verify final state
1566        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        // Run to delay
1583        let status = runner.run(&workflow, "inst-1", 10u32).await.unwrap();
1584        assert!(matches!(status, WorkflowStatus::Waiting { .. }));
1585
1586        // Cancel during delay via WorkflowClient
1587        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        // Resume should detect cancellation
1598        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        // Run to delay
1623        let status = runner.run(&workflow, "inst-1", 10u32).await.unwrap();
1624        assert!(matches!(status, WorkflowStatus::Waiting { .. }));
1625
1626        // Wait for delay to expire
1627        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1628
1629        // Resume — delay was the last node, should complete
1630        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        // step1 produces 11, delay passes it through, step2 receives 11 and doubles
1643        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                // Verify input is the passthrough value from step1
1648                assert_eq!(i, 11);
1649                Ok(i * 2)
1650            })
1651            .build()
1652            .unwrap();
1653
1654        // Run to delay
1655        runner.run(&workflow, "inst-1", 10u32).await.unwrap();
1656
1657        // Wait and resume
1658        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    // ========================================================================
1664    // Timeout tests
1665    // ========================================================================
1666
1667    #[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        // Route to "billing"
1762        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        // Workflow completed — check the final output (which is the branch envelope
1770        // since route is the last step)
1771        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        // With typed keys the default branch catches enum variants that
1787        // don't have an explicit `.branch()` call.  Route "b" has no
1788        // branch, so the default fires.
1789        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        // Send "b" — not explicitly branched, so the default fires
1812        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        // With typed keys, missing branches are caught at build time.
1832        // RouteKey has {billing, tech} but we only branch on billing → MissingBranches.
1833        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); // branch "a" returned 10, finalize adds 1
1903            }
1904            other => panic!("Expected Completed, got: {other:?}"),
1905        }
1906    }
1907}