Skip to main content

sayiir_core/
workflow.rs

1//! Workflow structures, continuation tree, and serializable representations.
2//!
3//! The continuation tree ([`WorkflowContinuation`]) is the in-memory
4//! representation of a workflow's execution graph. Each node is either a
5//! [`Task`](WorkflowContinuation::Task),
6//! [`Fork`](WorkflowContinuation::Fork),
7//! [`Delay`](WorkflowContinuation::Delay),
8//! [`AwaitSignal`](WorkflowContinuation::AwaitSignal),
9//! [`Branch`](WorkflowContinuation::Branch), or
10//! [`ChildWorkflow`](WorkflowContinuation::ChildWorkflow).
11//!
12//! [`SerializableContinuation`] strips out function pointers so the tree
13//! can be persisted and later rehydrated via a [`TaskRegistry`].
14
15use crate::context::WorkflowContext;
16use crate::task::{RetryPolicy, UntypedCoreTask};
17use sha2::{Digest, Sha256};
18use std::collections::{HashMap, HashSet};
19use std::marker::PhantomData;
20use std::ops::Deref;
21use std::sync::Arc;
22
23/// Policy for what happens when a loop reaches its maximum iteration count.
24#[derive(
25    Debug,
26    Clone,
27    Copy,
28    PartialEq,
29    Eq,
30    serde::Serialize,
31    serde::Deserialize,
32    strum::EnumString,
33    strum::Display,
34)]
35pub enum MaxIterationsPolicy {
36    /// Fail the workflow with a `MaxIterationsExceeded` error.
37    #[strum(serialize = "fail")]
38    Fail,
39    /// Exit the loop with the last iteration's output (unwrapped from `LoopResult`).
40    #[strum(serialize = "exit_with_last")]
41    ExitWithLast,
42}
43
44/// Generate a `find_duplicate_id` method for continuation-like enums
45///
46macro_rules! impl_find_duplicate_id {
47    ($name:ident, task_fields: { $($task_extra:tt)* }, delay_extra: { $($delay_extra:tt)* }, deref_branch: $deref:expr, deref_branch_map: $deref_map:expr) => {
48        impl $name {
49            pub(crate) fn find_duplicate_id(&self) -> Option<String> {
50                fn collect(cont: &$name, seen: &mut HashSet<String>) -> Option<String> {
51                    match cont {
52                        $name::Task { id, next, $($task_extra)* } => {
53                            if !seen.insert(id.clone()) {
54                                return Some(id.clone());
55                            }
56                            next.as_ref().and_then(|n| collect(n, seen))
57                        }
58                        $name::Fork { id, branches, join } => {
59                            if !seen.insert(id.clone()) {
60                                return Some(id.clone());
61                            }
62                            let deref_fn: fn(&_) -> &$name = $deref;
63                            branches
64                                .iter()
65                                .find_map(|b| collect(deref_fn(b), seen))
66                                .or_else(|| join.as_ref().and_then(|j| collect(j, seen)))
67                        }
68                        $name::Branch { id, branches, default, next, .. } => {
69                            if !seen.insert(id.clone()) {
70                                return Some(id.clone());
71                            }
72                            let deref_map_fn: fn(&_) -> &$name = $deref_map;
73                            branches
74                                .values()
75                                .find_map(|b| collect(deref_map_fn(b), seen))
76                                .or_else(|| default.as_ref().and_then(|d| collect(d, seen)))
77                                .or_else(|| next.as_ref().and_then(|n| collect(n, seen)))
78                        }
79                        $name::Delay { id, next, $($delay_extra)* }
80                        | $name::AwaitSignal { id, next, $($delay_extra)* } => {
81                            if !seen.insert(id.clone()) {
82                                return Some(id.clone());
83                            }
84                            next.as_ref().and_then(|n| collect(n, seen))
85                        }
86                        $name::Loop { id, body, next, .. } => {
87                            if !seen.insert(id.clone()) {
88                                return Some(id.clone());
89                            }
90                            collect(body, seen)
91                                .or_else(|| next.as_ref().and_then(|n| collect(n, seen)))
92                        }
93                        $name::ChildWorkflow { id, child, next } => {
94                            if !seen.insert(id.clone()) {
95                                return Some(id.clone());
96                            }
97                            collect(child, seen)
98                                .or_else(|| next.as_ref().and_then(|n| collect(n, seen)))
99                        }
100                    }
101                }
102                collect(self, &mut HashSet::new())
103            }
104        }
105    };
106}
107
108/// The kind of node in a workflow continuation tree.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::AsRefStr, strum::Display, strum::EnumString)]
110#[strum(serialize_all = "snake_case")]
111pub enum NodeKind {
112    /// A sequential task node.
113    Task,
114    /// A parallel fork node.
115    Fork,
116    /// A durable delay node.
117    Delay,
118    /// A signal-wait node.
119    AwaitSignal,
120    /// A conditional branching node.
121    Branch,
122    /// A loop node.
123    Loop,
124    /// A child workflow node.
125    ChildWorkflow,
126}
127
128/// Metadata about a single node in the workflow DAG, returned by
129/// topological iteration.
130#[derive(Debug, Clone)]
131pub struct NodeInfo<'a> {
132    /// Unique node identifier.
133    pub id: &'a str,
134    /// The structural kind of this node.
135    pub kind: NodeKind,
136    /// ID of the node that precedes this one in execution order.
137    /// `None` for the root node.
138    pub predecessor_id: Option<&'a str>,
139    /// Timeout (task timeout, delay duration, or signal timeout).
140    pub timeout: Option<std::time::Duration>,
141    /// Retry policy (only populated for [`NodeKind::Task`]).
142    pub retry_policy: Option<&'a RetryPolicy>,
143    /// Execution priority (only populated for [`NodeKind::Task`]).
144    pub priority: Option<u8>,
145    /// Affinity tags (only populated for [`NodeKind::Task`]).
146    pub tags: &'a [String],
147    /// Schema version string (only populated for [`NodeKind::Task`]).
148    pub version: Option<&'a str>,
149}
150
151/// Lazy, stack-based iterator over workflow nodes in topological order.
152///
153/// Created by [`WorkflowContinuation::iter_nodes`].
154pub struct NodeIter<'a> {
155    stack: Vec<(&'a WorkflowContinuation, Option<&'a str>)>,
156}
157
158const EMPTY_TAGS: &[String] = &[];
159
160impl<'a> Iterator for NodeIter<'a> {
161    type Item = NodeInfo<'a>;
162
163    #[allow(clippy::too_many_lines)]
164    fn next(&mut self) -> Option<Self::Item> {
165        let (cont, predecessor) = self.stack.pop()?;
166
167        let (id, kind, timeout, retry_policy, priority, tags, version) = match cont {
168            WorkflowContinuation::Task {
169                id,
170                timeout,
171                retry_policy,
172                priority,
173                tags,
174                version,
175                ..
176            } => (
177                id.as_str(),
178                NodeKind::Task,
179                *timeout,
180                retry_policy.as_ref(),
181                *priority,
182                tags.as_slice(),
183                version.as_deref(),
184            ),
185            WorkflowContinuation::Fork { id, .. } => (
186                id.as_str(),
187                NodeKind::Fork,
188                None,
189                None,
190                None,
191                EMPTY_TAGS,
192                None,
193            ),
194            WorkflowContinuation::Delay { id, duration, .. } => (
195                id.as_str(),
196                NodeKind::Delay,
197                Some(*duration),
198                None,
199                None,
200                EMPTY_TAGS,
201                None,
202            ),
203            WorkflowContinuation::AwaitSignal { id, timeout, .. } => (
204                id.as_str(),
205                NodeKind::AwaitSignal,
206                *timeout,
207                None,
208                None,
209                EMPTY_TAGS,
210                None,
211            ),
212            WorkflowContinuation::Branch { id, .. } => (
213                id.as_str(),
214                NodeKind::Branch,
215                None,
216                None,
217                None,
218                EMPTY_TAGS,
219                None,
220            ),
221            WorkflowContinuation::Loop { id, .. } => (
222                id.as_str(),
223                NodeKind::Loop,
224                None,
225                None,
226                None,
227                EMPTY_TAGS,
228                None,
229            ),
230            WorkflowContinuation::ChildWorkflow { id, .. } => (
231                id.as_str(),
232                NodeKind::ChildWorkflow,
233                None,
234                None,
235                None,
236                EMPTY_TAGS,
237                None,
238            ),
239        };
240
241        // Push children in reverse order so the first child is popped next.
242        match cont {
243            WorkflowContinuation::Task { id, next, .. }
244            | WorkflowContinuation::Delay { id, next, .. }
245            | WorkflowContinuation::AwaitSignal { id, next, .. } => {
246                if let Some(n) = next {
247                    self.stack.push((n, Some(id)));
248                }
249            }
250            WorkflowContinuation::Fork { id, branches, join } => {
251                if let Some(j) = join {
252                    self.stack.push((j, Some(id)));
253                }
254                for b in branches.iter().rev() {
255                    self.stack.push((b, Some(id)));
256                }
257            }
258            WorkflowContinuation::Branch {
259                id,
260                branches,
261                default,
262                next,
263                ..
264            } => {
265                if let Some(n) = next {
266                    self.stack.push((n, Some(id)));
267                }
268                if let Some(d) = default {
269                    self.stack.push((d, Some(id)));
270                }
271                // stable sort for deterministic iteration
272                let mut keys: Vec<&String> = branches.keys().collect();
273                keys.sort();
274                for k in keys.into_iter().rev() {
275                    self.stack.push((&branches[k], Some(id)));
276                }
277            }
278            WorkflowContinuation::Loop { id, body, next, .. } => {
279                if let Some(n) = next {
280                    self.stack.push((n, Some(id)));
281                }
282                self.stack.push((body, Some(id)));
283            }
284            WorkflowContinuation::ChildWorkflow {
285                id, child, next, ..
286            } => {
287                if let Some(n) = next {
288                    self.stack.push((n, Some(id)));
289                }
290                self.stack.push((child, Some(id)));
291            }
292        }
293
294        Some(NodeInfo {
295            id,
296            kind,
297            predecessor_id: predecessor,
298            timeout,
299            retry_policy,
300            priority,
301            tags,
302            version,
303        })
304    }
305}
306
307/// A workflow structure representing the tasks to execute.
308pub enum WorkflowContinuation {
309    /// A sequential task node.
310    Task {
311        /// Unique task identifier.
312        id: String,
313        /// Task implementation. `None` for registry-based execution
314        /// where tasks are looked up by `id` at runtime.
315        func: Option<UntypedCoreTask>,
316        /// Maximum time the task is allowed to run before being cancelled.
317        timeout: Option<std::time::Duration>,
318        /// Retry policy for failed task executions.
319        retry_policy: Option<RetryPolicy>,
320        /// Schema version string (included in definition hash).
321        version: Option<String>,
322        /// Execution priority (1–5). `None` inherits the default (Normal = 3).
323        priority: Option<u8>,
324        /// Affinity tags for worker routing.
325        tags: Vec<String>,
326        /// Next node in the chain.
327        next: Option<Box<WorkflowContinuation>>,
328    },
329    /// A parallel fork node.
330    Fork {
331        /// Fork identifier (derived from branch IDs).
332        id: String,
333        /// Parallel branch continuations.
334        branches: Box<[Arc<WorkflowContinuation>]>,
335        /// Optional join task after all branches complete.
336        join: Option<Box<WorkflowContinuation>>,
337    },
338    /// A durable delay node. Input passes through unchanged.
339    Delay {
340        /// Unique delay identifier.
341        id: String,
342        /// How long to wait.
343        duration: std::time::Duration,
344        /// Next node in the chain.
345        next: Option<Box<WorkflowContinuation>>,
346    },
347    /// Wait for an external signal (event). Input passes through unchanged
348    /// when no signal payload is provided; otherwise the signal payload
349    /// becomes the input to the next step.
350    AwaitSignal {
351        /// Unique signal-wait identifier.
352        id: String,
353        /// Name of the signal to wait for.
354        signal_name: String,
355        /// Optional timeout duration.
356        timeout: Option<std::time::Duration>,
357        /// Next node in the chain.
358        next: Option<Box<WorkflowContinuation>>,
359    },
360    /// Conditional branching node. A key function extracts a routing key
361    /// from the previous step's output and dispatches to one of the named
362    /// sub-continuations.
363    Branch {
364        /// Unique branch identifier.
365        id: String,
366        /// Key function implementation. `None` for registry-based execution
367        /// where the key function is looked up by [`key_fn_id`] at runtime.
368        key_fn: Option<UntypedCoreTask>,
369        /// Named branch continuations keyed by routing key.
370        branches: HashMap<String, Box<WorkflowContinuation>>,
371        /// Optional default branch if no key matches.
372        default: Option<Box<WorkflowContinuation>>,
373        /// Continuation after the chosen branch completes.
374        next: Option<Box<WorkflowContinuation>>,
375    },
376    /// A loop node. Repeatedly executes its body until the task returns
377    /// `LoopResult::Done`, or until `max_iterations` is reached.
378    Loop {
379        /// Unique loop identifier.
380        id: String,
381        /// The body continuation to execute on each iteration.
382        body: Box<WorkflowContinuation>,
383        /// Maximum number of iterations before applying `on_max` policy.
384        max_iterations: u32,
385        /// What to do when `max_iterations` is reached.
386        on_max: MaxIterationsPolicy,
387        /// Continuation after the loop completes.
388        next: Option<Box<WorkflowContinuation>>,
389    },
390    /// A child workflow node. Executes another workflow's continuation inline.
391    ChildWorkflow {
392        /// Unique child workflow identifier.
393        id: String,
394        /// The child workflow's continuation tree (inlined, not a reference).
395        child: Arc<WorkflowContinuation>,
396        /// Continuation after the child workflow completes.
397        next: Option<Box<WorkflowContinuation>>,
398    },
399}
400
401impl_find_duplicate_id!(
402    WorkflowContinuation,
403    task_fields: { .. },
404    delay_extra: { .. },
405    deref_branch: |b: &Arc<WorkflowContinuation>| -> &WorkflowContinuation { b },
406    deref_branch_map: |b: &WorkflowContinuation| -> &WorkflowContinuation { b }
407);
408
409/// Derive the key-function task ID for a Branch node.
410///
411/// By convention the key function is registered under `"{branch_id}::key_fn"`.
412/// This helper centralises that convention so callers don't repeat the suffix.
413#[must_use]
414pub fn key_fn_id(branch_id: &str) -> String {
415    format!("{branch_id}::key_fn")
416}
417
418/// Derive a loop node ID from a counter value.
419///
420/// By convention loop nodes are named `"loop_0"`, `"loop_1"`, etc.,
421/// matching the pattern used by branch nodes (`"branch_0"`, …).
422#[must_use]
423pub fn loop_node_id(counter: usize) -> String {
424    format!("loop_{counter}")
425}
426
427impl WorkflowContinuation {
428    /// Derive a fork ID from a list of branch IDs.
429    ///
430    /// The fork ID is a concatenation of branch IDs separated by `||`.
431    #[must_use]
432    pub fn derive_fork_id(branch_ids: &[&str]) -> String {
433        branch_ids.join("||")
434    }
435
436    /// Get the ID of this continuation node.
437    #[must_use]
438    pub fn id(&self) -> &str {
439        match self {
440            WorkflowContinuation::Task { id, .. }
441            | WorkflowContinuation::Fork { id, .. }
442            | WorkflowContinuation::Delay { id, .. }
443            | WorkflowContinuation::AwaitSignal { id, .. }
444            | WorkflowContinuation::Branch { id, .. }
445            | WorkflowContinuation::Loop { id, .. }
446            | WorkflowContinuation::ChildWorkflow { id, .. } => id,
447        }
448    }
449
450    /// Get the next continuation in the chain, if any.
451    ///
452    #[must_use]
453    pub fn get_next(&self) -> Option<&WorkflowContinuation> {
454        match self {
455            Self::Task { next, .. }
456            | Self::Delay { next, .. }
457            | Self::AwaitSignal { next, .. }
458            | Self::Branch { next, .. }
459            | Self::Loop { next, .. }
460            | Self::ChildWorkflow { next, .. } => next.as_deref(),
461            Self::Fork { join, .. } => join.as_deref(),
462        }
463    }
464
465    /// Get the first task ID from this continuation.
466    ///
467    /// For a `Task`, returns its ID. For a `Fork`, returns the first task ID
468    /// from the first branch.
469    #[must_use]
470    pub fn first_task_id(&self) -> &str {
471        match self {
472            WorkflowContinuation::Task { id, .. }
473            | WorkflowContinuation::Delay { id, .. }
474            | WorkflowContinuation::AwaitSignal { id, .. }
475            | WorkflowContinuation::Branch { id, .. } => id,
476            WorkflowContinuation::Fork { branches, .. } => {
477                if let Some(first_branch) = branches.first() {
478                    first_branch.first_task_id()
479                } else {
480                    "unknown"
481                }
482            }
483            WorkflowContinuation::Loop { body, .. } => body.first_task_id(),
484            WorkflowContinuation::ChildWorkflow { child, .. } => child.first_task_id(),
485        }
486    }
487
488    /// Get the execution priority of the first task in this continuation.
489    ///
490    /// Returns `Some(priority)` for `Task` nodes, `None` for non-task nodes
491    /// (Delay, Signal, Branch). Recurses through Fork, Loop, and `ChildWorkflow`.
492    #[must_use]
493    pub fn first_task_priority(&self) -> Option<u8> {
494        match self {
495            WorkflowContinuation::Task { priority, .. } => *priority,
496            WorkflowContinuation::Delay { .. }
497            | WorkflowContinuation::AwaitSignal { .. }
498            | WorkflowContinuation::Branch { .. } => None,
499            WorkflowContinuation::Fork { branches, .. } => {
500                branches.first().and_then(|b| b.first_task_priority())
501            }
502            WorkflowContinuation::Loop { body, .. } => body.first_task_priority(),
503            WorkflowContinuation::ChildWorkflow { child, .. } => child.first_task_priority(),
504        }
505    }
506
507    /// Get the affinity tags of the first task in this continuation.
508    ///
509    /// Returns the tags for `Task` nodes, empty for non-task nodes
510    /// (Delay, Signal, Branch). Recurses through Fork, Loop, and `ChildWorkflow`.
511    #[must_use]
512    pub fn first_task_tags(&self) -> Vec<String> {
513        match self {
514            WorkflowContinuation::Task { tags, .. } => tags.clone(),
515            WorkflowContinuation::Delay { .. }
516            | WorkflowContinuation::AwaitSignal { .. }
517            | WorkflowContinuation::Branch { .. } => vec![],
518            WorkflowContinuation::Fork { branches, .. } => branches
519                .first()
520                .map(|b| b.first_task_tags())
521                .unwrap_or_default(),
522            WorkflowContinuation::Loop { body, .. } => body.first_task_tags(),
523            WorkflowContinuation::ChildWorkflow { child, .. } => child.first_task_tags(),
524        }
525    }
526
527    /// Build a [`crate::snapshot::TaskHint`] from the first task in this continuation.
528    ///
529    /// Combines [`Self::first_task_id`], [`Self::first_task_priority`], and
530    /// [`Self::first_task_tags`] into a single struct for passing through
531    /// `prepare_run` and `ParkReason`.
532    #[must_use]
533    pub fn first_task_hint(&self) -> crate::snapshot::TaskHint {
534        crate::snapshot::TaskHint::new(
535            self.first_task_id(),
536            self.first_task_priority(),
537            &self.first_task_tags(),
538        )
539    }
540
541    /// Get the terminal task ID of this continuation chain.
542    ///
543    /// Follows `get_next()` pointers to the end and returns the ID of the
544    /// last node. This is the task whose output is the "final" output of the
545    /// chain (e.g. the `LoopResult` envelope for a loop body).
546    #[must_use]
547    pub fn terminal_task_id(&self) -> &str {
548        let mut current = self;
549        while let Some(next) = current.get_next() {
550            current = next;
551        }
552        current.first_task_id()
553    }
554
555    /// Find a task node by ID (immutable).
556    ///
557    /// Recursively walks the full continuation tree, including through `Arc`
558    /// fork branches, and returns a reference to the matching `Task` node.
559    /// Resolve a [`TaskId`](crate::TaskId) back to the human-readable task
560    /// name stored on the matching `Task` node, if any. O(N) tree walk.
561    #[must_use]
562    pub fn find_task_name(&self, target_id: &crate::TaskId) -> Option<&str> {
563        self.find_task(target_id).and_then(|n| match n {
564            WorkflowContinuation::Task { id, .. } => Some(id.as_str()),
565            _ => None,
566        })
567    }
568
569    fn find_task(&self, target_id: &crate::TaskId) -> Option<&Self> {
570        match self {
571            WorkflowContinuation::Task { id, next, .. } => {
572                if crate::TaskId::from(id.as_str()) == *target_id {
573                    return Some(self);
574                }
575                next.as_ref().and_then(|n| n.find_task(target_id))
576            }
577            WorkflowContinuation::Delay { next, .. }
578            | WorkflowContinuation::AwaitSignal { next, .. } => {
579                next.as_ref().and_then(|n| n.find_task(target_id))
580            }
581            WorkflowContinuation::Fork { branches, join, .. } => {
582                for branch in branches {
583                    if let Some(found) = branch.find_task(target_id) {
584                        return Some(found);
585                    }
586                }
587                join.as_ref().and_then(|j| j.find_task(target_id))
588            }
589            WorkflowContinuation::Branch {
590                branches,
591                default,
592                next,
593                ..
594            } => {
595                for branch in branches.values() {
596                    if let Some(found) = branch.find_task(target_id) {
597                        return Some(found);
598                    }
599                }
600                if let Some(d) = default
601                    && let Some(found) = d.find_task(target_id)
602                {
603                    return Some(found);
604                }
605                next.as_ref().and_then(|n| n.find_task(target_id))
606            }
607            WorkflowContinuation::Loop { body, next, .. } => body
608                .find_task(target_id)
609                .or_else(|| next.as_ref().and_then(|n| n.find_task(target_id))),
610            WorkflowContinuation::ChildWorkflow { child, next, .. } => child
611                .find_task(target_id)
612                .or_else(|| next.as_ref().and_then(|n| n.find_task(target_id))),
613        }
614    }
615
616    /// Find a task node by ID (mutable).
617    ///
618    /// Same traversal as [`find_task`](Self::find_task) but returns a mutable
619    /// reference. Fork branches behind `Arc` are skipped since they cannot be
620    /// mutated; only the join continuation is searched.
621    fn find_task_mut(&mut self, target_id: &crate::TaskId) -> Option<&mut Self> {
622        match self {
623            WorkflowContinuation::Task { id, .. }
624                if crate::TaskId::from(id.as_str()) == *target_id =>
625            {
626                Some(self)
627            }
628            WorkflowContinuation::Task { next, .. } => {
629                next.as_mut().and_then(|n| n.find_task_mut(target_id))
630            }
631            WorkflowContinuation::Delay { next, .. }
632            | WorkflowContinuation::AwaitSignal { next, .. } => {
633                next.as_mut().and_then(|n| n.find_task_mut(target_id))
634            }
635            WorkflowContinuation::Fork { join, .. } => {
636                join.as_mut().and_then(|j| j.find_task_mut(target_id))
637            }
638            WorkflowContinuation::Branch {
639                branches,
640                default,
641                next,
642                ..
643            } => {
644                for branch in branches.values_mut() {
645                    if let Some(found) = branch.find_task_mut(target_id) {
646                        return Some(found);
647                    }
648                }
649                if let Some(d) = default
650                    && let Some(found) = d.find_task_mut(target_id)
651                {
652                    return Some(found);
653                }
654                next.as_mut().and_then(|n| n.find_task_mut(target_id))
655            }
656            WorkflowContinuation::Loop { body, next, .. } => {
657                if let Some(found) = body.find_task_mut(target_id) {
658                    return Some(found);
659                }
660                next.as_mut().and_then(|n| n.find_task_mut(target_id))
661            }
662            WorkflowContinuation::ChildWorkflow { next, .. } => {
663                // Arc child branches cannot be mutated; only search next.
664                next.as_mut().and_then(|n| n.find_task_mut(target_id))
665            }
666        }
667    }
668
669    /// Set the timeout on a specific task node found by ID.
670    pub fn set_task_timeout(
671        &mut self,
672        target_id: &crate::TaskId,
673        timeout: Option<std::time::Duration>,
674    ) {
675        if let Some(WorkflowContinuation::Task { timeout: t, .. }) = self.find_task_mut(target_id) {
676            *t = timeout;
677        }
678    }
679
680    /// Set the retry policy on a specific task node found by ID.
681    pub fn set_task_retry_policy(
682        &mut self,
683        target_id: &crate::TaskId,
684        policy: Option<RetryPolicy>,
685    ) {
686        if let Some(WorkflowContinuation::Task { retry_policy, .. }) = self.find_task_mut(target_id)
687        {
688            *retry_policy = policy;
689        }
690    }
691
692    /// Set the schema version on a specific task node found by ID.
693    pub fn set_task_version(&mut self, target_id: &crate::TaskId, ver: Option<String>) {
694        if let Some(WorkflowContinuation::Task { version, .. }) = self.find_task_mut(target_id) {
695            *version = ver;
696        }
697    }
698
699    /// Look up the retry policy configured on a specific task by ID.
700    #[must_use]
701    pub fn get_task_retry_policy(&self, task_id: &crate::TaskId) -> Option<&RetryPolicy> {
702        match self.find_task(task_id)? {
703            WorkflowContinuation::Task { retry_policy, .. } => retry_policy.as_ref(),
704            _ => None,
705        }
706    }
707
708    /// Look up the timeout configured on a specific task by ID.
709    #[must_use]
710    pub fn get_task_timeout(&self, task_id: &crate::TaskId) -> Option<std::time::Duration> {
711        match self.find_task(task_id)? {
712            WorkflowContinuation::Task { timeout, .. } => *timeout,
713            _ => None,
714        }
715    }
716
717    /// Look up the priority configured on a specific task by ID.
718    #[must_use]
719    pub fn get_task_priority(&self, task_id: &crate::TaskId) -> Option<u8> {
720        match self.find_task(task_id)? {
721            WorkflowContinuation::Task { priority, .. } => *priority,
722            _ => None,
723        }
724    }
725
726    /// Look up the affinity tags configured on a specific task by ID.
727    #[must_use]
728    pub fn get_task_tags(&self, task_id: &crate::TaskId) -> Vec<String> {
729        match self.find_task(task_id) {
730            Some(WorkflowContinuation::Task { tags, .. }) => tags.clone(),
731            _ => vec![],
732        }
733    }
734
735    /// Set the affinity tags on a specific task node found by ID.
736    pub fn set_task_tags(&mut self, target_id: &crate::TaskId, new_tags: Vec<String>) {
737        if let Some(WorkflowContinuation::Task { tags, .. }) = self.find_task_mut(target_id) {
738            *tags = new_tags;
739        }
740    }
741
742    /// Build a [`TaskMetadata`](crate::task::TaskMetadata) from the fields
743    /// available on the continuation node for the given task.
744    ///
745    /// Only `timeout`, `retries`, `version`, and `tags` are populated — display
746    /// name and description are left as defaults since they are not stored in
747    /// the continuation tree.
748    #[must_use]
749    pub fn build_task_metadata(&self, task_id: &crate::TaskId) -> crate::task::TaskMetadata {
750        match self.find_task(task_id) {
751            Some(WorkflowContinuation::Task {
752                timeout,
753                retry_policy,
754                version,
755                priority,
756                tags,
757                ..
758            }) => crate::task::TaskMetadata::from_node_fields(
759                *timeout,
760                retry_policy.clone(),
761                version.clone(),
762                *priority,
763                tags.clone(),
764            ),
765            _ => crate::task::TaskMetadata::default(),
766        }
767    }
768
769    /// Returns a lazy iterator over all nodes in topological (execution) order.
770    ///
771    /// The traversal mirrors the order that the workflow engine would visit
772    /// each node during execution, making the result useful for introspection,
773    /// UI visualisation, and documentation generation.
774    ///
775    /// Each [`NodeInfo`] includes a `predecessor_id` linking back to the node
776    /// whose completion triggers this one. The root node has `None`.
777    #[must_use]
778    pub fn iter_nodes(&self) -> NodeIter<'_> {
779        NodeIter {
780            stack: vec![(self, None)],
781        }
782    }
783
784    /// Convert to a serializable representation (strips out task implementations).
785    #[must_use]
786    pub fn to_serializable(&self) -> SerializableContinuation {
787        match self {
788            #[allow(clippy::cast_possible_truncation)] // Durations > u64::MAX ms are not realistic
789            WorkflowContinuation::Task {
790                id,
791                timeout,
792                retry_policy,
793                version,
794                priority,
795                tags,
796                next,
797                ..
798            } => SerializableContinuation::Task {
799                id: id.clone(),
800                timeout_ms: timeout.map(|d| d.as_millis() as u64),
801                retry_policy: retry_policy.clone(),
802                version: version.clone(),
803                priority: *priority,
804                tags: tags.clone(),
805                next: next.as_ref().map(|n| Box::new(n.to_serializable())),
806            },
807            WorkflowContinuation::Fork { id, branches, join } => SerializableContinuation::Fork {
808                id: id.clone(),
809                branches: branches.iter().map(|b| b.to_serializable()).collect(),
810                join: join.as_ref().map(|j| Box::new(j.to_serializable())),
811            },
812            #[allow(clippy::cast_possible_truncation)] // Durations > u64::MAX ms are not realistic
813            WorkflowContinuation::Delay { id, duration, next } => SerializableContinuation::Delay {
814                id: id.clone(),
815                duration_ms: duration.as_millis() as u64,
816                next: next.as_ref().map(|n| Box::new(n.to_serializable())),
817            },
818            #[allow(clippy::cast_possible_truncation)]
819            WorkflowContinuation::AwaitSignal {
820                id,
821                signal_name,
822                timeout,
823                next,
824            } => SerializableContinuation::AwaitSignal {
825                id: id.clone(),
826                signal_name: signal_name.clone(),
827                timeout_ms: timeout.map(|d| d.as_millis() as u64),
828                next: next.as_ref().map(|n| Box::new(n.to_serializable())),
829            },
830            WorkflowContinuation::Branch {
831                id,
832                branches,
833                default,
834                next,
835                ..
836            } => SerializableContinuation::Branch {
837                id: id.clone(),
838                branches: branches
839                    .iter()
840                    .map(|(k, v)| (k.clone(), Box::new(v.to_serializable())))
841                    .collect(),
842                default: default.as_ref().map(|d| Box::new(d.to_serializable())),
843                next: next.as_ref().map(|n| Box::new(n.to_serializable())),
844            },
845            WorkflowContinuation::ChildWorkflow { id, child, next } => {
846                SerializableContinuation::ChildWorkflow {
847                    id: id.clone(),
848                    child: Box::new(child.to_serializable()),
849                    next: next.as_ref().map(|n| Box::new(n.to_serializable())),
850                }
851            }
852            WorkflowContinuation::Loop {
853                id,
854                body,
855                max_iterations,
856                on_max,
857                next,
858            } => SerializableContinuation::Loop {
859                id: id.clone(),
860                body: Box::new(body.to_serializable()),
861                max_iterations: *max_iterations,
862                on_max: *on_max,
863                next: next.as_ref().map(|n| Box::new(n.to_serializable())),
864            },
865        }
866    }
867
868    /// Append a new node to the end of this continuation chain.
869    ///
870    /// Recursively walks the chain to find the tail and attaches `new_node` there.
871    pub fn append_to_chain(&mut self, new_node: WorkflowContinuation) {
872        match self {
873            WorkflowContinuation::Task { next, .. }
874            | WorkflowContinuation::Delay { next, .. }
875            | WorkflowContinuation::AwaitSignal { next, .. }
876            | WorkflowContinuation::Branch { next, .. }
877            | WorkflowContinuation::Loop { next, .. }
878            | WorkflowContinuation::ChildWorkflow { next, .. } => match next {
879                Some(next_box) => next_box.append_to_chain(new_node),
880                None => *next = Some(Box::new(new_node)),
881            },
882            WorkflowContinuation::Fork { join, .. } => match join {
883                Some(join_box) => join_box.append_to_chain(new_node),
884                None => *join = Some(Box::new(new_node)),
885            },
886        }
887    }
888}
889
890/// A serializable workflow continuation (stores only IDs and structure).
891///
892/// This type can be serialized/deserialized and later converted back into a runnable
893/// `WorkflowContinuation` using a `TaskRegistry`.
894///
895/// # Serialization
896///
897/// ```rust
898/// # use sayiir_core::prelude::*;
899/// # use sayiir_core::codec::{Encoder, Decoder, sealed};
900/// # use sayiir_core::workflow::SerializableContinuation;
901/// # use bytes::Bytes;
902/// # use std::sync::Arc;
903/// # struct MyCodec;
904/// # impl Encoder for MyCodec {}
905/// # impl Decoder for MyCodec {}
906/// # impl<T> sealed::EncodeValue<T> for MyCodec {
907/// #     fn encode_value(&self, _: &T) -> Result<Bytes, BoxError> { Ok(Bytes::new()) }
908/// # }
909/// # impl<T> sealed::DecodeValue<T> for MyCodec {
910/// #     fn decode_value(&self, _: Bytes) -> Result<T, BoxError> { Err("dummy".into()) }
911/// # }
912/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
913/// # let codec = Arc::new(MyCodec);
914/// # let ctx = WorkflowContext::new("wf", codec.clone(), Arc::new(()));
915/// # let workflow = WorkflowBuilder::new(ctx)
916/// #     .with_registry()
917/// #     .then("step1", |i: u32| async move { Ok(i + 1) })
918/// #     .build()?;
919/// # let mut registry = TaskRegistry::new();
920/// # registry.register_fn("step1", codec, |i: u32| async move { Ok(i + 1) });
921/// // Serialize a workflow
922/// let serializable = workflow.continuation().to_serializable();
923/// let json = serde_json::to_string(&serializable)?;
924///
925/// // Deserialize and convert to runnable
926/// let serializable: SerializableContinuation = serde_json::from_str(&json)?;
927/// let continuation = serializable.to_runnable(&registry)?;
928/// # Ok(())
929/// # }
930/// ```
931#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
932pub enum SerializableContinuation {
933    /// A sequential task node.
934    Task {
935        /// Unique task identifier.
936        id: String,
937        /// Optional timeout in milliseconds.
938        #[serde(default, skip_serializing_if = "Option::is_none")]
939        timeout_ms: Option<u64>,
940        /// Optional retry policy.
941        #[serde(default, skip_serializing_if = "Option::is_none")]
942        retry_policy: Option<RetryPolicy>,
943        /// Schema version string (included in definition hash).
944        #[serde(default, skip_serializing_if = "Option::is_none")]
945        version: Option<String>,
946        /// Execution priority (1–5). `None` inherits the default (Normal = 3).
947        #[serde(default, skip_serializing_if = "Option::is_none")]
948        priority: Option<u8>,
949        /// Affinity tags for worker routing.
950        #[serde(default, skip_serializing_if = "Vec::is_empty")]
951        tags: Vec<String>,
952        /// Next node in the chain.
953        next: Option<Box<SerializableContinuation>>,
954    },
955    /// A parallel fork node.
956    Fork {
957        /// Fork identifier (derived from branch IDs).
958        id: String,
959        /// Parallel branches.
960        branches: Vec<SerializableContinuation>,
961        /// Optional join task after all branches complete.
962        join: Option<Box<SerializableContinuation>>,
963    },
964    /// A durable delay node.
965    Delay {
966        /// Unique delay identifier.
967        id: String,
968        /// Duration in milliseconds.
969        duration_ms: u64,
970        /// Next node in the chain.
971        next: Option<Box<SerializableContinuation>>,
972    },
973    /// A signal-wait node.
974    AwaitSignal {
975        /// Unique signal-wait identifier.
976        id: String,
977        /// Name of the signal to wait for.
978        signal_name: String,
979        /// Optional timeout in milliseconds.
980        #[serde(default, skip_serializing_if = "Option::is_none")]
981        timeout_ms: Option<u64>,
982        /// Next node in the chain.
983        next: Option<Box<SerializableContinuation>>,
984    },
985    /// A conditional branching node.
986    Branch {
987        /// Unique branch identifier.
988        id: String,
989        /// Named branch continuations keyed by routing key.
990        branches: HashMap<String, Box<SerializableContinuation>>,
991        /// Optional default branch if no key matches.
992        #[serde(default, skip_serializing_if = "Option::is_none")]
993        default: Option<Box<SerializableContinuation>>,
994        /// Continuation after the chosen branch completes.
995        next: Option<Box<SerializableContinuation>>,
996    },
997    /// A loop node.
998    Loop {
999        /// Unique loop identifier.
1000        id: String,
1001        /// The body continuation to execute on each iteration.
1002        body: Box<SerializableContinuation>,
1003        /// Maximum number of iterations.
1004        max_iterations: u32,
1005        /// What to do when `max_iterations` is reached.
1006        on_max: MaxIterationsPolicy,
1007        /// Continuation after the loop completes.
1008        next: Option<Box<SerializableContinuation>>,
1009    },
1010    /// A child workflow node.
1011    ChildWorkflow {
1012        /// Unique child workflow identifier.
1013        id: String,
1014        /// The child workflow's continuation tree.
1015        child: Box<SerializableContinuation>,
1016        /// Continuation after the child workflow completes.
1017        next: Option<Box<SerializableContinuation>>,
1018    },
1019}
1020
1021impl_find_duplicate_id!(
1022    SerializableContinuation,
1023    task_fields: { .. },
1024    delay_extra: { .. },
1025    deref_branch: |b: &SerializableContinuation| -> &SerializableContinuation { b },
1026    deref_branch_map: |b: &SerializableContinuation| -> &SerializableContinuation { b }
1027);
1028
1029impl SerializableContinuation {
1030    /// Convert this serializable continuation into a runnable `WorkflowContinuation`.
1031    ///
1032    /// Looks up each task ID in the registry to get the actual implementation.
1033    ///
1034    /// # Errors
1035    ///
1036    /// Returns `BuildError::TaskNotFound` if any task ID is not in the registry.
1037    pub fn to_runnable(
1038        &self,
1039        registry: &crate::registry::TaskRegistry,
1040    ) -> Result<WorkflowContinuation, crate::error::BuildError> {
1041        if let Some(dup) = self.find_duplicate_id() {
1042            return Err(crate::error::BuildError::DuplicateTaskId(dup));
1043        }
1044
1045        self.to_runnable_unchecked(registry)
1046    }
1047
1048    /// Convert without duplicate check (called after validation).
1049    #[allow(clippy::too_many_lines)]
1050    fn to_runnable_unchecked(
1051        &self,
1052        registry: &crate::registry::TaskRegistry,
1053    ) -> Result<WorkflowContinuation, crate::error::BuildError> {
1054        match self {
1055            SerializableContinuation::Task {
1056                id,
1057                timeout_ms,
1058                retry_policy,
1059                version,
1060                priority,
1061                tags,
1062                next,
1063            } => {
1064                let func = registry
1065                    .get(id)
1066                    .ok_or_else(|| crate::error::BuildError::TaskNotFound(id.clone()))?;
1067                let next = next
1068                    .as_ref()
1069                    .map(|n| n.to_runnable_unchecked(registry).map(Box::new))
1070                    .transpose()?;
1071                Ok(WorkflowContinuation::Task {
1072                    id: id.clone(),
1073                    func: Some(func),
1074                    timeout: timeout_ms.map(std::time::Duration::from_millis),
1075                    retry_policy: retry_policy.clone(),
1076                    version: version.clone(),
1077                    priority: *priority,
1078                    tags: tags.clone(),
1079                    next,
1080                })
1081            }
1082            SerializableContinuation::Fork { id, branches, join } => {
1083                let branches: Result<Vec<_>, _> = branches
1084                    .iter()
1085                    .map(|b| b.to_runnable_unchecked(registry).map(Arc::new))
1086                    .collect();
1087                let join = join
1088                    .as_ref()
1089                    .map(|j| j.to_runnable_unchecked(registry).map(Box::new))
1090                    .transpose()?;
1091                Ok(WorkflowContinuation::Fork {
1092                    id: id.clone(),
1093                    branches: branches?.into_boxed_slice(),
1094                    join,
1095                })
1096            }
1097            SerializableContinuation::Delay {
1098                id,
1099                duration_ms,
1100                next,
1101            } => {
1102                let next = next
1103                    .as_ref()
1104                    .map(|n| n.to_runnable_unchecked(registry).map(Box::new))
1105                    .transpose()?;
1106                Ok(WorkflowContinuation::Delay {
1107                    id: id.clone(),
1108                    duration: std::time::Duration::from_millis(*duration_ms),
1109                    next,
1110                })
1111            }
1112            SerializableContinuation::AwaitSignal {
1113                id,
1114                signal_name,
1115                timeout_ms,
1116                next,
1117            } => {
1118                let next = next
1119                    .as_ref()
1120                    .map(|n| n.to_runnable_unchecked(registry).map(Box::new))
1121                    .transpose()?;
1122                Ok(WorkflowContinuation::AwaitSignal {
1123                    id: id.clone(),
1124                    signal_name: signal_name.clone(),
1125                    timeout: timeout_ms.map(std::time::Duration::from_millis),
1126                    next,
1127                })
1128            }
1129            SerializableContinuation::Branch {
1130                id,
1131                branches,
1132                default,
1133                next,
1134            } => {
1135                let kf_id = key_fn_id(id);
1136                let key_fn = registry
1137                    .get(&kf_id)
1138                    .ok_or(crate::error::BuildError::TaskNotFound(kf_id))?;
1139                let branches: Result<HashMap<_, _>, _> = branches
1140                    .iter()
1141                    .map(|(k, v)| {
1142                        v.to_runnable_unchecked(registry)
1143                            .map(|c| (k.clone(), Box::new(c)))
1144                    })
1145                    .collect();
1146                let default = default
1147                    .as_ref()
1148                    .map(|d| d.to_runnable_unchecked(registry).map(Box::new))
1149                    .transpose()?;
1150                let next = next
1151                    .as_ref()
1152                    .map(|n| n.to_runnable_unchecked(registry).map(Box::new))
1153                    .transpose()?;
1154                Ok(WorkflowContinuation::Branch {
1155                    id: id.clone(),
1156                    key_fn: Some(key_fn),
1157                    branches: branches?,
1158                    default,
1159                    next,
1160                })
1161            }
1162            SerializableContinuation::Loop {
1163                id,
1164                body,
1165                max_iterations,
1166                on_max,
1167                next,
1168            } => {
1169                let body = body.to_runnable_unchecked(registry)?;
1170                let next = next
1171                    .as_ref()
1172                    .map(|n| n.to_runnable_unchecked(registry).map(Box::new))
1173                    .transpose()?;
1174                Ok(WorkflowContinuation::Loop {
1175                    id: id.clone(),
1176                    body: Box::new(body),
1177                    max_iterations: *max_iterations,
1178                    on_max: *on_max,
1179                    next,
1180                })
1181            }
1182            SerializableContinuation::ChildWorkflow { id, child, next } => {
1183                let child = child.to_runnable_unchecked(registry)?;
1184                let next = next
1185                    .as_ref()
1186                    .map(|n| n.to_runnable_unchecked(registry).map(Box::new))
1187                    .transpose()?;
1188                Ok(WorkflowContinuation::ChildWorkflow {
1189                    id: id.clone(),
1190                    child: Arc::new(child),
1191                    next,
1192                })
1193            }
1194        }
1195    }
1196
1197    /// Get all task IDs referenced in this continuation.
1198    #[must_use]
1199    pub fn task_ids(&self) -> Vec<&str> {
1200        fn collect<'a>(cont: &'a SerializableContinuation, ids: &mut Vec<&'a str>) {
1201            match cont {
1202                SerializableContinuation::Task { id, next, .. }
1203                | SerializableContinuation::Delay { id, next, .. }
1204                | SerializableContinuation::AwaitSignal { id, next, .. } => {
1205                    ids.push(id.as_str());
1206                    if let Some(n) = next {
1207                        collect(n, ids);
1208                    }
1209                }
1210                SerializableContinuation::Fork { id, branches, join } => {
1211                    ids.push(id.as_str());
1212                    for b in branches {
1213                        collect(b, ids);
1214                    }
1215                    if let Some(j) = join {
1216                        collect(j, ids);
1217                    }
1218                }
1219                SerializableContinuation::Branch {
1220                    id,
1221                    branches,
1222                    default,
1223                    next,
1224                } => {
1225                    ids.push(id.as_str());
1226                    for b in branches.values() {
1227                        collect(b, ids);
1228                    }
1229                    if let Some(d) = default {
1230                        collect(d, ids);
1231                    }
1232                    if let Some(n) = next {
1233                        collect(n, ids);
1234                    }
1235                }
1236                SerializableContinuation::Loop { id, body, next, .. } => {
1237                    ids.push(id.as_str());
1238                    collect(body, ids);
1239                    if let Some(n) = next {
1240                        collect(n, ids);
1241                    }
1242                }
1243                SerializableContinuation::ChildWorkflow { id, child, next } => {
1244                    ids.push(id.as_str());
1245                    collect(child, ids);
1246                    if let Some(n) = next {
1247                        collect(n, ids);
1248                    }
1249                }
1250            }
1251        }
1252        let mut ids = vec![];
1253        collect(self, &mut ids);
1254        ids
1255    }
1256
1257    /// Compute a SHA256 hash of this continuation's structure.
1258    ///
1259    /// This hash serves as a "version" identifier for the workflow definition.
1260    /// It can be used to detect when a serialized workflow state was created
1261    /// with a different workflow definition than the current one.
1262    ///
1263    /// The hash is computed from the canonical structure of task IDs and their
1264    /// arrangement.
1265    #[must_use]
1266    #[allow(clippy::too_many_lines)]
1267    pub fn compute_definition_hash(&self) -> crate::DefinitionHash {
1268        #[allow(clippy::too_many_lines)]
1269        fn hash_continuation(cont: &SerializableContinuation, hasher: &mut Sha256) {
1270            match cont {
1271                SerializableContinuation::Task {
1272                    id,
1273                    timeout_ms,
1274                    retry_policy,
1275                    version,
1276                    next,
1277                    ..
1278                } => {
1279                    hasher.update(b"T:"); // Tag for Task
1280                    hasher.update(id.as_bytes());
1281                    if let Some(ms) = timeout_ms {
1282                        hasher.update(b":t:");
1283                        hasher.update(ms.to_string().as_bytes());
1284                    }
1285                    if let Some(rp) = retry_policy {
1286                        hasher.update(b":r:");
1287                        hasher.update(rp.max_retries.to_string().as_bytes());
1288                        hasher.update(b":");
1289                        hasher.update(rp.initial_delay.as_millis().to_string().as_bytes());
1290                        hasher.update(b":");
1291                        hasher.update(rp.backoff_multiplier.to_string().as_bytes());
1292                    }
1293                    if let Some(v) = version {
1294                        hasher.update(b":v:");
1295                        hasher.update(v.as_bytes());
1296                    }
1297                    hasher.update(b";");
1298                    if let Some(n) = next {
1299                        hash_continuation(n, hasher);
1300                    }
1301                }
1302                SerializableContinuation::Fork { id, branches, join } => {
1303                    hasher.update(b"F:");
1304                    hasher.update(id.as_bytes());
1305                    hasher.update(b"[");
1306                    for branch in branches {
1307                        hash_continuation(branch, hasher);
1308                        hasher.update(b",");
1309                    }
1310                    hasher.update(b"]");
1311                    if let Some(j) = join {
1312                        hasher.update(b"J:");
1313                        hash_continuation(j, hasher);
1314                    }
1315                }
1316                SerializableContinuation::Delay {
1317                    id,
1318                    duration_ms,
1319                    next,
1320                } => {
1321                    hasher.update(b"D:");
1322                    hasher.update(id.as_bytes());
1323                    hasher.update(b":");
1324                    hasher.update(duration_ms.to_string().as_bytes());
1325                    hasher.update(b";");
1326                    if let Some(n) = next {
1327                        hash_continuation(n, hasher);
1328                    }
1329                }
1330                SerializableContinuation::AwaitSignal {
1331                    id,
1332                    signal_name,
1333                    timeout_ms,
1334                    next,
1335                } => {
1336                    hasher.update(b"S:");
1337                    hasher.update(id.as_bytes());
1338                    hasher.update(b":");
1339                    hasher.update(signal_name.as_bytes());
1340                    if let Some(ms) = timeout_ms {
1341                        hasher.update(b":t:");
1342                        hasher.update(ms.to_string().as_bytes());
1343                    }
1344                    hasher.update(b";");
1345                    if let Some(n) = next {
1346                        hash_continuation(n, hasher);
1347                    }
1348                }
1349                SerializableContinuation::Branch {
1350                    id,
1351                    branches,
1352                    default,
1353                    next,
1354                } => {
1355                    hasher.update(b"B:");
1356                    hasher.update(id.as_bytes());
1357                    hasher.update(b"{");
1358                    // Sort keys for deterministic hashing
1359                    let mut keys: Vec<&String> = branches.keys().collect();
1360                    keys.sort();
1361                    for key in keys {
1362                        hasher.update(key.as_bytes());
1363                        hasher.update(b"=>");
1364                        if let Some(branch) = branches.get(key) {
1365                            hash_continuation(branch, hasher);
1366                        }
1367                        hasher.update(b",");
1368                    }
1369                    hasher.update(b"}");
1370                    if let Some(d) = default {
1371                        hasher.update(b"_=>");
1372                        hash_continuation(d, hasher);
1373                    }
1374                    hasher.update(b";");
1375                    if let Some(n) = next {
1376                        hash_continuation(n, hasher);
1377                    }
1378                }
1379                SerializableContinuation::Loop {
1380                    id,
1381                    body,
1382                    max_iterations,
1383                    on_max,
1384                    next,
1385                } => {
1386                    hasher.update(b"L:");
1387                    hasher.update(id.as_bytes());
1388                    hasher.update(b":");
1389                    hasher.update(max_iterations.to_string().as_bytes());
1390                    hasher.update(b":");
1391                    hasher.update(on_max.to_string().as_bytes());
1392                    hasher.update(b"{");
1393                    hash_continuation(body, hasher);
1394                    hasher.update(b"}");
1395                    hasher.update(b";");
1396                    if let Some(n) = next {
1397                        hash_continuation(n, hasher);
1398                    }
1399                }
1400                SerializableContinuation::ChildWorkflow { id, child, next } => {
1401                    hasher.update(b"CW:");
1402                    hasher.update(id.as_bytes());
1403                    hasher.update(b"{");
1404                    hash_continuation(child, hasher);
1405                    hasher.update(b"}");
1406                    hasher.update(b";");
1407                    if let Some(n) = next {
1408                        hash_continuation(n, hasher);
1409                    }
1410                }
1411            }
1412        }
1413
1414        let mut hasher = Sha256::new();
1415        hash_continuation(self, &mut hasher);
1416        crate::DefinitionHash::from_hash(crate::Hash32::from_digest(hasher))
1417    }
1418}
1419
1420/// A complete serializable workflow state including version information.
1421///
1422/// This type wraps `SerializableContinuation` with workflow identification and
1423/// a definition hash that serves as a version check. When deserializing, the
1424/// hash is verified to ensure the serialized state matches the current workflow
1425/// definition.
1426#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1427pub struct SerializedWorkflowState {
1428    /// The workflow identifier.
1429    pub workflow_id: String,
1430    /// SHA256 hash of the workflow definition structure.
1431    /// Used to detect version mismatches during deserialization.
1432    pub definition_hash: crate::DefinitionHash,
1433    /// The serializable continuation structure.
1434    pub continuation: SerializableContinuation,
1435}
1436
1437/// Policy controlling what happens when a workflow `run()` is called
1438/// with an `instance_id` that already has a persisted snapshot.
1439#[derive(
1440    Debug,
1441    Clone,
1442    Copy,
1443    Default,
1444    PartialEq,
1445    Eq,
1446    strum::EnumString,
1447    strum::Display,
1448    strum::VariantNames,
1449)]
1450#[strum(serialize_all = "snake_case")]
1451pub enum ConflictPolicy {
1452    /// Return an error if the instance already exists (default).
1453    #[default]
1454    Fail,
1455    /// Reuse the existing snapshot: return its current status without re-executing.
1456    #[strum(serialize = "use_existing", serialize = "useExisting")]
1457    UseExisting,
1458    /// Terminate the existing instance (delete snapshot + clear signals) and start fresh.
1459    #[strum(serialize = "terminate_existing", serialize = "terminateExisting")]
1460    TerminateExisting,
1461}
1462
1463impl ConflictPolicy {
1464    /// Parse an optional conflict-policy string. `None` yields the default
1465    /// (`Fail`). On a malformed string, returns the original rejected value
1466    /// so callers can build a binding-flavored error envelope (`JsValue`,
1467    /// `napi::Error`, etc.).
1468    ///
1469    /// # Errors
1470    ///
1471    /// Returns the original `&str` on failure to parse.
1472    pub fn parse_optional(s: Option<&str>) -> Result<Self, &str> {
1473        match s {
1474            None => Ok(Self::default()),
1475            Some(val) => val.parse::<Self>().map_err(|_| val),
1476        }
1477    }
1478
1479    /// Canonical names accepted by [`Self::parse_optional`] (`snake_case`).
1480    ///
1481    /// Re-exports `<Self as strum::VariantNames>::VARIANTS` under a name
1482    /// that callers can use without depending on `strum` directly — handy
1483    /// for building error messages like
1484    /// `"valid values: fail, use_existing, terminate_existing"`.
1485    #[must_use]
1486    pub fn valid_names() -> &'static [&'static str] {
1487        <Self as strum::VariantNames>::VARIANTS
1488    }
1489}
1490
1491/// The status of a workflow execution.
1492#[derive(Debug, strum::AsRefStr, strum::EnumDiscriminants)]
1493#[strum_discriminants(name(WorkflowStatusKind))]
1494#[strum_discriminants(derive(strum::AsRefStr))]
1495#[strum_discriminants(strum(serialize_all = "snake_case"))]
1496#[strum_discriminants(doc = "Fieldless discriminant of [`WorkflowStatus`] for string comparisons.")]
1497pub enum WorkflowStatus {
1498    /// The workflow is still in progress (task completed, workflow continues).
1499    #[strum(serialize = "in_progress")]
1500    InProgress,
1501    /// The workflow completed successfully.
1502    #[strum(serialize = "completed")]
1503    Completed,
1504    /// The workflow failed with an error.
1505    #[strum(serialize = "failed")]
1506    Failed(String),
1507    /// The workflow was cancelled.
1508    #[strum(serialize = "cancelled")]
1509    Cancelled {
1510        /// Optional reason for the cancellation.
1511        reason: Option<String>,
1512        /// Optional identifier of who cancelled the workflow.
1513        cancelled_by: Option<String>,
1514    },
1515    /// The workflow was paused.
1516    #[strum(serialize = "paused")]
1517    Paused {
1518        /// Optional reason for the pause.
1519        reason: Option<String>,
1520        /// Optional identifier of who paused the workflow.
1521        paused_by: Option<String>,
1522    },
1523    /// The workflow is waiting for a delay to expire.
1524    #[strum(serialize = "waiting")]
1525    Waiting {
1526        /// When the delay expires.
1527        wake_at: chrono::DateTime<chrono::Utc>,
1528        /// The delay node ID.
1529        delay_id: crate::TaskId,
1530    },
1531    /// The workflow is waiting for an external signal.
1532    #[strum(serialize = "awaiting_signal")]
1533    AwaitingSignal {
1534        /// The signal node ID.
1535        signal_id: crate::TaskId,
1536        /// The named signal being waited on (user-defined string).
1537        signal_name: String,
1538        /// Optional timeout deadline.
1539        wake_at: Option<chrono::DateTime<chrono::Utc>>,
1540    },
1541}
1542
1543/// Flattened representation of [`WorkflowStatus`] for binding crates.
1544///
1545/// Both the Node.js and Python bindings expose a flat struct with string
1546/// fields to their respective languages. This struct holds the common
1547/// fields so bindings only need to map the language-specific output.
1548#[derive(Debug, Default)]
1549pub struct FlatWorkflowStatus {
1550    /// One of: `"completed"`, `"in_progress"`, `"failed"`, `"cancelled"`,
1551    /// `"paused"`, `"waiting"`, `"awaiting_signal"`.
1552    pub status: String,
1553    /// Error message (present when `status == "failed"`).
1554    pub error: Option<String>,
1555    /// Reason (present when `status` is `"cancelled"` or `"paused"`).
1556    pub reason: Option<String>,
1557    /// Who cancelled (present when `status == "cancelled"`).
1558    pub cancelled_by: Option<String>,
1559    /// Who paused (present when `status == "paused"`).
1560    pub paused_by: Option<String>,
1561    /// ISO-8601 wake-up timestamp (present when `status` is `"waiting"` or `"awaiting_signal"`).
1562    pub wake_at: Option<String>,
1563    /// Delay step identifier (present when `status == "waiting"`).
1564    pub delay_id: Option<String>,
1565    /// Signal step identifier (present when `status == "awaiting_signal"`).
1566    pub signal_id: Option<String>,
1567    /// Signal name (present when `status == "awaiting_signal"`).
1568    pub signal_name: Option<String>,
1569}
1570
1571impl From<WorkflowStatus> for FlatWorkflowStatus {
1572    fn from(status: WorkflowStatus) -> Self {
1573        let mut flat = Self {
1574            status: status.as_ref().to_string(),
1575            ..Self::default()
1576        };
1577        match status {
1578            WorkflowStatus::Completed | WorkflowStatus::InProgress => {}
1579            WorkflowStatus::Failed(e) => flat.error = Some(e),
1580            WorkflowStatus::Cancelled {
1581                reason,
1582                cancelled_by,
1583            } => {
1584                flat.reason = reason;
1585                flat.cancelled_by = cancelled_by;
1586            }
1587            WorkflowStatus::Paused { reason, paused_by } => {
1588                flat.reason = reason;
1589                flat.paused_by = paused_by;
1590            }
1591            WorkflowStatus::Waiting { wake_at, delay_id } => {
1592                flat.wake_at = Some(wake_at.to_rfc3339());
1593                // FFI / flattened form keeps strings — render the hash as hex.
1594                flat.delay_id = Some(delay_id.to_hex());
1595            }
1596            WorkflowStatus::AwaitingSignal {
1597                signal_id,
1598                signal_name,
1599                wake_at,
1600            } => {
1601                flat.signal_id = Some(signal_id.to_hex());
1602                flat.signal_name = Some(signal_name);
1603                flat.wake_at = wake_at.map(|t| t.to_rfc3339());
1604            }
1605        }
1606        flat
1607    }
1608}
1609
1610// Re-export builder types for backwards compatibility.
1611pub use crate::builder::{
1612    BranchCollector, ContinuationState, ForkBuilder, NoContinuation, NoRegistry, RegistryBehavior,
1613    RouteBuilder, SubBuilder, WorkflowBuilder,
1614};
1615
1616use crate::registry::TaskRegistry;
1617
1618/// A built workflow that can be executed.
1619pub struct Workflow<C, Input, M = ()> {
1620    pub(crate) definition_hash: crate::DefinitionHash,
1621    pub(crate) context: WorkflowContext<C, M>,
1622    pub(crate) continuation: WorkflowContinuation,
1623    /// Per-workflow `TaskId → metadata` index, built once at `build()` time.
1624    ///
1625    /// Avoids re-hashing every node id with SHA-256 on every dispatch lookup
1626    /// (`find_task_name`, `get_task_*`, `build_task_metadata`).
1627    pub(crate) task_index: Arc<crate::task_index::TaskIndex>,
1628    pub(crate) _phantom: PhantomData<Input>,
1629}
1630
1631impl<C, Input, M> Workflow<C, Input, M> {
1632    /// Get the workflow name (human-readable).
1633    #[must_use]
1634    pub fn workflow_id(&self) -> &str {
1635        &self.context.workflow_name
1636    }
1637
1638    /// Get the definition hash.
1639    ///
1640    /// This hash is computed from the workflow's continuation structure and serves
1641    /// as a version identifier. It can be used to detect when a serialized workflow
1642    /// state was created with a different workflow definition.
1643    #[must_use]
1644    pub fn definition_hash(&self) -> &crate::DefinitionHash {
1645        &self.definition_hash
1646    }
1647
1648    /// Get a reference to the context of this workflow.
1649    #[must_use]
1650    pub fn context(&self) -> &WorkflowContext<C, M> {
1651        &self.context
1652    }
1653
1654    /// Get a reference to the codec used by this workflow.
1655    #[must_use]
1656    pub fn codec(&self) -> &Arc<C> {
1657        &self.context.codec
1658    }
1659
1660    /// Get a reference to the continuation of this workflow.
1661    #[must_use]
1662    pub fn continuation(&self) -> &WorkflowContinuation {
1663        &self.continuation
1664    }
1665
1666    /// Borrow the `TaskId → metadata` index. Built once at build time.
1667    #[must_use]
1668    pub fn task_index(&self) -> &crate::task_index::TaskIndex {
1669        &self.task_index
1670    }
1671
1672    /// Clone the shared `Arc<TaskIndex>` — for callers (FFI / runtime) that
1673    /// want to hold onto the index alongside the continuation.
1674    #[must_use]
1675    pub fn task_index_arc(&self) -> Arc<crate::task_index::TaskIndex> {
1676        Arc::clone(&self.task_index)
1677    }
1678
1679    /// Get a reference to the metadata attached to this workflow.
1680    #[must_use]
1681    pub fn metadata(&self) -> &Arc<M> {
1682        &self.context.metadata
1683    }
1684
1685    /// Returns a lazy iterator over all nodes in topological (execution) order.
1686    ///
1687    /// Convenience wrapper around [`WorkflowContinuation::iter_nodes`].
1688    #[must_use]
1689    pub fn iter_nodes(&self) -> NodeIter<'_> {
1690        self.continuation.iter_nodes()
1691    }
1692
1693    /// Consume the workflow and return its continuation tree.
1694    ///
1695    /// Useful for inlining this workflow as a child inside another workflow.
1696    #[must_use]
1697    pub fn into_continuation(self) -> WorkflowContinuation {
1698        self.continuation
1699    }
1700}
1701
1702// ============================================================================
1703// Serializable Workflow
1704// ============================================================================
1705
1706/// A workflow that can be serialized and deserialized.
1707///
1708/// This is a wrapper around `Workflow` that carries an internal `TaskRegistry`,
1709/// automatically populated during building. This enables serialization without
1710/// manually setting up a separate registry.
1711///
1712/// # Example
1713///
1714/// ```rust
1715/// # use sayiir_core::prelude::*;
1716/// # use sayiir_core::codec::{Encoder, Decoder, sealed};
1717/// # use sayiir_core::workflow::SerializedWorkflowState;
1718/// # use bytes::Bytes;
1719/// # use std::sync::Arc;
1720/// # struct MyCodec;
1721/// # impl Encoder for MyCodec {}
1722/// # impl Decoder for MyCodec {}
1723/// # impl<T> sealed::EncodeValue<T> for MyCodec {
1724/// #     fn encode_value(&self, _: &T) -> Result<Bytes, BoxError> { Ok(Bytes::new()) }
1725/// # }
1726/// # impl<T> sealed::DecodeValue<T> for MyCodec {
1727/// #     fn decode_value(&self, _: Bytes) -> Result<T, BoxError> { Err("dummy".into()) }
1728/// # }
1729/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1730/// # let codec = Arc::new(MyCodec);
1731/// # let ctx = WorkflowContext::new("my-workflow", codec, Arc::new(()));
1732/// // Build a serializable workflow
1733/// let workflow = WorkflowBuilder::new(ctx)
1734///     .with_registry()  // Enable serialization
1735///     .then("step1", |i: u32| async move { Ok(i + 1) })
1736///     .then("step2", |i: u32| async move { Ok(i * 2) })
1737///     .build()?;
1738///
1739/// // Serialize
1740/// let serialized = workflow.to_serializable();
1741/// let json = serde_json::to_string(&serialized)?;
1742///
1743/// // Deserialize (uses internal registry)
1744/// let deserialized: SerializedWorkflowState = serde_json::from_str(&json)?;
1745/// let restored = workflow.to_runnable(&deserialized)?;
1746/// # Ok(())
1747/// # }
1748/// ```
1749pub struct SerializableWorkflow<C, Input, M = ()> {
1750    pub(crate) inner: Workflow<C, Input, M>,
1751    pub(crate) registry: TaskRegistry,
1752}
1753
1754impl<C, Input, M> SerializableWorkflow<C, Input, M> {
1755    /// Get the workflow ID.
1756    #[must_use]
1757    pub fn workflow_id(&self) -> &str {
1758        self.inner.workflow_id()
1759    }
1760
1761    /// Get the definition hash.
1762    #[must_use]
1763    pub fn definition_hash(&self) -> &crate::DefinitionHash {
1764        self.inner.definition_hash()
1765    }
1766
1767    /// Get a reference to the inner workflow.
1768    #[must_use]
1769    pub fn workflow(&self) -> &Workflow<C, Input, M> {
1770        &self.inner
1771    }
1772
1773    /// Get a reference to the context.
1774    #[must_use]
1775    pub fn context(&self) -> &WorkflowContext<C, M> {
1776        self.inner.context()
1777    }
1778
1779    /// Get a reference to the codec.
1780    #[must_use]
1781    pub fn codec(&self) -> &Arc<C> {
1782        self.inner.codec()
1783    }
1784
1785    /// Get a reference to the continuation.
1786    #[must_use]
1787    pub fn continuation(&self) -> &WorkflowContinuation {
1788        self.inner.continuation()
1789    }
1790
1791    /// Get a reference to the metadata.
1792    #[must_use]
1793    pub fn metadata(&self) -> &Arc<M> {
1794        self.inner.metadata()
1795    }
1796
1797    /// Get a reference to the internal task registry.
1798    #[must_use]
1799    pub fn registry(&self) -> &TaskRegistry {
1800        &self.registry
1801    }
1802
1803    /// Consume the workflow and return its continuation tree and task registry.
1804    ///
1805    /// Useful for inlining this workflow as a child inside another workflow
1806    /// while merging task registries.
1807    #[must_use]
1808    pub fn into_parts(self) -> (WorkflowContinuation, TaskRegistry) {
1809        (self.inner.continuation, self.registry)
1810    }
1811
1812    /// Convert to a serializable state representation.
1813    ///
1814    /// Returns a `SerializedWorkflowState` that includes the workflow ID,
1815    /// definition hash, and continuation structure. This can be serialized
1816    /// and later deserialized to resume the workflow.
1817    #[must_use]
1818    pub fn to_serializable(&self) -> SerializedWorkflowState {
1819        SerializedWorkflowState {
1820            workflow_id: self.inner.workflow_id().to_string(),
1821            definition_hash: self.inner.definition_hash,
1822            continuation: self.inner.continuation().to_serializable(),
1823        }
1824    }
1825
1826    /// Convert a serialized workflow state to runnable using the internal registry.
1827    ///
1828    /// # Errors
1829    ///
1830    /// Returns `BuildError::DefinitionMismatch` if the definition hash doesn't
1831    /// match this workflow's hash, indicating the serialized state was created with
1832    /// a different workflow definition.
1833    ///
1834    /// Returns `BuildError::TaskNotFound` if any task ID is not in the registry.
1835    pub fn to_runnable(
1836        &self,
1837        state: &SerializedWorkflowState,
1838    ) -> Result<WorkflowContinuation, crate::error::BuildError> {
1839        if state.definition_hash != self.inner.definition_hash {
1840            return Err(crate::error::BuildError::DefinitionMismatch {
1841                expected: self.inner.definition_hash,
1842                found: state.definition_hash,
1843            });
1844        }
1845        state.continuation.to_runnable(&self.registry)
1846    }
1847}
1848
1849impl<C, Input, M> Deref for SerializableWorkflow<C, Input, M> {
1850    type Target = Workflow<C, Input, M>;
1851
1852    fn deref(&self) -> &Self::Target {
1853        &self.inner
1854    }
1855}
1856
1857#[cfg(test)]
1858#[allow(
1859    clippy::unwrap_used,
1860    clippy::panic,
1861    clippy::cast_lossless,
1862    clippy::cast_possible_truncation,
1863    clippy::uninlined_format_args,
1864    clippy::manual_let_else,
1865    clippy::too_many_lines,
1866    clippy::items_after_statements,
1867    clippy::indexing_slicing
1868)]
1869mod tests {
1870    use crate::codec::{Decoder, Encoder, sealed};
1871    use crate::error::BoxError;
1872    use crate::workflow::WorkflowBuilder;
1873    use bytes::Bytes;
1874
1875    struct DummyCodec;
1876
1877    impl Encoder for DummyCodec {}
1878    impl Decoder for DummyCodec {}
1879
1880    impl<Input> sealed::EncodeValue<Input> for DummyCodec {
1881        fn encode_value(&self, _value: &Input) -> Result<Bytes, BoxError> {
1882            Ok(Bytes::new())
1883        }
1884    }
1885    impl<Output> sealed::DecodeValue<Output> for DummyCodec {
1886        fn decode_value(&self, _bytes: Bytes) -> Result<Output, BoxError> {
1887            Err("Not implemented".into())
1888        }
1889    }
1890
1891    #[test]
1892    fn test_workflow_build() {
1893        use crate::context::WorkflowContext;
1894        use crate::workflow::Workflow;
1895        use std::sync::Arc;
1896
1897        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
1898        let workflow: Workflow<DummyCodec, u32> = WorkflowBuilder::new(ctx)
1899            .then("test", |i: u32| async move { Ok(i + 1) })
1900            .build()
1901            .unwrap();
1902
1903        // Verify the workflow was built successfully
1904        // The workflow can be executed using a WorkflowRunner from sayiir-runtime
1905        let _workflow_ref = &workflow;
1906    }
1907
1908    #[test]
1909    fn test_workflow_with_metadata() {
1910        use crate::context::WorkflowContext;
1911        use crate::workflow::Workflow;
1912        use std::sync::Arc;
1913
1914        let ctx = WorkflowContext::new(
1915            "test-workflow",
1916            Arc::new(DummyCodec),
1917            Arc::new("test_metadata"),
1918        );
1919        let workflow: Workflow<DummyCodec, u32, &str> = WorkflowBuilder::new(ctx)
1920            .then("test", |i: u32| async move { Ok(i + 1) })
1921            .build()
1922            .unwrap();
1923
1924        assert_eq!(**workflow.metadata(), "test_metadata");
1925    }
1926
1927    #[test]
1928    fn test_task_order() {
1929        use crate::context::WorkflowContext;
1930        use crate::workflow::Workflow;
1931        use std::sync::Arc;
1932
1933        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
1934        let workflow: Workflow<DummyCodec, u32> = WorkflowBuilder::new(ctx)
1935            .then("first", |i: u32| async move { Ok(i + 1) })
1936            .then("second", |i: u32| async move { Ok(i + 2) })
1937            .then("third", |i: u32| async move { Ok(i + 3) })
1938            .build()
1939            .unwrap();
1940
1941        // Verify the continuation chain structure
1942        // Tasks should be linked in order: first -> second -> third
1943        let mut current = workflow.continuation();
1944        let mut task_ids = vec![];
1945
1946        while let crate::workflow::WorkflowContinuation::Task { id, next, .. } = current {
1947            task_ids.push(id.clone());
1948            match next {
1949                Some(next_box) => current = next_box.as_ref(),
1950                None => break,
1951            }
1952        }
1953
1954        assert_eq!(
1955            task_ids,
1956            vec!["first", "second", "third"],
1957            "Tasks should execute in the order they were added"
1958        );
1959    }
1960
1961    #[test]
1962    fn test_heterogeneous_fork_join_compiles() {
1963        use crate::context::WorkflowContext;
1964        use crate::task::BranchOutputs;
1965        use crate::workflow::Workflow;
1966        use std::sync::Arc;
1967
1968        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
1969        // This test verifies that the heterogeneous fork-join API compiles correctly.
1970        // Each branch can return a different type thanks to type erasure.
1971        let workflow: Workflow<DummyCodec, u32> = WorkflowBuilder::new(ctx)
1972            .then("prepare", |i: u32| async move { Ok(i) })
1973            .branches(|b| {
1974                // Returns u32
1975                b.add("count", |i: u32| async move { Ok(i * 2) });
1976                // Returns String - heterogeneous output type!
1977                b.add("name", |i: u32| async move { Ok(format!("item_{}", i)) });
1978                // Returns f64 - another different type!
1979                b.add("ratio", |i: u32| async move { Ok(i as f64 / 100.0) });
1980            })
1981            .join("combine", |outputs: BranchOutputs<DummyCodec>| async move {
1982                // In a real workflow with a proper codec, you would:
1983                // let count: u32 = outputs.get_by_id("count")?;
1984                // let name: String = outputs.get_by_id("name")?;
1985                // let ratio: f64 = outputs.get_by_id("ratio")?;
1986                // For this test, just verify the API compiles
1987                let _ = outputs.len();
1988                Ok(format!("combined {} branches", outputs.len()))
1989            })
1990            .then("final", |s: String| async move { Ok(s.len() as u32) })
1991            .build()
1992            .unwrap();
1993
1994        let _workflow_ref = &workflow;
1995    }
1996
1997    #[test]
1998    fn test_duplicate_branch_id_returns_error() {
1999        use crate::context::WorkflowContext;
2000        use crate::error::BuildError;
2001        use std::sync::Arc;
2002
2003        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2004        let result = WorkflowBuilder::<_, u32, _>::new(ctx)
2005            .then("prepare", |i: u32| async move { Ok(i) })
2006            .branches(|b| {
2007                b.add("count", |i: u32| async move { Ok(i * 2) });
2008                b.add("count", |i: u32| async move { Ok(i * 3) }); // Duplicate!
2009            })
2010            .join("combine", |_outputs| async move { Ok(0u32) })
2011            .build();
2012
2013        let err = match result {
2014            Err(e) => e,
2015            Ok(_) => panic!("expected build error"),
2016        };
2017        assert!(
2018            err.iter()
2019                .any(|e| matches!(e, BuildError::DuplicateTaskId(id) if id == "count"))
2020        );
2021    }
2022
2023    #[test]
2024    fn test_serializable_continuation() {
2025        use crate::context::WorkflowContext;
2026        use crate::error::BuildError;
2027        use crate::registry::TaskRegistry;
2028        use std::sync::Arc;
2029
2030        // Build a workflow
2031        let codec = Arc::new(DummyCodec);
2032        let ctx = WorkflowContext::new("test-workflow", codec.clone(), Arc::new(()));
2033        let workflow = WorkflowBuilder::new(ctx)
2034            .then("step1", |i: u32| async move { Ok(i + 1) })
2035            .then("step2", |i: u32| async move { Ok(i * 2) })
2036            .build()
2037            .unwrap();
2038
2039        // Convert to serializable
2040        let serializable = workflow.continuation().to_serializable();
2041
2042        // Check structure
2043        let task_ids = serializable.task_ids();
2044        assert_eq!(task_ids, vec!["step1", "step2"]);
2045
2046        // Hydration fails without registry
2047        let empty_registry = TaskRegistry::new();
2048        let result = serializable.to_runnable(&empty_registry);
2049        assert!(matches!(result, Err(BuildError::TaskNotFound(id)) if id == "step1"));
2050
2051        // Hydration succeeds with proper registry
2052        let mut registry = TaskRegistry::new();
2053        registry.register_fn("step1", codec.clone(), |i: u32| async move { Ok(i + 1) });
2054        registry.register_fn("step2", codec.clone(), |i: u32| async move { Ok(i * 2) });
2055
2056        let hydrated = serializable.to_runnable(&registry);
2057        assert!(hydrated.is_ok());
2058    }
2059
2060    #[test]
2061    fn test_serializable_fork_join() {
2062        use crate::context::WorkflowContext;
2063        use crate::task::BranchOutputs;
2064        use std::sync::Arc;
2065
2066        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2067        let workflow = WorkflowBuilder::new(ctx)
2068            .then("prepare", |i: u32| async move { Ok(i) })
2069            .branches(|b| {
2070                b.add("branch_a", |i: u32| async move { Ok(i * 2) });
2071                b.add("branch_b", |i: u32| async move { Ok(i + 10) });
2072            })
2073            .join(
2074                "merge",
2075                |_: BranchOutputs<DummyCodec>| async move { Ok(0u32) },
2076            )
2077            .build()
2078            .unwrap();
2079
2080        let serializable = workflow.continuation().to_serializable();
2081        let task_ids = serializable.task_ids();
2082
2083        // Should contain: prepare, fork (branch_a||branch_b), branch_a, branch_b, merge
2084        assert!(task_ids.contains(&"prepare"));
2085        assert!(task_ids.contains(&"branch_a||branch_b"));
2086        assert!(task_ids.contains(&"branch_a"));
2087        assert!(task_ids.contains(&"branch_b"));
2088        assert!(task_ids.contains(&"merge"));
2089        assert_eq!(task_ids.len(), 5);
2090    }
2091
2092    #[test]
2093    fn test_serializable_workflow_builder() {
2094        use crate::context::WorkflowContext;
2095        use std::sync::Arc;
2096
2097        let codec = Arc::new(DummyCodec);
2098        let ctx = WorkflowContext::new("test-workflow", codec, Arc::new(()));
2099
2100        // Build with with_registry() - registry is auto-populated
2101        let workflow = WorkflowBuilder::new(ctx)
2102            .with_registry()
2103            .then("step1", |i: u32| async move { Ok(i + 1) })
2104            .then("step2", |i: u32| async move { Ok(i * 2) })
2105            .build()
2106            .unwrap();
2107
2108        // Registry was auto-populated
2109        assert!(workflow.registry().contains("step1"));
2110        assert!(workflow.registry().contains("step2"));
2111        assert_eq!(workflow.registry().len(), 2);
2112
2113        // Can serialize
2114        let serializable = workflow.to_serializable();
2115        assert_eq!(serializable.continuation.task_ids(), vec!["step1", "step2"]);
2116
2117        // Can hydrate using internal registry
2118        let hydrated = workflow.to_runnable(&serializable);
2119        assert!(hydrated.is_ok());
2120    }
2121
2122    #[test]
2123    fn test_with_existing_registry_and_then_registered() {
2124        use crate::context::WorkflowContext;
2125        use crate::registry::TaskRegistry;
2126        use crate::workflow::SerializableWorkflow;
2127        use std::sync::Arc;
2128
2129        let codec = Arc::new(DummyCodec);
2130
2131        // Pre-register tasks in a registry
2132        let mut registry = TaskRegistry::new();
2133        registry.register_fn("double", codec.clone(), |i: u32| async move { Ok(i * 2) });
2134        registry.register_fn("add_ten", codec.clone(), |i: u32| async move { Ok(i + 10) });
2135
2136        // Build workflow using existing registry and referencing pre-registered tasks
2137        let ctx = WorkflowContext::new("test-workflow", codec.clone(), Arc::new(()));
2138        let workflow: SerializableWorkflow<_, u32> = WorkflowBuilder::new(ctx)
2139            .with_existing_registry(registry)
2140            .then_registered::<u32>("double")
2141            .then_registered::<u32>("add_ten")
2142            .build()
2143            .unwrap();
2144
2145        // Registry should contain the pre-registered tasks
2146        assert!(workflow.registry().contains("double"));
2147        assert!(workflow.registry().contains("add_ten"));
2148
2149        // Workflow structure should reference those tasks
2150        let serializable = workflow.to_serializable();
2151        assert_eq!(
2152            serializable.continuation.task_ids(),
2153            vec!["double", "add_ten"]
2154        );
2155
2156        // Can hydrate using the same registry
2157        let hydrated = workflow.to_runnable(&serializable);
2158        assert!(hydrated.is_ok());
2159    }
2160
2161    #[test]
2162    fn test_mixed_inline_and_registered_tasks() {
2163        use crate::context::WorkflowContext;
2164        use crate::registry::TaskRegistry;
2165        use crate::workflow::SerializableWorkflow;
2166        use std::sync::Arc;
2167
2168        let codec = Arc::new(DummyCodec);
2169
2170        // Pre-register one task
2171        let mut registry = TaskRegistry::new();
2172        registry.register_fn(
2173            "preregistered",
2174            codec.clone(),
2175            |i: u32| async move { Ok(i * 2) },
2176        );
2177
2178        // Build workflow mixing pre-registered and inline tasks
2179        let ctx = WorkflowContext::new("test-workflow", codec.clone(), Arc::new(()));
2180        let workflow: SerializableWorkflow<_, u32> = WorkflowBuilder::new(ctx)
2181            .with_existing_registry(registry)
2182            .then_registered::<u32>("preregistered") // Use pre-registered
2183            .then("inline", |i: u32| async move { Ok(i + 5) }) // Define inline
2184            .build()
2185            .unwrap();
2186
2187        // Registry should have both tasks
2188        assert!(workflow.registry().contains("preregistered"));
2189        assert!(workflow.registry().contains("inline"));
2190        assert_eq!(workflow.registry().len(), 2);
2191    }
2192
2193    #[test]
2194    fn test_workflow_id_and_definition_hash() {
2195        use crate::context::WorkflowContext;
2196        use std::sync::Arc;
2197
2198        let ctx = WorkflowContext::new("my-workflow-id", Arc::new(DummyCodec), Arc::new(()));
2199        let workflow = WorkflowBuilder::new(ctx)
2200            .with_registry()
2201            .then("step1", |i: u32| async move { Ok(i + 1) })
2202            .then("step2", |i: u32| async move { Ok(i * 2) })
2203            .build()
2204            .unwrap();
2205
2206        // Check workflow_id is set correctly
2207        assert_eq!(workflow.workflow_id(), "my-workflow-id");
2208
2209        // Definition hash should be non-zero
2210        assert_ne!(
2211            *workflow.definition_hash(),
2212            crate::DefinitionHash::from_bytes([0u8; 32])
2213        );
2214
2215        // Serializable state should contain the same id and hash
2216        let state = workflow.to_serializable();
2217        assert_eq!(state.workflow_id, "my-workflow-id");
2218        assert_eq!(&state.definition_hash, workflow.definition_hash());
2219    }
2220
2221    #[test]
2222    fn test_definition_hash_changes_with_structure() {
2223        use crate::context::WorkflowContext;
2224        use std::sync::Arc;
2225
2226        // Build two workflows with different structures
2227        let ctx1 = WorkflowContext::new("workflow", Arc::new(DummyCodec), Arc::new(()));
2228        let workflow1 = WorkflowBuilder::new(ctx1)
2229            .with_registry()
2230            .then("step1", |i: u32| async move { Ok(i + 1) })
2231            .build()
2232            .unwrap();
2233
2234        let ctx2 = WorkflowContext::new("workflow", Arc::new(DummyCodec), Arc::new(()));
2235        let workflow2 = WorkflowBuilder::new(ctx2)
2236            .with_registry()
2237            .then("step1", |i: u32| async move { Ok(i + 1) })
2238            .then("step2", |i: u32| async move { Ok(i * 2) })
2239            .build()
2240            .unwrap();
2241
2242        assert_ne!(workflow1.definition_hash(), workflow2.definition_hash());
2243    }
2244
2245    #[test]
2246    fn test_definition_mismatch_error() {
2247        use crate::context::WorkflowContext;
2248        use crate::error::BuildError;
2249        use std::sync::Arc;
2250
2251        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2252        let workflow = WorkflowBuilder::new(ctx)
2253            .with_registry()
2254            .then("step1", |i: u32| async move { Ok(i + 1) })
2255            .build()
2256            .unwrap();
2257
2258        // Create a state with wrong hash
2259        let mut state = workflow.to_serializable();
2260        state.definition_hash = crate::DefinitionHash::sha256(b"wrong-hash");
2261
2262        // to_runnable should fail with DefinitionMismatch
2263        let result = workflow.to_runnable(&state);
2264        assert!(matches!(result, Err(BuildError::DefinitionMismatch { .. })));
2265    }
2266
2267    #[test]
2268    fn test_duplicate_id_tampering_detection() {
2269        use crate::error::BuildError;
2270        use crate::registry::TaskRegistry;
2271        use crate::workflow::SerializableContinuation;
2272        use std::sync::Arc;
2273
2274        let codec = Arc::new(DummyCodec);
2275
2276        // Create a registry with tasks
2277        let mut registry = TaskRegistry::new();
2278        registry.register_fn("step1", codec.clone(), |i: u32| async move { Ok(i + 1) });
2279        registry.register_fn("step2", codec.clone(), |i: u32| async move { Ok(i * 2) });
2280
2281        // Manually construct a tampered continuation with duplicate IDs
2282        let tampered = SerializableContinuation::Task {
2283            id: "step1".to_string(),
2284            timeout_ms: None,
2285            retry_policy: None,
2286            version: None,
2287            priority: None,
2288
2289            tags: vec![],
2290            next: Some(Box::new(SerializableContinuation::Task {
2291                id: "step1".to_string(), // Duplicate!
2292                timeout_ms: None,
2293                retry_policy: None,
2294                version: None,
2295                priority: None,
2296
2297                tags: vec![],
2298                next: None,
2299            })),
2300        };
2301
2302        // to_runnable should detect the tampering
2303        let result = tampered.to_runnable(&registry);
2304        assert!(matches!(
2305            result,
2306            Err(BuildError::DuplicateTaskId(id)) if id == "step1"
2307        ));
2308    }
2309
2310    // ========================================================================
2311    // Delay tests
2312    // ========================================================================
2313
2314    #[test]
2315    fn test_delay_builder() {
2316        use crate::context::WorkflowContext;
2317        use crate::workflow::{Workflow, WorkflowContinuation};
2318        use std::sync::Arc;
2319        use std::time::Duration;
2320
2321        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2322        let workflow: Workflow<DummyCodec, u32> = WorkflowBuilder::new(ctx)
2323            .then("step1", |i: u32| async move { Ok(i + 1) })
2324            .delay("wait_1s", Duration::from_secs(1))
2325            .then("step2", |i: u32| async move { Ok(i * 2) })
2326            .build()
2327            .unwrap();
2328
2329        // Verify the chain structure: Task -> Delay -> Task
2330        let mut ids = vec![];
2331        let mut current = workflow.continuation();
2332        loop {
2333            match current {
2334                WorkflowContinuation::Task { id, next, .. } => {
2335                    ids.push(format!("task:{id}"));
2336                    match next {
2337                        Some(n) => current = n,
2338                        None => break,
2339                    }
2340                }
2341                WorkflowContinuation::Delay {
2342                    id, duration, next, ..
2343                } => {
2344                    ids.push(format!("delay:{id}:{}ms", duration.as_millis()));
2345                    match next {
2346                        Some(n) => current = n,
2347                        None => break,
2348                    }
2349                }
2350                _ => break,
2351            }
2352        }
2353
2354        assert_eq!(
2355            ids,
2356            vec!["task:step1", "delay:wait_1s:1000ms", "task:step2"]
2357        );
2358    }
2359
2360    #[test]
2361    fn test_delay_serialization_roundtrip() {
2362        use crate::context::WorkflowContext;
2363        use crate::workflow::SerializableContinuation;
2364        use std::sync::Arc;
2365        use std::time::Duration;
2366
2367        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2368        let workflow = WorkflowBuilder::new(ctx)
2369            .with_registry()
2370            .then("step1", |i: u32| async move { Ok(i + 1) })
2371            .delay("wait_5s", Duration::from_secs(5))
2372            .then("step2", |i: u32| async move { Ok(i * 2) })
2373            .build()
2374            .unwrap();
2375
2376        // Convert to serializable
2377        let serializable = workflow.to_serializable();
2378
2379        // Check structure
2380        let task_ids = serializable.continuation.task_ids();
2381        assert_eq!(task_ids, vec!["step1", "wait_5s", "step2"]);
2382
2383        // Check delay duration is preserved
2384        match &serializable.continuation {
2385            SerializableContinuation::Task { next, .. } => {
2386                let next = next.as_ref().unwrap();
2387                match next.as_ref() {
2388                    SerializableContinuation::Delay {
2389                        id, duration_ms, ..
2390                    } => {
2391                        assert_eq!(id, "wait_5s");
2392                        assert_eq!(*duration_ms, 5000);
2393                    }
2394                    other => panic!("Expected Delay, got {other:?}"),
2395                }
2396            }
2397            other => panic!("Expected Task, got {other:?}"),
2398        }
2399
2400        // Hydrate back to runnable
2401        let hydrated = workflow.to_runnable(&serializable);
2402        assert!(hydrated.is_ok());
2403    }
2404
2405    #[test]
2406    fn test_delay_first_task_id() {
2407        use crate::context::WorkflowContext;
2408        use std::sync::Arc;
2409        use std::time::Duration;
2410
2411        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2412        let workflow = WorkflowBuilder::new(ctx)
2413            .delay("initial_delay", Duration::from_secs(10))
2414            .then("step1", |i: u32| async move { Ok(i + 1) })
2415            .build()
2416            .unwrap();
2417
2418        assert_eq!(workflow.continuation().first_task_id(), "initial_delay");
2419    }
2420
2421    #[test]
2422    fn test_delay_duplicate_id_detection() {
2423        use crate::context::WorkflowContext;
2424        use crate::error::BuildError;
2425        use std::sync::Arc;
2426        use std::time::Duration;
2427
2428        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2429        let result = WorkflowBuilder::<_, u32, _>::new(ctx)
2430            .then("dup", |i: u32| async move { Ok(i + 1) })
2431            .delay("dup", Duration::from_secs(1))
2432            .build();
2433
2434        let err = match result {
2435            Err(e) => e,
2436            Ok(_) => panic!("expected build error"),
2437        };
2438        assert!(
2439            err.iter()
2440                .any(|e| matches!(e, BuildError::DuplicateTaskId(id) if id == "dup"))
2441        );
2442    }
2443
2444    #[test]
2445    fn test_delay_definition_hash_includes_duration() {
2446        use crate::context::WorkflowContext;
2447        use crate::workflow::SerializableWorkflow;
2448        use std::sync::Arc;
2449        use std::time::Duration;
2450
2451        // Workflow with 1-second delay
2452        let ctx1 = WorkflowContext::new("workflow", Arc::new(DummyCodec), Arc::new(()));
2453        let wf1: SerializableWorkflow<_, u32> = WorkflowBuilder::new(ctx1)
2454            .with_registry()
2455            .then("step1", |i: u32| async move { Ok(i + 1) })
2456            .delay("wait", Duration::from_secs(1))
2457            .build()
2458            .unwrap();
2459
2460        // Workflow with 60-second delay (same ID, different duration)
2461        let ctx2 = WorkflowContext::new("workflow", Arc::new(DummyCodec), Arc::new(()));
2462        let wf2: SerializableWorkflow<_, u32> = WorkflowBuilder::new(ctx2)
2463            .with_registry()
2464            .then("step1", |i: u32| async move { Ok(i + 1) })
2465            .delay("wait", Duration::from_mins(1))
2466            .build()
2467            .unwrap();
2468
2469        // Hashes should differ because duration differs
2470        assert_ne!(wf1.definition_hash(), wf2.definition_hash());
2471    }
2472
2473    #[test]
2474    fn test_delay_definition_hash_differs_from_task() {
2475        use crate::context::WorkflowContext;
2476        use crate::workflow::SerializableWorkflow;
2477        use std::sync::Arc;
2478        use std::time::Duration;
2479
2480        // Workflow with task
2481        let ctx1 = WorkflowContext::new("workflow", Arc::new(DummyCodec), Arc::new(()));
2482        let wf1: SerializableWorkflow<_, u32> = WorkflowBuilder::new(ctx1)
2483            .with_registry()
2484            .then("step1", |i: u32| async move { Ok(i + 1) })
2485            .build()
2486            .unwrap();
2487
2488        // Workflow with delay instead
2489        let ctx2 = WorkflowContext::new("workflow", Arc::new(DummyCodec), Arc::new(()));
2490        let wf2: SerializableWorkflow<_, u32> = WorkflowBuilder::new(ctx2)
2491            .with_registry()
2492            .delay("step1", Duration::from_secs(1))
2493            .build()
2494            .unwrap();
2495
2496        // Hashes should differ (Task vs Delay are tagged differently)
2497        assert_ne!(wf1.definition_hash(), wf2.definition_hash());
2498    }
2499
2500    #[test]
2501    fn test_delay_task_ids() {
2502        use crate::context::WorkflowContext;
2503        use std::sync::Arc;
2504        use std::time::Duration;
2505
2506        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2507        let workflow = WorkflowBuilder::new(ctx)
2508            .then("fetch", |i: u32| async move { Ok(i) })
2509            .delay("wait_24h", Duration::from_hours(24))
2510            .then("process", |i: u32| async move { Ok(i + 1) })
2511            .build()
2512            .unwrap();
2513
2514        let serializable = workflow.continuation().to_serializable();
2515        let ids = serializable.task_ids();
2516        assert_eq!(ids, vec!["fetch", "wait_24h", "process"]);
2517    }
2518
2519    #[test]
2520    fn test_delay_only_workflow() {
2521        use crate::context::WorkflowContext;
2522        use std::sync::Arc;
2523        use std::time::Duration;
2524
2525        use crate::workflow::Workflow;
2526
2527        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2528        let workflow: Workflow<DummyCodec, u32> = WorkflowBuilder::new(ctx)
2529            .delay("just_wait", Duration::from_millis(10))
2530            .build()
2531            .unwrap();
2532
2533        assert_eq!(workflow.continuation().first_task_id(), "just_wait");
2534
2535        let serializable = workflow.continuation().to_serializable();
2536        assert_eq!(serializable.task_ids(), vec!["just_wait"]);
2537    }
2538
2539    #[test]
2540    fn test_delay_to_runnable_no_registry_needed() {
2541        use crate::registry::TaskRegistry;
2542        use crate::workflow::SerializableContinuation;
2543
2544        // A delay doesn't need a registry entry (it has no func)
2545        let delay = SerializableContinuation::Delay {
2546            id: "wait".to_string(),
2547            duration_ms: 5000,
2548            next: None,
2549        };
2550
2551        let empty_registry = TaskRegistry::new();
2552        let result = delay.to_runnable(&empty_registry);
2553        assert!(result.is_ok());
2554
2555        let runnable = result.unwrap();
2556        match runnable {
2557            crate::workflow::WorkflowContinuation::Delay {
2558                id, duration, next, ..
2559            } => {
2560                assert_eq!(id, "wait");
2561                assert_eq!(duration, std::time::Duration::from_secs(5));
2562                assert!(next.is_none());
2563            }
2564            _ => panic!("Expected Delay variant"),
2565        }
2566    }
2567
2568    // ========================================================================
2569    // Timeout tests
2570    // ========================================================================
2571
2572    #[test]
2573    fn test_timeout_serialization_roundtrip() {
2574        use crate::context::WorkflowContext;
2575        use crate::task::TaskMetadata;
2576        use crate::workflow::SerializableContinuation;
2577        use std::sync::Arc;
2578        use std::time::Duration;
2579
2580        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2581        let workflow = WorkflowBuilder::new(ctx)
2582            .with_registry()
2583            .then("step1", |i: u32| async move { Ok(i + 1) })
2584            .with_metadata(TaskMetadata {
2585                timeout: Some(Duration::from_secs(30)),
2586                ..Default::default()
2587            })
2588            .then("step2", |i: u32| async move { Ok(i * 2) })
2589            .build()
2590            .unwrap();
2591
2592        // Convert to serializable
2593        let serializable = workflow.to_serializable();
2594
2595        // Check timeout is preserved in serialization
2596        match &serializable.continuation {
2597            SerializableContinuation::Task { id, timeout_ms, .. } => {
2598                assert_eq!(id, "step1");
2599                assert_eq!(*timeout_ms, Some(30_000));
2600            }
2601            other => panic!("Expected Task, got {other:?}"),
2602        }
2603
2604        // Hydrate back to runnable and verify timeout
2605        let hydrated = workflow.to_runnable(&serializable).unwrap();
2606        match &hydrated {
2607            crate::workflow::WorkflowContinuation::Task { id, timeout, .. } => {
2608                assert_eq!(id, "step1");
2609                assert_eq!(*timeout, Some(Duration::from_secs(30)));
2610            }
2611            _ => panic!("Expected Task variant"),
2612        }
2613    }
2614
2615    #[test]
2616    fn test_timeout_changes_definition_hash() {
2617        use crate::context::WorkflowContext;
2618        use crate::task::TaskMetadata;
2619        use crate::workflow::SerializableWorkflow;
2620        use std::sync::Arc;
2621        use std::time::Duration;
2622
2623        // Workflow without timeout
2624        let ctx1 = WorkflowContext::new("workflow", Arc::new(DummyCodec), Arc::new(()));
2625        let wf1: SerializableWorkflow<_, u32> = WorkflowBuilder::new(ctx1)
2626            .with_registry()
2627            .then("step1", |i: u32| async move { Ok(i + 1) })
2628            .build()
2629            .unwrap();
2630
2631        // Workflow with timeout (same ID, different timeout)
2632        let ctx2 = WorkflowContext::new("workflow", Arc::new(DummyCodec), Arc::new(()));
2633        let wf2: SerializableWorkflow<_, u32> = WorkflowBuilder::new(ctx2)
2634            .with_registry()
2635            .then("step1", |i: u32| async move { Ok(i + 1) })
2636            .with_metadata(TaskMetadata {
2637                timeout: Some(Duration::from_secs(30)),
2638                ..Default::default()
2639            })
2640            .build()
2641            .unwrap();
2642
2643        // Hashes should differ because timeout differs
2644        assert_ne!(wf1.definition_hash(), wf2.definition_hash());
2645    }
2646
2647    #[test]
2648    fn test_no_timeout_field_absent_in_serialization() {
2649        use crate::context::WorkflowContext;
2650        use std::sync::Arc;
2651
2652        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2653        let workflow = WorkflowBuilder::new(ctx)
2654            .with_registry()
2655            .then("step1", |i: u32| async move { Ok(i + 1) })
2656            .build()
2657            .unwrap();
2658
2659        let serializable = workflow.to_serializable();
2660        // With serde skip_serializing_if, timeout_ms should not appear in JSON
2661        let json = serde_json::to_string(&serializable.continuation).unwrap();
2662        assert!(
2663            !json.contains("timeout_ms"),
2664            "timeout_ms should be absent when None: {json}"
2665        );
2666    }
2667
2668    #[test]
2669    fn test_task_version_changes_definition_hash() {
2670        use crate::context::WorkflowContext;
2671        use crate::task::TaskMetadata;
2672        use crate::workflow::SerializableWorkflow;
2673        use std::sync::Arc;
2674
2675        // Workflow without version
2676        let ctx1 = WorkflowContext::new("workflow", Arc::new(DummyCodec), Arc::new(()));
2677        let wf_no_version: SerializableWorkflow<_, u32> = WorkflowBuilder::new(ctx1)
2678            .with_registry()
2679            .then("step1", |i: u32| async move { Ok(i + 1) })
2680            .build()
2681            .unwrap();
2682
2683        // Workflow with version "1.0"
2684        let ctx2 = WorkflowContext::new("workflow", Arc::new(DummyCodec), Arc::new(()));
2685        let wf_v1: SerializableWorkflow<_, u32> = WorkflowBuilder::new(ctx2)
2686            .with_registry()
2687            .then("step1", |i: u32| async move { Ok(i + 1) })
2688            .with_metadata(TaskMetadata {
2689                version: Some("1.0".into()),
2690                ..Default::default()
2691            })
2692            .build()
2693            .unwrap();
2694
2695        // Workflow with version "2.0"
2696        let ctx3 = WorkflowContext::new("workflow", Arc::new(DummyCodec), Arc::new(()));
2697        let wf_v2: SerializableWorkflow<_, u32> = WorkflowBuilder::new(ctx3)
2698            .with_registry()
2699            .then("step1", |i: u32| async move { Ok(i + 1) })
2700            .with_metadata(TaskMetadata {
2701                version: Some("2.0".into()),
2702                ..Default::default()
2703            })
2704            .build()
2705            .unwrap();
2706
2707        // Same version produces same hash
2708        let ctx4 = WorkflowContext::new("workflow", Arc::new(DummyCodec), Arc::new(()));
2709        let wf_v1_again: SerializableWorkflow<_, u32> = WorkflowBuilder::new(ctx4)
2710            .with_registry()
2711            .then("step1", |i: u32| async move { Ok(i + 1) })
2712            .with_metadata(TaskMetadata {
2713                version: Some("1.0".into()),
2714                ..Default::default()
2715            })
2716            .build()
2717            .unwrap();
2718
2719        assert_ne!(
2720            wf_no_version.definition_hash(),
2721            wf_v1.definition_hash(),
2722            "Adding version should change hash"
2723        );
2724        assert_ne!(
2725            wf_v1.definition_hash(),
2726            wf_v2.definition_hash(),
2727            "Different versions should produce different hashes"
2728        );
2729        assert_eq!(
2730            wf_v1.definition_hash(),
2731            wf_v1_again.definition_hash(),
2732            "Same version should produce same hash"
2733        );
2734    }
2735
2736    #[test]
2737    fn test_version_absent_in_serialization_when_none() {
2738        use crate::context::WorkflowContext;
2739        use std::sync::Arc;
2740
2741        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2742        let workflow = WorkflowBuilder::new(ctx)
2743            .with_registry()
2744            .then("step1", |i: u32| async move { Ok(i + 1) })
2745            .build()
2746            .unwrap();
2747
2748        let serializable = workflow.to_serializable();
2749        let json = serde_json::to_string(&serializable.continuation).unwrap();
2750        assert!(
2751            !json.contains("version"),
2752            "version should be absent when None: {json}"
2753        );
2754    }
2755
2756    #[test]
2757    fn test_version_present_in_serialization_when_set() {
2758        use crate::context::WorkflowContext;
2759        use crate::task::TaskMetadata;
2760        use std::sync::Arc;
2761
2762        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2763        let workflow = WorkflowBuilder::new(ctx)
2764            .with_registry()
2765            .then("step1", |i: u32| async move { Ok(i + 1) })
2766            .with_metadata(TaskMetadata {
2767                version: Some("3.0".into()),
2768                ..Default::default()
2769            })
2770            .build()
2771            .unwrap();
2772
2773        let serializable = workflow.to_serializable();
2774        let json = serde_json::to_string(&serializable.continuation).unwrap();
2775        assert!(
2776            json.contains(r#""version":"3.0""#),
2777            "version should be present in JSON: {json}"
2778        );
2779    }
2780
2781    // ========================================================================
2782    // Topological nodes() tests
2783    // ========================================================================
2784
2785    #[test]
2786    fn test_nodes_single_task() {
2787        use crate::context::WorkflowContext;
2788        use crate::workflow::{NodeKind, Workflow};
2789        use std::sync::Arc;
2790
2791        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2792        let workflow: Workflow<DummyCodec, u32> = WorkflowBuilder::new(ctx)
2793            .then("only", |i: u32| async move { Ok(i + 1) })
2794            .build()
2795            .unwrap();
2796
2797        let nodes: Vec<_> = workflow.iter_nodes().collect();
2798        assert_eq!(nodes.len(), 1);
2799        assert_eq!(nodes[0].id, "only");
2800        assert_eq!(nodes[0].kind, NodeKind::Task);
2801        assert!(nodes[0].predecessor_id.is_none());
2802    }
2803
2804    #[test]
2805    fn test_nodes_chain_order() {
2806        use crate::context::WorkflowContext;
2807        use crate::workflow::{NodeKind, Workflow};
2808        use std::sync::Arc;
2809
2810        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2811        let workflow: Workflow<DummyCodec, u32> = WorkflowBuilder::new(ctx)
2812            .then("a", |i: u32| async move { Ok(i + 1) })
2813            .then("b", |i: u32| async move { Ok(i + 2) })
2814            .then("c", |i: u32| async move { Ok(i + 3) })
2815            .build()
2816            .unwrap();
2817
2818        let nodes: Vec<_> = workflow.iter_nodes().collect();
2819        let ids: Vec<&str> = nodes.iter().map(|n| n.id).collect();
2820        assert_eq!(ids, vec!["a", "b", "c"]);
2821        assert!(nodes.iter().all(|n| n.kind == NodeKind::Task));
2822
2823        // Predecessor chain
2824        assert_eq!(nodes[0].predecessor_id, None);
2825        assert_eq!(nodes[1].predecessor_id, Some("a"));
2826        assert_eq!(nodes[2].predecessor_id, Some("b"));
2827    }
2828
2829    #[test]
2830    fn test_nodes_fork_with_join() {
2831        use crate::context::WorkflowContext;
2832        use crate::task::BranchOutputs;
2833        use crate::workflow::{NodeKind, Workflow};
2834        use std::sync::Arc;
2835
2836        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2837        let workflow: Workflow<DummyCodec, u32> = WorkflowBuilder::new(ctx)
2838            .then("prepare", |i: u32| async move { Ok(i) })
2839            .branches(|b| {
2840                b.add("left", |i: u32| async move { Ok(i * 2) });
2841                b.add("right", |i: u32| async move { Ok(i + 10) });
2842            })
2843            .join(
2844                "merge",
2845                |_: BranchOutputs<DummyCodec>| async move { Ok(0u32) },
2846            )
2847            .build()
2848            .unwrap();
2849
2850        let nodes: Vec<_> = workflow.iter_nodes().collect();
2851        let ids: Vec<&str> = nodes.iter().map(|n| n.id).collect();
2852
2853        // prepare → fork → (left, right) → merge
2854        assert_eq!(ids[0], "prepare");
2855        assert_eq!(nodes[1].kind, NodeKind::Fork);
2856        assert!(ids.contains(&"left"));
2857        assert!(ids.contains(&"right"));
2858        assert_eq!(*ids.last().unwrap(), "merge");
2859
2860        // Fork's predecessor is prepare
2861        assert_eq!(nodes[1].predecessor_id, Some("prepare"));
2862
2863        // Branches' predecessor is the fork node
2864        let fork_id = nodes[1].id;
2865        let left_node = nodes.iter().find(|n| n.id == "left").unwrap();
2866        let right_node = nodes.iter().find(|n| n.id == "right").unwrap();
2867        assert_eq!(left_node.predecessor_id, Some(fork_id));
2868        assert_eq!(right_node.predecessor_id, Some(fork_id));
2869
2870        // Merge's predecessor is the fork node
2871        let merge_node = nodes.iter().find(|n| n.id == "merge").unwrap();
2872        assert_eq!(merge_node.predecessor_id, Some(fork_id));
2873    }
2874
2875    #[test]
2876    fn test_nodes_loop() {
2877        use crate::context::WorkflowContext;
2878        use crate::loop_result::LoopResult;
2879        use crate::workflow::{NodeKind, Workflow};
2880        use std::sync::Arc;
2881
2882        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2883        let workflow: Workflow<DummyCodec, u32> = WorkflowBuilder::new(ctx)
2884            .loop_task(
2885                "iterate",
2886                |i: u32| async move { Ok(LoopResult::Done(i)) },
2887                5,
2888            )
2889            .then("after", |i: u32| async move { Ok(i) })
2890            .build()
2891            .unwrap();
2892
2893        let nodes: Vec<_> = workflow.iter_nodes().collect();
2894
2895        // loop_0 (Loop) → iterate (Task, body) → after (Task, next)
2896        assert_eq!(nodes[0].kind, NodeKind::Loop);
2897        assert_eq!(nodes[1].id, "iterate");
2898        assert_eq!(nodes[1].kind, NodeKind::Task);
2899        assert_eq!(nodes[2].id, "after");
2900        assert_eq!(nodes[2].kind, NodeKind::Task);
2901
2902        // Predecessors
2903        assert_eq!(nodes[0].predecessor_id, None);
2904        assert_eq!(nodes[1].predecessor_id, Some(nodes[0].id)); // body → loop
2905        assert_eq!(nodes[2].predecessor_id, Some(nodes[0].id)); // next → loop
2906    }
2907
2908    #[test]
2909    fn test_nodes_delay_reports_duration_as_timeout() {
2910        use crate::context::WorkflowContext;
2911        use crate::workflow::{NodeKind, Workflow};
2912        use std::sync::Arc;
2913        use std::time::Duration;
2914
2915        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2916        let workflow: Workflow<DummyCodec, u32> = WorkflowBuilder::new(ctx)
2917            .delay("wait_5s", Duration::from_secs(5))
2918            .then("after", |i: u32| async move { Ok(i) })
2919            .build()
2920            .unwrap();
2921
2922        let nodes: Vec<_> = workflow.iter_nodes().collect();
2923        assert_eq!(nodes[0].id, "wait_5s");
2924        assert_eq!(nodes[0].kind, NodeKind::Delay);
2925        assert_eq!(nodes[0].timeout, Some(Duration::from_secs(5)));
2926        assert_eq!(nodes[0].predecessor_id, None);
2927
2928        assert_eq!(nodes[1].id, "after");
2929        assert_eq!(nodes[1].predecessor_id, Some("wait_5s"));
2930    }
2931
2932    #[test]
2933    fn test_nodes_metadata_extraction() {
2934        use crate::context::WorkflowContext;
2935        use crate::task::{RetryPolicy, TaskMetadata};
2936        use crate::workflow::NodeKind;
2937        use std::sync::Arc;
2938        use std::time::Duration;
2939
2940        let retry = RetryPolicy {
2941            max_retries: 3,
2942            initial_delay: Duration::from_millis(100),
2943            backoff_multiplier: 2.0,
2944            max_delay: Some(Duration::from_secs(10)),
2945        };
2946
2947        let ctx = WorkflowContext::new("test-workflow", Arc::new(DummyCodec), Arc::new(()));
2948        let workflow = WorkflowBuilder::new(ctx)
2949            .with_registry()
2950            .then("step", |i: u32| async move { Ok(i) })
2951            .with_metadata(TaskMetadata {
2952                timeout: Some(Duration::from_secs(30)),
2953                retries: Some(retry.clone()),
2954                version: Some("2.0".into()),
2955                ..Default::default()
2956            })
2957            .build()
2958            .unwrap();
2959
2960        let nodes: Vec<_> = workflow.iter_nodes().collect();
2961        assert_eq!(nodes.len(), 1);
2962        let node = &nodes[0];
2963
2964        assert_eq!(node.id, "step");
2965        assert_eq!(node.kind, NodeKind::Task);
2966        assert_eq!(node.timeout, Some(Duration::from_secs(30)));
2967        assert_eq!(node.retry_policy.unwrap().max_retries, 3);
2968    }
2969}
2970
2971#[cfg(test)]
2972#[allow(
2973    clippy::unwrap_used,
2974    clippy::expect_used,
2975    clippy::panic,
2976    clippy::indexing_slicing,
2977    clippy::too_many_lines,
2978    clippy::items_after_statements
2979)]
2980mod proptests {
2981    use super::{MaxIterationsPolicy, SerializableContinuation};
2982    use proptest::prelude::*;
2983
2984    /// Strategy for alphanumeric IDs (1..8 chars).
2985    fn arb_id() -> impl Strategy<Value = String> {
2986        "[a-z0-9]{1,8}"
2987    }
2988
2989    /// Recursive strategy for `SerializableContinuation` with bounded depth.
2990    fn arb_continuation(depth: usize) -> BoxedStrategy<SerializableContinuation> {
2991        let leaf = arb_id().prop_map(|id| SerializableContinuation::Task {
2992            id,
2993            timeout_ms: None,
2994            retry_policy: None,
2995            version: None,
2996            priority: None,
2997
2998            tags: vec![],
2999            next: None,
3000        });
3001
3002        if depth == 0 {
3003            return leaf.boxed();
3004        }
3005
3006        prop_oneof![
3007            // Task with optional next and optional timeout
3008            (
3009                arb_id(),
3010                prop::option::of(any::<u64>()),
3011                prop::option::of(arb_continuation(depth - 1).prop_map(Box::new)),
3012            )
3013                .prop_map(|(id, timeout_ms, next)| SerializableContinuation::Task {
3014                    id,
3015                    timeout_ms,
3016                    retry_policy: None,
3017                    version: None,
3018                    priority: None,
3019
3020                    tags: vec![],
3021                    next,
3022                }),
3023            // Fork with branches and optional join
3024            (
3025                arb_id(),
3026                prop::collection::vec(arb_continuation(depth - 1), 0..3),
3027                prop::option::of(arb_continuation(depth - 1).prop_map(Box::new)),
3028            )
3029                .prop_map(|(id, branches, join)| SerializableContinuation::Fork {
3030                    id,
3031                    branches,
3032                    join,
3033                }),
3034            // Delay with optional next
3035            (
3036                arb_id(),
3037                any::<u64>(),
3038                prop::option::of(arb_continuation(depth - 1).prop_map(Box::new)),
3039            )
3040                .prop_map(|(id, duration_ms, next)| SerializableContinuation::Delay {
3041                    id,
3042                    duration_ms,
3043                    next,
3044                }),
3045            // AwaitSignal with optional next
3046            (
3047                arb_id(),
3048                arb_id(),
3049                prop::option::of(any::<u64>()),
3050                prop::option::of(arb_continuation(depth - 1).prop_map(Box::new)),
3051            )
3052                .prop_map(|(id, signal_name, timeout_ms, next)| {
3053                    SerializableContinuation::AwaitSignal {
3054                        id,
3055                        signal_name,
3056                        timeout_ms,
3057                        next,
3058                    }
3059                }),
3060            // Branch with named branches, optional default and next
3061            (
3062                arb_id(),
3063                prop::collection::hash_map(
3064                    arb_id(),
3065                    arb_continuation(depth - 1).prop_map(Box::new),
3066                    0..3
3067                ),
3068                prop::option::of(arb_continuation(depth - 1).prop_map(Box::new)),
3069                prop::option::of(arb_continuation(depth - 1).prop_map(Box::new)),
3070            )
3071                .prop_map(|(id, branches, default, next)| {
3072                    SerializableContinuation::Branch {
3073                        id,
3074                        branches,
3075                        default,
3076                        next,
3077                    }
3078                }),
3079            // Loop with body and optional next
3080            (
3081                arb_id(),
3082                arb_continuation(depth - 1).prop_map(Box::new),
3083                1..100u32,
3084                prop::bool::ANY.prop_map(|b| if b {
3085                    MaxIterationsPolicy::Fail
3086                } else {
3087                    MaxIterationsPolicy::ExitWithLast
3088                }),
3089                prop::option::of(arb_continuation(depth - 1).prop_map(Box::new)),
3090            )
3091                .prop_map(|(id, body, max_iterations, on_max, next)| {
3092                    SerializableContinuation::Loop {
3093                        id,
3094                        body,
3095                        max_iterations,
3096                        on_max,
3097                        next,
3098                    }
3099                }),
3100            // ChildWorkflow with child and optional next
3101            (
3102                arb_id(),
3103                arb_continuation(depth - 1).prop_map(Box::new),
3104                prop::option::of(arb_continuation(depth - 1).prop_map(Box::new)),
3105            )
3106                .prop_map(|(id, child, next)| {
3107                    SerializableContinuation::ChildWorkflow { id, child, next }
3108                }),
3109        ]
3110        .boxed()
3111    }
3112
3113    /// Strategy for a continuation tree where all IDs are guaranteed unique.
3114    ///
3115    /// Each node gets an ID formed by its path index to prevent collisions.
3116    fn arb_unique_continuation(
3117        depth: usize,
3118        prefix: &str,
3119    ) -> BoxedStrategy<SerializableContinuation> {
3120        let id = format!("{prefix}n");
3121
3122        if depth == 0 {
3123            return Just(SerializableContinuation::Task {
3124                id,
3125                timeout_ms: None,
3126                retry_policy: None,
3127                version: None,
3128                priority: None,
3129
3130                tags: vec![],
3131                next: None,
3132            })
3133            .boxed();
3134        }
3135
3136        let id_clone = id.clone();
3137        prop_oneof![
3138            // Task with optional next
3139            prop::option::of(
3140                arb_unique_continuation(depth - 1, &format!("{prefix}0_")).prop_map(Box::new),
3141            )
3142            .prop_map(move |next| SerializableContinuation::Task {
3143                id: id_clone.clone(),
3144                timeout_ms: None,
3145                retry_policy: None,
3146                version: None,
3147                priority: None,
3148
3149                tags: vec![],
3150                next,
3151            }),
3152            // Fork with 0..3 branches (each gets unique prefix) and optional join
3153            {
3154                let id_f = id.clone();
3155                let prefix_f = prefix.to_string();
3156                (0..3u8)
3157                    .prop_flat_map(move |branch_count| {
3158                        let id_inner = id_f.clone();
3159                        let prefix_inner = prefix_f.clone();
3160                        let branches: Vec<BoxedStrategy<SerializableContinuation>> = (0
3161                            ..branch_count)
3162                            .map(|i| {
3163                                arb_unique_continuation(depth - 1, &format!("{prefix_inner}b{i}_"))
3164                            })
3165                            .collect();
3166                        let join = prop::option::of(
3167                            arb_unique_continuation(depth - 1, &format!("{prefix_inner}j_"))
3168                                .prop_map(Box::new),
3169                        );
3170                        (branches, join).prop_map(move |(branches, join)| {
3171                            SerializableContinuation::Fork {
3172                                id: id_inner.clone(),
3173                                branches,
3174                                join,
3175                            }
3176                        })
3177                    })
3178                    .boxed()
3179            },
3180            // Delay with optional next
3181            {
3182                let id_d = id.clone();
3183                let prefix_d = prefix.to_string();
3184                (
3185                    any::<u64>(),
3186                    prop::option::of(
3187                        arb_unique_continuation(depth - 1, &format!("{prefix_d}d_"))
3188                            .prop_map(Box::new),
3189                    ),
3190                )
3191                    .prop_map(move |(duration_ms, next)| {
3192                        SerializableContinuation::Delay {
3193                            id: id_d.clone(),
3194                            duration_ms,
3195                            next,
3196                        }
3197                    })
3198            },
3199            // AwaitSignal with optional next
3200            {
3201                let id_s = id.clone();
3202                let prefix_s = prefix.to_string();
3203                (
3204                    arb_id(),
3205                    prop::option::of(any::<u64>()),
3206                    prop::option::of(
3207                        arb_unique_continuation(depth - 1, &format!("{prefix_s}s_"))
3208                            .prop_map(Box::new),
3209                    ),
3210                )
3211                    .prop_map(move |(signal_name, timeout_ms, next)| {
3212                        SerializableContinuation::AwaitSignal {
3213                            id: id_s.clone(),
3214                            signal_name,
3215                            timeout_ms,
3216                            next,
3217                        }
3218                    })
3219            },
3220            // Branch with two named branches, optional default and next
3221            {
3222                let id_b = id.clone();
3223                let prefix_b = prefix.to_string();
3224                let b0 = arb_unique_continuation(depth - 1, &format!("{prefix_b}br0_"))
3225                    .prop_map(Box::new);
3226                let b1 = arb_unique_continuation(depth - 1, &format!("{prefix_b}br1_"))
3227                    .prop_map(Box::new);
3228                let default = prop::option::of(
3229                    arb_unique_continuation(depth - 1, &format!("{prefix_b}bd_"))
3230                        .prop_map(Box::new),
3231                );
3232                let next = prop::option::of(
3233                    arb_unique_continuation(depth - 1, &format!("{prefix_b}bn_"))
3234                        .prop_map(Box::new),
3235                );
3236                (b0, b1, default, next).prop_map(move |(branch0, branch1, default, next)| {
3237                    let mut branches = std::collections::HashMap::new();
3238                    branches.insert("k0".to_string(), branch0);
3239                    branches.insert("k1".to_string(), branch1);
3240                    SerializableContinuation::Branch {
3241                        id: id_b.clone(),
3242                        branches,
3243                        default,
3244                        next,
3245                    }
3246                })
3247            },
3248            // Loop with body and optional next
3249            {
3250                let id_l = id.clone();
3251                let prefix_l = prefix.to_string();
3252                let body = arb_unique_continuation(depth - 1, &format!("{prefix_l}lb_"))
3253                    .prop_map(Box::new);
3254                let next = prop::option::of(
3255                    arb_unique_continuation(depth - 1, &format!("{prefix_l}ln_"))
3256                        .prop_map(Box::new),
3257                );
3258                (
3259                    body,
3260                    1..100u32,
3261                    prop::bool::ANY.prop_map(|b| {
3262                        if b {
3263                            MaxIterationsPolicy::Fail
3264                        } else {
3265                            MaxIterationsPolicy::ExitWithLast
3266                        }
3267                    }),
3268                    next,
3269                )
3270                    .prop_map(move |(body, max_iterations, on_max, next)| {
3271                        SerializableContinuation::Loop {
3272                            id: id_l.clone(),
3273                            body,
3274                            max_iterations,
3275                            on_max,
3276                            next,
3277                        }
3278                    })
3279            },
3280            // ChildWorkflow with child and optional next
3281            {
3282                let id_cw = id;
3283                let prefix_cw = prefix.to_string();
3284                let child = arb_unique_continuation(depth - 1, &format!("{prefix_cw}cc_"))
3285                    .prop_map(Box::new);
3286                let next = prop::option::of(
3287                    arb_unique_continuation(depth - 1, &format!("{prefix_cw}cn_"))
3288                        .prop_map(Box::new),
3289                );
3290                (child, next).prop_map(move |(child, next)| {
3291                    SerializableContinuation::ChildWorkflow {
3292                        id: id_cw.clone(),
3293                        child,
3294                        next,
3295                    }
3296                })
3297            },
3298        ]
3299        .boxed()
3300    }
3301
3302    /// Collect all IDs in a continuation tree.
3303    fn collect_ids(cont: &SerializableContinuation) -> Vec<String> {
3304        let mut ids = vec![];
3305        fn walk(c: &SerializableContinuation, out: &mut Vec<String>) {
3306            match c {
3307                SerializableContinuation::Task { id, next, .. }
3308                | SerializableContinuation::Delay { id, next, .. }
3309                | SerializableContinuation::AwaitSignal { id, next, .. } => {
3310                    out.push(id.clone());
3311                    if let Some(n) = next {
3312                        walk(n, out);
3313                    }
3314                }
3315                SerializableContinuation::Fork { id, branches, join } => {
3316                    out.push(id.clone());
3317                    for b in branches {
3318                        walk(b, out);
3319                    }
3320                    if let Some(j) = join {
3321                        walk(j, out);
3322                    }
3323                }
3324                SerializableContinuation::Branch {
3325                    id,
3326                    branches,
3327                    default,
3328                    next,
3329                } => {
3330                    out.push(id.clone());
3331                    for b in branches.values() {
3332                        walk(b, out);
3333                    }
3334                    if let Some(d) = default {
3335                        walk(d, out);
3336                    }
3337                    if let Some(n) = next {
3338                        walk(n, out);
3339                    }
3340                }
3341                SerializableContinuation::Loop { id, body, next, .. } => {
3342                    out.push(id.clone());
3343                    walk(body, out);
3344                    if let Some(n) = next {
3345                        walk(n, out);
3346                    }
3347                }
3348                SerializableContinuation::ChildWorkflow { id, child, next } => {
3349                    out.push(id.clone());
3350                    walk(child, out);
3351                    if let Some(n) = next {
3352                        walk(n, out);
3353                    }
3354                }
3355            }
3356        }
3357        walk(cont, &mut ids);
3358        ids
3359    }
3360
3361    /// Inject a duplicate ID into a continuation by replacing the first node's ID.
3362    fn inject_duplicate(cont: &SerializableContinuation, dup_id: &str) -> SerializableContinuation {
3363        match cont {
3364            SerializableContinuation::Task {
3365                timeout_ms,
3366                retry_policy,
3367                version,
3368                next,
3369                ..
3370            } => SerializableContinuation::Task {
3371                id: dup_id.to_string(),
3372                timeout_ms: *timeout_ms,
3373                retry_policy: retry_policy.clone(),
3374                version: version.clone(),
3375                priority: None,
3376                tags: vec![],
3377                next: next.clone(),
3378            },
3379            SerializableContinuation::Fork { branches, join, .. } => {
3380                SerializableContinuation::Fork {
3381                    id: dup_id.to_string(),
3382                    branches: branches.clone(),
3383                    join: join.clone(),
3384                }
3385            }
3386            SerializableContinuation::Delay {
3387                duration_ms, next, ..
3388            } => SerializableContinuation::Delay {
3389                id: dup_id.to_string(),
3390                duration_ms: *duration_ms,
3391                next: next.clone(),
3392            },
3393            SerializableContinuation::AwaitSignal {
3394                signal_name,
3395                timeout_ms,
3396                next,
3397                ..
3398            } => SerializableContinuation::AwaitSignal {
3399                id: dup_id.to_string(),
3400                signal_name: signal_name.clone(),
3401                timeout_ms: *timeout_ms,
3402                next: next.clone(),
3403            },
3404            SerializableContinuation::Branch {
3405                branches,
3406                default,
3407                next,
3408                ..
3409            } => SerializableContinuation::Branch {
3410                id: dup_id.to_string(),
3411                branches: branches.clone(),
3412                default: default.clone(),
3413                next: next.clone(),
3414            },
3415            SerializableContinuation::Loop {
3416                body,
3417                max_iterations,
3418                on_max,
3419                next,
3420                ..
3421            } => SerializableContinuation::Loop {
3422                id: dup_id.to_string(),
3423                body: body.clone(),
3424                max_iterations: *max_iterations,
3425                on_max: *on_max,
3426                next: next.clone(),
3427            },
3428            SerializableContinuation::ChildWorkflow { child, next, .. } => {
3429                SerializableContinuation::ChildWorkflow {
3430                    id: dup_id.to_string(),
3431                    child: child.clone(),
3432                    next: next.clone(),
3433                }
3434            }
3435        }
3436    }
3437
3438    proptest! {
3439        // Property 4: `compute_definition_hash` is deterministic.
3440        #[test]
3441        fn hash_is_deterministic(cont in arb_continuation(3)) {
3442            let h1 = cont.compute_definition_hash();
3443            let h2 = cont.compute_definition_hash();
3444            prop_assert_eq!(h1, h2);
3445        }
3446
3447        // Property 5: serde roundtrip preserves the definition hash.
3448        #[test]
3449        fn serde_roundtrip_preserves_hash(cont in arb_continuation(3)) {
3450            let original_hash = cont.compute_definition_hash();
3451            let json = serde_json::to_string(&cont).unwrap();
3452            let recovered: SerializableContinuation = serde_json::from_str(&json).unwrap();
3453            prop_assert_eq!(original_hash, recovered.compute_definition_hash());
3454        }
3455
3456        // Property 6: a tree with guaranteed-unique IDs has no duplicates.
3457        #[test]
3458        fn unique_ids_means_none(cont in arb_unique_continuation(3, "r_")) {
3459            prop_assert!(cont.find_duplicate_id().is_none());
3460        }
3461
3462        // Property 7: injecting a duplicate ID is always detected.
3463        #[test]
3464        fn injected_duplicate_is_detected(cont in arb_unique_continuation(3, "r_")) {
3465            let ids = collect_ids(&cont);
3466            // Need at least 2 nodes to have a meaningful duplicate injection
3467            if ids.len() >= 2 {
3468                // Pick the second ID and inject it into the root (which has the first ID)
3469                let dup_id = &ids[1];
3470                let tampered = inject_duplicate(&cont, dup_id);
3471                prop_assert!(tampered.find_duplicate_id().is_some());
3472            }
3473        }
3474    }
3475}