1use std::collections::{HashMap, HashSet};
10use std::fmt;
11use std::sync::{Arc, Mutex};
12use std::time::Instant;
13
14use chrono::{DateTime, TimeDelta, Utc};
15use rust_decimal::Decimal;
16use serde_json::{Value, to_value};
17use tokio::spawn;
18use tracing::{error, info, warn};
19use uuid::Uuid;
20
21use ironflow_core::error::OperationError;
22#[cfg(feature = "prometheus")]
23use ironflow_core::metric_names::{
24 RUN_BUDGET_EXCEEDED_TOTAL, RUN_COST_USD, RUN_DURATION_SECONDS, RUNS_ACTIVE, RUNS_TOTAL,
25};
26use ironflow_core::provider::{AgentProvider, LABEL_ROOT_RUN_ID};
27use ironflow_store::error::StoreError;
28use ironflow_store::models::{
29 NewRun, NewSignal, Run, RunActor, RunCreation, RunFilter, RunStatus, RunUpdate, SignalInsert,
30 SignalStepResolution, StepStatus, StepUpdate, TriggerKind,
31};
32use ironflow_store::store::Store;
33#[cfg(feature = "prometheus")]
34use metrics::{counter, gauge, histogram};
35
36use crate::artifact::ArtifactSink;
37use crate::budget::{BudgetConfig, month_start};
38use crate::context::{PARENT_RUN_ID_LABEL, WorkflowContext, interrupt_running_steps};
39use crate::error::EngineError;
40use crate::executor::{StepInterceptor, StepResult};
41use crate::guard::{WorkflowGuardConfig, new_shared_guard_state};
42use crate::handler::{WorkflowHandler, WorkflowInfo};
43use crate::log_sender::LogSender;
44use crate::notify::{
45 ApprovalRequestedEvent, Event, EventPublisher, EventSubscriber, RunBudgetExceededEvent,
46 RunFailedEvent, RunStatusChangedEvent, SignalAwaitedEvent, SignalReceivedEvent,
47 WorkflowEventBus,
48};
49use crate::plan::{
50 ExecutionPlan, PlanOptions, PlanRecorder, SharedPlanRecorder, estimate_durations, lock_plan,
51};
52use crate::retry_policy::{backoff_for_retry, is_run_retryable};
53use crate::schedule::CronSchedule;
54use crate::signal::{
55 Signal, SignalDelivery, SignalRejected, SignalResumed, received_output, validate_step_payload,
56};
57use ironflow_core::decision::DecisionProvider;
58
59#[derive(Debug, Clone)]
78pub struct WorkflowResult {
79 pub run: Run,
81 pub steps: Vec<StepResult>,
83}
84
85#[derive(Debug, Clone, Default)]
104pub struct EnqueueOptions {
105 pub max_retries: u32,
107 pub labels: HashMap<String, String>,
109 pub scheduled_at: Option<DateTime<Utc>>,
112 pub max_cost_usd: Option<Decimal>,
116 pub created_by: Option<RunActor>,
119 pub idempotency_key: Option<String>,
125 pub concurrency_key: Option<String>,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
144pub enum ExecutionMode {
145 #[default]
149 Local,
150 Workers,
153}
154
155pub struct Engine {
196 store: Arc<dyn Store>,
197 provider: Arc<dyn AgentProvider>,
198 handlers: HashMap<String, Arc<dyn WorkflowHandler>>,
199 event_publisher: EventPublisher,
200 log_sender: Option<LogSender>,
201 budget: BudgetConfig,
202 artifact_sink: Option<Arc<dyn ArtifactSink>>,
203 guard_config: Option<WorkflowGuardConfig>,
204 event_bus: Option<WorkflowEventBus>,
205 decision_provider: Option<Arc<dyn DecisionProvider>>,
206 step_interceptor: Option<Arc<dyn StepInterceptor>>,
207 execution_mode: ExecutionMode,
208}
209
210fn validate_category(handler_name: &str, category: &str) -> Result<(), EngineError> {
220 let reject = |reason: &str| {
221 Err(EngineError::InvalidWorkflow(format!(
222 "handler '{handler_name}' has invalid category '{category}': {reason}"
223 )))
224 };
225
226 if category.is_empty() {
227 return reject("empty category");
228 }
229 if category.starts_with('/') {
230 return reject("leading '/'");
231 }
232 if category.ends_with('/') {
233 return reject("trailing '/'");
234 }
235 for segment in category.split('/') {
236 if segment.is_empty() {
237 return reject("empty segment (double '/')");
238 }
239 if segment.trim().is_empty() {
240 return reject("whitespace-only segment");
241 }
242 }
243 Ok(())
244}
245
246fn chain_label(run: &Run, key: &str) -> Option<Uuid> {
251 if !matches!(run.trigger, TriggerKind::Workflow) {
252 return None;
253 }
254 let id = Uuid::parse_str(run.labels.get(key)?).ok()?;
255 (id != run.id).then_some(id)
256}
257
258pub(crate) fn chain_root(run: &Run) -> Option<Uuid> {
263 chain_label(run, LABEL_ROOT_RUN_ID)
264}
265
266fn chain_parent(run: &Run) -> Option<Uuid> {
268 chain_label(run, PARENT_RUN_ID_LABEL)
269}
270
271impl Engine {
272 pub fn new(store: Arc<dyn Store>, provider: Arc<dyn AgentProvider>) -> Self {
288 Self {
289 store,
290 provider,
291 handlers: HashMap::new(),
292 event_publisher: EventPublisher::new(),
293 log_sender: None,
294 budget: BudgetConfig::new(),
295 artifact_sink: None,
296 guard_config: None,
297 event_bus: None,
298 decision_provider: None,
299 step_interceptor: None,
300 execution_mode: ExecutionMode::default(),
301 }
302 }
303
304 pub fn with_decision_provider(mut self, provider: Arc<dyn DecisionProvider>) -> Self {
325 self.decision_provider = Some(provider);
326 self
327 }
328
329 pub fn with_step_interceptor(mut self, interceptor: Arc<dyn StepInterceptor>) -> Self {
353 self.step_interceptor = Some(interceptor);
354 self
355 }
356
357 pub fn step_interceptor(&self) -> Option<&Arc<dyn StepInterceptor>> {
359 self.step_interceptor.as_ref()
360 }
361
362 pub fn with_budget_config(mut self, budget: BudgetConfig) -> Self {
383 self.budget = budget;
384 self
385 }
386
387 pub fn budget_config(&self) -> &BudgetConfig {
389 &self.budget
390 }
391
392 pub fn with_guard_config(mut self, config: WorkflowGuardConfig) -> Self {
414 self.guard_config = Some(config);
415 self
416 }
417
418 pub fn guard_config(&self) -> Option<&WorkflowGuardConfig> {
420 self.guard_config.as_ref()
421 }
422
423 pub fn with_execution_mode(mut self, mode: ExecutionMode) -> Self {
444 self.execution_mode = mode;
445 self
446 }
447
448 pub fn execution_mode(&self) -> ExecutionMode {
450 self.execution_mode
451 }
452
453 pub fn set_log_sender(&mut self, sender: LogSender) {
459 self.log_sender = Some(sender);
460 }
461
462 pub fn set_artifact_sink(&mut self, sink: Arc<dyn ArtifactSink>) {
480 self.artifact_sink = Some(sink);
481 }
482
483 pub fn artifact_sink(&self) -> Option<&Arc<dyn ArtifactSink>> {
485 self.artifact_sink.as_ref()
486 }
487
488 pub fn set_event_bus(&mut self, bus: WorkflowEventBus) {
505 self.event_bus = Some(bus);
506 }
507
508 pub fn event_bus(&self) -> Option<&WorkflowEventBus> {
510 self.event_bus.as_ref()
511 }
512
513 pub fn store(&self) -> &Arc<dyn Store> {
515 &self.store
516 }
517
518 pub fn provider(&self) -> &Arc<dyn AgentProvider> {
520 &self.provider
521 }
522
523 fn build_context(&self, run: &Run) -> WorkflowContext {
532 let handlers = self.handlers.clone();
533 let resolver: crate::context::HandlerResolver =
534 Arc::new(move |name: &str| handlers.get(name).cloned());
535 let mut ctx = WorkflowContext::with_handler_resolver(
536 run.id,
537 run.workflow_name.clone(),
538 self.store.clone(),
539 self.provider.clone(),
540 resolver,
541 );
542 ctx.carry_over_run_totals(run.retry_count + 1, run.cost_usd, run.duration_ms);
543 ctx.set_max_cost_usd(run.max_cost_usd);
544 ctx.set_run_created_at(run.created_at);
545 if let Some(ref sender) = self.log_sender {
546 ctx.set_log_sender(sender.clone());
547 }
548 if let Some(ref sink) = self.artifact_sink {
549 ctx.set_artifact_sink(sink.clone());
550 }
551 if let Some(ref bus) = self.event_bus {
552 ctx.set_event_bus(bus.clone());
553 }
554 if let Some(ref provider) = self.decision_provider {
555 ctx.set_decision_provider(provider.clone());
556 }
557 if let Some(ref interceptor) = self.step_interceptor {
558 ctx.set_step_interceptor(interceptor.clone());
559 }
560 ctx
561 }
562
563 fn build_context_with_guard(
569 &self,
570 run: &Run,
571 handler: &dyn WorkflowHandler,
572 ) -> WorkflowContext {
573 let mut ctx = self.build_context(run);
574 let guard_config = handler.guard_config().or_else(|| self.guard_config.clone());
575 if let Some(config) = guard_config {
576 ctx.set_guard(config, new_shared_guard_state());
577 }
578 ctx
579 }
580
581 async fn check_monthly_quota(&self, workflow_name: &str) -> Result<(), EngineError> {
592 let Some(limit) = self.budget.monthly_cost_limit_usd else {
593 return Ok(());
594 };
595
596 let stats = self
597 .store
598 .get_stats(RunFilter {
599 created_after: Some(month_start(Utc::now())),
600 ..RunFilter::default()
601 })
602 .await?;
603
604 if stats.total_cost_usd < limit {
605 return Ok(());
606 }
607
608 warn!(
609 workflow = %workflow_name,
610 limit_usd = %limit,
611 spent_usd = %stats.total_cost_usd,
612 "monthly cost quota exhausted, refusing new run"
613 );
614
615 #[cfg(feature = "prometheus")]
616 counter!(
617 RUN_BUDGET_EXCEEDED_TOTAL,
618 "workflow" => workflow_name.to_string(),
619 "scope" => "monthly",
620 )
621 .increment(1);
622
623 Err(EngineError::MonthlyBudgetExceeded {
624 limit_usd: limit,
625 spent_usd: stats.total_cost_usd,
626 })
627 }
628
629 pub fn register(&mut self, handler: impl WorkflowHandler + 'static) -> Result<(), EngineError> {
673 let name = handler.name().to_string();
674 if self.handlers.contains_key(&name) {
675 return Err(EngineError::InvalidWorkflow(format!(
676 "handler '{}' already registered",
677 name
678 )));
679 }
680 if let Some(category) = handler.category() {
681 validate_category(&name, category)?;
682 }
683 self.handlers.insert(name, Arc::new(handler));
684 Ok(())
685 }
686
687 pub fn register_boxed(&mut self, handler: Box<dyn WorkflowHandler>) -> Result<(), EngineError> {
694 let name = handler.name().to_string();
695 if self.handlers.contains_key(&name) {
696 return Err(EngineError::InvalidWorkflow(format!(
697 "handler '{}' already registered",
698 name
699 )));
700 }
701 if let Some(category) = handler.category() {
702 validate_category(&name, category)?;
703 }
704 self.handlers.insert(name, Arc::from(handler));
705 Ok(())
706 }
707
708 pub fn get_handler(&self, name: &str) -> Option<&Arc<dyn WorkflowHandler>> {
710 self.handlers.get(name)
711 }
712
713 pub fn handler_names(&self) -> Vec<&str> {
715 self.handlers.keys().map(|s| s.as_str()).collect()
716 }
717
718 pub fn handler_info(&self, name: &str) -> Option<WorkflowInfo> {
720 self.handlers.get(name).map(|h| h.describe())
721 }
722
723 pub fn scheduled_handlers(&self) -> Vec<(&str, &CronSchedule)> {
747 self.handlers
748 .iter()
749 .filter_map(|(name, handler)| handler.schedule().map(|sched| (name.as_str(), sched)))
750 .collect()
751 }
752
753 pub fn subscribe(
778 &mut self,
779 subscriber: impl EventSubscriber + 'static,
780 event_types: &[&'static str],
781 ) {
782 self.event_publisher.subscribe(subscriber, event_types);
783 }
784
785 pub fn event_publisher(&self) -> &EventPublisher {
790 &self.event_publisher
791 }
792
793 #[tracing::instrument(name = "engine.run_handler", skip_all, fields(workflow = %handler_name))]
823 pub async fn run_handler(
824 &self,
825 handler_name: &str,
826 trigger: TriggerKind,
827 payload: Value,
828 ) -> Result<WorkflowResult, EngineError> {
829 let handler = self
830 .handlers
831 .get(handler_name)
832 .ok_or_else(|| {
833 EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
834 })?
835 .clone();
836
837 self.check_monthly_quota(handler_name).await?;
838
839 let handler_version = handler.version().map(str::to_string);
840 let max_cost_usd = self
841 .budget
842 .resolve_run_cap(None, handler.default_max_cost_usd());
843 let run = self
844 .store
845 .create_run(NewRun {
846 created_by: None,
847 workflow_name: handler_name.to_string(),
848 trigger,
849 payload,
850 max_retries: 0,
851 handler_version,
852 labels: handler.default_labels(),
853 scheduled_at: None,
854 idempotency_key: None,
855 concurrency_key: None,
856 max_cost_usd,
857 })
858 .await?
859 .into_run();
860
861 let run_id = run.id;
862 info!(run_id = %run_id, handler_version = run.handler_version.as_deref().unwrap_or(""), "run created");
863
864 self.store
865 .update_run_status(run_id, RunStatus::Running)
866 .await?;
867
868 #[cfg(feature = "prometheus")]
869 gauge!(RUNS_ACTIVE, "workflow" => handler_name.to_string()).increment(1.0);
870
871 let run_start = Instant::now();
872 let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
873
874 let result = handler.execute(&mut ctx).await;
875 self.finalize_run(run_id, handler_name, result, &ctx, run_start, run.labels)
876 .await
877 }
878
879 #[tracing::instrument(name = "engine.plan_handler", skip_all, fields(workflow = %handler_name))]
921 pub async fn plan_handler(
922 &self,
923 handler_name: &str,
924 payload: Value,
925 options: PlanOptions,
926 ) -> Result<ExecutionPlan, EngineError> {
927 if options.max_depth == 0 {
928 return Err(EngineError::InvalidWorkflow(
929 "max_depth must be at least 1".to_string(),
930 ));
931 }
932
933 let handler = self
934 .handlers
935 .get(handler_name)
936 .ok_or_else(|| {
937 EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
938 })?
939 .clone();
940
941 let estimates = if options.estimate_durations {
942 estimate_durations(&self.store, handler_name, options.sample_runs).await?
943 } else {
944 HashMap::new()
945 };
946
947 let shared: SharedPlanRecorder = Arc::new(Mutex::new(PlanRecorder::new(
948 handler_name.to_string(),
949 payload,
950 options.max_depth,
951 estimates,
952 )));
953
954 let handlers = self.handlers.clone();
957 let resolver: crate::context::HandlerResolver =
958 Arc::new(move |name: &str| handlers.get(name).cloned());
959 let mut ctx = WorkflowContext::with_handler_resolver(
960 Uuid::now_v7(),
961 handler_name.to_string(),
962 self.store.clone(),
963 self.provider.clone(),
964 resolver,
965 );
966 ctx.set_plan(shared.clone());
967
968 if let Err(err) = handler.execute(&mut ctx).await {
969 lock_plan(&shared).fail(err.to_string());
970 }
971 drop(ctx);
972
973 let plan = match Arc::try_unwrap(shared) {
974 Ok(mutex) => mutex
975 .into_inner()
976 .unwrap_or_else(|poisoned| poisoned.into_inner())
977 .into_plan(),
978 Err(shared) => lock_plan(&shared).snapshot(),
979 };
980
981 info!(
982 workflow = %handler_name,
983 steps = plan.steps.len(),
984 truncated = plan.truncated,
985 "execution plan built"
986 );
987
988 Ok(plan)
989 }
990
991 #[tracing::instrument(name = "engine.enqueue_handler", skip_all, fields(workflow = %handler_name))]
1002 pub async fn enqueue_handler(
1003 &self,
1004 handler_name: &str,
1005 trigger: TriggerKind,
1006 payload: Value,
1007 max_retries: u32,
1008 ) -> Result<Run, EngineError> {
1009 self.enqueue_handler_with_options(
1010 handler_name,
1011 trigger,
1012 payload,
1013 EnqueueOptions {
1014 max_retries,
1015 ..Default::default()
1016 },
1017 )
1018 .await
1019 .map(RunCreation::into_run)
1020 }
1021
1022 #[tracing::instrument(name = "engine.enqueue_handler_with_options", skip_all, fields(workflow = %handler_name))]
1068 pub async fn enqueue_handler_with_options(
1069 &self,
1070 handler_name: &str,
1071 trigger: TriggerKind,
1072 payload: Value,
1073 options: EnqueueOptions,
1074 ) -> Result<RunCreation, EngineError> {
1075 let EnqueueOptions {
1076 max_retries,
1077 labels,
1078 scheduled_at,
1079 max_cost_usd,
1080 created_by,
1081 idempotency_key,
1082 concurrency_key,
1083 } = options;
1084
1085 let handler = self.handlers.get(handler_name).ok_or_else(|| {
1086 EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
1087 })?;
1088
1089 self.check_monthly_quota(handler_name).await?;
1090
1091 let handler_version = handler.version().map(str::to_string);
1092 let mut merged_labels = handler.default_labels();
1093 merged_labels.extend(labels);
1094 let resolved_cap = self
1095 .budget
1096 .resolve_run_cap(max_cost_usd, handler.default_max_cost_usd());
1097
1098 let creation = self
1099 .store
1100 .create_run(NewRun {
1101 workflow_name: handler_name.to_string(),
1102 trigger,
1103 payload,
1104 max_retries,
1105 handler_version,
1106 labels: merged_labels,
1107 scheduled_at,
1108 created_by,
1109 idempotency_key,
1110 concurrency_key,
1111 max_cost_usd: resolved_cap,
1112 })
1113 .await?;
1114
1115 match &creation {
1116 RunCreation::Created(run) => info!(
1117 run_id = %run.id,
1118 workflow = %handler_name,
1119 max_cost_usd = ?resolved_cap,
1120 "handler run enqueued"
1121 ),
1122 RunCreation::Existing(run) => info!(
1123 run_id = %run.id,
1124 workflow = %handler_name,
1125 "idempotent replay, nothing enqueued"
1126 ),
1127 }
1128
1129 Ok(creation)
1130 }
1131
1132 #[tracing::instrument(name = "engine.execute_handler_run", skip_all, fields(run_id = %run_id))]
1152 pub async fn execute_handler_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
1153 let run = self
1154 .store
1155 .get_run(run_id)
1156 .await?
1157 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
1158
1159 if let Some(root_run_id) = chain_root(&run) {
1160 return self.resume_chain(run_id, root_run_id).await;
1161 }
1162
1163 let handler = self
1164 .handlers
1165 .get(&run.workflow_name)
1166 .ok_or_else(|| {
1167 EngineError::InvalidWorkflow(format!(
1168 "no handler registered: {}",
1169 run.workflow_name
1170 ))
1171 })?
1172 .clone();
1173
1174 #[cfg(feature = "prometheus")]
1175 gauge!(RUNS_ACTIVE, "workflow" => run.workflow_name.clone()).increment(1.0);
1176
1177 let run_start = Instant::now();
1178 let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
1179
1180 let result = if handler.is_version_compatible(run.handler_version.as_deref()) {
1193 ctx.load_replay_steps().await?;
1194 self.release_then_execute(run_id, handler.as_ref(), &mut ctx)
1195 .await
1196 } else {
1197 Err(EngineError::HandlerVersionMismatch {
1198 run_id,
1199 workflow_name: run.workflow_name.clone(),
1200 run_version: run
1201 .handler_version
1202 .clone()
1203 .unwrap_or_else(|| "unknown".to_string()),
1204 current_version: handler
1205 .version()
1206 .map(str::to_string)
1207 .unwrap_or_else(|| "unknown".to_string()),
1208 })
1209 };
1210
1211 self.finalize_run(
1212 run_id,
1213 &run.workflow_name,
1214 result,
1215 &ctx,
1216 run_start,
1217 run.labels,
1218 )
1219 .await
1220 }
1221
1222 #[tracing::instrument(name = "engine.execute_run", skip_all, fields(run_id = %run_id))]
1230 pub async fn execute_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
1231 self.execute_handler_run(run_id).await
1232 }
1233
1234 #[tracing::instrument(name = "engine.resume_run", skip_all, fields(run_id = %run_id))]
1263 pub async fn resume_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
1264 let run = self
1265 .store
1266 .get_run(run_id)
1267 .await?
1268 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
1269
1270 if let Some(root_run_id) = chain_root(&run) {
1271 return self.resume_chain(run_id, root_run_id).await;
1272 }
1273
1274 self.resume_loaded_run(run).await
1275 }
1276
1277 async fn resume_chain(
1285 &self,
1286 child_run_id: Uuid,
1287 root_run_id: Uuid,
1288 ) -> Result<WorkflowResult, EngineError> {
1289 let root = self
1290 .store
1291 .get_run(root_run_id)
1292 .await?
1293 .ok_or(EngineError::Store(StoreError::RunNotFound(root_run_id)))?;
1294
1295 match root.status.state {
1296 RunStatus::AwaitingApproval | RunStatus::Pending => {
1297 self.store
1298 .update_run_status(root_run_id, RunStatus::Running)
1299 .await?;
1300 }
1301 RunStatus::Sleeping => {
1302 self.store
1303 .update_run_status(root_run_id, RunStatus::Pending)
1304 .await?;
1305 self.store
1306 .update_run_status(root_run_id, RunStatus::Running)
1307 .await?;
1308 }
1309 other => {
1310 let reason = format!(
1311 "cannot resume child run {child_run_id}: root run {root_run_id} is {other}"
1312 );
1313 if let Err(err) = self
1314 .fail_or_schedule_retry(child_run_id, &reason, false, None, None)
1315 .await
1316 {
1317 error!(
1318 run_id = %child_run_id,
1319 error = %err,
1320 "failed to fail a child run whose root cannot resume"
1321 );
1322 }
1323 return Err(EngineError::InvalidWorkflow(reason));
1324 }
1325 }
1326
1327 info!(
1328 run_id = %child_run_id,
1329 root_run_id = %root_run_id,
1330 "child run resumed through its root run"
1331 );
1332
1333 let root = self
1334 .store
1335 .get_run(root_run_id)
1336 .await?
1337 .ok_or(EngineError::Store(StoreError::RunNotFound(root_run_id)))?;
1338 self.resume_loaded_run(root).await
1339 }
1340
1341 async fn resume_loaded_run(&self, run: Run) -> Result<WorkflowResult, EngineError> {
1343 let run_id = run.id;
1344 let handler = self
1345 .handlers
1346 .get(&run.workflow_name)
1347 .ok_or_else(|| {
1348 EngineError::InvalidWorkflow(format!(
1349 "no handler registered: {}",
1350 run.workflow_name
1351 ))
1352 })?
1353 .clone();
1354
1355 info!(run_id = %run_id, workflow = %run.workflow_name, "resuming run after approval");
1356
1357 let run_start = Instant::now();
1358 let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
1359
1360 let result = if handler.is_version_compatible(run.handler_version.as_deref()) {
1361 ctx.load_replay_steps().await?;
1362 self.release_then_execute(run_id, handler.as_ref(), &mut ctx)
1363 .await
1364 } else {
1365 Err(EngineError::HandlerVersionMismatch {
1366 run_id,
1367 workflow_name: run.workflow_name.clone(),
1368 run_version: run
1369 .handler_version
1370 .clone()
1371 .unwrap_or_else(|| "unknown".to_string()),
1372 current_version: handler
1373 .version()
1374 .map(str::to_string)
1375 .unwrap_or_else(|| "unknown".to_string()),
1376 })
1377 };
1378
1379 self.finalize_run(
1380 run_id,
1381 &run.workflow_name,
1382 result,
1383 &ctx,
1384 run_start,
1385 run.labels,
1386 )
1387 .await
1388 }
1389
1390 pub async fn deliver_signal(
1436 self: &Arc<Self>,
1437 signal: NewSignal,
1438 ) -> Result<SignalDelivery, EngineError> {
1439 if signal.name.trim().is_empty() {
1440 return Err(EngineError::InvalidSignal(
1441 "signal name must not be empty".to_string(),
1442 ));
1443 }
1444 if signal.key.trim().is_empty() {
1445 return Err(EngineError::InvalidSignal(
1446 "signal key must not be empty".to_string(),
1447 ));
1448 }
1449
1450 let stored = match self.store.insert_signal(signal).await? {
1451 SignalInsert::Created(stored) => stored,
1452 SignalInsert::Duplicate(existing) => {
1453 info!(
1454 signal_id = %existing.id,
1455 signal = %existing.name,
1456 key = %existing.key,
1457 "duplicate signal ignored"
1458 );
1459 return Ok(SignalDelivery {
1460 signal_id: existing.id,
1461 duplicate: true,
1462 resumed: Vec::new(),
1463 rejected: Vec::new(),
1464 });
1465 }
1466 };
1467
1468 let waiters = self
1469 .store
1470 .list_signal_waiters(&stored.name, &stored.key)
1471 .await?;
1472 let mut resumed = Vec::new();
1473 let mut rejected = Vec::new();
1474
1475 for step in waiters {
1476 if let Err(error) = validate_step_payload(step.input.as_ref(), &stored.payload) {
1477 rejected.push(SignalRejected {
1478 run_id: step.run_id,
1479 step_id: step.id,
1480 error,
1481 });
1482 continue;
1483 }
1484
1485 match self
1486 .store
1487 .resolve_signal_step(step.id, received_output(&stored))
1488 .await
1489 {
1490 Ok(SignalStepResolution::Resolved {
1491 run_id,
1492 run_resumed,
1493 }) => {
1494 resumed.push(SignalResumed {
1495 run_id,
1496 step_id: step.id,
1497 });
1498 if run_resumed && self.execution_mode == ExecutionMode::Local {
1499 self.spawn_local_resume(run_id);
1500 }
1501 }
1502 Ok(SignalStepResolution::NotWaiting { .. }) => {}
1504 Err(err) => {
1505 error!(
1506 run_id = %step.run_id,
1507 step_id = %step.id,
1508 error = %err,
1509 "failed to resolve a waiting signal step"
1510 );
1511 rejected.push(SignalRejected {
1512 run_id: step.run_id,
1513 step_id: step.id,
1514 error: err.to_string(),
1515 });
1516 }
1517 }
1518 }
1519
1520 info!(
1521 signal_id = %stored.id,
1522 signal = %stored.name,
1523 key = %stored.key,
1524 resumed = resumed.len(),
1525 rejected = rejected.len(),
1526 "signal received"
1527 );
1528 self.event_publisher
1529 .publish(Event::SignalReceived(SignalReceivedEvent {
1530 signal_id: stored.id,
1531 name: stored.name.clone(),
1532 key: stored.key.clone(),
1533 resumed_runs: resumed.iter().map(|r| r.run_id).collect(),
1534 at: stored.received_at,
1535 }));
1536
1537 Ok(SignalDelivery {
1538 signal_id: stored.id,
1539 duplicate: false,
1540 resumed,
1541 rejected,
1542 })
1543 }
1544
1545 pub async fn send_signal<S: Signal>(
1583 self: &Arc<Self>,
1584 signal: &S,
1585 key: &str,
1586 idempotency_id: Option<&str>,
1587 ) -> Result<SignalDelivery, EngineError> {
1588 let payload = to_value(signal)?;
1589 self.deliver_signal(NewSignal {
1590 name: S::NAME.to_string(),
1591 key: key.to_string(),
1592 payload,
1593 idempotency_id: idempotency_id.map(str::to_string),
1594 })
1595 .await
1596 }
1597
1598 pub(crate) fn spawn_local_resume(self: &Arc<Self>, run_id: Uuid) {
1604 let engine = Arc::clone(self);
1605 spawn(async move {
1606 if let Err(err) = engine
1607 .store
1608 .update_run_status(run_id, RunStatus::Running)
1609 .await
1610 {
1611 error!(run_id = %run_id, error = %err, "failed to restart a woken run");
1612 return;
1613 }
1614 if let Err(err) = engine.resume_run(run_id).await {
1615 error!(run_id = %run_id, error = %err, "failed to resume a woken run");
1616 }
1617 });
1618 }
1619
1620 pub async fn fail_or_schedule_retry(
1665 &self,
1666 run_id: Uuid,
1667 error: &str,
1668 retryable: bool,
1669 cost_usd: Option<Decimal>,
1670 duration_ms: Option<u64>,
1671 ) -> Result<RunStatus, EngineError> {
1672 let run = self
1673 .store
1674 .get_run(run_id)
1675 .await?
1676 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
1677
1678 let has_attempts_left = run.retry_count < run.max_retries;
1679 let update = if retryable && has_attempts_left {
1680 let backoff = backoff_for_retry(run.retry_count);
1681 let scheduled_at = Utc::now() + TimeDelta::milliseconds(backoff.as_millis() as i64);
1682
1683 info!(
1684 run_id = %run_id,
1685 workflow = %run.workflow_name,
1686 attempt = run.retry_count + 1,
1687 max_retries = run.max_retries,
1688 backoff_secs = backoff.as_secs(),
1689 scheduled_at = %scheduled_at,
1690 "run failed, scheduling retry"
1691 );
1692
1693 RunUpdate {
1694 status: Some(RunStatus::Retrying),
1695 error: Some(error.to_string()),
1696 increment_retry: true,
1697 cost_usd,
1698 duration_ms,
1699 scheduled_at: Some(scheduled_at),
1700 ..RunUpdate::default()
1701 }
1702 } else {
1703 RunUpdate {
1704 status: Some(RunStatus::Failed),
1705 error: Some(error.to_string()),
1706 cost_usd,
1707 duration_ms,
1708 completed_at: Some(Utc::now()),
1709 ..RunUpdate::default()
1710 }
1711 };
1712
1713 let status = update.status.unwrap_or(RunStatus::Failed);
1714 self.store.update_run(run_id, update).await?;
1715 self.fail_orphaned_steps(run_id, error).await?;
1716
1717 Ok(status)
1718 }
1719
1720 pub async fn interrupt_running_steps(&self, run_id: Uuid) -> Result<(), EngineError> {
1750 interrupt_running_steps(self.store.as_ref(), run_id).await
1751 }
1752
1753 pub async fn fail_orphaned_steps(
1767 &self,
1768 run_id: Uuid,
1769 error_message: &str,
1770 ) -> Result<(), EngineError> {
1771 let steps = self.store.list_steps(run_id).await?;
1772 let now = Utc::now();
1773
1774 for step in steps {
1775 if step.status.state.is_terminal() {
1776 continue;
1777 }
1778
1779 let (target_status, error) = match step.status.state {
1780 StepStatus::Running | StepStatus::AwaitingApproval => {
1781 let err = if step.error.is_some() {
1782 None
1783 } else {
1784 Some(error_message.to_string())
1785 };
1786 (StepStatus::Failed, err)
1787 }
1788 StepStatus::Pending => (StepStatus::Skipped, None),
1789 _ => continue,
1790 };
1791
1792 if let Err(e) = self
1793 .store
1794 .update_step(
1795 step.id,
1796 StepUpdate {
1797 status: Some(target_status),
1798 error,
1799 completed_at: Some(now),
1800 ..StepUpdate::default()
1801 },
1802 )
1803 .await
1804 {
1805 warn!(
1806 run_id = %run_id,
1807 step_id = %step.id,
1808 step_name = %step.name,
1809 error = %e,
1810 "failed to cleanup orphaned step"
1811 );
1812 } else {
1813 info!(
1814 run_id = %run_id,
1815 step_id = %step.id,
1816 step_name = %step.name,
1817 from = %step.status.state,
1818 to = %target_status,
1819 "cleaned up orphaned step"
1820 );
1821 }
1822 }
1823
1824 Ok(())
1825 }
1826
1827 async fn release_then_execute(
1832 &self,
1833 run_id: Uuid,
1834 handler: &dyn WorkflowHandler,
1835 ctx: &mut WorkflowContext,
1836 ) -> Result<(), EngineError> {
1837 match self.provider.release_run(&run_id.to_string()).await {
1838 Ok(()) => handler.execute(ctx).await,
1839 Err(e) => Err(EngineError::Operation(OperationError::Agent(e))),
1840 }
1841 }
1842
1843 async fn finalize_run(
1849 &self,
1850 run_id: Uuid,
1851 workflow_name: &str,
1852 result: Result<(), EngineError>,
1853 ctx: &WorkflowContext,
1854 run_start: Instant,
1855 run_labels: HashMap<String, String>,
1856 ) -> Result<WorkflowResult, EngineError> {
1857 let total_duration = ctx.carried_duration_ms() + run_start.elapsed().as_millis() as u64;
1860 let completed_at = Utc::now();
1861
1862 let final_status;
1863 let final_run;
1864
1865 match result {
1866 Ok(()) => {
1867 final_status = if ctx.has_allowed_failure() {
1868 RunStatus::Warning
1869 } else {
1870 RunStatus::Completed
1871 };
1872 final_run = self
1873 .store
1874 .update_run_returning(
1875 run_id,
1876 RunUpdate {
1877 status: Some(final_status),
1878 cost_usd: Some(ctx.total_cost_usd()),
1879 duration_ms: Some(total_duration),
1880 completed_at: Some(completed_at),
1881 output: ctx.output().cloned(),
1882 ..RunUpdate::default()
1883 },
1884 )
1885 .await?;
1886
1887 info!(
1888 run_id = %run_id,
1889 status = %final_status,
1890 cost_usd = %ctx.total_cost_usd(),
1891 duration_ms = total_duration,
1892 "run completed"
1893 );
1894 }
1895 Err(EngineError::ApprovalRequired {
1896 run_id: approval_run_id,
1897 step_id,
1898 ref message,
1899 }) => {
1900 final_status = RunStatus::AwaitingApproval;
1901 final_run = self
1902 .store
1903 .update_run_returning(
1904 run_id,
1905 RunUpdate {
1906 status: Some(RunStatus::AwaitingApproval),
1907 cost_usd: Some(ctx.total_cost_usd()),
1908 duration_ms: Some(total_duration),
1909 ..RunUpdate::default()
1910 },
1911 )
1912 .await?;
1913
1914 info!(
1915 run_id = %approval_run_id,
1916 step_id = %step_id,
1917 message = %message,
1918 "run awaiting approval"
1919 );
1920
1921 self.publish_approval_requested(approval_run_id, step_id, message)
1922 .await?;
1923 }
1924 Err(EngineError::ChildSuspended {
1925 run_id: child_run_id,
1926 ref cause,
1927 }) => {
1928 final_status = cause.suspension_status();
1929 final_run = self
1933 .store
1934 .update_run_returning(
1935 run_id,
1936 RunUpdate {
1937 status: Some(final_status),
1938 cost_usd: Some(ctx.total_cost_usd()),
1939 duration_ms: Some(total_duration),
1940 ..RunUpdate::default()
1941 },
1942 )
1943 .await?;
1944
1945 let leaf = cause.suspension_leaf();
1946 info!(
1947 run_id = %run_id,
1948 child_run_id = %child_run_id,
1949 status = %final_status,
1950 cause = %leaf,
1951 "run suspended with its child run"
1952 );
1953
1954 match leaf {
1955 EngineError::ApprovalRequired {
1956 run_id: approval_run_id,
1957 step_id,
1958 message,
1959 } => {
1960 self.publish_approval_requested(*approval_run_id, *step_id, message)
1961 .await?;
1962 }
1963 EngineError::SignalWaiting {
1964 run_id: wait_run_id,
1965 step_id,
1966 step_name,
1967 name,
1968 key,
1969 deadline_at,
1970 } => {
1971 self.event_publisher
1972 .publish(Event::SignalAwaited(SignalAwaitedEvent {
1973 run_id: *wait_run_id,
1974 step_id: *step_id,
1975 step_name: step_name.clone(),
1976 name: name.clone(),
1977 key: key.clone(),
1978 deadline_at: *deadline_at,
1979 at: Utc::now(),
1980 }));
1981 }
1982 _ => {}
1985 }
1986 }
1987 Err(EngineError::HumanInputRequired {
1988 run_id: input_run_id,
1989 step_id,
1990 ref message,
1991 }) => {
1992 final_status = RunStatus::AwaitingApproval;
1993 final_run = self
1994 .store
1995 .update_run_returning(
1996 run_id,
1997 RunUpdate {
1998 status: Some(RunStatus::AwaitingApproval),
1999 cost_usd: Some(ctx.total_cost_usd()),
2000 duration_ms: Some(total_duration),
2001 ..RunUpdate::default()
2002 },
2003 )
2004 .await?;
2005
2006 info!(
2008 run_id = %input_run_id,
2009 step_id = %step_id,
2010 message = %message,
2011 "run awaiting human input"
2012 );
2013 }
2014 Err(EngineError::DelaySleeping {
2015 run_id: delay_run_id,
2016 step_id,
2017 wake_at,
2018 }) => {
2019 final_status = RunStatus::Sleeping;
2020 final_run = self
2021 .store
2022 .update_run_returning(
2023 run_id,
2024 RunUpdate {
2025 status: Some(RunStatus::Sleeping),
2026 cost_usd: Some(ctx.total_cost_usd()),
2027 duration_ms: Some(total_duration),
2028 scheduled_at: Some(wake_at),
2029 ..RunUpdate::default()
2030 },
2031 )
2032 .await?;
2033
2034 info!(
2035 run_id = %delay_run_id,
2036 step_id = %step_id,
2037 wake_at = %wake_at,
2038 "run sleeping until delay elapses"
2039 );
2040 }
2041 Err(EngineError::SignalWaiting {
2042 run_id: wait_run_id,
2043 step_id,
2044 ref step_name,
2045 ref name,
2046 ref key,
2047 deadline_at,
2048 }) => {
2049 final_status = RunStatus::Sleeping;
2050 let waiting = self
2054 .store
2055 .suspend_run_on_signal(run_id, step_id, deadline_at)
2056 .await?;
2057 final_run = self
2058 .store
2059 .update_run_returning(
2060 run_id,
2061 RunUpdate {
2062 cost_usd: Some(ctx.total_cost_usd()),
2063 duration_ms: Some(total_duration),
2064 ..RunUpdate::default()
2065 },
2066 )
2067 .await?;
2068
2069 if waiting {
2070 self.event_publisher
2071 .publish(Event::SignalAwaited(SignalAwaitedEvent {
2072 run_id: wait_run_id,
2073 step_id,
2074 step_name: step_name.clone(),
2075 name: name.clone(),
2076 key: key.clone(),
2077 deadline_at,
2078 at: Utc::now(),
2079 }));
2080 }
2081
2082 info!(
2083 run_id = %wait_run_id,
2084 step_id = %step_id,
2085 signal = %name,
2086 key = %key,
2087 deadline_at = %deadline_at,
2088 waiting,
2089 "run sleeping until a signal arrives"
2090 );
2091 }
2092 Err(err) => {
2093 let guardrail_stop = matches!(
2097 err,
2098 EngineError::RunBudgetExceeded { .. } | EngineError::WorkflowGuardRejected(_)
2099 );
2100
2101 final_status = if guardrail_stop {
2102 if let Err(store_err) = self
2103 .store
2104 .update_run(
2105 run_id,
2106 RunUpdate {
2107 status: Some(RunStatus::Cancelled),
2108 error: Some(err.to_string()),
2109 cost_usd: Some(ctx.total_cost_usd()),
2110 duration_ms: Some(total_duration),
2111 completed_at: Some(completed_at),
2112 output: ctx.output().cloned(),
2113 ..RunUpdate::default()
2114 },
2115 )
2116 .await
2117 {
2118 error!(run_id = %run_id, store_error = %store_err, "failed to persist run cancellation");
2119 }
2120 if let Err(cleanup_err) = self
2121 .fail_orphaned_steps(run_id, "run stopped: guardrail limit reached")
2122 .await
2123 {
2124 error!(run_id = %run_id, store_error = %cleanup_err, "failed to cleanup orphaned steps");
2125 }
2126 RunStatus::Cancelled
2127 } else {
2128 if let Some(output) = ctx.output()
2131 && let Err(store_err) = self
2132 .store
2133 .update_run(
2134 run_id,
2135 RunUpdate {
2136 output: Some(output.clone()),
2137 ..RunUpdate::default()
2138 },
2139 )
2140 .await
2141 {
2142 error!(run_id = %run_id, store_error = %store_err, "failed to persist run output");
2143 }
2144 self.fail_or_schedule_retry(
2145 run_id,
2146 &err.to_string(),
2147 is_run_retryable(&err),
2148 Some(ctx.total_cost_usd()),
2149 Some(total_duration),
2150 )
2151 .await
2152 .unwrap_or_else(|store_err| {
2153 error!(run_id = %run_id, store_error = %store_err, "failed to persist run failure");
2154 RunStatus::Failed
2155 })
2156 };
2157
2158 if matches!(err, EngineError::RunBudgetExceeded { .. }) {
2159 self.on_run_budget_exceeded(workflow_name, run_id, &err);
2160 }
2161
2162 error!(run_id = %run_id, status = %final_status, error = %err, "run stopped");
2163
2164 self.publish_run_status_changed(
2165 workflow_name,
2166 run_id,
2167 final_status,
2168 Some(err.to_string()),
2169 ctx,
2170 total_duration,
2171 run_labels,
2172 );
2173
2174 #[cfg(feature = "prometheus")]
2175 self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
2176
2177 return Err(err);
2178 }
2179 }
2180
2181 self.publish_run_status_changed(
2182 workflow_name,
2183 run_id,
2184 final_status,
2185 None,
2186 ctx,
2187 total_duration,
2188 run_labels,
2189 );
2190
2191 #[cfg(feature = "prometheus")]
2192 self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
2193
2194 Ok(WorkflowResult {
2195 run: final_run,
2196 steps: ctx.step_results().to_vec(),
2197 })
2198 }
2199
2200 async fn publish_approval_requested(
2203 &self,
2204 run_id: Uuid,
2205 step_id: Uuid,
2206 message: &str,
2207 ) -> Result<(), EngineError> {
2208 let requirement = self
2209 .store
2210 .get_step(step_id)
2211 .await?
2212 .and_then(|s| s.approval_requirement);
2213 self.event_publisher
2214 .publish(Event::ApprovalRequested(ApprovalRequestedEvent {
2215 run_id,
2216 step_id,
2217 message: message.to_string(),
2218 requirement,
2219 at: Utc::now(),
2220 }));
2221 Ok(())
2222 }
2223
2224 pub async fn fail_ancestors(&self, run_id: Uuid, reason: &str) -> Result<(), EngineError> {
2253 let mut current = self
2254 .store
2255 .get_run(run_id)
2256 .await?
2257 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
2258 let mut visited = HashSet::from([run_id]);
2260
2261 while let Some(parent_id) = chain_parent(¤t) {
2262 if !visited.insert(parent_id) {
2263 break;
2264 }
2265 let status = self
2266 .fail_or_schedule_retry(parent_id, reason, false, None, None)
2267 .await?;
2268 info!(
2269 run_id = %run_id,
2270 ancestor_run_id = %parent_id,
2271 status = %status,
2272 "ancestor run failed with its child"
2273 );
2274 current = self
2275 .store
2276 .get_run(parent_id)
2277 .await?
2278 .ok_or(EngineError::Store(StoreError::RunNotFound(parent_id)))?;
2279 }
2280
2281 Ok(())
2282 }
2283
2284 #[cfg(feature = "prometheus")]
2286 fn emit_run_metrics(
2287 &self,
2288 workflow_name: &str,
2289 status: RunStatus,
2290 duration_ms: u64,
2291 ctx: &WorkflowContext,
2292 ) {
2293 let status_str = status.to_string();
2294 let wf = workflow_name.to_string();
2295
2296 counter!(RUNS_TOTAL, "workflow" => wf.clone(), "status" => status_str.clone()).increment(1);
2297 histogram!(RUN_DURATION_SECONDS, "workflow" => wf.clone(), "status" => status_str)
2298 .record(duration_ms as f64 / 1000.0);
2299 histogram!(RUN_COST_USD, "workflow" => wf.clone()).record(
2300 ctx.total_cost_usd()
2301 .to_string()
2302 .parse::<f64>()
2303 .unwrap_or(0.0),
2304 );
2305 gauge!(RUNS_ACTIVE, "workflow" => wf).decrement(1.0);
2306 }
2307
2308 fn on_run_budget_exceeded(&self, workflow_name: &str, run_id: Uuid, err: &EngineError) {
2314 let EngineError::RunBudgetExceeded {
2315 limit_usd,
2316 spent_usd,
2317 step_budget_usd,
2318 ..
2319 } = err
2320 else {
2321 return;
2322 };
2323
2324 #[cfg(feature = "prometheus")]
2325 counter!(
2326 RUN_BUDGET_EXCEEDED_TOTAL,
2327 "workflow" => workflow_name.to_string(),
2328 "scope" => "run",
2329 )
2330 .increment(1);
2331
2332 self.event_publisher
2333 .publish(Event::RunBudgetExceeded(RunBudgetExceededEvent {
2334 run_id,
2335 workflow_name: workflow_name.to_string(),
2336 limit_usd: *limit_usd,
2337 spent_usd: *spent_usd,
2338 step_budget_usd: *step_budget_usd,
2339 at: Utc::now(),
2340 }));
2341 }
2342
2343 #[allow(clippy::too_many_arguments)]
2348 fn publish_run_status_changed(
2349 &self,
2350 workflow_name: &str,
2351 run_id: Uuid,
2352 to: RunStatus,
2353 error: Option<String>,
2354 ctx: &WorkflowContext,
2355 duration_ms: u64,
2356 labels: HashMap<String, String>,
2357 ) {
2358 let now = Utc::now();
2359 let cost_usd = ctx.total_cost_usd();
2360 let wf = workflow_name.to_string();
2361
2362 self.event_publisher
2363 .publish(Event::RunStatusChanged(RunStatusChangedEvent {
2364 run_id,
2365 workflow_name: wf.clone(),
2366 from: RunStatus::Running,
2367 to,
2368 error: error.clone(),
2369 cost_usd,
2370 duration_ms,
2371 labels: labels.clone(),
2372 at: now,
2373 }));
2374
2375 if to == RunStatus::Failed {
2376 self.event_publisher
2377 .publish(Event::RunFailed(RunFailedEvent {
2378 run_id,
2379 workflow_name: wf,
2380 error,
2381 cost_usd,
2382 duration_ms,
2383 labels,
2384 at: now,
2385 }));
2386 }
2387 }
2388}
2389
2390impl fmt::Debug for Engine {
2391 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2392 f.debug_struct("Engine")
2393 .field("handlers", &self.handlers.keys().collect::<Vec<_>>())
2394 .finish_non_exhaustive()
2395 }
2396}
2397
2398#[cfg(test)]
2399mod tests {
2400 use super::*;
2401 use crate::config::ShellConfig;
2402 use crate::handler::{HandlerFuture, WorkflowHandler};
2403 use ironflow_core::providers::claude::ClaudeCodeProvider;
2404 use ironflow_core::providers::record_replay::RecordReplayProvider;
2405 use ironflow_store::memory::InMemoryStore;
2406 use ironflow_store::models::StepStatus;
2407 use serde_json::json;
2408
2409 struct EchoWorkflow;
2411
2412 impl WorkflowHandler for EchoWorkflow {
2413 fn name(&self) -> &str {
2414 "echo-workflow"
2415 }
2416
2417 fn describe(&self) -> WorkflowInfo {
2418 WorkflowInfo {
2419 description: "A simple workflow that echoes hello".to_string(),
2420 source_code: None,
2421 sub_workflows: Vec::new(),
2422 category: None,
2423 version: self.version().map(str::to_string),
2424 compatible_versions: Vec::new(),
2425 input_schema: None,
2426 default_labels: HashMap::new(),
2427 schedule: self.schedule().cloned(),
2428 default_max_cost_usd: self.default_max_cost_usd(),
2429 }
2430 }
2431
2432 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2433 Box::pin(async move {
2434 ctx.shell("greet", ShellConfig::new("echo hello")).await?;
2435 Ok(())
2436 })
2437 }
2438 }
2439
2440 struct FailingWorkflow;
2442
2443 impl WorkflowHandler for FailingWorkflow {
2444 fn name(&self) -> &str {
2445 "failing-workflow"
2446 }
2447
2448 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2449 Box::pin(async move {
2450 ctx.shell("fail", ShellConfig::new("exit 1")).await?;
2451 Ok(())
2452 })
2453 }
2454 }
2455
2456 fn create_test_engine() -> Engine {
2457 let store = Arc::new(InMemoryStore::new());
2458 let inner = ClaudeCodeProvider::new();
2459 let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
2460 inner,
2461 "/tmp/ironflow-fixtures",
2462 ));
2463 Engine::new(store, provider)
2464 }
2465
2466 #[test]
2467 fn engine_new_creates_instance() {
2468 let engine = create_test_engine();
2469 assert_eq!(engine.handler_names().len(), 0);
2470 }
2471
2472 #[test]
2473 fn execution_mode_defaults_to_local() {
2474 let engine = create_test_engine();
2475 assert_eq!(engine.execution_mode(), ExecutionMode::Local);
2476 }
2477
2478 #[test]
2479 fn with_execution_mode_overrides_the_default() {
2480 let engine = create_test_engine().with_execution_mode(ExecutionMode::Workers);
2481 assert_eq!(engine.execution_mode(), ExecutionMode::Workers);
2482 }
2483
2484 #[test]
2485 fn engine_register_handler() {
2486 let mut engine = create_test_engine();
2487 let result = engine.register(EchoWorkflow);
2488 assert!(result.is_ok());
2489 assert_eq!(engine.handler_names().len(), 1);
2490 assert!(engine.handler_names().contains(&"echo-workflow"));
2491 }
2492
2493 #[test]
2494 fn engine_register_duplicate_returns_error() {
2495 let mut engine = create_test_engine();
2496 engine.register(EchoWorkflow).unwrap();
2497 let result = engine.register(EchoWorkflow);
2498 assert!(result.is_err());
2499 }
2500
2501 #[test]
2502 fn engine_get_handler_found() {
2503 let mut engine = create_test_engine();
2504 engine.register(EchoWorkflow).unwrap();
2505 let handler = engine.get_handler("echo-workflow");
2506 assert!(handler.is_some());
2507 }
2508
2509 #[test]
2510 fn engine_get_handler_not_found() {
2511 let engine = create_test_engine();
2512 let handler = engine.get_handler("nonexistent");
2513 assert!(handler.is_none());
2514 }
2515
2516 #[test]
2517 fn engine_handler_names_lists_all() {
2518 let mut engine = create_test_engine();
2519 engine.register(EchoWorkflow).unwrap();
2520 engine.register(FailingWorkflow).unwrap();
2521 let names = engine.handler_names();
2522 assert_eq!(names.len(), 2);
2523 assert!(names.contains(&"echo-workflow"));
2524 assert!(names.contains(&"failing-workflow"));
2525 }
2526
2527 #[test]
2528 fn engine_handler_info_returns_description() {
2529 let mut engine = create_test_engine();
2530 engine.register(EchoWorkflow).unwrap();
2531 let info = engine.handler_info("echo-workflow");
2532 assert!(info.is_some());
2533 let info = info.unwrap();
2534 assert_eq!(info.description, "A simple workflow that echoes hello");
2535 }
2536
2537 struct CategorizedWorkflow;
2538
2539 impl WorkflowHandler for CategorizedWorkflow {
2540 fn name(&self) -> &str {
2541 "categorized"
2542 }
2543 fn category(&self) -> Option<&str> {
2544 Some("data/etl")
2545 }
2546 fn execute<'a>(
2547 &'a self,
2548 _ctx: &'a mut WorkflowContext,
2549 ) -> crate::handler::HandlerFuture<'a> {
2550 Box::pin(async move { Ok(()) })
2551 }
2552 }
2553
2554 #[test]
2555 fn engine_default_describe_propagates_category() {
2556 let mut engine = create_test_engine();
2557 engine.register(CategorizedWorkflow).unwrap();
2558 let info = engine.handler_info("categorized").unwrap();
2559 assert_eq!(info.category.as_deref(), Some("data/etl"));
2560 }
2561
2562 #[test]
2563 fn engine_default_describe_without_category() {
2564 let mut engine = create_test_engine();
2565 engine.register(EchoWorkflow).unwrap();
2566 let info = engine.handler_info("echo-workflow").unwrap();
2567 assert!(info.category.is_none());
2568 }
2569
2570 struct ScheduledWorkflow {
2575 schedule: CronSchedule,
2576 }
2577
2578 impl ScheduledWorkflow {
2579 fn new() -> Self {
2580 Self {
2581 schedule: CronSchedule::new("0 0 * * * *").unwrap(),
2582 }
2583 }
2584 }
2585
2586 impl WorkflowHandler for ScheduledWorkflow {
2587 fn name(&self) -> &str {
2588 "scheduled"
2589 }
2590 fn schedule(&self) -> Option<&CronSchedule> {
2591 Some(&self.schedule)
2592 }
2593 fn execute<'a>(
2594 &'a self,
2595 _ctx: &'a mut WorkflowContext,
2596 ) -> crate::handler::HandlerFuture<'a> {
2597 Box::pin(async move { Ok(()) })
2598 }
2599 }
2600
2601 #[test]
2602 fn engine_default_describe_propagates_schedule() {
2603 let mut engine = create_test_engine();
2604 engine.register(ScheduledWorkflow::new()).unwrap();
2605 let info = engine.handler_info("scheduled").unwrap();
2606 assert_eq!(
2607 info.schedule.as_ref().map(|s| s.as_str()),
2608 Some("0 0 * * * *")
2609 );
2610 }
2611
2612 #[test]
2613 fn engine_default_describe_without_schedule() {
2614 let mut engine = create_test_engine();
2615 engine.register(EchoWorkflow).unwrap();
2616 let info = engine.handler_info("echo-workflow").unwrap();
2617 assert!(info.schedule.is_none());
2618 }
2619
2620 #[test]
2621 fn scheduled_handlers_returns_only_scheduled() {
2622 let mut engine = create_test_engine();
2623 engine.register(EchoWorkflow).unwrap();
2624 engine.register(ScheduledWorkflow::new()).unwrap();
2625 engine.register(FailingWorkflow).unwrap();
2626
2627 let scheduled = engine.scheduled_handlers();
2628 assert_eq!(scheduled.len(), 1);
2629 assert_eq!(scheduled[0].0, "scheduled");
2630 assert_eq!(scheduled[0].1.as_str(), "0 0 * * * *");
2631 }
2632
2633 #[test]
2634 fn scheduled_handlers_empty_when_none_scheduled() {
2635 let mut engine = create_test_engine();
2636 engine.register(EchoWorkflow).unwrap();
2637 engine.register(FailingWorkflow).unwrap();
2638
2639 let scheduled = engine.scheduled_handlers();
2640 assert!(scheduled.is_empty());
2641 }
2642
2643 struct BadCategoryWorkflow(&'static str);
2644
2645 impl WorkflowHandler for BadCategoryWorkflow {
2646 fn name(&self) -> &str {
2647 "bad-category"
2648 }
2649 fn category(&self) -> Option<&str> {
2650 Some(self.0)
2651 }
2652 fn execute<'a>(
2653 &'a self,
2654 _ctx: &'a mut WorkflowContext,
2655 ) -> crate::handler::HandlerFuture<'a> {
2656 Box::pin(async move { Ok(()) })
2657 }
2658 }
2659
2660 #[test]
2661 fn engine_register_rejects_empty_category() {
2662 let mut engine = create_test_engine();
2663 let err = engine.register(BadCategoryWorkflow("")).unwrap_err();
2664 match err {
2665 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty category")),
2666 other => panic!("expected InvalidWorkflow, got {other:?}"),
2667 }
2668 }
2669
2670 #[test]
2671 fn engine_register_rejects_leading_slash_category() {
2672 let mut engine = create_test_engine();
2673 let err = engine
2674 .register(BadCategoryWorkflow("/data/etl"))
2675 .unwrap_err();
2676 match err {
2677 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("leading '/'")),
2678 other => panic!("expected InvalidWorkflow, got {other:?}"),
2679 }
2680 }
2681
2682 #[test]
2683 fn engine_register_rejects_trailing_slash_category() {
2684 let mut engine = create_test_engine();
2685 let err = engine
2686 .register(BadCategoryWorkflow("data/etl/"))
2687 .unwrap_err();
2688 match err {
2689 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("trailing '/'")),
2690 other => panic!("expected InvalidWorkflow, got {other:?}"),
2691 }
2692 }
2693
2694 #[test]
2695 fn engine_register_rejects_double_slash_category() {
2696 let mut engine = create_test_engine();
2697 let err = engine
2698 .register(BadCategoryWorkflow("data//etl"))
2699 .unwrap_err();
2700 match err {
2701 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty segment")),
2702 other => panic!("expected InvalidWorkflow, got {other:?}"),
2703 }
2704 }
2705
2706 #[test]
2707 fn engine_register_rejects_whitespace_only_segment_category() {
2708 let mut engine = create_test_engine();
2709 let err = engine
2710 .register(BadCategoryWorkflow("data/ /etl"))
2711 .unwrap_err();
2712 match err {
2713 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("whitespace-only segment")),
2714 other => panic!("expected InvalidWorkflow, got {other:?}"),
2715 }
2716 }
2717
2718 #[test]
2719 fn engine_register_accepts_valid_nested_category() {
2720 let mut engine = create_test_engine();
2721 assert!(engine.register(CategorizedWorkflow).is_ok());
2722 }
2723
2724 #[tokio::test]
2725 async fn engine_unknown_workflow_returns_error() {
2726 let engine = create_test_engine();
2727 let result = engine
2728 .run_handler("unknown", TriggerKind::Manual, json!({}))
2729 .await;
2730 assert!(result.is_err());
2731 match result {
2732 Err(EngineError::InvalidWorkflow(msg)) => {
2733 assert!(msg.contains("no handler registered"));
2734 }
2735 _ => panic!("expected InvalidWorkflow error"),
2736 }
2737 }
2738
2739 #[tokio::test]
2740 async fn engine_enqueue_handler_creates_pending_run() {
2741 let mut engine = create_test_engine();
2742 engine.register(EchoWorkflow).unwrap();
2743
2744 let run = engine
2745 .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
2746 .await
2747 .unwrap();
2748 assert_eq!(run.status.state, RunStatus::Pending);
2749 assert_eq!(run.workflow_name, "echo-workflow");
2750 }
2751
2752 #[tokio::test]
2753 async fn enqueue_handler_leaves_the_run_unattributed() {
2754 let mut engine = create_test_engine();
2755 engine.register(EchoWorkflow).unwrap();
2756
2757 let run = engine
2758 .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
2759 .await
2760 .unwrap();
2761
2762 assert!(run.created_by.is_none());
2763 }
2764
2765 #[tokio::test]
2766 async fn enqueue_handler_with_options_records_the_author() {
2767 let mut engine = create_test_engine();
2768 engine.register(EchoWorkflow).unwrap();
2769 let actor = RunActor::User {
2770 user_id: Uuid::now_v7(),
2771 };
2772
2773 let run = engine
2774 .enqueue_handler_with_options(
2775 "echo-workflow",
2776 TriggerKind::Api,
2777 json!({}),
2778 EnqueueOptions {
2779 created_by: Some(actor.clone()),
2780 ..Default::default()
2781 },
2782 )
2783 .await
2784 .unwrap()
2785 .into_run();
2786
2787 assert_eq!(run.created_by, Some(actor));
2788 }
2789
2790 #[tokio::test]
2791 async fn enqueue_handler_with_options_accepts_no_author() {
2792 let mut engine = create_test_engine();
2793 engine.register(EchoWorkflow).unwrap();
2794
2795 let run = engine
2796 .enqueue_handler_with_options(
2797 "echo-workflow",
2798 TriggerKind::Cron {
2799 schedule: "0 * * * * *".to_string(),
2800 },
2801 json!({}),
2802 EnqueueOptions::default(),
2803 )
2804 .await
2805 .unwrap()
2806 .into_run();
2807
2808 assert!(run.created_by.is_none());
2809 }
2810
2811 #[tokio::test]
2812 async fn run_handler_leaves_the_run_unattributed() {
2813 let mut engine = create_test_engine();
2814 engine.register(EchoWorkflow).unwrap();
2815
2816 let run = engine
2817 .run_handler("echo-workflow", TriggerKind::Manual, json!({}))
2818 .await
2819 .unwrap()
2820 .run;
2821
2822 assert!(run.created_by.is_none());
2823 }
2824
2825 #[tokio::test]
2826 async fn engine_register_boxed() {
2827 let mut engine = create_test_engine();
2828 let handler: Box<dyn WorkflowHandler> = Box::new(EchoWorkflow);
2829 let result = engine.register_boxed(handler);
2830 assert!(result.is_ok());
2831 assert_eq!(engine.handler_names().len(), 1);
2832 }
2833
2834 #[tokio::test]
2835 async fn engine_store_and_provider_accessors() {
2836 let store = Arc::new(InMemoryStore::new());
2837 let inner = ClaudeCodeProvider::new();
2838 let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
2839 inner,
2840 "/tmp/ironflow-fixtures",
2841 ));
2842 let engine = Engine::new(store.clone(), provider.clone());
2843
2844 let _ = engine.store();
2846 let _ = engine.provider();
2847 }
2848
2849 use crate::operation::{Operation, OperationContext};
2854 use async_trait::async_trait;
2855 use ironflow_core::error::OperationError;
2856 use ironflow_store::models::StepKind;
2857
2858 struct FakeGitlabOp {
2859 project_id: u64,
2860 title: String,
2861 }
2862
2863 #[async_trait]
2864 impl Operation for FakeGitlabOp {
2865 fn kind(&self) -> &str {
2866 "gitlab"
2867 }
2868
2869 async fn execute(&self, _ctx: &OperationContext) -> Result<Value, OperationError> {
2870 Ok(json!({
2871 "issue_id": 42,
2872 "project_id": self.project_id,
2873 "title": self.title,
2874 }))
2875 }
2876
2877 fn input(&self) -> Option<Value> {
2878 Some(json!({
2879 "project_id": self.project_id,
2880 "title": self.title,
2881 }))
2882 }
2883 }
2884
2885 struct FailingOp;
2886
2887 #[async_trait]
2888 impl Operation for FailingOp {
2889 fn kind(&self) -> &str {
2890 "broken-service"
2891 }
2892
2893 async fn execute(&self, _ctx: &OperationContext) -> Result<Value, OperationError> {
2894 Err(OperationError::Http {
2895 status: None,
2896 message: "service unavailable".to_string(),
2897 })
2898 }
2899 }
2900
2901 struct OperationWorkflow;
2902
2903 impl WorkflowHandler for OperationWorkflow {
2904 fn name(&self) -> &str {
2905 "operation-workflow"
2906 }
2907
2908 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2909 Box::pin(async move {
2910 let op = FakeGitlabOp {
2911 project_id: 123,
2912 title: "Bug report".to_string(),
2913 };
2914 ctx.operation("create-issue", &op).await?;
2915 Ok(())
2916 })
2917 }
2918 }
2919
2920 struct FailingOperationWorkflow;
2921
2922 impl WorkflowHandler for FailingOperationWorkflow {
2923 fn name(&self) -> &str {
2924 "failing-operation-workflow"
2925 }
2926
2927 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2928 Box::pin(async move {
2929 ctx.operation("broken-call", &FailingOp).await?;
2930 Ok(())
2931 })
2932 }
2933 }
2934
2935 struct MixedWorkflow;
2936
2937 impl WorkflowHandler for MixedWorkflow {
2938 fn name(&self) -> &str {
2939 "mixed-workflow"
2940 }
2941
2942 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2943 Box::pin(async move {
2944 ctx.shell("build", ShellConfig::new("echo built")).await?;
2945 let op = FakeGitlabOp {
2946 project_id: 456,
2947 title: "Deploy done".to_string(),
2948 };
2949 let result = ctx.operation("notify-gitlab", &op).await?;
2950 assert_eq!(result.output["issue_id"], 42);
2951 Ok(())
2952 })
2953 }
2954 }
2955
2956 #[tokio::test]
2957 async fn operation_step_happy_path() {
2958 let mut engine = create_test_engine();
2959 engine.register(OperationWorkflow).unwrap();
2960
2961 let run = engine
2962 .run_handler("operation-workflow", TriggerKind::Manual, json!({}))
2963 .await
2964 .unwrap()
2965 .run;
2966
2967 assert_eq!(run.status.state, RunStatus::Completed);
2968
2969 let steps = engine.store().list_steps(run.id).await.unwrap();
2970
2971 assert_eq!(steps.len(), 1);
2972 assert_eq!(steps[0].name, "create-issue");
2973 assert_eq!(steps[0].kind, StepKind::Custom("gitlab".to_string()));
2974 assert_eq!(
2975 steps[0].status.state,
2976 ironflow_store::models::StepStatus::Completed
2977 );
2978
2979 let output = steps[0].output.as_ref().unwrap();
2980 assert_eq!(output["issue_id"], 42);
2981 assert_eq!(output["project_id"], 123);
2982
2983 let input = steps[0].input.as_ref().unwrap();
2984 assert_eq!(input["project_id"], 123);
2985 assert_eq!(input["title"], "Bug report");
2986 }
2987
2988 #[tokio::test]
2989 async fn operation_step_failure_marks_run_failed() {
2990 let mut engine = create_test_engine();
2991 engine.register(FailingOperationWorkflow).unwrap();
2992
2993 let result = engine
2994 .run_handler("failing-operation-workflow", TriggerKind::Manual, json!({}))
2995 .await;
2996
2997 assert!(result.is_err());
2998 }
2999
3000 #[tokio::test]
3001 async fn operation_mixed_with_shell_steps() {
3002 let mut engine = create_test_engine();
3003 engine.register(MixedWorkflow).unwrap();
3004
3005 let run = engine
3006 .run_handler("mixed-workflow", TriggerKind::Manual, json!({}))
3007 .await
3008 .unwrap()
3009 .run;
3010
3011 assert_eq!(run.status.state, RunStatus::Completed);
3012
3013 let steps = engine.store().list_steps(run.id).await.unwrap();
3014
3015 assert_eq!(steps.len(), 2);
3016 assert_eq!(steps[0].kind, StepKind::Shell);
3017 assert_eq!(steps[1].kind, StepKind::Custom("gitlab".to_string()));
3018 assert_eq!(steps[0].position, 0);
3019 assert_eq!(steps[1].position, 1);
3020 }
3021
3022 use crate::config::ApprovalConfig;
3027
3028 struct SingleApprovalWorkflow;
3029
3030 impl WorkflowHandler for SingleApprovalWorkflow {
3031 fn name(&self) -> &str {
3032 "single-approval"
3033 }
3034
3035 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3036 Box::pin(async move {
3037 ctx.shell("build", ShellConfig::new("echo built")).await?;
3038 ctx.approval("gate", ApprovalConfig::new("OK?")).await?;
3039 ctx.shell("deploy", ShellConfig::new("echo deployed"))
3040 .await?;
3041 Ok(())
3042 })
3043 }
3044 }
3045
3046 struct DoubleApprovalWorkflow;
3047
3048 impl WorkflowHandler for DoubleApprovalWorkflow {
3049 fn name(&self) -> &str {
3050 "double-approval"
3051 }
3052
3053 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3054 Box::pin(async move {
3055 ctx.shell("build", ShellConfig::new("echo built")).await?;
3056 ctx.approval("staging-gate", ApprovalConfig::new("Deploy staging?"))
3057 .await?;
3058 ctx.shell("deploy-staging", ShellConfig::new("echo staging"))
3059 .await?;
3060 ctx.approval("prod-gate", ApprovalConfig::new("Deploy prod?"))
3061 .await?;
3062 ctx.shell("deploy-prod", ShellConfig::new("echo prod"))
3063 .await?;
3064 Ok(())
3065 })
3066 }
3067 }
3068
3069 #[tokio::test]
3070 async fn approval_pauses_run() {
3071 let mut engine = create_test_engine();
3072 engine.register(SingleApprovalWorkflow).unwrap();
3073
3074 let run = engine
3075 .run_handler("single-approval", TriggerKind::Manual, json!({}))
3076 .await
3077 .unwrap()
3078 .run;
3079
3080 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
3081
3082 let steps = engine.store().list_steps(run.id).await.unwrap();
3083 assert_eq!(steps.len(), 2); assert_eq!(steps[0].kind, StepKind::Shell);
3085 assert_eq!(steps[0].status.state, StepStatus::Completed);
3086 assert_eq!(steps[1].kind, StepKind::Approval);
3087 assert_eq!(steps[1].status.state, StepStatus::AwaitingApproval);
3088 }
3089
3090 #[tokio::test]
3091 async fn approval_resume_completes_run() {
3092 let mut engine = create_test_engine();
3093 engine.register(SingleApprovalWorkflow).unwrap();
3094
3095 let run = engine
3097 .run_handler("single-approval", TriggerKind::Manual, json!({}))
3098 .await
3099 .unwrap()
3100 .run;
3101 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
3102
3103 engine
3105 .store()
3106 .update_run_status(run.id, RunStatus::Running)
3107 .await
3108 .unwrap();
3109
3110 let resumed = engine.resume_run(run.id).await.unwrap().run;
3112 assert_eq!(resumed.status.state, RunStatus::Completed);
3113
3114 let steps = engine.store().list_steps(run.id).await.unwrap();
3115 assert_eq!(steps.len(), 3); assert_eq!(steps[0].name, "build");
3117 assert_eq!(steps[0].status.state, StepStatus::Completed);
3118 assert_eq!(steps[1].name, "gate");
3119 assert_eq!(steps[1].kind, StepKind::Approval);
3120 assert_eq!(steps[1].status.state, StepStatus::Completed);
3121 assert_eq!(steps[2].name, "deploy");
3122 assert_eq!(steps[2].status.state, StepStatus::Completed);
3123 }
3124
3125 #[tokio::test]
3126 async fn double_approval_two_resumes() {
3127 let mut engine = create_test_engine();
3128 engine.register(DoubleApprovalWorkflow).unwrap();
3129
3130 let run = engine
3132 .run_handler("double-approval", TriggerKind::Manual, json!({}))
3133 .await
3134 .unwrap()
3135 .run;
3136 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
3137
3138 let steps = engine.store().list_steps(run.id).await.unwrap();
3139 assert_eq!(steps.len(), 2); engine
3143 .store()
3144 .update_run_status(run.id, RunStatus::Running)
3145 .await
3146 .unwrap();
3147
3148 let resumed = engine.resume_run(run.id).await.unwrap().run;
3149 assert_eq!(resumed.status.state, RunStatus::AwaitingApproval);
3150
3151 let steps = engine.store().list_steps(run.id).await.unwrap();
3152 assert_eq!(steps.len(), 4); engine
3156 .store()
3157 .update_run_status(run.id, RunStatus::Running)
3158 .await
3159 .unwrap();
3160
3161 let final_run = engine.resume_run(run.id).await.unwrap().run;
3162 assert_eq!(final_run.status.state, RunStatus::Completed);
3163
3164 let steps = engine.store().list_steps(run.id).await.unwrap();
3165 assert_eq!(steps.len(), 5);
3166 assert_eq!(steps[0].name, "build");
3167 assert_eq!(steps[1].name, "staging-gate");
3168 assert_eq!(steps[2].name, "deploy-staging");
3169 assert_eq!(steps[3].name, "prod-gate");
3170 assert_eq!(steps[4].name, "deploy-prod");
3171
3172 for step in &steps {
3173 assert_eq!(step.status.state, StepStatus::Completed);
3174 }
3175 }
3176
3177 use ironflow_store::models::{NewStep, StepUpdate, step_trace_id};
3182
3183 async fn create_step_with_status(
3184 store: &Arc<dyn Store>,
3185 run_id: Uuid,
3186 name: &str,
3187 position: u32,
3188 status: StepStatus,
3189 ) -> ironflow_store::models::Step {
3190 let step = store
3191 .create_step(NewStep {
3192 run_id,
3193 trace_id: step_trace_id(run_id, name, position),
3194 name: name.to_string(),
3195 kind: StepKind::Shell,
3196 position,
3197 input: None,
3198 is_error_handler: false,
3199 })
3200 .await
3201 .unwrap();
3202
3203 match status {
3204 StepStatus::Pending => {}
3205 StepStatus::Running => {
3206 store
3207 .update_step(
3208 step.id,
3209 StepUpdate {
3210 status: Some(StepStatus::Running),
3211 ..StepUpdate::default()
3212 },
3213 )
3214 .await
3215 .unwrap();
3216 }
3217 StepStatus::Completed => {
3218 store
3219 .update_step(
3220 step.id,
3221 StepUpdate {
3222 status: Some(StepStatus::Running),
3223 ..StepUpdate::default()
3224 },
3225 )
3226 .await
3227 .unwrap();
3228 store
3229 .update_step(
3230 step.id,
3231 StepUpdate {
3232 status: Some(StepStatus::Completed),
3233 ..StepUpdate::default()
3234 },
3235 )
3236 .await
3237 .unwrap();
3238 }
3239 StepStatus::AwaitingApproval => {
3240 store
3241 .update_step(
3242 step.id,
3243 StepUpdate {
3244 status: Some(StepStatus::Running),
3245 ..StepUpdate::default()
3246 },
3247 )
3248 .await
3249 .unwrap();
3250 store
3251 .update_step(
3252 step.id,
3253 StepUpdate {
3254 status: Some(StepStatus::AwaitingApproval),
3255 ..StepUpdate::default()
3256 },
3257 )
3258 .await
3259 .unwrap();
3260 }
3261 _ => panic!("unsupported status for test helper: {status}"),
3262 }
3263
3264 store.get_step(step.id).await.unwrap().unwrap()
3265 }
3266
3267 #[tokio::test]
3268 async fn fail_orphaned_steps_marks_running_as_failed() {
3269 let engine = create_test_engine();
3270 let run = engine
3271 .store()
3272 .create_run(NewRun {
3273 created_by: None,
3274 workflow_name: "test".to_string(),
3275 trigger: TriggerKind::Manual,
3276 payload: json!({}),
3277 max_retries: 0,
3278 handler_version: None,
3279 labels: HashMap::new(),
3280 scheduled_at: None,
3281 idempotency_key: None,
3282 concurrency_key: None,
3283 max_cost_usd: None,
3284 })
3285 .await
3286 .unwrap()
3287 .into_run();
3288
3289 let step = create_step_with_status(
3290 engine.store(),
3291 run.id,
3292 "running-step",
3293 0,
3294 StepStatus::Running,
3295 )
3296 .await;
3297
3298 engine
3299 .fail_orphaned_steps(run.id, "parent run timed out")
3300 .await
3301 .unwrap();
3302
3303 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
3304 assert_eq!(updated.status.state, StepStatus::Failed);
3305 assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
3306 assert!(updated.completed_at.is_some());
3307 }
3308
3309 #[tokio::test]
3310 async fn fail_orphaned_steps_marks_pending_as_skipped() {
3311 let engine = create_test_engine();
3312 let run = engine
3313 .store()
3314 .create_run(NewRun {
3315 created_by: None,
3316 workflow_name: "test".to_string(),
3317 trigger: TriggerKind::Manual,
3318 payload: json!({}),
3319 max_retries: 0,
3320 handler_version: None,
3321 labels: HashMap::new(),
3322 scheduled_at: None,
3323 idempotency_key: None,
3324 concurrency_key: None,
3325 max_cost_usd: None,
3326 })
3327 .await
3328 .unwrap()
3329 .into_run();
3330
3331 let step = create_step_with_status(
3332 engine.store(),
3333 run.id,
3334 "pending-step",
3335 0,
3336 StepStatus::Pending,
3337 )
3338 .await;
3339
3340 engine
3341 .fail_orphaned_steps(run.id, "parent run timed out")
3342 .await
3343 .unwrap();
3344
3345 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
3346 assert_eq!(updated.status.state, StepStatus::Skipped);
3347 assert!(updated.error.is_none());
3348 assert!(updated.completed_at.is_some());
3349 }
3350
3351 #[tokio::test]
3352 async fn fail_orphaned_steps_marks_awaiting_approval_as_failed() {
3353 let engine = create_test_engine();
3354 let run = engine
3355 .store()
3356 .create_run(NewRun {
3357 created_by: None,
3358 workflow_name: "test".to_string(),
3359 trigger: TriggerKind::Manual,
3360 payload: json!({}),
3361 max_retries: 0,
3362 handler_version: None,
3363 labels: HashMap::new(),
3364 scheduled_at: None,
3365 idempotency_key: None,
3366 concurrency_key: None,
3367 max_cost_usd: None,
3368 })
3369 .await
3370 .unwrap()
3371 .into_run();
3372
3373 let step = create_step_with_status(
3374 engine.store(),
3375 run.id,
3376 "approval-step",
3377 0,
3378 StepStatus::AwaitingApproval,
3379 )
3380 .await;
3381
3382 engine
3383 .fail_orphaned_steps(run.id, "parent run timed out")
3384 .await
3385 .unwrap();
3386
3387 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
3388 assert_eq!(updated.status.state, StepStatus::Failed);
3389 assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
3390 assert!(updated.completed_at.is_some());
3391 }
3392
3393 #[tokio::test]
3394 async fn fail_orphaned_steps_skips_terminal_steps() {
3395 let engine = create_test_engine();
3396 let run = engine
3397 .store()
3398 .create_run(NewRun {
3399 created_by: None,
3400 workflow_name: "test".to_string(),
3401 trigger: TriggerKind::Manual,
3402 payload: json!({}),
3403 max_retries: 0,
3404 handler_version: None,
3405 labels: HashMap::new(),
3406 scheduled_at: None,
3407 idempotency_key: None,
3408 concurrency_key: None,
3409 max_cost_usd: None,
3410 })
3411 .await
3412 .unwrap()
3413 .into_run();
3414
3415 let completed_step =
3416 create_step_with_status(engine.store(), run.id, "done", 0, StepStatus::Completed).await;
3417 let running_step =
3418 create_step_with_status(engine.store(), run.id, "in-flight", 1, StepStatus::Running)
3419 .await;
3420
3421 engine
3422 .fail_orphaned_steps(run.id, "parent run timed out")
3423 .await
3424 .unwrap();
3425
3426 let completed = engine
3427 .store()
3428 .get_step(completed_step.id)
3429 .await
3430 .unwrap()
3431 .unwrap();
3432 assert_eq!(completed.status.state, StepStatus::Completed);
3433
3434 let failed = engine
3435 .store()
3436 .get_step(running_step.id)
3437 .await
3438 .unwrap()
3439 .unwrap();
3440 assert_eq!(failed.status.state, StepStatus::Failed);
3441 }
3442
3443 #[tokio::test]
3444 async fn fail_orphaned_steps_mixed_states() {
3445 let engine = create_test_engine();
3446 let run = engine
3447 .store()
3448 .create_run(NewRun {
3449 created_by: None,
3450 workflow_name: "test".to_string(),
3451 trigger: TriggerKind::Manual,
3452 payload: json!({}),
3453 max_retries: 0,
3454 handler_version: None,
3455 labels: HashMap::new(),
3456 scheduled_at: None,
3457 idempotency_key: None,
3458 concurrency_key: None,
3459 max_cost_usd: None,
3460 })
3461 .await
3462 .unwrap()
3463 .into_run();
3464
3465 let s_completed =
3466 create_step_with_status(engine.store(), run.id, "step-1", 0, StepStatus::Completed)
3467 .await;
3468 let s_running =
3469 create_step_with_status(engine.store(), run.id, "step-2", 1, StepStatus::Running).await;
3470 let s_pending =
3471 create_step_with_status(engine.store(), run.id, "step-3", 2, StepStatus::Pending).await;
3472
3473 engine.fail_orphaned_steps(run.id, "timeout").await.unwrap();
3474
3475 let r_completed = engine
3476 .store()
3477 .get_step(s_completed.id)
3478 .await
3479 .unwrap()
3480 .unwrap();
3481 assert_eq!(r_completed.status.state, StepStatus::Completed);
3482
3483 let r_running = engine
3484 .store()
3485 .get_step(s_running.id)
3486 .await
3487 .unwrap()
3488 .unwrap();
3489 assert_eq!(r_running.status.state, StepStatus::Failed);
3490 assert_eq!(r_running.error.as_deref(), Some("timeout"));
3491
3492 let r_pending = engine
3493 .store()
3494 .get_step(s_pending.id)
3495 .await
3496 .unwrap()
3497 .unwrap();
3498 assert_eq!(r_pending.status.state, StepStatus::Skipped);
3499 assert!(r_pending.error.is_none());
3500 }
3501
3502 #[tokio::test]
3503 async fn fail_orphaned_steps_no_steps_is_noop() {
3504 let engine = create_test_engine();
3505 let run = engine
3506 .store()
3507 .create_run(NewRun {
3508 created_by: None,
3509 workflow_name: "test".to_string(),
3510 trigger: TriggerKind::Manual,
3511 payload: json!({}),
3512 max_retries: 0,
3513 handler_version: None,
3514 labels: HashMap::new(),
3515 scheduled_at: None,
3516 idempotency_key: None,
3517 concurrency_key: None,
3518 max_cost_usd: None,
3519 })
3520 .await
3521 .unwrap()
3522 .into_run();
3523
3524 let result = engine.fail_orphaned_steps(run.id, "timeout").await;
3525 assert!(result.is_ok());
3526 }
3527
3528 #[tokio::test]
3529 async fn fail_orphaned_steps_preserves_existing_error() {
3530 let engine = create_test_engine();
3531 let run = engine
3532 .store()
3533 .create_run(NewRun {
3534 created_by: None,
3535 workflow_name: "test".to_string(),
3536 trigger: TriggerKind::Manual,
3537 payload: json!({}),
3538 max_retries: 0,
3539 handler_version: None,
3540 labels: HashMap::new(),
3541 scheduled_at: None,
3542 idempotency_key: None,
3543 concurrency_key: None,
3544 max_cost_usd: None,
3545 })
3546 .await
3547 .unwrap()
3548 .into_run();
3549
3550 let step_with_error = create_step_with_status(
3551 engine.store(),
3552 run.id,
3553 "already-errored",
3554 0,
3555 StepStatus::Running,
3556 )
3557 .await;
3558
3559 engine
3560 .store()
3561 .update_step(
3562 step_with_error.id,
3563 StepUpdate {
3564 error: Some("real error from provider".to_string()),
3565 ..StepUpdate::default()
3566 },
3567 )
3568 .await
3569 .unwrap();
3570
3571 let step_no_error = create_step_with_status(
3572 engine.store(),
3573 run.id,
3574 "no-error-yet",
3575 1,
3576 StepStatus::Running,
3577 )
3578 .await;
3579
3580 engine
3581 .fail_orphaned_steps(run.id, "parent run failed")
3582 .await
3583 .unwrap();
3584
3585 let updated_with = engine
3586 .store()
3587 .get_step(step_with_error.id)
3588 .await
3589 .unwrap()
3590 .unwrap();
3591 assert_eq!(updated_with.status.state, StepStatus::Failed);
3592 assert_eq!(
3593 updated_with.error.as_deref(),
3594 Some("real error from provider"),
3595 );
3596
3597 let updated_without = engine
3598 .store()
3599 .get_step(step_no_error.id)
3600 .await
3601 .unwrap()
3602 .unwrap();
3603 assert_eq!(updated_without.status.state, StepStatus::Failed);
3604 assert_eq!(updated_without.error.as_deref(), Some("parent run failed"),);
3605 }
3606}