use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use serde_json::{Map, Value, json};
use tinyagents::{
Command, CompiledGraph, END, GraphBuilder, Interrupt, NodeResult, StateReducer, TinyAgentsError,
};
pub use tinyagents::{Checkpointer, DurabilityMode, FileCheckpointer, InMemoryCheckpointer};
use crate::caps::Capabilities;
use crate::compiler::CompiledWorkflow;
use crate::data::Item;
use crate::error::{EngineError, Result, ValidationError};
use crate::model::NodeKind;
use crate::nodes::{NodeContext, executor_for};
use crate::observability::{ExecutionStep, Run, RunObserver, RunStatus, StepStatus};
static NEXT_RUN_ID: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone)]
pub struct RunOutcome {
pub output: Value,
pub pending_approvals: Vec<String>,
}
struct MergeReducer;
impl StateReducer<Value, Value> for MergeReducer {
fn apply(&self, mut state: Value, update: Value) -> tinyagents::Result<Value> {
merge(&mut state, update);
Ok(state)
}
}
fn merge(base: &mut Value, update: Value) {
match (base, update) {
(Value::Object(base), Value::Object(update)) => {
for (key, value) in update {
merge(base.entry(key).or_insert(Value::Null), value);
}
}
(base, update) => *base = update,
}
}
fn collect_input(state: &Value, predecessors: &[String]) -> Vec<Item> {
let mut items = Vec::new();
for pred in predecessors {
if let Some(array) = state
.get("nodes")
.and_then(|nodes| nodes.get(pred))
.and_then(|slot| slot.get("items"))
.and_then(Value::as_array)
{
for value in array {
if let Ok(item) = serde_json::from_value::<Item>(value.clone()) {
items.push(item);
}
}
}
}
items
}
fn items_update(node_id: &str, items: &[Item], port: Option<&str>) -> tinyagents::Result<Value> {
let mut slot = Map::new();
slot.insert("items".to_string(), serde_json::to_value(items)?);
if let Some(port) = port {
slot.insert("port".to_string(), Value::String(port.to_string()));
}
let mut nodes = Map::new();
nodes.insert(node_id.to_string(), Value::Object(slot));
let mut root = Map::new();
root.insert("nodes".to_string(), Value::Object(nodes));
Ok(Value::Object(root))
}
fn error_item(node_id: &str, e: &EngineError) -> Item {
Item::new(json!({ "error": { "message": e.to_string(), "node": node_id } }))
}
pub async fn run(
workflow: &CompiledWorkflow,
input: Value,
capabilities: &Capabilities,
) -> Result<RunOutcome> {
run_with_observer(
workflow,
input,
capabilities,
&(Arc::new(crate::observability::NoopObserver) as Arc<dyn RunObserver>),
)
.await
}
pub async fn run_with_observer(
workflow: &CompiledWorkflow,
input: Value,
capabilities: &Capabilities,
observer: &Arc<dyn RunObserver>,
) -> Result<RunOutcome> {
let checkpointer: Arc<dyn Checkpointer<Value>> =
Arc::new(InMemoryCheckpointer::<Value>::default());
let thread_id = default_thread_id(workflow)?;
let (_graph, _thread_id, outcome) = build_and_run(
workflow,
input,
capabilities,
observer,
checkpointer,
thread_id,
)
.await?;
Ok(outcome)
}
fn build_graph(
workflow: &CompiledWorkflow,
capabilities: &Capabilities,
observer: &Arc<dyn RunObserver>,
steps: &Arc<Mutex<Vec<ExecutionStep>>>,
checkpointer: Arc<dyn Checkpointer<Value>>,
) -> Result<(CompiledGraph<Value, Value>, String)> {
let graph = &workflow.graph;
let trigger = graph
.trigger()
.ok_or(EngineError::Validation(ValidationError::MissingTrigger))?;
let trigger_id = trigger.id.clone();
let recursion_limit = trigger
.config
.get("recursion_limit")
.and_then(Value::as_u64)
.filter(|n| *n > 0);
let node_timeout_secs = trigger
.config
.get("node_timeout_secs")
.and_then(Value::as_u64)
.filter(|n| *n > 0);
tracing::info!(node_count = graph.nodes.len(), trigger = %trigger_id, "workflow run starting");
let mut incoming_counts: std::collections::HashMap<&str, usize> =
std::collections::HashMap::new();
for edge in &graph.edges {
*incoming_counts.entry(edge.to_node.as_str()).or_default() += 1;
}
let fan_out_targets = |node_id: &str| -> Option<Vec<String>> {
let outgoing: Vec<_> = graph
.edges
.iter()
.filter(|e| e.from_node == node_id)
.collect();
if outgoing.len() < 2 {
return None;
}
let first_port = outgoing[0].from_port.as_str();
if outgoing.iter().all(|e| e.from_port == first_port) {
Some(outgoing.iter().map(|e| e.to_node.clone()).collect())
} else {
None
}
};
let mut builder = GraphBuilder::<Value, Value>::new()
.with_parallel(true)
.set_reducer(MergeReducer);
if let Some(limit) = recursion_limit {
builder = builder.with_recursion_limit(limit as usize);
}
if let Some(secs) = node_timeout_secs {
builder = builder.with_node_timeout(std::time::Duration::from_secs(secs));
}
for node in &graph.nodes {
let node = node.clone();
let predecessors: Vec<String> = graph
.edges
.iter()
.filter(|e| e.to_node == node.id)
.map(|e| e.from_node.clone())
.collect();
let caps = capabilities.clone();
let observer = observer.clone();
let steps = steps.clone();
let is_trigger = node.kind == NodeKind::Trigger;
let fan_out = fan_out_targets(&node.id);
builder = builder.add_node(node.id.clone(), move |state: Value, ctx| {
let node = node.clone();
let predecessors = predecessors.clone();
let caps = caps.clone();
let observer = observer.clone();
let steps = steps.clone();
let fan_out = fan_out.clone();
let resumed = ctx.resume.is_some();
async move {
let emit = |update: Value| match &fan_out {
Some(targets) => {
NodeResult::Command(Command::goto(targets.clone()).with_update(update))
}
None => NodeResult::Update(update),
};
if is_trigger {
return Ok(emit(json!({})));
}
let requires_approval = node
.config
.get("requires_approval")
.and_then(Value::as_bool)
.unwrap_or(false);
if requires_approval {
let approved = state
.get("run")
.and_then(|run| run.get("trigger"))
.and_then(|trigger| trigger.get("approvals"))
.and_then(Value::as_array)
.is_some_and(|approvals| {
approvals
.iter()
.filter_map(Value::as_str)
.any(|id| id == node.id)
});
if !approved && !resumed {
tracing::info!(node = %node.id, "node paused awaiting approval");
let payload = if node.config.is_null() {
json!({})
} else {
node.config.clone()
};
return Ok(NodeResult::Interrupt(Interrupt {
id: node.id.clone(),
node: node.id.clone().into(),
payload,
}));
}
}
let input = collect_input(&state, &predecessors);
let run_meta = state.get("run").cloned().unwrap_or(Value::Null);
let on_error = node
.config
.get("on_error")
.and_then(Value::as_str)
.unwrap_or("stop");
let max_attempts = node
.config
.get("retry")
.and_then(|retry| retry.get("max_attempts"))
.and_then(Value::as_u64)
.unwrap_or(1)
.max(1);
let backoff_ms = node
.config
.get("retry")
.and_then(|retry| retry.get("backoff_ms"))
.and_then(Value::as_u64)
.unwrap_or(0);
let exponential = node
.config
.get("retry")
.and_then(|retry| retry.get("backoff"))
.and_then(Value::as_str)
== Some("exponential");
let mut output = None;
let mut last_err: Option<EngineError> = None;
let started = Instant::now();
for attempt in 0..max_attempts {
let ctx = NodeContext {
node: &node,
input: &input,
run: &run_meta,
caps: &caps,
};
match executor_for(&node.kind).execute(ctx).await {
Ok(ok) => {
output = Some(ok);
break;
}
Err(err) => last_err = Some(err),
}
if backoff_ms > 0 && attempt + 1 < max_attempts {
let delay = if exponential {
2u64.checked_pow(attempt as u32)
.and_then(|factor| backoff_ms.checked_mul(factor))
.unwrap_or(u64::MAX)
} else {
backoff_ms
}
.min(60_000);
futures_timer::Delay::new(std::time::Duration::from_millis(delay)).await;
}
}
let duration_ms = started.elapsed().as_millis();
match output {
Some(output) => {
tracing::debug!(node = %node.id, ?node.kind, item_count = output.items.len(), "node executed");
let step = ExecutionStep {
node_id: node.id.clone(),
status: StepStatus::Success,
output: serde_json::to_value(&output.items).unwrap_or(Value::Null),
duration_ms,
};
steps.lock().expect("steps mutex poisoned").push(step.clone());
observer.on_step_finish(&step);
Ok(emit(items_update(
&node.id,
&output.items,
output.port.as_deref(),
)?))
}
None => {
tracing::warn!(node = %node.id, "node failed after retries");
let step = ExecutionStep {
node_id: node.id.clone(),
status: StepStatus::Error,
output: Value::Null,
duration_ms,
};
steps.lock().expect("steps mutex poisoned").push(step.clone());
observer.on_step_finish(&step);
let Some(err) = last_err else {
return Ok(emit(items_update(&node.id, &[], None)?));
};
match on_error {
"continue" => Ok(emit(items_update(
&node.id,
&[error_item(&node.id, &err)],
None,
)?)),
"route" => Ok(emit(items_update(
&node.id,
&[error_item(&node.id, &err)],
Some("error"),
)?)),
_ => Err(TinyAgentsError::Graph(err.to_string())),
}
}
}
}
});
}
builder = builder.set_entry(trigger_id.clone());
for node in &graph.nodes {
if node
.config
.get("requires_approval")
.and_then(Value::as_bool)
.unwrap_or(false)
{
builder = builder.mark_interrupt(node.id.clone());
}
let outgoing: Vec<_> = graph
.edges
.iter()
.filter(|e| e.from_node == node.id)
.collect();
if outgoing.is_empty() {
builder = builder.add_edge(node.id.clone(), END);
} else if let Some(dests) = fan_out_targets(&node.id) {
builder = builder.with_command_destinations(node.id.clone(), dests);
} else if let [edge] = outgoing.as_slice() {
let target = edge.to_node.clone();
if incoming_counts
.get(edge.to_node.as_str())
.copied()
.unwrap_or(0)
> 1
{
builder = builder.add_waiting_edge(node.id.clone(), target);
} else {
builder = builder.add_edge(node.id.clone(), target);
}
} else {
let from = node.id.clone();
let routes: Vec<(String, String)> = outgoing
.iter()
.map(|e| (e.from_port.clone(), e.to_node.clone()))
.collect();
builder = builder.add_conditional_edges(
node.id.clone(),
move |state: &Value| -> String {
state
.get("nodes")
.and_then(|nodes| nodes.get(&from))
.and_then(|slot| slot.get("port"))
.and_then(Value::as_str)
.unwrap_or("main")
.to_string()
},
routes,
);
}
}
let compiled = builder
.compile()
.map_err(|e| EngineError::Capability(e.to_string()))?
.with_checkpointer(checkpointer);
Ok((compiled, trigger_id))
}
fn default_thread_id(workflow: &CompiledWorkflow) -> Result<String> {
Ok(workflow
.graph
.trigger()
.ok_or(EngineError::Validation(ValidationError::MissingTrigger))?
.id
.clone())
}
async fn build_and_run(
workflow: &CompiledWorkflow,
input: Value,
capabilities: &Capabilities,
observer: &Arc<dyn RunObserver>,
checkpointer: Arc<dyn Checkpointer<Value>>,
thread_id: String,
) -> Result<(CompiledGraph<Value, Value>, String, RunOutcome)> {
let run_id = format!("run-{}", NEXT_RUN_ID.fetch_add(1, Ordering::Relaxed));
observer.on_run_start(&run_id);
let steps: Arc<Mutex<Vec<ExecutionStep>>> = Arc::new(Mutex::new(Vec::new()));
let (compiled, trigger_id) =
build_graph(workflow, capabilities, observer, &steps, checkpointer)?;
let seed_items = items_update(&trigger_id, &[Item::new(input.clone())], None)
.map_err(|e| EngineError::Capability(e.to_string()))?;
let mut initial = json!({ "run": { "trigger": input } });
merge(&mut initial, seed_items);
let execution = compiled
.run_with_thread(thread_id.clone(), initial)
.await
.map_err(|e| EngineError::Capability(e.to_string()))?;
let pending_approvals: Vec<String> = execution
.interrupts
.iter()
.map(|interrupt| interrupt.node.as_str().to_string())
.collect();
tracing::info!(
steps = execution.steps,
visited = execution.visited.len(),
pending_approvals = pending_approvals.len(),
"workflow run finished"
);
let run_record = Run {
id: run_id,
status: RunStatus::Completed,
steps: steps.lock().expect("steps mutex poisoned").clone(),
};
observer.on_run_finish(&run_record);
Ok((
compiled,
thread_id,
RunOutcome {
output: execution.state,
pending_approvals,
},
))
}
pub async fn resume(
workflow: &CompiledWorkflow,
input: Value,
newly_approved: Vec<String>,
capabilities: &Capabilities,
) -> Result<RunOutcome> {
let mut approvals: Vec<String> = input
.get("approvals")
.and_then(Value::as_array)
.map(|existing| {
existing
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
for id in newly_approved {
if !approvals.contains(&id) {
approvals.push(id);
}
}
let mut merged_input = input;
if let Value::Object(map) = &mut merged_input {
map.insert("approvals".to_string(), json!(approvals));
} else {
merged_input = json!({ "approvals": approvals });
}
run(workflow, merged_input, capabilities).await
}
pub struct ResumableRun {
graph: CompiledGraph<Value, Value>,
thread_id: String,
outcome: RunOutcome,
}
impl ResumableRun {
pub fn outcome(&self) -> &RunOutcome {
&self.outcome
}
pub async fn resume(&self, newly_approved: Vec<String>) -> Result<RunOutcome> {
let approvals_update = json!({
"run": { "trigger": { "approvals": newly_approved } }
});
let execution = self
.graph
.resume(
self.thread_id.as_str(),
Command::resume(json!(true)).with_update(approvals_update),
)
.await
.map_err(|e| EngineError::Capability(e.to_string()))?;
let pending_approvals: Vec<String> = execution
.interrupts
.iter()
.map(|interrupt| interrupt.node.as_str().to_string())
.collect();
Ok(RunOutcome {
output: execution.state,
pending_approvals,
})
}
}
pub async fn run_resumable(
workflow: &CompiledWorkflow,
input: Value,
capabilities: &Capabilities,
) -> Result<ResumableRun> {
let observer = Arc::new(crate::observability::NoopObserver) as Arc<dyn RunObserver>;
let checkpointer: Arc<dyn Checkpointer<Value>> =
Arc::new(InMemoryCheckpointer::<Value>::default());
let thread_id = default_thread_id(workflow)?;
let (graph, thread_id, outcome) = build_and_run(
workflow,
input,
capabilities,
&observer,
checkpointer,
thread_id,
)
.await?;
Ok(ResumableRun {
graph,
thread_id,
outcome,
})
}
pub async fn run_with_checkpointer(
workflow: &CompiledWorkflow,
input: Value,
capabilities: &Capabilities,
checkpointer: Arc<dyn Checkpointer<Value>>,
thread_id: &str,
) -> Result<RunOutcome> {
let observer = Arc::new(crate::observability::NoopObserver) as Arc<dyn RunObserver>;
let (_graph, _thread_id, outcome) = build_and_run(
workflow,
input,
capabilities,
&observer,
checkpointer,
thread_id.to_string(),
)
.await?;
Ok(outcome)
}
pub async fn resume_with_checkpointer(
workflow: &CompiledWorkflow,
capabilities: &Capabilities,
checkpointer: Arc<dyn Checkpointer<Value>>,
thread_id: &str,
newly_approved: Vec<String>,
) -> Result<RunOutcome> {
let observer = Arc::new(crate::observability::NoopObserver) as Arc<dyn RunObserver>;
let steps: Arc<Mutex<Vec<ExecutionStep>>> = Arc::new(Mutex::new(Vec::new()));
let (compiled, _trigger_id) =
build_graph(workflow, capabilities, &observer, &steps, checkpointer)?;
let approvals_update = json!({
"run": { "trigger": { "approvals": newly_approved } }
});
let execution = compiled
.resume(
thread_id,
Command::resume(json!(true)).with_update(approvals_update),
)
.await
.map_err(|e| EngineError::Capability(e.to_string()))?;
let pending_approvals: Vec<String> = execution
.interrupts
.iter()
.map(|interrupt| interrupt.node.as_str().to_string())
.collect();
Ok(RunOutcome {
output: execution.state,
pending_approvals,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::caps::mock::mock_capabilities;
use crate::compiler::compile;
use crate::model::{Edge, Node, WorkflowGraph};
use std::sync::{Arc, Mutex};
fn node(id: &str, kind: NodeKind) -> Node {
Node {
id: id.to_string(),
kind,
type_version: 1,
name: id.to_string(),
config: Value::Null,
ports: Vec::new(),
position: None,
}
}
#[tokio::test]
async fn trigger_only_workflow_runs_end_to_end() {
let graph = WorkflowGraph {
nodes: vec![node("t", NodeKind::Trigger)],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "hello": "world" }), &caps)
.await
.expect("run");
assert_eq!(
outcome.output["run"]["trigger"],
json!({ "hello": "world" })
);
assert_eq!(
outcome.output["nodes"]["t"]["items"][0]["json"],
json!({ "hello": "world" })
);
}
#[tokio::test]
async fn linear_edge_drives_downstream_node() {
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
node("p", NodeKind::OutputParser),
],
edges: vec![Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "p".to_string(),
to_port: "main".to_string(),
}],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "x": 1 }), &caps).await.expect("run");
assert_eq!(
outcome.output["nodes"]["p"]["items"][0]["json"],
json!({ "x": 1 })
);
}
#[tokio::test]
async fn condition_routes_only_the_taken_branch() {
let mut condition = node("c", NodeKind::Condition);
condition.config = json!({ "field": "active" });
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
condition,
node("pass_a", NodeKind::OutputParser),
node("pass_b", NodeKind::OutputParser),
],
edges: vec![
Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "c".to_string(),
to_port: "main".to_string(),
},
Edge {
from_node: "c".to_string(),
from_port: "true".to_string(),
to_node: "pass_a".to_string(),
to_port: "main".to_string(),
},
Edge {
from_node: "c".to_string(),
from_port: "false".to_string(),
to_node: "pass_b".to_string(),
to_port: "main".to_string(),
},
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "active": true }), &caps)
.await
.expect("run");
assert!(
!outcome.output["nodes"]["pass_a"]["items"].is_null(),
"true branch should have run"
);
assert!(
outcome.output["nodes"]["pass_b"].is_null(),
"false branch should not have run"
);
}
#[tokio::test]
async fn fan_out_runs_both_branches() {
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
node("a", NodeKind::Transform),
node("b", NodeKind::Transform),
],
edges: vec![
Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "a".to_string(),
to_port: "main".to_string(),
},
Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "b".to_string(),
to_port: "main".to_string(),
},
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "v": 1 }), &caps).await.expect("run");
assert!(
!outcome.output["nodes"]["a"]["items"].is_null(),
"fan-out branch a should have run"
);
assert!(
!outcome.output["nodes"]["b"]["items"].is_null(),
"fan-out branch b should have run"
);
}
#[tokio::test]
async fn diamond_fan_out_and_merge() {
let edge = |from: &str, port: &str, to: &str| Edge {
from_node: from.to_string(),
from_port: port.to_string(),
to_node: to.to_string(),
to_port: "main".to_string(),
};
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
node("d", NodeKind::OutputParser),
node("a", NodeKind::OutputParser),
node("b", NodeKind::OutputParser),
node("m", NodeKind::Merge),
node("done", NodeKind::OutputParser),
],
edges: vec![
edge("t", "main", "d"),
edge("d", "main", "a"),
edge("d", "main", "b"),
edge("a", "main", "m"),
edge("b", "main", "m"),
edge("m", "main", "done"),
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "v": 1 }), &caps).await.expect("run");
assert!(
!outcome.output["nodes"]["a"]["items"].is_null(),
"fan-out branch a should have run"
);
assert!(
!outcome.output["nodes"]["b"]["items"].is_null(),
"fan-out branch b should have run"
);
let merged = outcome.output["nodes"]["m"]["items"]
.as_array()
.expect("merge should have produced items");
assert!(
merged.len() >= 2,
"merge should concatenate both branches' items, got {}",
merged.len()
);
assert!(
!outcome.output["nodes"]["done"]["items"].is_null(),
"the node past the merge barrier should have run"
);
}
#[tokio::test]
async fn on_error_continue_emits_error_item() {
let mut tool = node("x", NodeKind::ToolCall);
tool.config = json!({ "on_error": "continue" });
let graph = WorkflowGraph {
nodes: vec![node("t", NodeKind::Trigger), tool],
edges: vec![Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "x".to_string(),
to_port: "main".to_string(),
}],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({}), &caps).await.expect("run");
assert_eq!(
outcome.output["nodes"]["x"]["items"][0]["json"]["error"]["node"],
json!("x")
);
}
#[tokio::test]
async fn on_error_route_sends_error_item_to_error_port() {
let mut tool = node("x", NodeKind::ToolCall);
tool.config = json!({ "on_error": "route" });
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
tool,
node("h", NodeKind::OutputParser),
],
edges: vec![
Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "x".to_string(),
to_port: "main".to_string(),
},
Edge {
from_node: "x".to_string(),
from_port: "error".to_string(),
to_node: "h".to_string(),
to_port: "main".to_string(),
},
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({}), &caps).await.expect("run");
assert!(
!outcome.output["nodes"]["h"]["items"][0]["json"]["error"].is_null(),
"handler should have received the routed error item"
);
}
#[tokio::test]
async fn on_error_stop_is_default() {
let graph = WorkflowGraph {
nodes: vec![node("t", NodeKind::Trigger), node("x", NodeKind::ToolCall)],
edges: vec![Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "x".to_string(),
to_port: "main".to_string(),
}],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
assert!(run(&compiled, json!({}), &caps).await.is_err());
}
#[tokio::test]
async fn retry_then_continue_completes() {
let mut tool = node("x", NodeKind::ToolCall);
tool.config = json!({ "retry": { "max_attempts": 3 }, "on_error": "continue" });
let graph = WorkflowGraph {
nodes: vec![node("t", NodeKind::Trigger), tool],
edges: vec![Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "x".to_string(),
to_port: "main".to_string(),
}],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({}), &caps).await.expect("run");
assert_eq!(
outcome.output["nodes"]["x"]["items"][0]["json"]["error"]["node"],
json!("x")
);
}
#[tokio::test]
async fn retry_backoff_runs_without_hanging() {
let mut tool = node("x", NodeKind::ToolCall);
tool.config = json!({
"retry": { "max_attempts": 2, "backoff_ms": 1, "backoff": "exponential" },
"on_error": "continue"
});
let graph = WorkflowGraph {
nodes: vec![node("t", NodeKind::Trigger), tool],
edges: vec![Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "x".to_string(),
to_port: "main".to_string(),
}],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({}), &caps).await.expect("run");
assert_eq!(
outcome.output["nodes"]["x"]["items"][0]["json"]["error"]["node"],
json!("x")
);
}
#[tokio::test]
async fn run_level_knobs_accepted() {
let mut trigger = node("t", NodeKind::Trigger);
trigger.config = json!({ "recursion_limit": 100, "node_timeout_secs": 30 });
let graph = WorkflowGraph {
nodes: vec![trigger, node("p", NodeKind::OutputParser)],
edges: vec![Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "p".to_string(),
to_port: "main".to_string(),
}],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "x": 1 }), &caps).await.expect("run");
assert_eq!(
outcome.output["nodes"]["p"]["items"][0]["json"],
json!({ "x": 1 })
);
}
#[tokio::test]
async fn run_is_instrumented_and_still_succeeds() {
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
node("p", NodeKind::OutputParser),
],
edges: vec![Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "p".to_string(),
to_port: "main".to_string(),
}],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "ok": true }), &caps)
.await
.expect("instrumented run should still succeed");
assert_eq!(
outcome.output["nodes"]["p"]["items"][0]["json"],
json!({ "ok": true })
);
}
#[tokio::test]
async fn approval_gate_pauses_until_approved() {
let mut gate = node("gate", NodeKind::OutputParser);
gate.config = json!({ "requires_approval": true });
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
gate,
node("downstream", NodeKind::OutputParser),
],
edges: vec![
Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "gate".to_string(),
to_port: "main".to_string(),
},
Edge {
from_node: "gate".to_string(),
from_port: "main".to_string(),
to_node: "downstream".to_string(),
to_port: "main".to_string(),
},
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "x": 1 }), &caps).await.expect("run");
assert!(
outcome.pending_approvals.contains(&"gate".to_string()),
"gate should be reported as pending approval"
);
assert!(
outcome.output["nodes"]["downstream"].is_null(),
"downstream must not run while the gate is pending"
);
}
#[tokio::test]
async fn approved_gate_completes() {
let mut gate = node("gate", NodeKind::OutputParser);
gate.config = json!({ "requires_approval": true });
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
gate,
node("downstream", NodeKind::OutputParser),
],
edges: vec![
Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "gate".to_string(),
to_port: "main".to_string(),
},
Edge {
from_node: "gate".to_string(),
from_port: "main".to_string(),
to_node: "downstream".to_string(),
to_port: "main".to_string(),
},
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "approvals": ["gate"] }), &caps)
.await
.expect("run");
assert!(
outcome.pending_approvals.is_empty(),
"no approvals should be pending once the gate is approved"
);
assert!(
!outcome.output["nodes"]["downstream"]["items"].is_null(),
"downstream should run once the gate is approved"
);
}
struct Capture {
steps: Arc<Mutex<Vec<String>>>,
runs: Arc<Mutex<u32>>,
}
impl RunObserver for Capture {
fn on_run_start(&self, _run_id: &str) {
*self.runs.lock().unwrap() += 1;
}
fn on_step_finish(&self, step: &ExecutionStep) {
self.steps.lock().unwrap().push(step.node_id.clone());
}
}
#[tokio::test]
async fn observer_receives_run_start_and_step_finish() {
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
node("p", NodeKind::OutputParser),
],
edges: vec![Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "p".to_string(),
to_port: "main".to_string(),
}],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let steps = Arc::new(Mutex::new(Vec::new()));
let runs = Arc::new(Mutex::new(0));
let observer: Arc<dyn RunObserver> = Arc::new(Capture {
steps: steps.clone(),
runs: runs.clone(),
});
run_with_observer(&compiled, json!({ "x": 1 }), &caps, &observer)
.await
.expect("run");
assert_eq!(*runs.lock().unwrap(), 1, "on_run_start should fire once");
assert!(
steps.lock().unwrap().contains(&"p".to_string()),
"on_step_finish should record the output_parser node"
);
}
#[tokio::test]
async fn resume_completes_a_paused_run() {
let gate = |id: &str| {
let mut gate = node(id, NodeKind::OutputParser);
gate.config = json!({ "requires_approval": true });
gate
};
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
gate("gate"),
node("downstream", NodeKind::OutputParser),
],
edges: vec![
Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "gate".to_string(),
to_port: "main".to_string(),
},
Edge {
from_node: "gate".to_string(),
from_port: "main".to_string(),
to_node: "downstream".to_string(),
to_port: "main".to_string(),
},
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let paused = run(&compiled, json!({}), &caps).await.expect("run");
assert!(
paused.pending_approvals.contains(&"gate".to_string()),
"gate should be reported as pending approval"
);
assert!(
paused.output["nodes"]["downstream"].is_null(),
"downstream must not run while the gate is pending"
);
let done = resume(&compiled, json!({}), vec!["gate".to_string()], &caps)
.await
.expect("resume");
assert!(
done.pending_approvals.is_empty(),
"no approvals should be pending once the gate is approved"
);
assert!(
!done.output["nodes"]["downstream"]["items"].is_null(),
"downstream should run once the gate is approved via resume"
);
}
#[tokio::test]
async fn resume_unions_new_approval_with_existing() {
let gate = |id: &str| {
let mut gate = node(id, NodeKind::OutputParser);
gate.config = json!({ "requires_approval": true });
gate
};
let edge = |from: &str, to: &str| Edge {
from_node: from.to_string(),
from_port: "main".to_string(),
to_node: to.to_string(),
to_port: "main".to_string(),
};
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
gate("other"),
gate("gate"),
node("downstream", NodeKind::OutputParser),
],
edges: vec![
edge("t", "other"),
edge("other", "gate"),
edge("gate", "downstream"),
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let done = resume(
&compiled,
json!({ "approvals": ["other"] }),
vec!["gate".to_string()],
&caps,
)
.await
.expect("resume");
assert!(
done.pending_approvals.is_empty(),
"unioning `gate` into the existing `other` approval should clear both gates, \
got pending: {:?}",
done.pending_approvals
);
assert!(
!done.output["nodes"]["downstream"]["items"].is_null(),
"downstream should run once both gates are approved via the unioned set"
);
}
#[tokio::test]
async fn resumable_run_resumes_from_checkpoint() {
let mut gate = node("gate", NodeKind::OutputParser);
gate.config = json!({ "requires_approval": true });
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
gate,
node("downstream", NodeKind::OutputParser),
],
edges: vec![
Edge {
from_node: "t".to_string(),
from_port: "main".to_string(),
to_node: "gate".to_string(),
to_port: "main".to_string(),
},
Edge {
from_node: "gate".to_string(),
from_port: "main".to_string(),
to_node: "downstream".to_string(),
to_port: "main".to_string(),
},
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let rr = run_resumable(&compiled, json!({}), &caps)
.await
.expect("run_resumable");
assert!(
rr.outcome().pending_approvals.contains(&"gate".to_string()),
"gate should be reported as pending approval"
);
assert!(
rr.outcome().output["nodes"]["downstream"].is_null(),
"downstream must not run while the gate is pending"
);
let done = rr.resume(vec!["gate".to_string()]).await.expect("resume");
assert!(
done.pending_approvals.is_empty(),
"no approvals should be pending once the gate is resumed, got: {:?}",
done.pending_approvals
);
assert!(
!done.output["nodes"]["downstream"]["items"].is_null(),
"downstream should run once the run resumes from the checkpoint"
);
}
fn edge(from: &str, to: &str) -> Edge {
Edge {
from_node: from.to_string(),
from_port: "main".to_string(),
to_node: to.to_string(),
to_port: "main".to_string(),
}
}
fn port_edge(from: &str, port: &str, to: &str) -> Edge {
Edge {
from_node: from.to_string(),
from_port: port.to_string(),
to_node: to.to_string(),
to_port: "main".to_string(),
}
}
fn gate(id: &str) -> Node {
let mut g = node(id, NodeKind::OutputParser);
g.config = json!({ "requires_approval": true });
g
}
#[tokio::test]
async fn linear_three_node_passthrough() {
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
node("a", NodeKind::OutputParser),
node("b", NodeKind::OutputParser),
node("c", NodeKind::OutputParser),
],
edges: vec![edge("t", "a"), edge("a", "b"), edge("b", "c")],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "n": 1 }), &caps).await.expect("run");
assert_eq!(
outcome.output["nodes"]["a"]["items"][0]["json"],
json!({ "n": 1 })
);
assert_eq!(
outcome.output["nodes"]["b"]["items"][0]["json"],
json!({ "n": 1 })
);
assert_eq!(
outcome.output["nodes"]["c"]["items"][0]["json"],
json!({ "n": 1 })
);
}
#[tokio::test]
async fn condition_truthy_takes_true_branch_only() {
let mut condition = node("c", NodeKind::Condition);
condition.config = json!({ "field": "active" });
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
condition,
node("yes", NodeKind::OutputParser),
node("no", NodeKind::OutputParser),
],
edges: vec![
edge("t", "c"),
port_edge("c", "true", "yes"),
port_edge("c", "false", "no"),
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "active": true }), &caps)
.await
.expect("run");
assert!(
!outcome.output["nodes"]["yes"]["items"].is_null(),
"true branch must run for a truthy input"
);
assert!(
outcome.output["nodes"]["no"].is_null(),
"false branch must not run for a truthy input"
);
}
#[tokio::test]
async fn condition_falsey_takes_false_branch_only() {
let mut condition = node("c", NodeKind::Condition);
condition.config = json!({ "field": "active" });
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
condition,
node("yes", NodeKind::OutputParser),
node("no", NodeKind::OutputParser),
],
edges: vec![
edge("t", "c"),
port_edge("c", "true", "yes"),
port_edge("c", "false", "no"),
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "active": false }), &caps)
.await
.expect("run");
assert!(
outcome.output["nodes"]["yes"].is_null(),
"true branch must not run for a falsey input"
);
assert!(
!outcome.output["nodes"]["no"]["items"].is_null(),
"false branch must run for a falsey input"
);
}
#[tokio::test]
async fn condition_without_field_uses_whole_item() {
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
node("c", NodeKind::Condition),
node("yes", NodeKind::OutputParser),
node("no", NodeKind::OutputParser),
],
edges: vec![
edge("t", "c"),
port_edge("c", "true", "yes"),
port_edge("c", "false", "no"),
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "x": 1 }), &caps).await.expect("run");
assert!(
!outcome.output["nodes"]["yes"]["items"].is_null(),
"a non-empty object item is truthy and routes true"
);
assert!(
outcome.output["nodes"]["no"].is_null(),
"false branch must not run"
);
}
#[tokio::test]
async fn switch_field_matching_case_routes_there() {
let mut switch = node("sw", NodeKind::Switch);
switch.config = json!({ "field": "kind" });
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
switch,
node("case_a", NodeKind::OutputParser),
node("fallback", NodeKind::OutputParser),
],
edges: vec![
edge("t", "sw"),
port_edge("sw", "a", "case_a"),
port_edge("sw", "default", "fallback"),
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "kind": "a" }), &caps)
.await
.expect("run");
assert!(
!outcome.output["nodes"]["case_a"]["items"].is_null(),
"matching `a` case must run"
);
assert!(
outcome.output["nodes"]["fallback"].is_null(),
"default fallback must not run when a case matches"
);
}
#[tokio::test]
async fn switch_no_match_routes_to_default() {
let mut switch = node("sw", NodeKind::Switch);
switch.config = json!({ "field": "kind" });
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
switch,
node("case_a", NodeKind::OutputParser),
node("fallback", NodeKind::OutputParser),
],
edges: vec![
edge("t", "sw"),
port_edge("sw", "a", "case_a"),
port_edge("sw", "default", "fallback"),
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "other": "z" }), &caps)
.await
.expect("run");
assert!(
outcome.output["nodes"]["case_a"].is_null(),
"no case matches, so the `a` branch must not run"
);
assert!(
!outcome.output["nodes"]["fallback"]["items"].is_null(),
"a null case value routes to the default fallback"
);
}
#[tokio::test]
async fn parallel_fan_out_of_three_runs_all() {
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
node("a", NodeKind::OutputParser),
node("b", NodeKind::OutputParser),
node("c", NodeKind::OutputParser),
],
edges: vec![edge("t", "a"), edge("t", "b"), edge("t", "c")],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "v": 1 }), &caps).await.expect("run");
for id in ["a", "b", "c"] {
assert!(
!outcome.output["nodes"][id]["items"].is_null(),
"fan-out branch {id} should have run"
);
}
}
#[tokio::test]
async fn merge_fan_in_concatenates_three_items() {
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
node("d", NodeKind::OutputParser),
node("a", NodeKind::OutputParser),
node("b", NodeKind::OutputParser),
node("c", NodeKind::OutputParser),
node("m", NodeKind::Merge),
],
edges: vec![
edge("t", "d"),
edge("d", "a"),
edge("d", "b"),
edge("d", "c"),
edge("a", "m"),
edge("b", "m"),
edge("c", "m"),
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "v": 1 }), &caps).await.expect("run");
let merged = outcome.output["nodes"]["m"]["items"]
.as_array()
.expect("merge should have produced an items array");
assert_eq!(
merged.len(),
3,
"merge should concatenate one item from each of the 3 branches"
);
}
#[tokio::test]
async fn diamond_merge_produces_two_items() {
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
node("d", NodeKind::OutputParser),
node("a", NodeKind::OutputParser),
node("b", NodeKind::OutputParser),
node("m", NodeKind::Merge),
node("done", NodeKind::OutputParser),
],
edges: vec![
edge("t", "d"),
edge("d", "a"),
edge("d", "b"),
edge("a", "m"),
edge("b", "m"),
edge("m", "done"),
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "v": 1 }), &caps).await.expect("run");
assert_eq!(
outcome.output["nodes"]["m"]["items"]
.as_array()
.expect("merge items")
.len(),
2,
"two branches merge into two items"
);
assert_eq!(
outcome.output["nodes"]["done"]["items"]
.as_array()
.expect("done items")
.len(),
2,
"the node past the barrier receives both merged items"
);
}
#[tokio::test]
async fn on_error_stop_fails_the_run() {
let graph = WorkflowGraph {
nodes: vec![node("t", NodeKind::Trigger), node("x", NodeKind::ToolCall)],
edges: vec![edge("t", "x")],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
assert!(
run(&compiled, json!({}), &caps).await.is_err(),
"a failing node under the default stop policy must fail the run"
);
}
#[tokio::test]
async fn on_error_continue_completes_with_error_item() {
let mut tool = node("x", NodeKind::ToolCall);
tool.config = json!({ "on_error": "continue" });
let graph = WorkflowGraph {
nodes: vec![node("t", NodeKind::Trigger), tool],
edges: vec![edge("t", "x")],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({}), &caps).await.expect("run");
assert_eq!(
outcome.output["nodes"]["x"]["items"][0]["json"]["error"]["node"],
json!("x")
);
}
#[tokio::test]
async fn on_error_route_delivers_error_item_to_recovery_node() {
let mut tool = node("x", NodeKind::ToolCall);
tool.config = json!({ "on_error": "route" });
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
tool,
node("recover", NodeKind::OutputParser),
node("normal", NodeKind::OutputParser),
],
edges: vec![
edge("t", "x"),
port_edge("x", "error", "recover"),
port_edge("x", "main", "normal"),
],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({}), &caps).await.expect("run");
assert_eq!(
outcome.output["nodes"]["recover"]["items"][0]["json"]["error"]["node"],
json!("x"),
"recovery node must receive the routed error item"
);
assert!(
outcome.output["nodes"]["normal"].is_null(),
"the main branch must not run when the error routes to `error`"
);
}
#[tokio::test]
async fn error_item_has_node_and_message_fields() {
let mut tool = node("x", NodeKind::ToolCall);
tool.config = json!({ "on_error": "continue" });
let graph = WorkflowGraph {
nodes: vec![node("t", NodeKind::Trigger), tool],
edges: vec![edge("t", "x")],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({}), &caps).await.expect("run");
let err = &outcome.output["nodes"]["x"]["items"][0]["json"]["error"];
assert_eq!(err["node"], json!("x"));
assert!(
err["message"].as_str().is_some_and(|m| !m.is_empty()),
"error item must carry a non-empty message, got {err:?}"
);
}
#[tokio::test]
async fn retry_max_attempts_then_continue_completes() {
let mut tool = node("x", NodeKind::ToolCall);
tool.config = json!({ "retry": { "max_attempts": 4 }, "on_error": "continue" });
let graph = WorkflowGraph {
nodes: vec![node("t", NodeKind::Trigger), tool],
edges: vec![edge("t", "x")],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({}), &caps).await.expect("run");
let err = &outcome.output["nodes"]["x"]["items"][0]["json"]["error"];
assert_eq!(err["node"], json!("x"));
assert!(err["message"].as_str().is_some_and(|m| !m.is_empty()));
}
#[tokio::test]
async fn hitl_gate_pauses_and_blocks_downstream() {
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
gate("g"),
node("downstream", NodeKind::OutputParser),
],
edges: vec![edge("t", "g"), edge("g", "downstream")],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "x": 1 }), &caps).await.expect("run");
assert!(
outcome.pending_approvals.contains(&"g".to_string()),
"gate should be reported pending"
);
assert!(
outcome.output["nodes"]["downstream"].is_null(),
"downstream must not run behind a pending gate"
);
}
#[tokio::test]
async fn hitl_two_gates_resume_one_leaves_next_pending() {
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
gate("g1"),
gate("g2"),
node("done", NodeKind::OutputParser),
],
edges: vec![edge("t", "g1"), edge("g1", "g2"), edge("g2", "done")],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let rr = run_resumable(&compiled, json!({}), &caps)
.await
.expect("run_resumable");
assert!(
rr.outcome().pending_approvals.contains(&"g1".to_string()),
"g1 should be the first pending gate"
);
assert!(
!rr.outcome().pending_approvals.contains(&"g2".to_string()),
"g2 is not reached until g1 is approved"
);
let after_g1 = rr.resume(vec!["g1".to_string()]).await.expect("resume g1");
assert!(
after_g1.pending_approvals.contains(&"g2".to_string()),
"g2 becomes pending after g1 is approved, got {:?}",
after_g1.pending_approvals
);
assert!(
after_g1.output["nodes"]["done"].is_null(),
"done stays blocked while g2 is pending"
);
let done = rr.resume(vec!["g2".to_string()]).await.expect("resume g2");
assert!(
done.pending_approvals.is_empty(),
"no gate pending once both are approved, got {:?}",
done.pending_approvals
);
assert!(
!done.output["nodes"]["done"]["items"].is_null(),
"done runs once both gates are approved"
);
}
#[tokio::test]
async fn approval_via_input_proceeds_immediately() {
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
gate("g"),
node("downstream", NodeKind::OutputParser),
],
edges: vec![edge("t", "g"), edge("g", "downstream")],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "approvals": ["g"] }), &caps)
.await
.expect("run");
assert!(
outcome.pending_approvals.is_empty(),
"an input-approved gate leaves nothing pending"
);
assert!(
!outcome.output["nodes"]["g"]["items"].is_null(),
"the approved gate itself runs"
);
assert!(
!outcome.output["nodes"]["downstream"]["items"].is_null(),
"downstream runs once the gate is approved via input"
);
}
#[derive(Default)]
struct FullCapture {
starts: Mutex<u32>,
finishes: Mutex<u32>,
steps: Mutex<Vec<String>>,
}
impl RunObserver for FullCapture {
fn on_run_start(&self, _run_id: &str) {
*self.starts.lock().unwrap() += 1;
}
fn on_step_finish(&self, step: &ExecutionStep) {
self.steps.lock().unwrap().push(step.node_id.clone());
}
fn on_run_finish(&self, _run: &Run) {
*self.finishes.lock().unwrap() += 1;
}
}
#[tokio::test]
async fn observer_fires_start_finish_and_run_finish_counts() {
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
node("a", NodeKind::OutputParser),
node("b", NodeKind::OutputParser),
],
edges: vec![edge("t", "a"), edge("a", "b")],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let capture = Arc::new(FullCapture::default());
let observer: Arc<dyn RunObserver> = capture.clone();
run_with_observer(&compiled, json!({ "x": 1 }), &caps, &observer)
.await
.expect("run");
assert_eq!(*capture.starts.lock().unwrap(), 1, "on_run_start once");
assert_eq!(*capture.finishes.lock().unwrap(), 1, "on_run_finish once");
let steps = capture.steps.lock().unwrap();
assert_eq!(steps.len(), 2, "one step per non-trigger node");
assert!(steps.contains(&"a".to_string()));
assert!(steps.contains(&"b".to_string()));
assert!(
!steps.contains(&"t".to_string()),
"the trigger must not produce a step"
);
}
#[tokio::test]
async fn run_level_knobs_do_not_break_execution() {
let mut trigger = node("t", NodeKind::Trigger);
trigger.config = json!({ "recursion_limit": 100, "node_timeout_secs": 30 });
let graph = WorkflowGraph {
nodes: vec![
trigger,
node("a", NodeKind::OutputParser),
node("b", NodeKind::OutputParser),
],
edges: vec![edge("t", "a"), edge("a", "b")],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "x": 1 }), &caps).await.expect("run");
assert_eq!(
outcome.output["nodes"]["b"]["items"][0]["json"],
json!({ "x": 1 })
);
}
#[tokio::test]
async fn trigger_only_completes_cleanly() {
let graph = WorkflowGraph {
nodes: vec![node("t", NodeKind::Trigger)],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "seed": 7 }), &caps)
.await
.expect("run");
assert!(
outcome.pending_approvals.is_empty(),
"a trigger-only run has nothing pending"
);
assert_eq!(
outcome.output["nodes"]["t"]["items"][0]["json"],
json!({ "seed": 7 })
);
}
#[test]
fn resumable_run_and_outcome_are_send_sync() {
fn _assert<T: Send + Sync>() {}
_assert::<ResumableRun>();
_assert::<RunOutcome>();
}
#[tokio::test]
async fn durable_resume_via_injected_checkpointer() {
let cp: Arc<dyn Checkpointer<Value>> = Arc::new(InMemoryCheckpointer::<Value>::default());
let mut gate = node("gate", NodeKind::OutputParser);
gate.config = json!({ "requires_approval": true });
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
gate,
node("downstream", NodeKind::OutputParser),
],
edges: vec![edge("t", "gate"), edge("gate", "downstream")],
..Default::default()
};
let compiled = compile(&graph).expect("compile");
let caps = mock_capabilities();
let paused = run_with_checkpointer(&compiled, json!({}), &caps, cp.clone(), "thread-A")
.await
.expect("run_with_checkpointer");
assert_eq!(
paused.pending_approvals,
vec!["gate".to_string()],
"the gate must be reported pending"
);
assert!(
paused.output["nodes"]["downstream"].is_null(),
"downstream must not run behind a pending gate"
);
let caps = mock_capabilities();
let done = resume_with_checkpointer(
&compiled,
&caps,
cp.clone(),
"thread-A",
vec!["gate".to_string()],
)
.await
.expect("resume_with_checkpointer");
assert!(
done.pending_approvals.is_empty(),
"nothing should be pending once the gate is approved, got {:?}",
done.pending_approvals
);
assert!(
!done.output["nodes"]["downstream"]["items"].is_null(),
"downstream should run once the run resumes from the durable checkpoint"
);
}
#[tokio::test]
async fn plain_run_and_resumable_unchanged_by_injectable_checkpointer() {
let linear = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
node("p", NodeKind::OutputParser),
],
edges: vec![edge("t", "p")],
..Default::default()
};
let compiled = compile(&linear).expect("compile");
let caps = mock_capabilities();
let outcome = run(&compiled, json!({ "x": 1 }), &caps).await.expect("run");
assert_eq!(
outcome.output["nodes"]["p"]["items"][0]["json"],
json!({ "x": 1 })
);
assert!(outcome.pending_approvals.is_empty());
let mut gate = node("gate", NodeKind::OutputParser);
gate.config = json!({ "requires_approval": true });
let gated = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
gate,
node("downstream", NodeKind::OutputParser),
],
edges: vec![edge("t", "gate"), edge("gate", "downstream")],
..Default::default()
};
let compiled = compile(&gated).expect("compile");
let caps = mock_capabilities();
let rr = run_resumable(&compiled, json!({}), &caps)
.await
.expect("run_resumable");
assert!(rr.outcome().pending_approvals.contains(&"gate".to_string()));
assert!(rr.outcome().output["nodes"]["downstream"].is_null());
let done = rr.resume(vec!["gate".to_string()]).await.expect("resume");
assert!(done.pending_approvals.is_empty());
assert!(!done.output["nodes"]["downstream"]["items"].is_null());
}
}