gemini_adk_rs/
workflow.rs

1//! Workflow graph runtime — the ADK 2.0 "graph execution" pattern.
2//!
3//! A [`Workflow`] is a directed acyclic graph (DAG) of execution nodes with
4//! dependencies. Unlike the [`crate::flow`] module (which *governs* conversation
5//! and tool-call ordering), workflows *execute* node graphs concurrently: agents,
6//! functions, and human-in-the-loop approval nodes.
7//!
8//! # Execution Model
9//!
10//! The runtime repeatedly collects *ready* nodes — those whose dependencies are
11//! all in a terminal state (finished or skipped; `join_any` nodes are ready as
12//! soon as one dependency *finishes*). Ready nodes whose `when` guard evaluates
13//! to `false` are marked *skipped* and do not run, and skips cascade: a node
14//! all of whose dependencies were skipped is itself skipped — an unselected
15//! branch never executes side effects. Ready nodes run concurrently via
16//! `tokio::task::JoinSet`, and readiness is recomputed after **every** node
17//! completion, so a `join_any` dependent starts the moment its first
18//! dependency finishes rather than waiting out the wave.
19//!
20//! Agent and function nodes write their outputs to:
21//! - The `WorkflowRun::outputs` map (under their node ID)
22//! - State key `workflow:<node_id>` (as JSON, for downstream instruction templates)
23//!
24//! Approval nodes suspend until an external [`WorkflowController`] approves or
25//! rejects them; decisions arriving before the node is ready are stored, never
26//! lost. Rejection fails the entire run. [`Workflow::run`] (no controller)
27//! rejects workflows containing approval nodes up front instead of hanging.
28//!
29//! # DAG Invariants
30//!
31//! - Cycle detection (topological check) at build time.
32//! - Unknown dependencies rejected at build time.
33//! - Duplicate node IDs rejected at build time.
34//! - Deadlock guard: if unfinished nodes exist but none are ready, the run fails.
35//!
36//! # Example
37//!
38//! ```rust,ignore
39//! let wf = Workflow::builder()
40//!     .function("fetch", |s| async { Ok(json!({"data": "value"})) })
41//!     .function("process", |s| async { Ok(json!({"result": "done"})) })
42//!     .after(&["fetch"])
43//!     .approval("review")
44//!     .after(&["process"])
45//!     .build()?;
46//!
47//! let run = wf.run_with(&state, controller).await?;
48//! assert!(run.outputs.contains_key("review"));
49//! ```
50
51use std::collections::{HashMap, HashSet};
52use std::future::Future;
53use std::sync::Arc;
54
55use serde_json::Value;
56use tokio::sync::Notify;
57use tokio::task::JoinSet;
58
59use crate::error::AgentError;
60use crate::state::State;
61use crate::text::TextAgent;
62use crate::{AsyncSourceFn, StatePredicate};
63
64// ──────────────────────────────────────────────────────────────────────────────
65// Error Type
66// ──────────────────────────────────────────────────────────────────────────────
67
68/// Errors that can occur during workflow execution or construction.
69#[derive(Debug, thiserror::Error)]
70pub enum WorkflowError {
71    /// The workflow graph contains a cycle.
72    #[error("Workflow cycle detected: {0}")]
73    CycleDetected(String),
74
75    /// A node references a dependency that does not exist.
76    #[error("Unknown dependency: {0}")]
77    UnknownDependency(String),
78
79    /// Duplicate node ID in the workflow.
80    #[error("Duplicate node ID: {0}")]
81    DuplicateNodeId(String),
82
83    /// A node failed during execution.
84    #[error("Node '{node_id}' failed: {reason}")]
85    NodeFailed {
86        /// The ID of the node that failed.
87        node_id: String,
88        /// The error message.
89        reason: String,
90    },
91
92    /// An approval node was rejected.
93    #[error("Approval node '{node_id}' rejected: {reason}")]
94    Rejected {
95        /// The ID of the approval node.
96        node_id: String,
97        /// The rejection reason.
98        reason: String,
99    },
100
101    /// Deadlock: unfinished nodes but none are ready.
102    #[error("Workflow deadlock: no ready nodes but work remains")]
103    Deadlock,
104
105    /// The workflow contains an approval node but was started without a
106    /// controller ([`Workflow::run`]); nobody could ever approve it.
107    #[error("Approval node '{0}' requires run_with(state, controller)")]
108    ApprovalWithoutController(String),
109
110    /// An agent node execution failed.
111    #[error("Agent error in node '{node_id}': {source}")]
112    AgentError {
113        /// The ID of the agent node.
114        node_id: String,
115        /// The underlying agent error.
116        source: AgentError,
117    },
118}
119
120// ──────────────────────────────────────────────────────────────────────────────
121// Public API Types
122// ──────────────────────────────────────────────────────────────────────────────
123
124/// The result of a completed workflow run.
125#[derive(Debug, Clone)]
126pub struct WorkflowRun {
127    /// Output values keyed by node ID.
128    pub outputs: HashMap<String, Value>,
129    /// Node IDs that were skipped (guard returned false, or all deps were skipped).
130    pub skipped: Vec<String>,
131}
132
133/// Controller for HITL (human-in-the-loop) approval nodes.
134///
135/// Approve or reject nodes by ID. Decisions are durable: a decision made
136/// *before* the approval node becomes ready (an external response arriving
137/// while upstream nodes still run) is stored and consumed when the node
138/// reaches the wait — never lost to a wakeup race.
139#[derive(Debug, Default)]
140pub struct WorkflowController {
141    /// Per-node decision: `Ok(())` approved, `Err(reason)` rejected.
142    decisions: tokio::sync::RwLock<HashMap<String, Result<(), String>>>,
143    /// Waiters registered by approval nodes that reached the wait.
144    waiters: tokio::sync::RwLock<HashMap<String, Arc<Notify>>>,
145}
146
147impl WorkflowController {
148    /// Create a new empty controller.
149    pub fn new() -> Arc<Self> {
150        Arc::new(Self::default())
151    }
152
153    /// Approve a node by ID. Unblocks the waiting approval node; if the node
154    /// has not reached its wait yet, the approval is stored for it.
155    pub async fn approve(&self, node_id: &str) {
156        self.decide(node_id, Ok(())).await;
157    }
158
159    /// Reject a node by ID with a reason. This fails the entire workflow run;
160    /// an early rejection is stored like an early approval.
161    pub async fn reject(&self, node_id: &str, reason: impl Into<String>) {
162        self.decide(node_id, Err(reason.into())).await;
163    }
164
165    async fn decide(&self, node_id: &str, decision: Result<(), String>) {
166        self.decisions
167            .write()
168            .await
169            .insert(node_id.to_string(), decision);
170        if let Some(notify) = self.waiters.read().await.get(node_id) {
171            notify.notify_one();
172        }
173    }
174
175    /// Suspend until a decision exists for `node_id`, consuming an earlier
176    /// decision immediately if one was already recorded.
177    async fn wait_for_decision(&self, node_id: &str) -> Result<(), String> {
178        let notify = Arc::new(Notify::new());
179        self.waiters
180            .write()
181            .await
182            .insert(node_id.to_string(), notify.clone());
183        loop {
184            // Create the notified future BEFORE checking, so a decision that
185            // lands between the check and the await stores a permit for us.
186            let notified = notify.notified();
187            if let Some(decision) = self.decisions.read().await.get(node_id) {
188                self.waiters.write().await.remove(node_id);
189                return decision.clone();
190            }
191            notified.await;
192        }
193    }
194}
195
196// ──────────────────────────────────────────────────────────────────────────────
197// Internal Node Types
198// ──────────────────────────────────────────────────────────────────────────────
199
200/// The kind of a workflow node.
201enum NodeKind {
202    Agent(Arc<dyn TextAgent>),
203    Function(AsyncSourceFn),
204    Approval,
205}
206
207/// Configuration for a single workflow node.
208struct WorkflowNode {
209    id: String,
210    kind: NodeKind,
211    dependencies: Vec<String>,
212    when: Option<StatePredicate>,
213    join_any: bool,
214}
215
216// ──────────────────────────────────────────────────────────────────────────────
217// Workflow Builder
218// ──────────────────────────────────────────────────────────────────────────────
219
220/// Builder for constructing a [`Workflow`].
221///
222/// Each method appends a node and returns `self` for chaining. Modifiers like
223/// `after()` and `when()` apply to the most recently added node.
224pub struct WorkflowBuilder {
225    nodes: Vec<WorkflowNode>,
226}
227
228impl WorkflowBuilder {
229    /// Start building a workflow.
230    pub fn new() -> Self {
231        Self { nodes: Vec::new() }
232    }
233
234    /// Add an agent node. The agent's returned string is stored as a JSON string value.
235    pub fn agent(mut self, id: &str, agent: Arc<dyn TextAgent>) -> Self {
236        self.nodes.push(WorkflowNode {
237            id: id.to_string(),
238            kind: NodeKind::Agent(agent),
239            dependencies: Vec::new(),
240            when: None,
241            join_any: false,
242        });
243        self
244    }
245
246    /// Add a function node. The function receives a State clone and returns a JSON value.
247    pub fn function<F, Fut>(mut self, id: &str, f: F) -> Self
248    where
249        F: Fn(State) -> Fut + Send + Sync + 'static,
250        Fut: Future<Output = Result<Value, String>> + Send + 'static,
251    {
252        let wrapped: AsyncSourceFn = Arc::new(move |state| Box::pin(f(state)));
253
254        self.nodes.push(WorkflowNode {
255            id: id.to_string(),
256            kind: NodeKind::Function(wrapped),
257            dependencies: Vec::new(),
258            when: None,
259            join_any: false,
260        });
261        self
262    }
263
264    /// Add an approval node. This node suspends until the controller approves or rejects.
265    pub fn approval(mut self, id: &str) -> Self {
266        self.nodes.push(WorkflowNode {
267            id: id.to_string(),
268            kind: NodeKind::Approval,
269            dependencies: Vec::new(),
270            when: None,
271            join_any: false,
272        });
273        self
274    }
275
276    /// Set dependencies for the most recently added node. Node runs after all deps are terminal.
277    pub fn after(mut self, deps: &[&str]) -> Self {
278        if let Some(node) = self.nodes.last_mut() {
279            node.dependencies = deps.iter().map(std::string::ToString::to_string).collect();
280        }
281        self
282    }
283
284    /// Set a guard for the most recently added node. If guard returns false, node is skipped.
285    pub fn when<G>(mut self, guard: G) -> Self
286    where
287        G: Fn(&State) -> bool + Send + Sync + 'static,
288    {
289        if let Some(node) = self.nodes.last_mut() {
290            node.when = Some(Arc::new(guard));
291        }
292        self
293    }
294
295    /// For the most recently added node, use `join_any` semantics: ready when ANY dep finishes
296    /// (instead of ALL). Skipped only if ALL deps skipped.
297    pub fn join_any(mut self) -> Self {
298        if let Some(node) = self.nodes.last_mut() {
299            node.join_any = true;
300        }
301        self
302    }
303
304    /// Build and validate the workflow. Returns an error if there are cycles,
305    /// unknown dependencies, or duplicate IDs.
306    pub fn build(self) -> Result<Workflow, WorkflowError> {
307        // Check for duplicate IDs.
308        let mut seen_ids = HashSet::new();
309        for node in &self.nodes {
310            if !seen_ids.insert(&node.id) {
311                return Err(WorkflowError::DuplicateNodeId(node.id.clone()));
312            }
313        }
314
315        // Check for unknown dependencies.
316        let id_set: HashSet<&str> = self.nodes.iter().map(|n| n.id.as_str()).collect();
317        for node in &self.nodes {
318            for dep in &node.dependencies {
319                if !id_set.contains(dep.as_str()) {
320                    return Err(WorkflowError::UnknownDependency(dep.clone()));
321                }
322            }
323        }
324
325        // Topological sort to detect cycles.
326        self.check_acyclic()?;
327
328        Ok(Workflow { nodes: self.nodes })
329    }
330
331    /// Check for cycles using DFS.
332    fn check_acyclic(&self) -> Result<(), WorkflowError> {
333        let mut visited = HashSet::new();
334        let mut rec_stack = HashSet::new();
335
336        for node in &self.nodes {
337            if !visited.contains(&node.id) {
338                self.dfs(&node.id, &mut visited, &mut rec_stack)?;
339            }
340        }
341        Ok(())
342    }
343
344    /// DFS helper for cycle detection.
345    fn dfs(
346        &self,
347        node_id: &str,
348        visited: &mut HashSet<String>,
349        rec_stack: &mut HashSet<String>,
350    ) -> Result<(), WorkflowError> {
351        visited.insert(node_id.to_string());
352        rec_stack.insert(node_id.to_string());
353
354        let node = self.nodes.iter().find(|n| n.id == node_id);
355        if let Some(node) = node {
356            for dep in &node.dependencies {
357                if !visited.contains(dep) {
358                    self.dfs(dep, visited, rec_stack)?;
359                } else if rec_stack.contains(dep) {
360                    return Err(WorkflowError::CycleDetected(format!("{node_id} -> {dep}")));
361                }
362            }
363        }
364
365        rec_stack.remove(node_id);
366        Ok(())
367    }
368}
369
370impl Default for WorkflowBuilder {
371    fn default() -> Self {
372        Self::new()
373    }
374}
375
376// ──────────────────────────────────────────────────────────────────────────────
377// Workflow Execution
378// ──────────────────────────────────────────────────────────────────────────────
379
380/// A validated workflow graph ready for execution.
381pub struct Workflow {
382    nodes: Vec<WorkflowNode>,
383}
384
385impl Workflow {
386    /// Create a new workflow builder.
387    pub fn builder() -> WorkflowBuilder {
388        WorkflowBuilder::new()
389    }
390
391    /// Run the workflow without HITL support. A workflow containing an
392    /// approval node is rejected up front — with no controller exposed,
393    /// nothing could ever approve it and the run would hang.
394    pub async fn run(&self, state: &State) -> Result<WorkflowRun, WorkflowError> {
395        if let Some(node) = self
396            .nodes
397            .iter()
398            .find(|n| matches!(n.kind, NodeKind::Approval))
399        {
400            return Err(WorkflowError::ApprovalWithoutController(node.id.clone()));
401        }
402        self.run_with(state, WorkflowController::new()).await
403    }
404
405    /// Run the workflow with HITL controller for approval nodes.
406    ///
407    /// The scheduler recomputes readiness after **every** node completion, so
408    /// a `join_any` dependent starts the moment its first dependency finishes
409    /// — it never waits out the rest of the wave.
410    pub async fn run_with(
411        &self,
412        state: &State,
413        controller: Arc<WorkflowController>,
414    ) -> Result<WorkflowRun, WorkflowError> {
415        let mut outputs: HashMap<String, Value> = HashMap::new();
416        let mut skipped: Vec<String> = Vec::new();
417        let mut finished: HashSet<String> = HashSet::new();
418        let mut running: HashSet<String> = HashSet::new();
419        let mut join_set: JoinSet<(String, Result<Value, String>)> = JoinSet::new();
420
421        loop {
422            // Schedule until quiescent: guard-skips cascade (a skip can make a
423            // dependent's deps terminal, which may skip it in turn), so loop
424            // to a fixpoint before waiting on completions.
425            loop {
426                let mut changed = false;
427                for node in &self.nodes {
428                    if finished.contains(&node.id)
429                        || skipped.iter().any(|s| s == &node.id)
430                        || running.contains(&node.id)
431                    {
432                        continue;
433                    }
434                    let is_terminal =
435                        |d: &String| finished.contains(d) || skipped.iter().any(|s| s == d);
436                    let deps_ready = if node.join_any {
437                        // join_any: a *finished* dep makes the node ready; a
438                        // skipped dep alone does not trigger execution.
439                        node.dependencies.is_empty()
440                            || node.dependencies.iter().any(|d| finished.contains(d))
441                    } else {
442                        node.dependencies.iter().all(&is_terminal)
443                    };
444                    // A node all of whose deps were skipped is itself skipped
445                    // — the branch was not selected, so its side effects must
446                    // not run. (Both join modes: with every dep skipped there
447                    // is no finished dep to satisfy join_any either.)
448                    let all_deps_skipped = !node.dependencies.is_empty()
449                        && node.dependencies.iter().all(&is_terminal)
450                        && !node.dependencies.iter().any(|d| finished.contains(d));
451                    if all_deps_skipped {
452                        skipped.push(node.id.clone());
453                        changed = true;
454                        continue;
455                    }
456                    if !deps_ready {
457                        continue;
458                    }
459                    if node.when.as_ref().is_some_and(|guard| !guard(state)) {
460                        skipped.push(node.id.clone());
461                        changed = true;
462                        continue;
463                    }
464
465                    // Spawn the node.
466                    running.insert(node.id.clone());
467                    changed = true;
468                    let node_id = node.id.clone();
469                    let state_clone = state.clone();
470                    match &node.kind {
471                        NodeKind::Agent(agent) => {
472                            let agent = agent.clone();
473                            join_set.spawn(async move {
474                                let value = match agent.run(&state_clone).await {
475                                    Ok(s) => Ok(Value::String(s)),
476                                    Err(e) => Err(e.to_string()),
477                                };
478                                (node_id, value)
479                            });
480                        }
481                        NodeKind::Function(f) => {
482                            let f = f.clone();
483                            join_set.spawn(async move {
484                                let result = f(state_clone).await;
485                                (node_id, result)
486                            });
487                        }
488                        NodeKind::Approval => {
489                            let controller = controller.clone();
490                            join_set.spawn(async move {
491                                let value = match controller.wait_for_decision(&node_id).await {
492                                    Ok(()) => Ok(Value::Bool(true)),
493                                    Err(reason) => Err(format!("Rejected: {reason}")),
494                                };
495                                (node_id, value)
496                            });
497                        }
498                    }
499                }
500                if !changed {
501                    break;
502                }
503            }
504
505            if running.is_empty() {
506                if finished.len() + skipped.len() < self.nodes.len() {
507                    return Err(WorkflowError::Deadlock);
508                }
509                break; // All done.
510            }
511
512            // Wait for ONE completion, then reschedule — this is what lets a
513            // join_any dependent start while its slower siblings still run.
514            match join_set.join_next().await {
515                Some(Ok((node_id, value_result))) => {
516                    running.remove(&node_id);
517                    match value_result {
518                        Ok(value) => {
519                            outputs.insert(node_id.clone(), value.clone());
520                            // Also write to state under workflow:<id>.
521                            let _ = state.set(format!("workflow:{node_id}"), value);
522                            finished.insert(node_id);
523                        }
524                        Err(e) => {
525                            // Fail fast; dropping the JoinSet aborts siblings.
526                            if let Some(reason) = e.strip_prefix("Rejected: ") {
527                                return Err(WorkflowError::Rejected {
528                                    node_id,
529                                    reason: reason.to_string(),
530                                });
531                            }
532                            return Err(WorkflowError::NodeFailed { node_id, reason: e });
533                        }
534                    }
535                }
536                Some(Err(e)) => {
537                    return Err(WorkflowError::NodeFailed {
538                        node_id: "unknown".to_string(),
539                        reason: e.to_string(),
540                    });
541                }
542                None => unreachable!("running non-empty implies join_set non-empty"),
543            }
544        }
545
546        Ok(WorkflowRun { outputs, skipped })
547    }
548}
549
550// ──────────────────────────────────────────────────────────────────────────────
551// Test types
552// ──────────────────────────────────────────────────────────────────────────────
553
554#[cfg(test)]
555/// A simple test agent that echoes the "input" state key.
556struct EchoAgent;
557
558#[cfg(test)]
559#[async_trait::async_trait]
560impl TextAgent for EchoAgent {
561    fn name(&self) -> &str {
562        "echo"
563    }
564
565    async fn run(&self, state: &State) -> Result<String, AgentError> {
566        let input: String = state.get("input").unwrap_or_else(|| "empty".to_string());
567        Ok(format!("Echo: {input}"))
568    }
569}
570
571// ──────────────────────────────────────────────────────────────────────────────
572// Tests
573// ──────────────────────────────────────────────────────────────────────────────
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use serde_json::json;
579
580    #[tokio::test]
581    async fn test_diamond_graph() {
582        // Diamond: a -> (b, c) -> d
583        let wf = Workflow::builder()
584            .function("a", |state| async move {
585                state.set("a_val", "value_a").map_err(|e| e.to_string())?;
586                Ok(json!({"a": "done"}))
587            })
588            .function("b", |state| async move {
589                let a_val: Option<String> = state.get("a_val");
590                let b_val = format!("value_b_from_{a_val:?}");
591                state.set("b_val", b_val).map_err(|e| e.to_string())?;
592                Ok(json!({"b": "done"}))
593            })
594            .after(&["a"])
595            .function("c", |state| async move {
596                let a_val: Option<String> = state.get("a_val");
597                let c_val = format!("value_c_from_{a_val:?}");
598                state.set("c_val", c_val).map_err(|e| e.to_string())?;
599                Ok(json!({"c": "done"}))
600            })
601            .after(&["a"])
602            .function("d", |state| async move {
603                let b_val: Option<String> = state.get("b_val");
604                let c_val: Option<String> = state.get("c_val");
605                let d_val = format!("value_d_from_b_{b_val:?}_c_{c_val:?}");
606                state.set("d_val", d_val).map_err(|e| e.to_string())?;
607                Ok(json!({"d": "done"}))
608            })
609            .after(&["b", "c"])
610            .build()
611            .expect("valid workflow");
612
613        let state = State::new();
614        let run = wf.run(&state).await.expect("run succeeds");
615
616        assert_eq!(run.outputs.len(), 4);
617        assert!(run.outputs.contains_key("a"));
618        assert!(run.outputs.contains_key("b"));
619        assert!(run.outputs.contains_key("c"));
620        assert!(run.outputs.contains_key("d"));
621        assert!(run.skipped.is_empty());
622    }
623
624    #[tokio::test]
625    async fn test_when_guard_skips_node_and_cascades() {
626        let wf = Workflow::builder()
627            .function("a", |_state| async move { Ok(json!({"a": "done"})) })
628            .function("b", |_state| async move { Ok(json!({"b": "done"})) })
629            .after(&["a"])
630            .when(|_state| false) // Always skip
631            .function("c", |_state| async move { Ok(json!({"c": "done"})) })
632            .after(&["b"])
633            .build()
634            .expect("valid workflow");
635
636        let state = State::new();
637        let run = wf.run(&state).await.expect("run succeeds");
638
639        // b's branch was not selected, so c (whose only dep was skipped)
640        // must not execute its side effects either.
641        assert_eq!(run.outputs.len(), 1);
642        assert!(run.outputs.contains_key("a"));
643        assert!(!run.outputs.contains_key("b"));
644        assert!(!run.outputs.contains_key("c"));
645        assert_eq!(run.skipped, vec!["b", "c"]);
646    }
647
648    #[tokio::test]
649    async fn test_join_any() {
650        let wf = Workflow::builder()
651            .function("a", |_state| async move { Ok(json!({"a": "done"})) })
652            .function("b", |_state| async move { Ok(json!({"b": "done"})) })
653            .when(|_state| false) // Skipped
654            .function("c", |_state| async move { Ok(json!({"c": "done"})) })
655            .after(&["a", "b"])
656            .join_any()
657            .build()
658            .expect("valid workflow");
659
660        let state = State::new();
661        let run = wf.run(&state).await.expect("run succeeds");
662
663        assert!(run.outputs.contains_key("a"));
664        assert!(!run.outputs.contains_key("b")); // Skipped
665        assert!(run.outputs.contains_key("c")); // Ran despite b being skipped
666        assert_eq!(run.skipped, vec!["b"]);
667    }
668
669    #[tokio::test]
670    async fn test_unknown_dep_rejected_at_build() {
671        let result = Workflow::builder()
672            .function("a", |_state| async move { Ok(json!({})) })
673            .function("b", |_state| async move { Ok(json!({})) })
674            .after(&["unknown_node"])
675            .build();
676
677        assert!(matches!(result, Err(WorkflowError::UnknownDependency(_))));
678    }
679
680    #[tokio::test]
681    async fn test_duplicate_id_rejected_at_build() {
682        let result = Workflow::builder()
683            .function("a", |_state| async move { Ok(json!({})) })
684            .function("a", |_state| async move { Ok(json!({})) }) // Duplicate
685            .build();
686
687        assert!(matches!(result, Err(WorkflowError::DuplicateNodeId(_))));
688    }
689
690    #[tokio::test]
691    async fn test_approval_approve() {
692        let wf = Workflow::builder()
693            .function("a", |_state| async move { Ok(json!({"a": "done"})) })
694            .approval("review")
695            .after(&["a"])
696            .build()
697            .expect("valid workflow");
698
699        let state = State::new();
700        let controller = WorkflowController::new();
701        let controller_clone = controller.clone();
702
703        // Spawn approval runner in background.
704        let run_task = tokio::spawn(async move { wf.run_with(&state, controller_clone).await });
705
706        // Give the run a moment to reach the approval node.
707        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
708
709        // Approve it.
710        controller.approve("review").await;
711
712        // Wait for run to complete.
713        let run = run_task.await.expect("task panicked");
714        assert!(run.is_ok());
715        let run = run.unwrap();
716        assert!(run.outputs.contains_key("a"));
717        assert!(run.outputs.contains_key("review"));
718    }
719
720    #[tokio::test]
721    async fn test_approval_reject() {
722        let wf = Workflow::builder()
723            .function("a", |_state| async move { Ok(json!({"a": "done"})) })
724            .approval("review")
725            .after(&["a"])
726            .build()
727            .expect("valid workflow");
728
729        let state = State::new();
730        let controller = WorkflowController::new();
731        let controller_clone = controller.clone();
732
733        let run_task = tokio::spawn(async move { wf.run_with(&state, controller_clone).await });
734
735        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
736        controller.reject("review", "not approved").await;
737
738        let run = run_task.await.expect("task panicked");
739        assert!(run.is_err());
740        match run.unwrap_err() {
741            WorkflowError::Rejected { node_id, .. } => {
742                assert_eq!(node_id, "review");
743            }
744            e => panic!("Expected Rejected, got {e:?}"),
745        }
746    }
747
748    #[tokio::test]
749    async fn test_run_rejects_approval_without_controller() {
750        let wf = Workflow::builder()
751            .approval("gate")
752            .build()
753            .expect("valid workflow");
754
755        let state = State::new();
756        // Must error immediately instead of hanging on an unreachable gate.
757        match wf.run(&state).await.unwrap_err() {
758            WorkflowError::ApprovalWithoutController(id) => assert_eq!(id, "gate"),
759            e => panic!("Expected ApprovalWithoutController, got {e:?}"),
760        }
761    }
762
763    #[tokio::test]
764    async fn test_early_approval_is_not_lost() {
765        let wf = Workflow::builder()
766            .function("a", |_state| async move { Ok(json!({"a": "done"})) })
767            .approval("review")
768            .after(&["a"])
769            .build()
770            .expect("valid workflow");
771
772        let state = State::new();
773        let controller = WorkflowController::new();
774        // Decide BEFORE the approval node is ready (before the run starts):
775        // the decision must be stored and consumed at the wait, not dropped.
776        controller.approve("review").await;
777
778        let run = tokio::time::timeout(
779            tokio::time::Duration::from_secs(5),
780            wf.run_with(&state, controller),
781        )
782        .await
783        .expect("run must not hang on an early approval")
784        .expect("run succeeds");
785        assert!(run.outputs.contains_key("review"));
786    }
787
788    #[tokio::test]
789    async fn test_early_rejection_is_not_lost() {
790        let wf = Workflow::builder()
791            .approval("review")
792            .build()
793            .expect("valid workflow");
794
795        let state = State::new();
796        let controller = WorkflowController::new();
797        controller.reject("review", "denied up front").await;
798
799        let result = tokio::time::timeout(
800            tokio::time::Duration::from_secs(5),
801            wf.run_with(&state, controller),
802        )
803        .await
804        .expect("run must not hang on an early rejection");
805        assert!(matches!(result, Err(WorkflowError::Rejected { .. })));
806    }
807
808    #[tokio::test]
809    async fn test_join_any_starts_before_slow_sibling_completes() {
810        // d is join_any on (a, b). b blocks until d has run (via a state
811        // flag), so the run only completes if the scheduler starts d after
812        // a finishes, while b is still in flight. A wave-based scheduler
813        // deadlocks here; the timeout turns that into a failure.
814        let wf = Workflow::builder()
815            .function("a", |_state| async move { Ok(json!({"a": "done"})) })
816            .function("b", |state| async move {
817                for _ in 0..500 {
818                    if state.get::<bool>("d_ran").unwrap_or(false) {
819                        return Ok(json!({"b": "done"}));
820                    }
821                    tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
822                }
823                Err("d never ran while b was in flight".to_string())
824            })
825            .function("d", |state| async move {
826                state.set("d_ran", true).map_err(|e| e.to_string())?;
827                Ok(json!({"d": "done"}))
828            })
829            .after(&["a", "b"])
830            .join_any()
831            .build()
832            .expect("valid workflow");
833
834        let state = State::new();
835        let run = tokio::time::timeout(tokio::time::Duration::from_secs(10), wf.run(&state))
836            .await
837            .expect("join_any dependent must start before the slow sibling")
838            .expect("run succeeds");
839        assert!(run.outputs.contains_key("d"));
840        assert!(run.outputs.contains_key("b"));
841    }
842
843    #[tokio::test]
844    async fn test_agent_node() {
845        let echo = Arc::new(EchoAgent);
846
847        let wf = Workflow::builder()
848            .agent("echo_node", echo)
849            .build()
850            .expect("valid workflow");
851
852        let state = State::new();
853        state.set("input", "hello").expect("state set succeeds");
854
855        let run = wf.run(&state).await.expect("run succeeds");
856        assert!(run.outputs.contains_key("echo_node"));
857        let output = &run.outputs["echo_node"];
858        assert!(output.is_string());
859        let s = output.as_str().unwrap();
860        assert!(s.contains("Echo") && s.contains("hello"));
861    }
862}