use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{broadcast, RwLock};
use uuid::Uuid;
use crate::env::Env;
use crate::value::Value;
pub type WorkflowId = String;
pub type StepId = String;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WorkflowContext {
pub workflow_id: WorkflowId,
pub current_step: Option<StepId>,
pub variables: HashMap<String, Value>,
pub results: HashMap<StepId, StepResult>,
pub metadata: HashMap<String, String>,
pub started_at: Option<u64>,
pub ended_at: Option<u64>,
#[serde(default)]
pub depth: u32,
}
impl WorkflowContext {
pub fn new(workflow_id: impl Into<WorkflowId>) -> Self {
Self {
workflow_id: workflow_id.into(),
..Default::default()
}
}
}
pub const MAX_SUBWORKFLOW_DEPTH: u32 = 8;
impl Default for WorkflowContext {
fn default() -> Self {
Self {
workflow_id: Uuid::new_v4().to_string(),
current_step: None,
variables: HashMap::new(),
results: HashMap::new(),
metadata: HashMap::new(),
started_at: None,
ended_at: None,
depth: 0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepResult {
pub step_id: StepId,
pub status: StepStatus,
pub output: Option<Value>,
pub error: Option<String>,
pub duration_ms: u64,
pub retries: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum StepStatus {
Pending,
Running,
Completed,
Failed,
Skipped,
Compensated,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WorkflowStatus {
Created,
Running,
Paused,
Completed,
Failed,
Cancelled,
Compensating,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum WorkflowEvent {
Started {
workflow_id: WorkflowId,
template: String,
},
StepStarted {
workflow_id: WorkflowId,
step_id: StepId,
},
StepCompleted {
workflow_id: WorkflowId,
step_id: StepId,
result: StepResult,
},
StepFailed {
workflow_id: WorkflowId,
step_id: StepId,
error: String,
},
Completed {
workflow_id: WorkflowId,
result: Value,
},
Failed {
workflow_id: WorkflowId,
error: String,
},
Compensating {
workflow_id: WorkflowId,
step_id: StepId,
},
Custom {
workflow_id: WorkflowId,
event_type: String,
payload: Value,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowStep {
pub id: StepId,
pub name: String,
pub step_type: StepType,
pub input_mapping: Option<String>,
pub output_mapping: Option<String>,
pub retry_config: Option<RetryConfig>,
pub timeout_ms: Option<u64>,
pub condition: Option<String>,
pub compensate: Option<Box<WorkflowStep>>,
pub metadata: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StepType {
Execute { function: String, args: Vec<Value> },
Agent { agent_id: String, prompt: String },
Http {
method: String,
url: String,
body: Option<Value>,
},
Parallel { steps: Vec<WorkflowStep> },
Branch {
conditions: Vec<(String, WorkflowStep)>,
default: Option<Box<WorkflowStep>>,
},
WaitForEvent {
event_type: String,
timeout_ms: Option<u64>,
},
EmitEvent { event_type: String, payload: Value },
Delay { duration_ms: u64 },
SubWorkflow {
template_id: String,
inputs: HashMap<String, Value>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryConfig {
pub max_retries: u32,
pub initial_delay_ms: u64,
pub max_delay_ms: u64,
pub backoff_multiplier: f64,
pub jitter: f64,
pub retry_on: Vec<String>,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
initial_delay_ms: 100,
max_delay_ms: 30000,
backoff_multiplier: 2.0,
jitter: 0.1,
retry_on: vec!["timeout".to_string(), "connection_error".to_string()],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowTemplate {
pub id: String,
pub name: String,
pub description: String,
pub version: String,
pub inputs: Vec<ParameterDef>,
pub outputs: Vec<ParameterDef>,
pub steps: Vec<WorkflowStep>,
pub metadata: HashMap<String, String>,
pub pattern: WorkflowPattern,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParameterDef {
pub name: String,
pub param_type: String,
pub required: bool,
pub default: Option<Value>,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WorkflowPattern {
Sequential,
Parallel,
MapReduce,
FanOutFanIn,
Saga,
Pipeline,
ScatterGather,
Choreography,
}
pub struct WorkflowTemplateFactory;
impl WorkflowTemplateFactory {
pub fn map_reduce(
name: &str,
description: &str,
mapper: WorkflowStep,
reducer: WorkflowStep,
) -> WorkflowTemplate {
WorkflowTemplate {
id: format!("map-reduce-{}", Uuid::new_v4()),
name: name.to_string(),
description: description.to_string(),
version: "1.0.0".to_string(),
inputs: vec![
ParameterDef {
name: "data".to_string(),
param_type: "array".to_string(),
required: true,
default: None,
description: "Input data array to process".to_string(),
},
ParameterDef {
name: "parallelism".to_string(),
param_type: "int".to_string(),
required: false,
default: Some(Value::Int(4)),
description: "Number of parallel mappers".to_string(),
},
],
outputs: vec![ParameterDef {
name: "result".to_string(),
param_type: "any".to_string(),
required: true,
default: None,
description: "Reduced result".to_string(),
}],
steps: vec![
WorkflowStep {
id: "map".to_string(),
name: "Map Phase".to_string(),
step_type: StepType::Parallel {
steps: vec![mapper],
},
input_mapping: Some("$.data".to_string()),
output_mapping: Some("$.mapped_results".to_string()),
retry_config: Some(RetryConfig::default()),
timeout_ms: Some(60000),
condition: None,
compensate: None,
metadata: HashMap::new(),
},
WorkflowStep {
id: "reduce".to_string(),
name: "Reduce Phase".to_string(),
step_type: reducer.step_type,
input_mapping: Some("$.mapped_results".to_string()),
output_mapping: Some("$.result".to_string()),
retry_config: Some(RetryConfig::default()),
timeout_ms: Some(30000),
condition: None,
compensate: None,
metadata: HashMap::new(),
},
],
metadata: HashMap::from([("pattern".to_string(), "map-reduce".to_string())]),
pattern: WorkflowPattern::MapReduce,
}
}
pub fn fan_out_fan_in(
name: &str,
description: &str,
workers: Vec<WorkflowStep>,
aggregator: WorkflowStep,
) -> WorkflowTemplate {
WorkflowTemplate {
id: format!("fan-out-fan-in-{}", Uuid::new_v4()),
name: name.to_string(),
description: description.to_string(),
version: "1.0.0".to_string(),
inputs: vec![ParameterDef {
name: "input".to_string(),
param_type: "any".to_string(),
required: true,
default: None,
description: "Input to fan out".to_string(),
}],
outputs: vec![ParameterDef {
name: "aggregated".to_string(),
param_type: "any".to_string(),
required: true,
default: None,
description: "Aggregated result".to_string(),
}],
steps: vec![
WorkflowStep {
id: "fan-out".to_string(),
name: "Fan Out".to_string(),
step_type: StepType::Parallel { steps: workers },
input_mapping: Some("$.input".to_string()),
output_mapping: Some("$.worker_results".to_string()),
retry_config: None,
timeout_ms: Some(120000),
condition: None,
compensate: None,
metadata: HashMap::new(),
},
WorkflowStep {
id: "fan-in".to_string(),
name: "Fan In".to_string(),
step_type: aggregator.step_type,
input_mapping: Some("$.worker_results".to_string()),
output_mapping: Some("$.aggregated".to_string()),
retry_config: Some(RetryConfig::default()),
timeout_ms: Some(30000),
condition: None,
compensate: None,
metadata: HashMap::new(),
},
],
metadata: HashMap::from([("pattern".to_string(), "fan-out-fan-in".to_string())]),
pattern: WorkflowPattern::FanOutFanIn,
}
}
pub fn saga(
name: &str,
description: &str,
transactions: Vec<(WorkflowStep, WorkflowStep)>, ) -> WorkflowTemplate {
let steps: Vec<WorkflowStep> = transactions
.into_iter()
.enumerate()
.map(|(i, (mut action, compensation))| {
action.id = format!("saga-step-{}", i);
action.compensate = Some(Box::new(compensation));
action
})
.collect();
WorkflowTemplate {
id: format!("saga-{}", Uuid::new_v4()),
name: name.to_string(),
description: description.to_string(),
version: "1.0.0".to_string(),
inputs: vec![ParameterDef {
name: "transaction_id".to_string(),
param_type: "string".to_string(),
required: false,
default: None,
description: "Transaction correlation ID".to_string(),
}],
outputs: vec![ParameterDef {
name: "success".to_string(),
param_type: "bool".to_string(),
required: true,
default: None,
description: "Whether the saga completed successfully".to_string(),
}],
steps,
metadata: HashMap::from([("pattern".to_string(), "saga".to_string())]),
pattern: WorkflowPattern::Saga,
}
}
pub fn pipeline(name: &str, description: &str, stages: Vec<WorkflowStep>) -> WorkflowTemplate {
WorkflowTemplate {
id: format!("pipeline-{}", Uuid::new_v4()),
name: name.to_string(),
description: description.to_string(),
version: "1.0.0".to_string(),
inputs: vec![ParameterDef {
name: "input".to_string(),
param_type: "any".to_string(),
required: true,
default: None,
description: "Pipeline input".to_string(),
}],
outputs: vec![ParameterDef {
name: "output".to_string(),
param_type: "any".to_string(),
required: true,
default: None,
description: "Pipeline output".to_string(),
}],
steps: stages,
metadata: HashMap::from([("pattern".to_string(), "pipeline".to_string())]),
pattern: WorkflowPattern::Pipeline,
}
}
pub fn scatter_gather(
name: &str,
description: &str,
scatter_targets: Vec<String>, gather_strategy: GatherStrategy,
) -> WorkflowTemplate {
let workers: Vec<WorkflowStep> = scatter_targets
.iter()
.enumerate()
.map(|(i, target)| WorkflowStep {
id: format!("scatter-{}", i),
name: format!("Scatter to {}", target),
step_type: StepType::Agent {
agent_id: target.clone(),
prompt: "$.prompt".to_string(),
},
input_mapping: Some("$.input".to_string()),
output_mapping: None,
retry_config: Some(RetryConfig::default()),
timeout_ms: Some(30000),
condition: None,
compensate: None,
metadata: HashMap::new(),
})
.collect();
WorkflowTemplate {
id: format!("scatter-gather-{}", Uuid::new_v4()),
name: name.to_string(),
description: description.to_string(),
version: "1.0.0".to_string(),
inputs: vec![
ParameterDef {
name: "input".to_string(),
param_type: "any".to_string(),
required: true,
default: None,
description: "Input to scatter".to_string(),
},
ParameterDef {
name: "timeout_ms".to_string(),
param_type: "int".to_string(),
required: false,
default: Some(Value::Int(30000)),
description: "Gather timeout".to_string(),
},
],
outputs: vec![ParameterDef {
name: "gathered".to_string(),
param_type: "array".to_string(),
required: true,
default: None,
description: "Gathered results".to_string(),
}],
steps: vec![
WorkflowStep {
id: "scatter".to_string(),
name: "Scatter Phase".to_string(),
step_type: StepType::Parallel { steps: workers },
input_mapping: Some("$.input".to_string()),
output_mapping: Some("$.scattered".to_string()),
retry_config: None,
timeout_ms: Some(60000),
condition: None,
compensate: None,
metadata: HashMap::new(),
},
WorkflowStep {
id: "gather".to_string(),
name: "Gather Phase".to_string(),
step_type: StepType::Execute {
function: "workflow_gather".to_string(),
args: vec![Value::Str(format!("{:?}", gather_strategy))],
},
input_mapping: Some("$.scattered".to_string()),
output_mapping: Some("$.gathered".to_string()),
retry_config: None,
timeout_ms: Some(10000),
condition: None,
compensate: None,
metadata: HashMap::new(),
},
],
metadata: HashMap::from([
("pattern".to_string(), "scatter-gather".to_string()),
(
"gather_strategy".to_string(),
format!("{:?}", gather_strategy),
),
]),
pattern: WorkflowPattern::ScatterGather,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GatherStrategy {
WaitAll,
FirstN(usize),
BestEffort { timeout_ms: u64 },
FirstSuccess,
Consensus { threshold: f64 },
}
#[derive(Debug, Clone, PartialEq)]
pub enum CircuitState {
Closed,
Open,
HalfOpen,
}
#[derive(Debug)]
pub struct CircuitBreaker {
name: String,
state: Arc<RwLock<CircuitState>>,
failure_count: Arc<RwLock<u32>>,
success_count: Arc<RwLock<u32>>,
last_failure_time: Arc<RwLock<Option<Instant>>>,
config: CircuitBreakerConfig,
}
#[derive(Debug, Clone)]
pub struct CircuitBreakerConfig {
pub failure_threshold: u32,
pub success_threshold: u32,
pub reset_timeout: Duration,
pub half_open_max_requests: u32,
}
impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
failure_threshold: 5,
success_threshold: 3,
reset_timeout: Duration::from_secs(30),
half_open_max_requests: 3,
}
}
}
impl CircuitBreaker {
pub fn new(name: &str, config: CircuitBreakerConfig) -> Self {
Self {
name: name.to_string(),
state: Arc::new(RwLock::new(CircuitState::Closed)),
failure_count: Arc::new(RwLock::new(0)),
success_count: Arc::new(RwLock::new(0)),
last_failure_time: Arc::new(RwLock::new(None)),
config,
}
}
pub async fn allow_request(&self) -> bool {
let mut state = self.state.write().await;
match *state {
CircuitState::Closed => true,
CircuitState::Open => {
if let Some(last_failure) = *self.last_failure_time.read().await {
if last_failure.elapsed() >= self.config.reset_timeout {
*state = CircuitState::HalfOpen;
*self.success_count.write().await = 0;
return true;
}
}
false
}
CircuitState::HalfOpen => {
*self.success_count.read().await < self.config.half_open_max_requests
}
}
}
pub async fn record_success(&self) {
let mut state = self.state.write().await;
match *state {
CircuitState::Closed => {
*self.failure_count.write().await = 0;
}
CircuitState::HalfOpen => {
let mut count = self.success_count.write().await;
*count += 1;
if *count >= self.config.success_threshold {
*state = CircuitState::Closed;
*self.failure_count.write().await = 0;
}
}
CircuitState::Open => {}
}
}
pub async fn record_failure(&self) {
let mut state = self.state.write().await;
match *state {
CircuitState::Closed => {
let mut count = self.failure_count.write().await;
*count += 1;
if *count >= self.config.failure_threshold {
*state = CircuitState::Open;
*self.last_failure_time.write().await = Some(Instant::now());
}
}
CircuitState::HalfOpen => {
*state = CircuitState::Open;
*self.last_failure_time.write().await = Some(Instant::now());
}
CircuitState::Open => {}
}
}
pub async fn get_state(&self) -> CircuitState {
self.state.read().await.clone()
}
pub fn name(&self) -> &str {
&self.name
}
}
fn truthy(v: &Value) -> bool {
match v {
Value::Null => false,
Value::Bool(b) => *b,
Value::Int(n) => *n != 0,
Value::Float(f) => *f != 0.0,
Value::Str(s) | Value::Uri(s) => !s.is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Record(r) => !r.is_empty(),
Value::Table(tbl) => !tbl.rows.is_empty(),
Value::Error(_) => false,
Value::Lambda(_) | Value::AsyncLambda(_) | Value::Future(_) | Value::Builtin(_) => true,
}
}
fn resolve_path(context: &WorkflowContext, path: &str) -> Option<Value> {
let path = path.trim();
if path == "$" {
return Some(Value::Record(
context
.variables
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
));
}
let rest = path.strip_prefix("$.").unwrap_or(path);
let mut parts = rest.split('.');
let mut cur = context.variables.get(parts.next()?)?.clone();
for part in parts {
cur = match cur {
Value::Record(ref r) => r.get(part)?.clone(),
_ => return None,
};
}
Some(cur)
}
fn store_path(context: &mut WorkflowContext, path: &str, value: Value) {
let path = path.trim();
let name = path.strip_prefix("$.").unwrap_or(path);
if name.is_empty() || name == "$" {
return;
}
context.variables.insert(name.to_string(), value);
}
fn context_env(context: &WorkflowContext) -> Env {
let mut env = Env::new();
for (k, v) in &context.variables {
let _ = env.set_var(k.clone(), v.clone());
}
env
}
fn resolve_string(context: &WorkflowContext, s: &str) -> String {
if !s.starts_with("$.") && s != "$" {
return s.to_string();
}
match resolve_path(context, s) {
Some(Value::Str(v)) | Some(Value::Uri(v)) => v,
Some(other) => other.to_string(),
None => s.to_string(),
}
}
fn resolve_value(context: &WorkflowContext, v: &Value) -> Value {
match v {
Value::Str(s) if s.starts_with("$.") || s == "$" => {
resolve_path(context, s).unwrap_or_else(|| v.clone())
}
_ => v.clone(),
}
}
const SEEN_EVENTS: &str = "__emitted_events";
fn record_seen_event(context: &mut WorkflowContext, event_type: &str, payload: Value) {
let entry = Value::Record(BTreeMap::from([
("event".to_string(), Value::Str(event_type.to_string())),
("payload".to_string(), payload),
]));
match context.variables.get_mut(SEEN_EVENTS) {
Some(Value::Array(seen)) => seen.push(entry),
_ => {
context
.variables
.insert(SEEN_EVENTS.to_string(), Value::Array(vec![entry]));
}
}
}
fn take_seen_event(context: &mut WorkflowContext, event_type: &str) -> Option<Value> {
let Some(Value::Array(seen)) = context.variables.get_mut(SEEN_EVENTS) else {
return None;
};
let idx = seen.iter().position(|e| {
matches!(e, Value::Record(r)
if matches!(r.get("event"), Some(Value::Str(t)) if t == event_type))
})?;
let entry = seen.remove(idx);
match entry {
Value::Record(mut r) => r.remove("payload"),
other => Some(other),
}
}
fn merge_branch_variables(parent: &mut WorkflowContext, branch: WorkflowContext) {
for (step_id, result) in branch.results {
parent.results.entry(step_id).or_insert(result);
}
for (k, v) in branch.variables {
if k == SEEN_EVENTS {
if let Value::Array(events) = v {
match parent.variables.get_mut(SEEN_EVENTS) {
Some(Value::Array(existing)) => {
for e in events {
if !existing.contains(&e) {
existing.push(e);
}
}
}
_ => {
parent
.variables
.insert(SEEN_EVENTS.to_string(), Value::Array(events));
}
}
}
continue;
}
parent.variables.entry(k).or_insert(v);
}
}
async fn http_step(method: String, url: String, body: Option<Value>) -> Result<Value> {
tokio::task::spawn_blocking(move || {
crate::builtins::guard_network("workflow_http", &url)?;
let url = crate::security::validate_http_url(&url)
.context("workflow http step: URL validation failed")?;
let client = crate::security::create_secure_http_client()
.context("workflow http step: failed to create HTTP client")?;
let mut req = match method.as_str() {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"PATCH" => client.patch(&url),
"DELETE" => client.delete(&url),
"HEAD" => client.head(&url),
other => return Err(anyhow!("workflow http step: unsupported method `{other}`")),
};
if let Some(body) = body {
req = match body {
Value::Str(s) => req.body(s),
other => req
.header("content-type", "application/json")
.body(other.to_json().to_string()),
};
}
let resp = req.send()?;
let status = resp.status().as_u16() as i64;
let mut headers = BTreeMap::<String, Value>::new();
for (k, v) in resp.headers().iter() {
headers.insert(
k.to_string(),
Value::Str(v.to_str().unwrap_or("").to_string()),
);
}
let body_text = resp.text().unwrap_or_default();
Ok(Value::Record(BTreeMap::from([
("url".to_string(), Value::Str(url)),
("method".to_string(), Value::Str(method)),
("status".to_string(), Value::Int(status)),
("headers".to_string(), Value::Record(headers)),
("body".to_string(), Value::Str(body_text)),
])))
})
.await
.map_err(|e| anyhow!("workflow http step panicked: {e}"))?
}
async fn call_builtin_off_runtime(
name: String,
args: Vec<Value>,
input: Option<Value>,
vars: HashMap<String, Value>,
) -> Result<Value> {
tokio::task::spawn_blocking(move || {
let mut env = Env::new();
for (k, v) in vars {
let _ = env.set_var(k, v);
}
crate::builtins::call_with_input(&name, args, input, &mut env)
})
.await
.map_err(|e| anyhow!("workflow step panicked: {e}"))?
}
#[derive(Clone)]
pub struct WorkflowEngine {
templates: Arc<RwLock<HashMap<String, WorkflowTemplate>>>,
instances: Arc<RwLock<HashMap<WorkflowId, WorkflowInstance>>>,
circuit_breakers: Arc<RwLock<HashMap<String, CircuitBreaker>>>,
event_tx: broadcast::Sender<WorkflowEvent>,
}
pub struct WorkflowInstance {
pub id: WorkflowId,
pub template_id: String,
pub context: WorkflowContext,
pub status: WorkflowStatus,
pub created_at: Instant,
}
impl WorkflowEngine {
pub fn new() -> Self {
let (event_tx, _) = broadcast::channel(1000);
Self {
templates: Arc::new(RwLock::new(HashMap::new())),
instances: Arc::new(RwLock::new(HashMap::new())),
circuit_breakers: Arc::new(RwLock::new(HashMap::new())),
event_tx,
}
}
pub async fn register_template(&self, template: WorkflowTemplate) {
self.templates
.write()
.await
.insert(template.id.clone(), template);
}
pub async fn get_template(&self, template_id: &str) -> Option<WorkflowTemplate> {
self.templates.read().await.get(template_id).cloned()
}
pub async fn list_templates(&self) -> Vec<WorkflowTemplate> {
self.templates.read().await.values().cloned().collect()
}
pub async fn create_instance(
&self,
template_id: &str,
inputs: HashMap<String, Value>,
) -> Result<WorkflowId> {
let template = self
.templates
.read()
.await
.get(template_id)
.cloned()
.ok_or_else(|| anyhow!("Template not found: {}", template_id))?;
for param in &template.inputs {
if param.required && !inputs.contains_key(¶m.name) {
return Err(anyhow!("Missing required input: {}", param.name));
}
}
let workflow_id = Uuid::new_v4().to_string();
let mut context = WorkflowContext::default();
context.workflow_id = workflow_id.clone();
context.variables = inputs;
context.started_at = Some(current_timestamp());
let instance = WorkflowInstance {
id: workflow_id.clone(),
template_id: template_id.to_string(),
context,
status: WorkflowStatus::Created,
created_at: Instant::now(),
};
self.instances
.write()
.await
.insert(workflow_id.clone(), instance);
let _ = self.event_tx.send(WorkflowEvent::Started {
workflow_id: workflow_id.clone(),
template: template_id.to_string(),
});
Ok(workflow_id)
}
pub async fn execute(&self, workflow_id: &WorkflowId) -> Result<Value> {
let (template, mut instance) = {
let instances = self.instances.read().await;
let instance = instances
.get(workflow_id)
.ok_or_else(|| anyhow!("Workflow instance not found: {}", workflow_id))?
.clone_minimal();
let templates = self.templates.read().await;
let template = templates
.get(&instance.template_id)
.cloned()
.ok_or_else(|| anyhow!("Template not found"))?;
(template, instance)
};
instance.status = WorkflowStatus::Running;
let result = match template.pattern {
WorkflowPattern::Sequential | WorkflowPattern::Pipeline => {
self.execute_sequential(&template.steps, &mut instance.context)
.await
}
WorkflowPattern::Parallel => {
self.execute_parallel(&template.steps, &mut instance.context)
.await
}
WorkflowPattern::FanOutFanIn | WorkflowPattern::ScatterGather => {
self.execute_sequential(&template.steps, &mut instance.context)
.await
}
WorkflowPattern::MapReduce => {
self.execute_map_reduce(&template.steps, &mut instance.context)
.await
}
WorkflowPattern::Saga => {
self.execute_saga(&template.steps, &mut instance.context)
.await
}
WorkflowPattern::Choreography => {
self.execute_choreography(&template.steps, &mut instance.context)
.await
}
};
{
let mut instances = self.instances.write().await;
if let Some(inst) = instances.get_mut(workflow_id) {
inst.status = match &result {
Ok(_) => WorkflowStatus::Completed,
Err(_) => WorkflowStatus::Failed,
};
instance.context.ended_at = Some(current_timestamp());
inst.context = instance.context.clone();
}
}
match result {
Ok(value) => {
let _ = self.event_tx.send(WorkflowEvent::Completed {
workflow_id: workflow_id.clone(),
result: value.clone(),
});
Ok(value)
}
Err(e) => {
let _ = self.event_tx.send(WorkflowEvent::Failed {
workflow_id: workflow_id.clone(),
error: e.to_string(),
});
Err(e)
}
}
}
async fn execute_sequential(
&self,
steps: &[WorkflowStep],
context: &mut WorkflowContext,
) -> Result<Value> {
let mut last_result = Value::Null;
for step in steps {
context.current_step = Some(step.id.clone());
let _ = self.event_tx.send(WorkflowEvent::StepStarted {
workflow_id: context.workflow_id.clone(),
step_id: step.id.clone(),
});
let start = Instant::now();
let result = self.execute_step(step, context).await;
let duration = start.elapsed();
match result {
Ok(output) => {
let step_result = StepResult {
step_id: step.id.clone(),
status: StepStatus::Completed,
output: Some(output.clone()),
error: None,
duration_ms: duration.as_millis() as u64,
retries: 0,
};
context.results.insert(step.id.clone(), step_result.clone());
let _ = self.event_tx.send(WorkflowEvent::StepCompleted {
workflow_id: context.workflow_id.clone(),
step_id: step.id.clone(),
result: step_result,
});
last_result = output;
}
Err(e) => {
let _ = self.event_tx.send(WorkflowEvent::StepFailed {
workflow_id: context.workflow_id.clone(),
step_id: step.id.clone(),
error: e.to_string(),
});
return Err(e);
}
}
}
Ok(last_result)
}
async fn execute_parallel(
&self,
steps: &[WorkflowStep],
context: &mut WorkflowContext,
) -> Result<Value> {
let mut handles = Vec::new();
for step in steps {
let engine = self.clone();
let step = step.clone();
let mut branch_ctx = context.clone();
let event_tx = self.event_tx.clone();
let workflow_id = context.workflow_id.clone();
handles.push(tokio::spawn(async move {
let _ = event_tx.send(WorkflowEvent::StepStarted {
workflow_id,
step_id: step.id.clone(),
});
let start = Instant::now();
let result = engine.execute_step(&step, &mut branch_ctx).await;
(step, result, start.elapsed(), branch_ctx)
}));
}
let mut outputs = Vec::new();
let mut first_error: Option<anyhow::Error> = None;
for handle in handles {
let (step, result, duration, branch_ctx) = match handle.await {
Ok(joined) => joined,
Err(e) => {
if first_error.is_none() {
first_error = Some(anyhow!("parallel branch panicked: {e}"));
}
continue;
}
};
match result {
Ok(value) => {
merge_branch_variables(context, branch_ctx);
if let Some(path) = &step.output_mapping {
store_path(context, path, value.clone());
}
let step_result = StepResult {
step_id: step.id.clone(),
status: StepStatus::Completed,
output: Some(value.clone()),
error: None,
duration_ms: duration.as_millis() as u64,
retries: 0,
};
context.results.insert(step.id.clone(), step_result.clone());
let _ = self.event_tx.send(WorkflowEvent::StepCompleted {
workflow_id: context.workflow_id.clone(),
step_id: step.id.clone(),
result: step_result,
});
outputs.push(value);
}
Err(e) => {
let message = e.to_string();
context.results.insert(
step.id.clone(),
StepResult {
step_id: step.id.clone(),
status: StepStatus::Failed,
output: None,
error: Some(message.clone()),
duration_ms: duration.as_millis() as u64,
retries: 0,
},
);
let _ = self.event_tx.send(WorkflowEvent::StepFailed {
workflow_id: context.workflow_id.clone(),
step_id: step.id.clone(),
error: message,
});
if first_error.is_none() {
first_error = Some(e);
}
}
}
}
match first_error {
Some(e) => Err(e),
None => Ok(Value::Array(outputs)),
}
}
async fn execute_map_reduce(
&self,
steps: &[WorkflowStep],
context: &mut WorkflowContext,
) -> Result<Value> {
if steps.len() < 2 {
return Err(anyhow!("Map-reduce requires at least 2 steps"));
}
let map_step = &steps[0];
if let StepType::Parallel { steps: map_steps } = &map_step.step_type {
let map_result = self.execute_parallel(map_steps, context).await?;
context
.variables
.insert("mapped_results".to_string(), map_result);
}
let reduce_step = &steps[1];
self.execute_step(reduce_step, context).await
}
async fn execute_saga(
&self,
steps: &[WorkflowStep],
context: &mut WorkflowContext,
) -> Result<Value> {
let mut completed_steps: Vec<&WorkflowStep> = Vec::new();
for step in steps {
context.current_step = Some(step.id.clone());
let result = self.execute_step(step, context).await;
match result {
Ok(output) => {
context.results.insert(
step.id.clone(),
StepResult {
step_id: step.id.clone(),
status: StepStatus::Completed,
output: Some(output),
error: None,
duration_ms: 0,
retries: 0,
},
);
completed_steps.push(step);
}
Err(e) => {
let _ = self.event_tx.send(WorkflowEvent::StepFailed {
workflow_id: context.workflow_id.clone(),
step_id: step.id.clone(),
error: e.to_string(),
});
for completed_step in completed_steps.iter().rev() {
if let Some(compensate) = &completed_step.compensate {
let _ = self.event_tx.send(WorkflowEvent::Compensating {
workflow_id: context.workflow_id.clone(),
step_id: completed_step.id.clone(),
});
let _ = self.execute_step(compensate, context).await;
if let Some(result) = context.results.get_mut(&completed_step.id) {
result.status = StepStatus::Compensated;
}
}
}
return Err(anyhow!("Saga failed at step {}: {}", step.id, e));
}
}
}
Ok(Value::Record(BTreeMap::from([(
"success".to_string(),
Value::Str("true".to_string()),
)])))
}
async fn execute_choreography(
&self,
steps: &[WorkflowStep],
context: &mut WorkflowContext,
) -> Result<Value> {
self.execute_sequential(steps, context).await
}
fn execute_step<'a>(
&'a self,
step: &'a WorkflowStep,
context: &'a mut WorkflowContext,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
Box::pin(async move {
if let Some(condition) = &step.condition {
if !self.evaluate_condition(condition, context) {
return Ok(Value::Null);
}
}
let input = step
.input_mapping
.as_deref()
.and_then(|path| resolve_path(context, path));
let mut retries = 0;
let max_retries = step
.retry_config
.as_ref()
.map(|c| c.max_retries)
.unwrap_or(0);
loop {
let attempt = self.execute_step_type(&step.step_type, input.clone(), context);
let result = match step.timeout_ms {
Some(ms) => {
match tokio::time::timeout(Duration::from_millis(ms), attempt).await {
Ok(r) => r,
Err(_) => {
Err(anyhow!("step `{}` exceeded its {}ms timeout", step.id, ms))
}
}
}
None => attempt.await,
};
match result {
Ok(value) => {
if let Some(path) = &step.output_mapping {
store_path(context, path, value.clone());
}
return Ok(value);
}
Err(_e) if retries < max_retries => {
retries += 1;
if let Some(config) = &step.retry_config {
let delay = config.initial_delay_ms as f64
* config.backoff_multiplier.powi(retries as i32);
let delay = delay.min(config.max_delay_ms as f64);
let jitter = delay * config.jitter * rand::random::<f64>();
tokio::time::sleep(Duration::from_millis((delay + jitter) as u64))
.await;
}
}
Err(e) => return Err(e),
}
}
})
}
fn execute_step_type<'a>(
&'a self,
step_type: &'a StepType,
input: Option<Value>,
context: &'a mut WorkflowContext,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
Box::pin(async move {
match step_type {
StepType::Execute { function, args } => {
let function = resolve_string(context, function);
let args = args.iter().map(|a| resolve_value(context, a)).collect();
call_builtin_off_runtime(function, args, input, context.variables.clone()).await
}
StepType::Agent { agent_id, prompt } => {
let goal = resolve_string(context, prompt);
let mut args = vec![Value::Str(goal)];
if let Some(Value::Array(tools)) = context.variables.get("tools") {
args.push(Value::Array(tools.clone()));
}
let out = call_builtin_off_runtime(
"agent".to_string(),
args,
input,
context.variables.clone(),
)
.await?;
Ok(Value::Record(BTreeMap::from([
("agent".to_string(), Value::Str(agent_id.clone())),
("output".to_string(), out),
])))
}
StepType::Http { method, url, body } => {
let method = resolve_string(context, method).to_uppercase();
let url = resolve_string(context, url);
let body = body.as_ref().map(|b| resolve_value(context, b));
http_step(method, url, body).await
}
StepType::Parallel { steps } => self.execute_parallel(steps, context).await,
StepType::Branch {
conditions,
default,
} => {
for (condition, step) in conditions {
if self.evaluate_condition(condition, context) {
return self.execute_step(step, context).await;
}
}
if let Some(default_step) = default {
self.execute_step(default_step, context).await
} else {
Ok(Value::Null)
}
}
StepType::WaitForEvent {
event_type,
timeout_ms,
} => {
let wanted = resolve_string(context, event_type);
if let Some(seen) = take_seen_event(context, &wanted) {
return Ok(seen);
}
let mut rx = self.event_tx.subscribe();
let workflow_id = context.workflow_id.clone();
let wait = async {
loop {
match rx.recv().await {
Ok(WorkflowEvent::Custom {
workflow_id: id,
event_type: ty,
payload,
}) if id == workflow_id && ty == wanted => return Ok(payload),
Ok(_) => continue,
Err(e) => return Err(anyhow!("event stream closed: {e}")),
}
}
};
match timeout_ms {
Some(ms) => {
match tokio::time::timeout(Duration::from_millis(*ms), wait).await {
Ok(v) => v,
Err(_) => Err(anyhow!(
"timed out after {}ms waiting for event `{}`",
ms,
wanted
)),
}
}
None => wait.await,
}
}
StepType::EmitEvent {
event_type,
payload,
} => {
let ty = resolve_string(context, event_type);
let payload = resolve_value(context, payload);
record_seen_event(context, &ty, payload.clone());
let _ = self.event_tx.send(WorkflowEvent::Custom {
workflow_id: context.workflow_id.clone(),
event_type: ty,
payload: payload.clone(),
});
Ok(payload)
}
StepType::Delay { duration_ms } => {
tokio::time::sleep(Duration::from_millis(*duration_ms)).await;
Ok(Value::Null)
}
StepType::SubWorkflow {
template_id,
inputs,
} => {
if context.depth >= MAX_SUBWORKFLOW_DEPTH {
return Err(anyhow!(
"sub-workflow nesting exceeded {} levels at template `{}`; that is \
almost always a cycle",
MAX_SUBWORKFLOW_DEPTH,
template_id
));
}
let mut child_inputs = context.variables.clone();
for (k, v) in inputs {
child_inputs.insert(k.clone(), resolve_value(context, v));
}
if let Some(input) = input {
child_inputs.insert("input".to_string(), input);
}
let child_id = self.create_instance(template_id, child_inputs).await?;
self.set_instance_depth(&child_id, context.depth + 1).await;
self.execute(&child_id).await
}
}
})
}
fn evaluate_condition(&self, condition: &str, context: &WorkflowContext) -> bool {
let condition = condition.trim();
if condition.is_empty() {
return true;
}
let Ok(stmts) = crate::parser::parse_program(condition) else {
return false;
};
let mut env = context_env(context);
match crate::eval::eval_program(&stmts, &mut env) {
Ok(v) => truthy(&v),
Err(_) => false,
}
}
async fn set_instance_depth(&self, workflow_id: &WorkflowId, depth: u32) {
if let Some(inst) = self.instances.write().await.get_mut(workflow_id) {
inst.context.depth = depth;
}
}
pub async fn get_instance(&self, workflow_id: &WorkflowId) -> Option<WorkflowInstanceInfo> {
self.instances
.read()
.await
.get(workflow_id)
.map(|i| WorkflowInstanceInfo {
id: i.id.clone(),
template_id: i.template_id.clone(),
status: i.status.clone(),
context: i.context.clone(),
})
}
pub async fn list_instances(&self) -> Vec<WorkflowInstanceInfo> {
self.instances
.read()
.await
.values()
.map(|i| WorkflowInstanceInfo {
id: i.id.clone(),
template_id: i.template_id.clone(),
status: i.status.clone(),
context: i.context.clone(),
})
.collect()
}
pub async fn cancel(&self, workflow_id: &WorkflowId) -> Result<()> {
let mut instances = self.instances.write().await;
if let Some(instance) = instances.get_mut(workflow_id) {
instance.status = WorkflowStatus::Cancelled;
Ok(())
} else {
Err(anyhow!("Workflow not found"))
}
}
pub async fn pause(&self, workflow_id: &WorkflowId) -> Result<()> {
let mut instances = self.instances.write().await;
if let Some(instance) = instances.get_mut(workflow_id) {
if instance.status == WorkflowStatus::Running {
instance.status = WorkflowStatus::Paused;
}
Ok(())
} else {
Err(anyhow!("Workflow not found"))
}
}
pub async fn resume(&self, workflow_id: &WorkflowId) -> Result<()> {
let mut instances = self.instances.write().await;
if let Some(instance) = instances.get_mut(workflow_id) {
if instance.status == WorkflowStatus::Paused {
instance.status = WorkflowStatus::Running;
}
Ok(())
} else {
Err(anyhow!("Workflow not found"))
}
}
pub fn subscribe(&self) -> broadcast::Receiver<WorkflowEvent> {
self.event_tx.subscribe()
}
pub async fn register_circuit_breaker(&self, name: &str, config: CircuitBreakerConfig) {
let breaker = CircuitBreaker::new(name, config);
self.circuit_breakers
.write()
.await
.insert(name.to_string(), breaker);
}
pub async fn get_circuit_breaker_status(&self, name: &str) -> Option<CircuitState> {
if let Some(breaker) = self.circuit_breakers.read().await.get(name) {
Some(breaker.get_state().await)
} else {
None
}
}
}
impl Default for WorkflowEngine {
fn default() -> Self {
Self::new()
}
}
impl WorkflowInstance {
fn clone_minimal(&self) -> Self {
Self {
id: self.id.clone(),
template_id: self.template_id.clone(),
context: self.context.clone(),
status: self.status.clone(),
created_at: self.created_at,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowInstanceInfo {
pub id: WorkflowId,
pub template_id: String,
pub status: WorkflowStatus,
pub context: WorkflowContext,
}
pub fn workflow_builtins() -> Vec<(&'static str, &'static str)> {
vec![
(
"workflow_create",
"Create a new workflow instance from a template",
),
("workflow_execute", "Execute a workflow instance"),
("workflow_status", "Get workflow instance status"),
("workflow_cancel", "Cancel a running workflow"),
("workflow_pause", "Pause a running workflow"),
("workflow_resume", "Resume a paused workflow"),
("workflow_list", "List all workflow instances"),
("workflow_templates", "List registered workflow templates"),
("workflow_register", "Register a new workflow template"),
("workflow_map_reduce", "Create a map-reduce workflow"),
("workflow_pipeline", "Create a pipeline workflow"),
(
"workflow_saga",
"Create a saga (distributed transaction) workflow",
),
("workflow_fan_out", "Create a fan-out/fan-in workflow"),
(
"workflow_scatter_gather",
"Create a scatter-gather workflow",
),
("circuit_breaker_create", "Create a circuit breaker"),
("circuit_breaker_status", "Get circuit breaker status"),
]
}
fn current_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_workflow_context_default() {
let ctx = WorkflowContext::default();
assert!(!ctx.workflow_id.is_empty());
assert!(ctx.variables.is_empty());
assert!(ctx.results.is_empty());
}
#[test]
fn test_retry_config_default() {
let config = RetryConfig::default();
assert_eq!(config.max_retries, 3);
assert_eq!(config.initial_delay_ms, 100);
assert_eq!(config.backoff_multiplier, 2.0);
}
#[test]
fn test_workflow_step_serialization() {
let step = WorkflowStep {
id: "test-step".to_string(),
name: "Test Step".to_string(),
step_type: StepType::Execute {
function: "test_fn".to_string(),
args: vec![Value::Int(42)],
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: Some(5000),
condition: None,
compensate: None,
metadata: HashMap::new(),
};
let json = serde_json::to_string(&step).unwrap();
let deserialized: WorkflowStep = serde_json::from_str(&json).unwrap();
assert_eq!(step.id, deserialized.id);
}
#[test]
fn test_map_reduce_template_creation() {
let mapper = WorkflowStep {
id: "mapper".to_string(),
name: "Mapper".to_string(),
step_type: StepType::Execute {
function: "map_fn".to_string(),
args: vec![],
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: None,
condition: None,
compensate: None,
metadata: HashMap::new(),
};
let reducer = WorkflowStep {
id: "reducer".to_string(),
name: "Reducer".to_string(),
step_type: StepType::Execute {
function: "reduce_fn".to_string(),
args: vec![],
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: None,
condition: None,
compensate: None,
metadata: HashMap::new(),
};
let template = WorkflowTemplateFactory::map_reduce(
"Test MapReduce",
"Test description",
mapper,
reducer,
);
assert_eq!(template.pattern, WorkflowPattern::MapReduce);
assert_eq!(template.steps.len(), 2);
}
#[test]
fn test_pipeline_template_creation() {
let stages = vec![
WorkflowStep {
id: "stage1".to_string(),
name: "Stage 1".to_string(),
step_type: StepType::Execute {
function: "stage1_fn".to_string(),
args: vec![],
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: None,
condition: None,
compensate: None,
metadata: HashMap::new(),
},
WorkflowStep {
id: "stage2".to_string(),
name: "Stage 2".to_string(),
step_type: StepType::Execute {
function: "stage2_fn".to_string(),
args: vec![],
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: None,
condition: None,
compensate: None,
metadata: HashMap::new(),
},
];
let template =
WorkflowTemplateFactory::pipeline("Test Pipeline", "Test description", stages);
assert_eq!(template.pattern, WorkflowPattern::Pipeline);
assert_eq!(template.steps.len(), 2);
}
#[test]
fn test_saga_template_creation() {
let transactions = vec![(
WorkflowStep {
id: "action1".to_string(),
name: "Action 1".to_string(),
step_type: StepType::Execute {
function: "action1".to_string(),
args: vec![],
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: None,
condition: None,
compensate: None,
metadata: HashMap::new(),
},
WorkflowStep {
id: "compensate1".to_string(),
name: "Compensate 1".to_string(),
step_type: StepType::Execute {
function: "undo_action1".to_string(),
args: vec![],
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: None,
condition: None,
compensate: None,
metadata: HashMap::new(),
},
)];
let template = WorkflowTemplateFactory::saga("Test Saga", "Test description", transactions);
assert_eq!(template.pattern, WorkflowPattern::Saga);
assert!(template.steps[0].compensate.is_some());
}
#[tokio::test]
async fn test_circuit_breaker_closed_state() {
let config = CircuitBreakerConfig::default();
let breaker = CircuitBreaker::new("test", config);
assert_eq!(breaker.get_state().await, CircuitState::Closed);
assert!(breaker.allow_request().await);
}
#[tokio::test]
async fn test_circuit_breaker_opens_on_failures() {
let config = CircuitBreakerConfig {
failure_threshold: 2,
..Default::default()
};
let breaker = CircuitBreaker::new("test", config);
breaker.record_failure().await;
assert_eq!(breaker.get_state().await, CircuitState::Closed);
breaker.record_failure().await;
assert_eq!(breaker.get_state().await, CircuitState::Open);
assert!(!breaker.allow_request().await);
}
#[tokio::test]
async fn test_circuit_breaker_success_resets_failures() {
let config = CircuitBreakerConfig {
failure_threshold: 3,
..Default::default()
};
let breaker = CircuitBreaker::new("test", config);
breaker.record_failure().await;
breaker.record_failure().await;
breaker.record_success().await;
assert_eq!(breaker.get_state().await, CircuitState::Closed);
}
#[tokio::test]
async fn test_workflow_engine_creation() {
let engine = WorkflowEngine::new();
let templates = engine.list_templates().await;
assert!(templates.is_empty());
}
#[tokio::test]
async fn test_workflow_engine_register_template() {
let engine = WorkflowEngine::new();
let template = WorkflowTemplateFactory::pipeline("Test", "Test pipeline", vec![]);
let template_id = template.id.clone();
engine.register_template(template).await;
let retrieved = engine.get_template(&template_id).await;
assert!(retrieved.is_some());
}
#[tokio::test]
async fn test_workflow_engine_create_instance() {
let engine = WorkflowEngine::new();
let template = WorkflowTemplateFactory::pipeline("Test", "Test pipeline", vec![]);
let template_id = template.id.clone();
engine.register_template(template).await;
let inputs = HashMap::from([("input".to_string(), Value::Str("test".to_string()))]);
let workflow_id = engine.create_instance(&template_id, inputs).await.unwrap();
assert!(!workflow_id.is_empty());
let instance = engine.get_instance(&workflow_id).await;
assert!(instance.is_some());
}
#[tokio::test]
async fn test_workflow_engine_execute_empty_pipeline() {
let engine = WorkflowEngine::new();
let template = WorkflowTemplateFactory::pipeline("Test", "Empty pipeline", vec![]);
let template_id = template.id.clone();
engine.register_template(template).await;
let inputs = HashMap::from([("input".to_string(), Value::Str("test".to_string()))]);
let workflow_id = engine.create_instance(&template_id, inputs).await.unwrap();
let result = engine.execute(&workflow_id).await.unwrap();
assert_eq!(result, Value::Null);
}
#[tokio::test]
async fn test_workflow_engine_cancel() {
let engine = WorkflowEngine::new();
let template = WorkflowTemplateFactory::pipeline("Test", "Test", vec![]);
let template_id = template.id.clone();
engine.register_template(template).await;
let inputs = HashMap::from([("input".to_string(), Value::Str("test".to_string()))]);
let workflow_id = engine.create_instance(&template_id, inputs).await.unwrap();
engine.cancel(&workflow_id).await.unwrap();
let instance = engine.get_instance(&workflow_id).await.unwrap();
assert_eq!(instance.status, WorkflowStatus::Cancelled);
}
#[test]
fn test_gather_strategy_variants() {
let _ = GatherStrategy::WaitAll;
let _ = GatherStrategy::FirstN(3);
let _ = GatherStrategy::BestEffort { timeout_ms: 5000 };
let _ = GatherStrategy::FirstSuccess;
let _ = GatherStrategy::Consensus { threshold: 0.5 };
}
#[test]
fn test_workflow_pattern_equality() {
assert_eq!(WorkflowPattern::MapReduce, WorkflowPattern::MapReduce);
assert_ne!(WorkflowPattern::MapReduce, WorkflowPattern::Saga);
}
#[test]
fn test_step_status_variants() {
assert_eq!(StepStatus::Pending, StepStatus::Pending);
assert_ne!(StepStatus::Running, StepStatus::Completed);
}
#[test]
fn test_workflow_builtins() {
let builtins = workflow_builtins();
assert!(builtins.len() >= 10);
assert!(builtins.iter().any(|(name, _)| *name == "workflow_create"));
assert!(builtins
.iter()
.any(|(name, _)| *name == "workflow_map_reduce"));
assert!(builtins
.iter()
.any(|(name, _)| *name == "circuit_breaker_create"));
}
fn exec_step(id: &str, function: &str, args: Vec<Value>) -> WorkflowStep {
WorkflowStep {
id: id.to_string(),
name: id.to_string(),
step_type: StepType::Execute {
function: function.to_string(),
args,
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: None,
condition: None,
compensate: None,
metadata: HashMap::new(),
}
}
async fn run(engine: &WorkflowEngine, template: WorkflowTemplate) -> Result<Value> {
let id = template.id.clone();
engine.register_template(template).await;
let inputs = HashMap::from([("input".to_string(), Value::Null)]);
let workflow_id = engine.create_instance(&id, inputs).await?;
engine.execute(&workflow_id).await
}
#[tokio::test]
async fn an_execute_step_calls_the_real_builtin() {
let engine = WorkflowEngine::new();
let step = exec_step("upper", "upper", vec![Value::Str("hello".into())]);
let template = WorkflowTemplateFactory::pipeline("Upper", "", vec![step]);
let out = run(&engine, template).await.expect("pipeline runs");
assert_eq!(
out,
Value::Str("HELLO".into()),
"the step must call the builtin, not describe the call"
);
}
#[tokio::test]
async fn a_step_result_is_never_a_placeholder_string() {
let engine = WorkflowEngine::new();
let step = exec_step(
"sum",
"sum",
vec![Value::Array(vec![Value::Int(1), Value::Int(2)])],
);
let template = WorkflowTemplateFactory::pipeline("Sum", "", vec![step]);
let out = run(&engine, template).await.expect("pipeline runs");
assert_eq!(out, Value::Int(3));
if let Value::Str(s) = &out {
assert!(
!s.starts_with("Executed"),
"the stubbed executor is back: {s}"
);
}
}
#[tokio::test]
async fn a_failing_builtin_fails_the_workflow() {
let engine = WorkflowEngine::new();
let step = exec_step("nope", "definitely_not_a_builtin", vec![]);
let template = WorkflowTemplateFactory::pipeline("Bad", "", vec![step]);
let err = run(&engine, template).await.expect_err("must not succeed");
assert!(
err.to_string().contains("definitely_not_a_builtin"),
"the builtin's own error must reach the caller, got: {err}"
);
}
#[tokio::test]
async fn output_mapping_feeds_the_next_step() {
let engine = WorkflowEngine::new();
let mut first = exec_step("first", "upper", vec![Value::Str("ab".into())]);
first.output_mapping = Some("$.shouted".to_string());
let mut second = exec_step("second", "len", vec![]);
second.input_mapping = Some("$.shouted".to_string());
let template = WorkflowTemplateFactory::pipeline("Chain", "", vec![first, second]);
let out = run(&engine, template).await.expect("pipeline runs");
assert_eq!(
out,
Value::Int(2),
"the second step must receive the first step's output through the mapping"
);
}
#[tokio::test]
async fn a_false_condition_skips_the_step() {
let engine = WorkflowEngine::new();
let mut yes = exec_step("yes", "upper", vec![Value::Str("run".into())]);
yes.condition = Some("1 == 1".to_string());
let mut no = exec_step("no", "upper", vec![Value::Str("skipped".into())]);
no.condition = Some("1 == 2".to_string());
let template = WorkflowTemplateFactory::pipeline("Guarded", "", vec![yes, no]);
let out = run(&engine, template).await.expect("pipeline runs");
assert_eq!(
out,
Value::Null,
"the guarded step must not run; a condition that always passed was \
the old behaviour"
);
}
#[tokio::test]
async fn a_condition_reads_the_workflow_variables() {
let engine = WorkflowEngine::new();
let mut step = exec_step("guarded", "upper", vec![Value::Str("ok".into())]);
step.condition = Some("threshold > 5".to_string());
let template = WorkflowTemplateFactory::pipeline("Vars", "", vec![step]);
let id = template.id.clone();
engine.register_template(template).await;
let over = engine
.create_instance(
&id,
HashMap::from([
("input".to_string(), Value::Null),
("threshold".to_string(), Value::Int(10)),
]),
)
.await
.unwrap();
assert_eq!(
engine.execute(&over).await.unwrap(),
Value::Str("OK".into())
);
let under = engine
.create_instance(
&id,
HashMap::from([
("input".to_string(), Value::Null),
("threshold".to_string(), Value::Int(1)),
]),
)
.await
.unwrap();
assert_eq!(engine.execute(&under).await.unwrap(), Value::Null);
}
#[tokio::test]
async fn an_unparseable_condition_is_false_not_true() {
let engine = WorkflowEngine::new();
let ctx = WorkflowContext::default();
assert!(
!engine.evaluate_condition("this is ( not [ syntax", &ctx),
"a guard that cannot be understood must not be treated as satisfied"
);
assert!(
engine.evaluate_condition("", &ctx),
"an empty condition is no condition at all"
);
}
#[tokio::test]
async fn a_branch_takes_the_arm_whose_condition_holds() {
let engine = WorkflowEngine::new();
let step = WorkflowStep {
id: "branch".to_string(),
name: "branch".to_string(),
step_type: StepType::Branch {
conditions: vec![
(
"1 == 2".to_string(),
exec_step("wrong", "upper", vec![Value::Str("wrong".into())]),
),
(
"2 == 2".to_string(),
exec_step("right", "upper", vec![Value::Str("right".into())]),
),
],
default: Some(Box::new(exec_step(
"fallback",
"upper",
vec![Value::Str("fallback".into())],
))),
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: None,
condition: None,
compensate: None,
metadata: HashMap::new(),
};
let template = WorkflowTemplateFactory::pipeline("Branch", "", vec![step]);
let out = run(&engine, template).await.expect("pipeline runs");
assert_eq!(
out,
Value::Str("RIGHT".into()),
"a branch must pick by condition, not always take the first arm"
);
}
#[tokio::test]
async fn parallel_branches_run_the_real_executor() {
let engine = WorkflowEngine::new();
let steps = vec![
exec_step("a", "upper", vec![Value::Str("a".into())]),
exec_step("b", "upper", vec![Value::Str("b".into())]),
exec_step("c", "upper", vec![Value::Str("c".into())]),
];
let template = WorkflowTemplateFactory::fan_out_fan_in(
"Fan",
"",
steps,
exec_step("agg", "len", vec![]),
);
let id = template.id.clone();
engine.register_template(template).await;
let workflow_id = engine
.create_instance(&id, HashMap::from([("input".to_string(), Value::Null)]))
.await
.unwrap();
engine.execute(&workflow_id).await.expect("fan-out runs");
let ctx = engine.get_instance(&workflow_id).await.unwrap().context;
for id in ["a", "b", "c"] {
let output = ctx.results.get(id).and_then(|r| r.output.clone());
assert_eq!(
output,
Some(Value::Str(id.to_uppercase())),
"branch {id} did not reach the real executor"
);
}
}
#[tokio::test]
async fn a_failing_parallel_branch_still_lets_the_others_finish() {
let engine = WorkflowEngine::new();
let steps = vec![
exec_step("ok", "upper", vec![Value::Str("ok".into())]),
exec_step("bad", "definitely_not_a_builtin", vec![]),
];
let template = WorkflowTemplateFactory::fan_out_fan_in(
"Fan",
"",
steps,
exec_step("agg", "len", vec![]),
);
let id = template.id.clone();
engine.register_template(template).await;
let workflow_id = engine
.create_instance(&id, HashMap::from([("input".to_string(), Value::Null)]))
.await
.unwrap();
let result = engine.execute(&workflow_id).await;
assert!(result.is_err(), "a failed branch must fail the workflow");
let ctx = engine.get_instance(&workflow_id).await.unwrap().context;
assert_eq!(
ctx.results.get("ok").map(|r| r.status.clone()),
Some(StepStatus::Completed),
"the healthy branch must still be awaited and recorded"
);
assert_eq!(
ctx.results.get("bad").map(|r| r.status.clone()),
Some(StepStatus::Failed)
);
}
#[tokio::test]
async fn emitting_an_event_delivers_its_payload_to_a_waiting_step() {
let engine = WorkflowEngine::new();
let emit = WorkflowStep {
id: "emit".to_string(),
name: "emit".to_string(),
step_type: StepType::EmitEvent {
event_type: "ready".to_string(),
payload: Value::Int(7),
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: None,
condition: None,
compensate: None,
metadata: HashMap::new(),
};
let wait = WorkflowStep {
id: "wait".to_string(),
name: "wait".to_string(),
step_type: StepType::WaitForEvent {
event_type: "ready".to_string(),
timeout_ms: Some(2000),
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: None,
condition: None,
compensate: None,
metadata: HashMap::new(),
};
let template = WorkflowTemplateFactory::pipeline("Events", "", vec![emit, wait]);
let out = run(&engine, template).await.expect("pipeline runs");
assert_eq!(
out,
Value::Int(7),
"the waiting step must receive the emitted payload"
);
}
#[tokio::test]
async fn waiting_for_an_event_nobody_emits_times_out() {
let engine = WorkflowEngine::new();
let wait = WorkflowStep {
id: "wait".to_string(),
name: "wait".to_string(),
step_type: StepType::WaitForEvent {
event_type: "never".to_string(),
timeout_ms: Some(50),
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: None,
condition: None,
compensate: None,
metadata: HashMap::new(),
};
let template = WorkflowTemplateFactory::pipeline("Waiting", "", vec![wait]);
let err = run(&engine, template).await.expect_err("must time out");
assert!(
err.to_string().contains("timed out"),
"expected a timeout, got: {err}"
);
}
#[tokio::test]
async fn a_step_that_overruns_its_timeout_fails() {
let engine = WorkflowEngine::new();
let mut slow = WorkflowStep {
id: "slow".to_string(),
name: "slow".to_string(),
step_type: StepType::Delay { duration_ms: 5000 },
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: Some(50),
condition: None,
compensate: None,
metadata: HashMap::new(),
};
slow.retry_config = None;
let template = WorkflowTemplateFactory::pipeline("Slow", "", vec![slow]);
let started = Instant::now();
let err = run(&engine, template).await.expect_err("must time out");
assert!(
err.to_string().contains("timeout"),
"expected a step timeout, got: {err}"
);
assert!(
started.elapsed() < Duration::from_secs(4),
"the timeout did not actually cut the step short"
);
}
#[tokio::test]
async fn a_sub_workflow_runs_its_template() {
let engine = WorkflowEngine::new();
let child = WorkflowTemplateFactory::pipeline(
"Child",
"",
vec![exec_step(
"inner",
"upper",
vec![Value::Str("child".into())],
)],
);
let child_id = child.id.clone();
engine.register_template(child).await;
let parent_step = WorkflowStep {
id: "call-child".to_string(),
name: "call-child".to_string(),
step_type: StepType::SubWorkflow {
template_id: child_id,
inputs: HashMap::new(),
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: None,
condition: None,
compensate: None,
metadata: HashMap::new(),
};
let parent = WorkflowTemplateFactory::pipeline("Parent", "", vec![parent_step]);
let out = run(&engine, parent).await.expect("parent runs");
assert_eq!(out, Value::Str("CHILD".into()));
}
#[tokio::test]
async fn a_self_referential_sub_workflow_is_stopped_not_hung() {
let engine = WorkflowEngine::new();
let mut looping = WorkflowTemplateFactory::pipeline("Loop", "", vec![]);
let looping_id = looping.id.clone();
looping.steps = vec![WorkflowStep {
id: "recurse".to_string(),
name: "recurse".to_string(),
step_type: StepType::SubWorkflow {
template_id: looping_id.clone(),
inputs: HashMap::new(),
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: None,
condition: None,
compensate: None,
metadata: HashMap::new(),
}];
engine.register_template(looping).await;
let workflow_id = engine
.create_instance(
&looping_id,
HashMap::from([("input".to_string(), Value::Null)]),
)
.await
.unwrap();
let err = engine
.execute(&workflow_id)
.await
.expect_err("a cycle must be refused, not run until the stack ends");
assert!(
err.to_string().contains("nesting exceeded"),
"expected the depth guard, got: {err}"
);
}
#[tokio::test]
async fn the_context_a_run_produced_survives_the_run() {
let engine = WorkflowEngine::new();
let mut step = exec_step("keep", "upper", vec![Value::Str("kept".into())]);
step.output_mapping = Some("$.kept".to_string());
let template = WorkflowTemplateFactory::pipeline("Keep", "", vec![step]);
let id = template.id.clone();
engine.register_template(template).await;
let workflow_id = engine
.create_instance(&id, HashMap::from([("input".to_string(), Value::Null)]))
.await
.unwrap();
engine.execute(&workflow_id).await.unwrap();
let info = engine.get_instance(&workflow_id).await.unwrap();
assert_eq!(
info.context.variables.get("kept"),
Some(&Value::Str("KEPT".into())),
"execute() worked on a clone and discarded it; the variables a run \
produced must be readable afterwards"
);
assert!(
info.context.results.contains_key("keep"),
"per-step results were discarded too"
);
}
#[tokio::test]
async fn map_reduce_hands_the_mapped_results_to_the_reducer() {
let engine = WorkflowEngine::new();
let mapper = exec_step("map-one", "upper", vec![Value::Str("x".into())]);
let reducer = exec_step("reduce", "len", vec![]);
let template = WorkflowTemplateFactory::map_reduce("MR", "", mapper, reducer);
let id = template.id.clone();
engine.register_template(template).await;
let workflow_id = engine
.create_instance(
&id,
HashMap::from([(
"data".to_string(),
Value::Array(vec![Value::Int(1), Value::Int(2)]),
)]),
)
.await
.unwrap();
engine.execute(&workflow_id).await.expect("map-reduce runs");
let ctx = engine.get_instance(&workflow_id).await.unwrap().context;
assert!(
ctx.variables.contains_key("mapped_results"),
"the map phase must publish through its output_mapping"
);
assert!(
ctx.variables.contains_key("result"),
"the reduce phase must publish through its output_mapping"
);
}
#[test]
fn path_resolution_reads_and_writes_the_variables() {
let mut ctx = WorkflowContext::default();
ctx.variables.insert(
"outer".to_string(),
Value::Record(BTreeMap::from([("inner".to_string(), Value::Int(4))])),
);
assert_eq!(resolve_path(&ctx, "$.outer.inner"), Some(Value::Int(4)));
assert_eq!(resolve_path(&ctx, "$.missing"), None);
assert!(matches!(resolve_path(&ctx, "$"), Some(Value::Record(_))));
store_path(&mut ctx, "$.written", Value::Int(9));
assert_eq!(ctx.variables.get("written"), Some(&Value::Int(9)));
}
#[test]
fn truthiness_matches_the_interpreter() {
assert!(!truthy(&Value::Null));
assert!(!truthy(&Value::Bool(false)));
assert!(!truthy(&Value::Int(0)));
assert!(!truthy(&Value::Str(String::new())));
assert!(!truthy(&Value::Array(vec![])));
assert!(truthy(&Value::Bool(true)));
assert!(truthy(&Value::Int(1)));
assert!(truthy(&Value::Str("x".into())));
assert!(truthy(&Value::Array(vec![Value::Null])));
}
fn http_step_with(method: &str, url: &str) -> WorkflowStep {
WorkflowStep {
id: "http".to_string(),
name: "http".to_string(),
step_type: StepType::Http {
method: method.to_string(),
url: url.to_string(),
body: None,
},
input_mapping: None,
output_mapping: None,
retry_config: None,
timeout_ms: None,
condition: None,
compensate: None,
metadata: HashMap::new(),
}
}
#[tokio::test]
async fn an_http_step_refuses_a_method_it_cannot_issue() {
let engine = WorkflowEngine::new();
let template = WorkflowTemplateFactory::pipeline(
"Http",
"",
vec![http_step_with("TRACE", "https://example.com/")],
);
let err = run(&engine, template).await.expect_err("must be refused");
assert!(
err.to_string().contains("unsupported method"),
"expected the method to be named, got: {err}"
);
}
#[tokio::test]
async fn an_http_step_goes_through_the_network_guard() {
let engine = WorkflowEngine::new();
let template =
WorkflowTemplateFactory::pipeline("Http", "", vec![http_step_with("GET", "-oevil")]);
let err = run(&engine, template).await.expect_err("must be refused");
let msg = err.to_string();
assert!(
msg.contains("option") || msg.contains("E_OPTION") || msg.contains("-oevil"),
"the network guard did not run on this path: {msg}"
);
}
#[tokio::test]
async fn a_saga_compensates_the_steps_that_already_ran() {
let engine = WorkflowEngine::new();
let charge = exec_step("charge", "upper", vec![Value::Str("charged".into())]);
let mut refund = exec_step("refund", "upper", vec![Value::Str("refunded".into())]);
refund.output_mapping = Some("$.compensated".to_string());
let ship = exec_step("ship", "definitely_not_a_builtin", vec![]);
let unship = exec_step("unship", "upper", vec![Value::Str("unshipped".into())]);
let template =
WorkflowTemplateFactory::saga("Order", "", vec![(charge, refund), (ship, unship)]);
let id = template.id.clone();
engine.register_template(template).await;
let workflow_id = engine
.create_instance(&id, HashMap::from([("input".to_string(), Value::Null)]))
.await
.unwrap();
let result = engine.execute(&workflow_id).await;
assert!(result.is_err(), "the saga must fail when a step fails");
let ctx = engine.get_instance(&workflow_id).await.unwrap().context;
assert_eq!(
ctx.variables.get("compensated"),
Some(&Value::Str("REFUNDED".into())),
"the compensation for the completed step must actually have run; \
before the executor was implemented it returned a string and did \
nothing"
);
}
}