1use std::collections::HashMap;
10use std::fmt;
11use std::sync::Arc;
12use std::time::Instant;
13
14use chrono::{DateTime, TimeDelta, Utc};
15use rust_decimal::Decimal;
16use serde_json::Value;
17use tracing::{error, info, warn};
18use uuid::Uuid;
19
20#[cfg(feature = "prometheus")]
21use ironflow_core::metric_names::{
22 RUN_BUDGET_EXCEEDED_TOTAL, RUN_COST_USD, RUN_DURATION_SECONDS, RUNS_ACTIVE, RUNS_TOTAL,
23};
24use ironflow_core::provider::AgentProvider;
25use ironflow_store::error::StoreError;
26use ironflow_store::models::{
27 NewRun, Run, RunActor, RunCreation, RunFilter, RunStatus, RunUpdate, StepStatus, StepUpdate,
28 TriggerKind,
29};
30use ironflow_store::store::Store;
31#[cfg(feature = "prometheus")]
32use metrics::{counter, gauge, histogram};
33
34use crate::artifact::ArtifactSink;
35use crate::budget::{BudgetConfig, month_start};
36use crate::context::WorkflowContext;
37use crate::error::EngineError;
38use crate::executor::StepResult;
39use crate::guard::{WorkflowGuardConfig, new_shared_guard_state};
40use crate::handler::{WorkflowHandler, WorkflowInfo};
41use crate::log_sender::LogSender;
42use crate::notify::{Event, EventPublisher, EventSubscriber, WorkflowEventBus};
43use crate::retry_policy::{backoff_for_retry, is_run_retryable};
44use crate::schedule::CronSchedule;
45
46#[derive(Debug, Clone)]
65pub struct WorkflowResult {
66 pub run: Run,
68 pub steps: Vec<StepResult>,
70}
71
72#[derive(Debug, Clone, Default)]
91pub struct EnqueueOptions {
92 pub max_retries: u32,
94 pub labels: HashMap<String, String>,
96 pub scheduled_at: Option<DateTime<Utc>>,
99 pub max_cost_usd: Option<Decimal>,
103 pub created_by: Option<RunActor>,
106 pub idempotency_key: Option<String>,
112}
113
114pub struct Engine {
155 store: Arc<dyn Store>,
156 provider: Arc<dyn AgentProvider>,
157 handlers: HashMap<String, Arc<dyn WorkflowHandler>>,
158 event_publisher: EventPublisher,
159 log_sender: Option<LogSender>,
160 budget: BudgetConfig,
161 artifact_sink: Option<Arc<dyn ArtifactSink>>,
162 guard_config: Option<WorkflowGuardConfig>,
163 event_bus: Option<WorkflowEventBus>,
164}
165
166fn validate_category(handler_name: &str, category: &str) -> Result<(), EngineError> {
176 let reject = |reason: &str| {
177 Err(EngineError::InvalidWorkflow(format!(
178 "handler '{handler_name}' has invalid category '{category}': {reason}"
179 )))
180 };
181
182 if category.is_empty() {
183 return reject("empty category");
184 }
185 if category.starts_with('/') {
186 return reject("leading '/'");
187 }
188 if category.ends_with('/') {
189 return reject("trailing '/'");
190 }
191 for segment in category.split('/') {
192 if segment.is_empty() {
193 return reject("empty segment (double '/')");
194 }
195 if segment.trim().is_empty() {
196 return reject("whitespace-only segment");
197 }
198 }
199 Ok(())
200}
201
202impl Engine {
203 pub fn new(store: Arc<dyn Store>, provider: Arc<dyn AgentProvider>) -> Self {
219 Self {
220 store,
221 provider,
222 handlers: HashMap::new(),
223 event_publisher: EventPublisher::new(),
224 log_sender: None,
225 budget: BudgetConfig::new(),
226 artifact_sink: None,
227 guard_config: None,
228 event_bus: None,
229 }
230 }
231
232 pub fn with_budget_config(mut self, budget: BudgetConfig) -> Self {
253 self.budget = budget;
254 self
255 }
256
257 pub fn budget_config(&self) -> &BudgetConfig {
259 &self.budget
260 }
261
262 pub fn with_guard_config(mut self, config: WorkflowGuardConfig) -> Self {
284 self.guard_config = Some(config);
285 self
286 }
287
288 pub fn guard_config(&self) -> Option<&WorkflowGuardConfig> {
290 self.guard_config.as_ref()
291 }
292
293 pub fn set_log_sender(&mut self, sender: LogSender) {
299 self.log_sender = Some(sender);
300 }
301
302 pub fn set_artifact_sink(&mut self, sink: Arc<dyn ArtifactSink>) {
320 self.artifact_sink = Some(sink);
321 }
322
323 pub fn artifact_sink(&self) -> Option<&Arc<dyn ArtifactSink>> {
325 self.artifact_sink.as_ref()
326 }
327
328 pub fn set_event_bus(&mut self, bus: WorkflowEventBus) {
345 self.event_bus = Some(bus);
346 }
347
348 pub fn event_bus(&self) -> Option<&WorkflowEventBus> {
350 self.event_bus.as_ref()
351 }
352
353 pub fn store(&self) -> &Arc<dyn Store> {
355 &self.store
356 }
357
358 pub fn provider(&self) -> &Arc<dyn AgentProvider> {
360 &self.provider
361 }
362
363 fn build_context(&self, run: &Run) -> WorkflowContext {
372 let handlers = self.handlers.clone();
373 let resolver: crate::context::HandlerResolver =
374 Arc::new(move |name: &str| handlers.get(name).cloned());
375 let mut ctx = WorkflowContext::with_handler_resolver(
376 run.id,
377 run.workflow_name.clone(),
378 self.store.clone(),
379 self.provider.clone(),
380 resolver,
381 );
382 ctx.carry_over_run_totals(run.retry_count + 1, run.cost_usd, run.duration_ms);
383 ctx.set_max_cost_usd(run.max_cost_usd);
384 if let Some(ref sender) = self.log_sender {
385 ctx.set_log_sender(sender.clone());
386 }
387 if let Some(ref sink) = self.artifact_sink {
388 ctx.set_artifact_sink(sink.clone());
389 }
390 if let Some(ref bus) = self.event_bus {
391 ctx.set_event_bus(bus.clone());
392 }
393 ctx
394 }
395
396 fn build_context_with_guard(
402 &self,
403 run: &Run,
404 handler: &dyn WorkflowHandler,
405 ) -> WorkflowContext {
406 let mut ctx = self.build_context(run);
407 let guard_config = handler.guard_config().or_else(|| self.guard_config.clone());
408 if let Some(config) = guard_config {
409 ctx.set_guard(config, new_shared_guard_state());
410 }
411 ctx
412 }
413
414 async fn check_monthly_quota(&self, workflow_name: &str) -> Result<(), EngineError> {
425 let Some(limit) = self.budget.monthly_cost_limit_usd else {
426 return Ok(());
427 };
428
429 let stats = self
430 .store
431 .get_stats(RunFilter {
432 created_after: Some(month_start(Utc::now())),
433 ..RunFilter::default()
434 })
435 .await?;
436
437 if stats.total_cost_usd < limit {
438 return Ok(());
439 }
440
441 warn!(
442 workflow = %workflow_name,
443 limit_usd = %limit,
444 spent_usd = %stats.total_cost_usd,
445 "monthly cost quota exhausted, refusing new run"
446 );
447
448 #[cfg(feature = "prometheus")]
449 counter!(
450 RUN_BUDGET_EXCEEDED_TOTAL,
451 "workflow" => workflow_name.to_string(),
452 "scope" => "monthly",
453 )
454 .increment(1);
455
456 Err(EngineError::MonthlyBudgetExceeded {
457 limit_usd: limit,
458 spent_usd: stats.total_cost_usd,
459 })
460 }
461
462 pub fn register(&mut self, handler: impl WorkflowHandler + 'static) -> Result<(), EngineError> {
506 let name = handler.name().to_string();
507 if self.handlers.contains_key(&name) {
508 return Err(EngineError::InvalidWorkflow(format!(
509 "handler '{}' already registered",
510 name
511 )));
512 }
513 if let Some(category) = handler.category() {
514 validate_category(&name, category)?;
515 }
516 self.handlers.insert(name, Arc::new(handler));
517 Ok(())
518 }
519
520 pub fn register_boxed(&mut self, handler: Box<dyn WorkflowHandler>) -> Result<(), EngineError> {
527 let name = handler.name().to_string();
528 if self.handlers.contains_key(&name) {
529 return Err(EngineError::InvalidWorkflow(format!(
530 "handler '{}' already registered",
531 name
532 )));
533 }
534 if let Some(category) = handler.category() {
535 validate_category(&name, category)?;
536 }
537 self.handlers.insert(name, Arc::from(handler));
538 Ok(())
539 }
540
541 pub fn get_handler(&self, name: &str) -> Option<&Arc<dyn WorkflowHandler>> {
543 self.handlers.get(name)
544 }
545
546 pub fn handler_names(&self) -> Vec<&str> {
548 self.handlers.keys().map(|s| s.as_str()).collect()
549 }
550
551 pub fn handler_info(&self, name: &str) -> Option<WorkflowInfo> {
553 self.handlers.get(name).map(|h| h.describe())
554 }
555
556 pub fn scheduled_handlers(&self) -> Vec<(&str, &CronSchedule)> {
580 self.handlers
581 .iter()
582 .filter_map(|(name, handler)| handler.schedule().map(|sched| (name.as_str(), sched)))
583 .collect()
584 }
585
586 pub fn subscribe(
611 &mut self,
612 subscriber: impl EventSubscriber + 'static,
613 event_types: &[&'static str],
614 ) {
615 self.event_publisher.subscribe(subscriber, event_types);
616 }
617
618 pub fn event_publisher(&self) -> &EventPublisher {
623 &self.event_publisher
624 }
625
626 #[tracing::instrument(name = "engine.run_handler", skip_all, fields(workflow = %handler_name))]
656 pub async fn run_handler(
657 &self,
658 handler_name: &str,
659 trigger: TriggerKind,
660 payload: Value,
661 ) -> Result<WorkflowResult, EngineError> {
662 let handler = self
663 .handlers
664 .get(handler_name)
665 .ok_or_else(|| {
666 EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
667 })?
668 .clone();
669
670 self.check_monthly_quota(handler_name).await?;
671
672 let handler_version = handler.version().map(str::to_string);
673 let max_cost_usd = self
674 .budget
675 .resolve_run_cap(None, handler.default_max_cost_usd());
676 let run = self
677 .store
678 .create_run(NewRun {
679 created_by: None,
680 workflow_name: handler_name.to_string(),
681 trigger,
682 payload,
683 max_retries: 0,
684 handler_version,
685 labels: handler.default_labels(),
686 scheduled_at: None,
687 idempotency_key: None,
688 max_cost_usd,
689 })
690 .await?
691 .into_run();
692
693 let run_id = run.id;
694 info!(run_id = %run_id, handler_version = run.handler_version.as_deref().unwrap_or(""), "run created");
695
696 self.store
697 .update_run_status(run_id, RunStatus::Running)
698 .await?;
699
700 #[cfg(feature = "prometheus")]
701 gauge!(RUNS_ACTIVE, "workflow" => handler_name.to_string()).increment(1.0);
702
703 let run_start = Instant::now();
704 let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
705
706 let result = handler.execute(&mut ctx).await;
707 self.finalize_run(run_id, handler_name, result, &ctx, run_start, run.labels)
708 .await
709 }
710
711 #[tracing::instrument(name = "engine.enqueue_handler", skip_all, fields(workflow = %handler_name))]
722 pub async fn enqueue_handler(
723 &self,
724 handler_name: &str,
725 trigger: TriggerKind,
726 payload: Value,
727 max_retries: u32,
728 ) -> Result<Run, EngineError> {
729 self.enqueue_handler_with_options(
730 handler_name,
731 trigger,
732 payload,
733 EnqueueOptions {
734 max_retries,
735 ..Default::default()
736 },
737 )
738 .await
739 .map(RunCreation::into_run)
740 }
741
742 #[tracing::instrument(name = "engine.enqueue_handler_with_options", skip_all, fields(workflow = %handler_name))]
787 pub async fn enqueue_handler_with_options(
788 &self,
789 handler_name: &str,
790 trigger: TriggerKind,
791 payload: Value,
792 options: EnqueueOptions,
793 ) -> Result<RunCreation, EngineError> {
794 let EnqueueOptions {
795 max_retries,
796 labels,
797 scheduled_at,
798 max_cost_usd,
799 created_by,
800 idempotency_key,
801 } = options;
802
803 let handler = self.handlers.get(handler_name).ok_or_else(|| {
804 EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
805 })?;
806
807 self.check_monthly_quota(handler_name).await?;
808
809 let handler_version = handler.version().map(str::to_string);
810 let mut merged_labels = handler.default_labels();
811 merged_labels.extend(labels);
812 let resolved_cap = self
813 .budget
814 .resolve_run_cap(max_cost_usd, handler.default_max_cost_usd());
815
816 let creation = self
817 .store
818 .create_run(NewRun {
819 workflow_name: handler_name.to_string(),
820 trigger,
821 payload,
822 max_retries,
823 handler_version,
824 labels: merged_labels,
825 scheduled_at,
826 created_by,
827 idempotency_key,
828 max_cost_usd: resolved_cap,
829 })
830 .await?;
831
832 match &creation {
833 RunCreation::Created(run) => info!(
834 run_id = %run.id,
835 workflow = %handler_name,
836 max_cost_usd = ?resolved_cap,
837 "handler run enqueued"
838 ),
839 RunCreation::Existing(run) => info!(
840 run_id = %run.id,
841 workflow = %handler_name,
842 "idempotent replay, nothing enqueued"
843 ),
844 }
845
846 Ok(creation)
847 }
848
849 #[tracing::instrument(name = "engine.execute_handler_run", skip_all, fields(run_id = %run_id))]
858 pub async fn execute_handler_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
859 let run = self
860 .store
861 .get_run(run_id)
862 .await?
863 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
864
865 let handler = self
866 .handlers
867 .get(&run.workflow_name)
868 .ok_or_else(|| {
869 EngineError::InvalidWorkflow(format!(
870 "no handler registered: {}",
871 run.workflow_name
872 ))
873 })?
874 .clone();
875
876 #[cfg(feature = "prometheus")]
877 gauge!(RUNS_ACTIVE, "workflow" => run.workflow_name.clone()).increment(1.0);
878
879 let run_start = Instant::now();
880 let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
881
882 if run.retry_count > 0 {
885 ctx.load_replay_steps().await?;
886 }
887
888 let result = handler.execute(&mut ctx).await;
889 self.finalize_run(
890 run_id,
891 &run.workflow_name,
892 result,
893 &ctx,
894 run_start,
895 run.labels,
896 )
897 .await
898 }
899
900 #[tracing::instrument(name = "engine.execute_run", skip_all, fields(run_id = %run_id))]
908 pub async fn execute_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
909 self.execute_handler_run(run_id).await
910 }
911
912 #[tracing::instrument(name = "engine.resume_run", skip_all, fields(run_id = %run_id))]
926 pub async fn resume_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
927 let run = self
928 .store
929 .get_run(run_id)
930 .await?
931 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
932
933 let handler = self
934 .handlers
935 .get(&run.workflow_name)
936 .ok_or_else(|| {
937 EngineError::InvalidWorkflow(format!(
938 "no handler registered: {}",
939 run.workflow_name
940 ))
941 })?
942 .clone();
943
944 info!(run_id = %run_id, workflow = %run.workflow_name, "resuming run after approval");
945
946 let run_start = Instant::now();
947 let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
948 ctx.load_replay_steps().await?;
949
950 let result = handler.execute(&mut ctx).await;
951 self.finalize_run(
952 run_id,
953 &run.workflow_name,
954 result,
955 &ctx,
956 run_start,
957 run.labels,
958 )
959 .await
960 }
961
962 pub async fn fail_or_schedule_retry(
1007 &self,
1008 run_id: Uuid,
1009 error: &str,
1010 retryable: bool,
1011 cost_usd: Option<Decimal>,
1012 duration_ms: Option<u64>,
1013 ) -> Result<RunStatus, EngineError> {
1014 let run = self
1015 .store
1016 .get_run(run_id)
1017 .await?
1018 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
1019
1020 let has_attempts_left = run.retry_count < run.max_retries;
1021 let update = if retryable && has_attempts_left {
1022 let backoff = backoff_for_retry(run.retry_count);
1023 let scheduled_at = Utc::now() + TimeDelta::milliseconds(backoff.as_millis() as i64);
1024
1025 info!(
1026 run_id = %run_id,
1027 workflow = %run.workflow_name,
1028 attempt = run.retry_count + 1,
1029 max_retries = run.max_retries,
1030 backoff_secs = backoff.as_secs(),
1031 scheduled_at = %scheduled_at,
1032 "run failed, scheduling retry"
1033 );
1034
1035 RunUpdate {
1036 status: Some(RunStatus::Retrying),
1037 error: Some(error.to_string()),
1038 increment_retry: true,
1039 cost_usd,
1040 duration_ms,
1041 scheduled_at: Some(scheduled_at),
1042 ..RunUpdate::default()
1043 }
1044 } else {
1045 RunUpdate {
1046 status: Some(RunStatus::Failed),
1047 error: Some(error.to_string()),
1048 cost_usd,
1049 duration_ms,
1050 completed_at: Some(Utc::now()),
1051 ..RunUpdate::default()
1052 }
1053 };
1054
1055 let status = update.status.unwrap_or(RunStatus::Failed);
1056 self.store.update_run(run_id, update).await?;
1057 self.fail_orphaned_steps(run_id, error).await?;
1058
1059 Ok(status)
1060 }
1061
1062 pub async fn fail_orphaned_steps(
1076 &self,
1077 run_id: Uuid,
1078 error_message: &str,
1079 ) -> Result<(), EngineError> {
1080 let steps = self.store.list_steps(run_id).await?;
1081 let now = Utc::now();
1082
1083 for step in steps {
1084 if step.status.state.is_terminal() {
1085 continue;
1086 }
1087
1088 let (target_status, error) = match step.status.state {
1089 StepStatus::Running | StepStatus::AwaitingApproval => {
1090 let err = if step.error.is_some() {
1091 None
1092 } else {
1093 Some(error_message.to_string())
1094 };
1095 (StepStatus::Failed, err)
1096 }
1097 StepStatus::Pending => (StepStatus::Skipped, None),
1098 _ => continue,
1099 };
1100
1101 if let Err(e) = self
1102 .store
1103 .update_step(
1104 step.id,
1105 StepUpdate {
1106 status: Some(target_status),
1107 error,
1108 completed_at: Some(now),
1109 ..StepUpdate::default()
1110 },
1111 )
1112 .await
1113 {
1114 warn!(
1115 run_id = %run_id,
1116 step_id = %step.id,
1117 step_name = %step.name,
1118 error = %e,
1119 "failed to cleanup orphaned step"
1120 );
1121 } else {
1122 info!(
1123 run_id = %run_id,
1124 step_id = %step.id,
1125 step_name = %step.name,
1126 from = %step.status.state,
1127 to = %target_status,
1128 "cleaned up orphaned step"
1129 );
1130 }
1131 }
1132
1133 Ok(())
1134 }
1135
1136 async fn finalize_run(
1142 &self,
1143 run_id: Uuid,
1144 workflow_name: &str,
1145 result: Result<(), EngineError>,
1146 ctx: &WorkflowContext,
1147 run_start: Instant,
1148 run_labels: HashMap<String, String>,
1149 ) -> Result<WorkflowResult, EngineError> {
1150 let total_duration = ctx.carried_duration_ms() + run_start.elapsed().as_millis() as u64;
1153 let completed_at = Utc::now();
1154
1155 let final_status;
1156 let final_run;
1157
1158 match result {
1159 Ok(()) => {
1160 final_status = if ctx.has_allowed_failure() {
1161 RunStatus::Warning
1162 } else {
1163 RunStatus::Completed
1164 };
1165 final_run = self
1166 .store
1167 .update_run_returning(
1168 run_id,
1169 RunUpdate {
1170 status: Some(final_status),
1171 cost_usd: Some(ctx.total_cost_usd()),
1172 duration_ms: Some(total_duration),
1173 completed_at: Some(completed_at),
1174 ..RunUpdate::default()
1175 },
1176 )
1177 .await?;
1178
1179 info!(
1180 run_id = %run_id,
1181 status = %final_status,
1182 cost_usd = %ctx.total_cost_usd(),
1183 duration_ms = total_duration,
1184 "run completed"
1185 );
1186 }
1187 Err(EngineError::ApprovalRequired {
1188 run_id: approval_run_id,
1189 step_id,
1190 ref message,
1191 }) => {
1192 final_status = RunStatus::AwaitingApproval;
1193 final_run = self
1194 .store
1195 .update_run_returning(
1196 run_id,
1197 RunUpdate {
1198 status: Some(RunStatus::AwaitingApproval),
1199 cost_usd: Some(ctx.total_cost_usd()),
1200 duration_ms: Some(total_duration),
1201 ..RunUpdate::default()
1202 },
1203 )
1204 .await?;
1205
1206 info!(
1207 run_id = %approval_run_id,
1208 step_id = %step_id,
1209 message = %message,
1210 "run awaiting approval"
1211 );
1212 }
1213 Err(EngineError::DelaySleeping {
1214 run_id: delay_run_id,
1215 step_id,
1216 wake_at,
1217 }) => {
1218 final_status = RunStatus::Sleeping;
1219 final_run = self
1220 .store
1221 .update_run_returning(
1222 run_id,
1223 RunUpdate {
1224 status: Some(RunStatus::Sleeping),
1225 cost_usd: Some(ctx.total_cost_usd()),
1226 duration_ms: Some(total_duration),
1227 scheduled_at: Some(wake_at),
1228 ..RunUpdate::default()
1229 },
1230 )
1231 .await?;
1232
1233 info!(
1234 run_id = %delay_run_id,
1235 step_id = %step_id,
1236 wake_at = %wake_at,
1237 "run sleeping until delay elapses"
1238 );
1239 }
1240 Err(err) => {
1241 let guardrail_stop = matches!(
1245 err,
1246 EngineError::RunBudgetExceeded { .. } | EngineError::WorkflowGuardRejected(_)
1247 );
1248
1249 final_status = if guardrail_stop {
1250 if let Err(store_err) = self
1251 .store
1252 .update_run(
1253 run_id,
1254 RunUpdate {
1255 status: Some(RunStatus::Cancelled),
1256 error: Some(err.to_string()),
1257 cost_usd: Some(ctx.total_cost_usd()),
1258 duration_ms: Some(total_duration),
1259 completed_at: Some(completed_at),
1260 ..RunUpdate::default()
1261 },
1262 )
1263 .await
1264 {
1265 error!(run_id = %run_id, store_error = %store_err, "failed to persist run cancellation");
1266 }
1267 if let Err(cleanup_err) = self
1268 .fail_orphaned_steps(run_id, "run stopped: guardrail limit reached")
1269 .await
1270 {
1271 error!(run_id = %run_id, store_error = %cleanup_err, "failed to cleanup orphaned steps");
1272 }
1273 RunStatus::Cancelled
1274 } else {
1275 self.fail_or_schedule_retry(
1276 run_id,
1277 &err.to_string(),
1278 is_run_retryable(&err),
1279 Some(ctx.total_cost_usd()),
1280 Some(total_duration),
1281 )
1282 .await
1283 .unwrap_or_else(|store_err| {
1284 error!(run_id = %run_id, store_error = %store_err, "failed to persist run failure");
1285 RunStatus::Failed
1286 })
1287 };
1288
1289 if matches!(err, EngineError::RunBudgetExceeded { .. }) {
1290 self.on_run_budget_exceeded(workflow_name, run_id, &err);
1291 }
1292
1293 error!(run_id = %run_id, status = %final_status, error = %err, "run stopped");
1294
1295 self.publish_run_status_changed(
1296 workflow_name,
1297 run_id,
1298 final_status,
1299 Some(err.to_string()),
1300 ctx,
1301 total_duration,
1302 run_labels,
1303 );
1304
1305 #[cfg(feature = "prometheus")]
1306 self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
1307
1308 return Err(err);
1309 }
1310 }
1311
1312 self.publish_run_status_changed(
1313 workflow_name,
1314 run_id,
1315 final_status,
1316 None,
1317 ctx,
1318 total_duration,
1319 run_labels,
1320 );
1321
1322 #[cfg(feature = "prometheus")]
1323 self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
1324
1325 Ok(WorkflowResult {
1326 run: final_run,
1327 steps: ctx.step_results().to_vec(),
1328 })
1329 }
1330
1331 #[cfg(feature = "prometheus")]
1333 fn emit_run_metrics(
1334 &self,
1335 workflow_name: &str,
1336 status: RunStatus,
1337 duration_ms: u64,
1338 ctx: &WorkflowContext,
1339 ) {
1340 let status_str = status.to_string();
1341 let wf = workflow_name.to_string();
1342
1343 counter!(RUNS_TOTAL, "workflow" => wf.clone(), "status" => status_str.clone()).increment(1);
1344 histogram!(RUN_DURATION_SECONDS, "workflow" => wf.clone(), "status" => status_str)
1345 .record(duration_ms as f64 / 1000.0);
1346 histogram!(RUN_COST_USD, "workflow" => wf.clone()).record(
1347 ctx.total_cost_usd()
1348 .to_string()
1349 .parse::<f64>()
1350 .unwrap_or(0.0),
1351 );
1352 gauge!(RUNS_ACTIVE, "workflow" => wf).decrement(1.0);
1353 }
1354
1355 fn on_run_budget_exceeded(&self, workflow_name: &str, run_id: Uuid, err: &EngineError) {
1361 let EngineError::RunBudgetExceeded {
1362 limit_usd,
1363 spent_usd,
1364 step_budget_usd,
1365 ..
1366 } = err
1367 else {
1368 return;
1369 };
1370
1371 #[cfg(feature = "prometheus")]
1372 counter!(
1373 RUN_BUDGET_EXCEEDED_TOTAL,
1374 "workflow" => workflow_name.to_string(),
1375 "scope" => "run",
1376 )
1377 .increment(1);
1378
1379 self.event_publisher.publish(Event::RunBudgetExceeded {
1380 run_id,
1381 workflow_name: workflow_name.to_string(),
1382 limit_usd: *limit_usd,
1383 spent_usd: *spent_usd,
1384 step_budget_usd: *step_budget_usd,
1385 at: Utc::now(),
1386 });
1387 }
1388
1389 #[allow(clippy::too_many_arguments)]
1394 fn publish_run_status_changed(
1395 &self,
1396 workflow_name: &str,
1397 run_id: Uuid,
1398 to: RunStatus,
1399 error: Option<String>,
1400 ctx: &WorkflowContext,
1401 duration_ms: u64,
1402 labels: HashMap<String, String>,
1403 ) {
1404 let now = Utc::now();
1405 let cost_usd = ctx.total_cost_usd();
1406 let wf = workflow_name.to_string();
1407
1408 self.event_publisher.publish(Event::RunStatusChanged {
1409 run_id,
1410 workflow_name: wf.clone(),
1411 from: RunStatus::Running,
1412 to,
1413 error: error.clone(),
1414 cost_usd,
1415 duration_ms,
1416 labels: labels.clone(),
1417 at: now,
1418 });
1419
1420 if to == RunStatus::Failed {
1421 self.event_publisher.publish(Event::RunFailed {
1422 run_id,
1423 workflow_name: wf,
1424 error,
1425 cost_usd,
1426 duration_ms,
1427 labels,
1428 at: now,
1429 });
1430 }
1431 }
1432}
1433
1434impl fmt::Debug for Engine {
1435 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1436 f.debug_struct("Engine")
1437 .field("handlers", &self.handlers.keys().collect::<Vec<_>>())
1438 .finish_non_exhaustive()
1439 }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444 use super::*;
1445 use crate::config::ShellConfig;
1446 use crate::handler::{HandlerFuture, WorkflowHandler};
1447 use ironflow_core::providers::claude::ClaudeCodeProvider;
1448 use ironflow_core::providers::record_replay::RecordReplayProvider;
1449 use ironflow_store::memory::InMemoryStore;
1450 use ironflow_store::models::StepStatus;
1451 use serde_json::json;
1452
1453 struct EchoWorkflow;
1455
1456 impl WorkflowHandler for EchoWorkflow {
1457 fn name(&self) -> &str {
1458 "echo-workflow"
1459 }
1460
1461 fn describe(&self) -> WorkflowInfo {
1462 WorkflowInfo {
1463 description: "A simple workflow that echoes hello".to_string(),
1464 source_code: None,
1465 sub_workflows: Vec::new(),
1466 category: None,
1467 version: self.version().map(str::to_string),
1468 compatible_versions: Vec::new(),
1469 input_schema: None,
1470 default_labels: HashMap::new(),
1471 schedule: self.schedule().cloned(),
1472 default_max_cost_usd: self.default_max_cost_usd(),
1473 }
1474 }
1475
1476 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1477 Box::pin(async move {
1478 ctx.shell("greet", ShellConfig::new("echo hello")).await?;
1479 Ok(())
1480 })
1481 }
1482 }
1483
1484 struct FailingWorkflow;
1486
1487 impl WorkflowHandler for FailingWorkflow {
1488 fn name(&self) -> &str {
1489 "failing-workflow"
1490 }
1491
1492 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1493 Box::pin(async move {
1494 ctx.shell("fail", ShellConfig::new("exit 1")).await?;
1495 Ok(())
1496 })
1497 }
1498 }
1499
1500 fn create_test_engine() -> Engine {
1501 let store = Arc::new(InMemoryStore::new());
1502 let inner = ClaudeCodeProvider::new();
1503 let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
1504 inner,
1505 "/tmp/ironflow-fixtures",
1506 ));
1507 Engine::new(store, provider)
1508 }
1509
1510 #[test]
1511 fn engine_new_creates_instance() {
1512 let engine = create_test_engine();
1513 assert_eq!(engine.handler_names().len(), 0);
1514 }
1515
1516 #[test]
1517 fn engine_register_handler() {
1518 let mut engine = create_test_engine();
1519 let result = engine.register(EchoWorkflow);
1520 assert!(result.is_ok());
1521 assert_eq!(engine.handler_names().len(), 1);
1522 assert!(engine.handler_names().contains(&"echo-workflow"));
1523 }
1524
1525 #[test]
1526 fn engine_register_duplicate_returns_error() {
1527 let mut engine = create_test_engine();
1528 engine.register(EchoWorkflow).unwrap();
1529 let result = engine.register(EchoWorkflow);
1530 assert!(result.is_err());
1531 }
1532
1533 #[test]
1534 fn engine_get_handler_found() {
1535 let mut engine = create_test_engine();
1536 engine.register(EchoWorkflow).unwrap();
1537 let handler = engine.get_handler("echo-workflow");
1538 assert!(handler.is_some());
1539 }
1540
1541 #[test]
1542 fn engine_get_handler_not_found() {
1543 let engine = create_test_engine();
1544 let handler = engine.get_handler("nonexistent");
1545 assert!(handler.is_none());
1546 }
1547
1548 #[test]
1549 fn engine_handler_names_lists_all() {
1550 let mut engine = create_test_engine();
1551 engine.register(EchoWorkflow).unwrap();
1552 engine.register(FailingWorkflow).unwrap();
1553 let names = engine.handler_names();
1554 assert_eq!(names.len(), 2);
1555 assert!(names.contains(&"echo-workflow"));
1556 assert!(names.contains(&"failing-workflow"));
1557 }
1558
1559 #[test]
1560 fn engine_handler_info_returns_description() {
1561 let mut engine = create_test_engine();
1562 engine.register(EchoWorkflow).unwrap();
1563 let info = engine.handler_info("echo-workflow");
1564 assert!(info.is_some());
1565 let info = info.unwrap();
1566 assert_eq!(info.description, "A simple workflow that echoes hello");
1567 }
1568
1569 struct CategorizedWorkflow;
1570
1571 impl WorkflowHandler for CategorizedWorkflow {
1572 fn name(&self) -> &str {
1573 "categorized"
1574 }
1575 fn category(&self) -> Option<&str> {
1576 Some("data/etl")
1577 }
1578 fn execute<'a>(
1579 &'a self,
1580 _ctx: &'a mut WorkflowContext,
1581 ) -> crate::handler::HandlerFuture<'a> {
1582 Box::pin(async move { Ok(()) })
1583 }
1584 }
1585
1586 #[test]
1587 fn engine_default_describe_propagates_category() {
1588 let mut engine = create_test_engine();
1589 engine.register(CategorizedWorkflow).unwrap();
1590 let info = engine.handler_info("categorized").unwrap();
1591 assert_eq!(info.category.as_deref(), Some("data/etl"));
1592 }
1593
1594 #[test]
1595 fn engine_default_describe_without_category() {
1596 let mut engine = create_test_engine();
1597 engine.register(EchoWorkflow).unwrap();
1598 let info = engine.handler_info("echo-workflow").unwrap();
1599 assert!(info.category.is_none());
1600 }
1601
1602 struct ScheduledWorkflow {
1607 schedule: CronSchedule,
1608 }
1609
1610 impl ScheduledWorkflow {
1611 fn new() -> Self {
1612 Self {
1613 schedule: CronSchedule::new("0 0 * * * *").unwrap(),
1614 }
1615 }
1616 }
1617
1618 impl WorkflowHandler for ScheduledWorkflow {
1619 fn name(&self) -> &str {
1620 "scheduled"
1621 }
1622 fn schedule(&self) -> Option<&CronSchedule> {
1623 Some(&self.schedule)
1624 }
1625 fn execute<'a>(
1626 &'a self,
1627 _ctx: &'a mut WorkflowContext,
1628 ) -> crate::handler::HandlerFuture<'a> {
1629 Box::pin(async move { Ok(()) })
1630 }
1631 }
1632
1633 #[test]
1634 fn engine_default_describe_propagates_schedule() {
1635 let mut engine = create_test_engine();
1636 engine.register(ScheduledWorkflow::new()).unwrap();
1637 let info = engine.handler_info("scheduled").unwrap();
1638 assert_eq!(
1639 info.schedule.as_ref().map(|s| s.as_str()),
1640 Some("0 0 * * * *")
1641 );
1642 }
1643
1644 #[test]
1645 fn engine_default_describe_without_schedule() {
1646 let mut engine = create_test_engine();
1647 engine.register(EchoWorkflow).unwrap();
1648 let info = engine.handler_info("echo-workflow").unwrap();
1649 assert!(info.schedule.is_none());
1650 }
1651
1652 #[test]
1653 fn scheduled_handlers_returns_only_scheduled() {
1654 let mut engine = create_test_engine();
1655 engine.register(EchoWorkflow).unwrap();
1656 engine.register(ScheduledWorkflow::new()).unwrap();
1657 engine.register(FailingWorkflow).unwrap();
1658
1659 let scheduled = engine.scheduled_handlers();
1660 assert_eq!(scheduled.len(), 1);
1661 assert_eq!(scheduled[0].0, "scheduled");
1662 assert_eq!(scheduled[0].1.as_str(), "0 0 * * * *");
1663 }
1664
1665 #[test]
1666 fn scheduled_handlers_empty_when_none_scheduled() {
1667 let mut engine = create_test_engine();
1668 engine.register(EchoWorkflow).unwrap();
1669 engine.register(FailingWorkflow).unwrap();
1670
1671 let scheduled = engine.scheduled_handlers();
1672 assert!(scheduled.is_empty());
1673 }
1674
1675 struct BadCategoryWorkflow(&'static str);
1676
1677 impl WorkflowHandler for BadCategoryWorkflow {
1678 fn name(&self) -> &str {
1679 "bad-category"
1680 }
1681 fn category(&self) -> Option<&str> {
1682 Some(self.0)
1683 }
1684 fn execute<'a>(
1685 &'a self,
1686 _ctx: &'a mut WorkflowContext,
1687 ) -> crate::handler::HandlerFuture<'a> {
1688 Box::pin(async move { Ok(()) })
1689 }
1690 }
1691
1692 #[test]
1693 fn engine_register_rejects_empty_category() {
1694 let mut engine = create_test_engine();
1695 let err = engine.register(BadCategoryWorkflow("")).unwrap_err();
1696 match err {
1697 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty category")),
1698 other => panic!("expected InvalidWorkflow, got {other:?}"),
1699 }
1700 }
1701
1702 #[test]
1703 fn engine_register_rejects_leading_slash_category() {
1704 let mut engine = create_test_engine();
1705 let err = engine
1706 .register(BadCategoryWorkflow("/data/etl"))
1707 .unwrap_err();
1708 match err {
1709 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("leading '/'")),
1710 other => panic!("expected InvalidWorkflow, got {other:?}"),
1711 }
1712 }
1713
1714 #[test]
1715 fn engine_register_rejects_trailing_slash_category() {
1716 let mut engine = create_test_engine();
1717 let err = engine
1718 .register(BadCategoryWorkflow("data/etl/"))
1719 .unwrap_err();
1720 match err {
1721 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("trailing '/'")),
1722 other => panic!("expected InvalidWorkflow, got {other:?}"),
1723 }
1724 }
1725
1726 #[test]
1727 fn engine_register_rejects_double_slash_category() {
1728 let mut engine = create_test_engine();
1729 let err = engine
1730 .register(BadCategoryWorkflow("data//etl"))
1731 .unwrap_err();
1732 match err {
1733 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty segment")),
1734 other => panic!("expected InvalidWorkflow, got {other:?}"),
1735 }
1736 }
1737
1738 #[test]
1739 fn engine_register_rejects_whitespace_only_segment_category() {
1740 let mut engine = create_test_engine();
1741 let err = engine
1742 .register(BadCategoryWorkflow("data/ /etl"))
1743 .unwrap_err();
1744 match err {
1745 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("whitespace-only segment")),
1746 other => panic!("expected InvalidWorkflow, got {other:?}"),
1747 }
1748 }
1749
1750 #[test]
1751 fn engine_register_accepts_valid_nested_category() {
1752 let mut engine = create_test_engine();
1753 assert!(engine.register(CategorizedWorkflow).is_ok());
1754 }
1755
1756 #[tokio::test]
1757 async fn engine_unknown_workflow_returns_error() {
1758 let engine = create_test_engine();
1759 let result = engine
1760 .run_handler("unknown", TriggerKind::Manual, json!({}))
1761 .await;
1762 assert!(result.is_err());
1763 match result {
1764 Err(EngineError::InvalidWorkflow(msg)) => {
1765 assert!(msg.contains("no handler registered"));
1766 }
1767 _ => panic!("expected InvalidWorkflow error"),
1768 }
1769 }
1770
1771 #[tokio::test]
1772 async fn engine_enqueue_handler_creates_pending_run() {
1773 let mut engine = create_test_engine();
1774 engine.register(EchoWorkflow).unwrap();
1775
1776 let run = engine
1777 .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
1778 .await
1779 .unwrap();
1780 assert_eq!(run.status.state, RunStatus::Pending);
1781 assert_eq!(run.workflow_name, "echo-workflow");
1782 }
1783
1784 #[tokio::test]
1785 async fn enqueue_handler_leaves_the_run_unattributed() {
1786 let mut engine = create_test_engine();
1787 engine.register(EchoWorkflow).unwrap();
1788
1789 let run = engine
1790 .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
1791 .await
1792 .unwrap();
1793
1794 assert!(run.created_by.is_none());
1795 }
1796
1797 #[tokio::test]
1798 async fn enqueue_handler_with_options_records_the_author() {
1799 let mut engine = create_test_engine();
1800 engine.register(EchoWorkflow).unwrap();
1801 let actor = RunActor::User {
1802 user_id: Uuid::now_v7(),
1803 };
1804
1805 let run = engine
1806 .enqueue_handler_with_options(
1807 "echo-workflow",
1808 TriggerKind::Api,
1809 json!({}),
1810 EnqueueOptions {
1811 created_by: Some(actor.clone()),
1812 ..Default::default()
1813 },
1814 )
1815 .await
1816 .unwrap()
1817 .into_run();
1818
1819 assert_eq!(run.created_by, Some(actor));
1820 }
1821
1822 #[tokio::test]
1823 async fn enqueue_handler_with_options_accepts_no_author() {
1824 let mut engine = create_test_engine();
1825 engine.register(EchoWorkflow).unwrap();
1826
1827 let run = engine
1828 .enqueue_handler_with_options(
1829 "echo-workflow",
1830 TriggerKind::Cron {
1831 schedule: "0 * * * * *".to_string(),
1832 },
1833 json!({}),
1834 EnqueueOptions::default(),
1835 )
1836 .await
1837 .unwrap()
1838 .into_run();
1839
1840 assert!(run.created_by.is_none());
1841 }
1842
1843 #[tokio::test]
1844 async fn run_handler_leaves_the_run_unattributed() {
1845 let mut engine = create_test_engine();
1846 engine.register(EchoWorkflow).unwrap();
1847
1848 let run = engine
1849 .run_handler("echo-workflow", TriggerKind::Manual, json!({}))
1850 .await
1851 .unwrap()
1852 .run;
1853
1854 assert!(run.created_by.is_none());
1855 }
1856
1857 #[tokio::test]
1858 async fn engine_register_boxed() {
1859 let mut engine = create_test_engine();
1860 let handler: Box<dyn WorkflowHandler> = Box::new(EchoWorkflow);
1861 let result = engine.register_boxed(handler);
1862 assert!(result.is_ok());
1863 assert_eq!(engine.handler_names().len(), 1);
1864 }
1865
1866 #[tokio::test]
1867 async fn engine_store_and_provider_accessors() {
1868 let store = Arc::new(InMemoryStore::new());
1869 let inner = ClaudeCodeProvider::new();
1870 let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
1871 inner,
1872 "/tmp/ironflow-fixtures",
1873 ));
1874 let engine = Engine::new(store.clone(), provider.clone());
1875
1876 let _ = engine.store();
1878 let _ = engine.provider();
1879 }
1880
1881 use crate::operation::{Operation, OperationContext};
1886 use async_trait::async_trait;
1887 use ironflow_core::error::OperationError;
1888 use ironflow_store::models::StepKind;
1889
1890 struct FakeGitlabOp {
1891 project_id: u64,
1892 title: String,
1893 }
1894
1895 #[async_trait]
1896 impl Operation for FakeGitlabOp {
1897 fn kind(&self) -> &str {
1898 "gitlab"
1899 }
1900
1901 async fn execute(&self, _ctx: &OperationContext) -> Result<Value, OperationError> {
1902 Ok(json!({
1903 "issue_id": 42,
1904 "project_id": self.project_id,
1905 "title": self.title,
1906 }))
1907 }
1908
1909 fn input(&self) -> Option<Value> {
1910 Some(json!({
1911 "project_id": self.project_id,
1912 "title": self.title,
1913 }))
1914 }
1915 }
1916
1917 struct FailingOp;
1918
1919 #[async_trait]
1920 impl Operation for FailingOp {
1921 fn kind(&self) -> &str {
1922 "broken-service"
1923 }
1924
1925 async fn execute(&self, _ctx: &OperationContext) -> Result<Value, OperationError> {
1926 Err(OperationError::Http {
1927 status: None,
1928 message: "service unavailable".to_string(),
1929 })
1930 }
1931 }
1932
1933 struct OperationWorkflow;
1934
1935 impl WorkflowHandler for OperationWorkflow {
1936 fn name(&self) -> &str {
1937 "operation-workflow"
1938 }
1939
1940 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1941 Box::pin(async move {
1942 let op = FakeGitlabOp {
1943 project_id: 123,
1944 title: "Bug report".to_string(),
1945 };
1946 ctx.operation("create-issue", &op).await?;
1947 Ok(())
1948 })
1949 }
1950 }
1951
1952 struct FailingOperationWorkflow;
1953
1954 impl WorkflowHandler for FailingOperationWorkflow {
1955 fn name(&self) -> &str {
1956 "failing-operation-workflow"
1957 }
1958
1959 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1960 Box::pin(async move {
1961 ctx.operation("broken-call", &FailingOp).await?;
1962 Ok(())
1963 })
1964 }
1965 }
1966
1967 struct MixedWorkflow;
1968
1969 impl WorkflowHandler for MixedWorkflow {
1970 fn name(&self) -> &str {
1971 "mixed-workflow"
1972 }
1973
1974 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1975 Box::pin(async move {
1976 ctx.shell("build", ShellConfig::new("echo built")).await?;
1977 let op = FakeGitlabOp {
1978 project_id: 456,
1979 title: "Deploy done".to_string(),
1980 };
1981 let result = ctx.operation("notify-gitlab", &op).await?;
1982 assert_eq!(result.output["issue_id"], 42);
1983 Ok(())
1984 })
1985 }
1986 }
1987
1988 #[tokio::test]
1989 async fn operation_step_happy_path() {
1990 let mut engine = create_test_engine();
1991 engine.register(OperationWorkflow).unwrap();
1992
1993 let run = engine
1994 .run_handler("operation-workflow", TriggerKind::Manual, json!({}))
1995 .await
1996 .unwrap()
1997 .run;
1998
1999 assert_eq!(run.status.state, RunStatus::Completed);
2000
2001 let steps = engine.store().list_steps(run.id).await.unwrap();
2002
2003 assert_eq!(steps.len(), 1);
2004 assert_eq!(steps[0].name, "create-issue");
2005 assert_eq!(steps[0].kind, StepKind::Custom("gitlab".to_string()));
2006 assert_eq!(
2007 steps[0].status.state,
2008 ironflow_store::models::StepStatus::Completed
2009 );
2010
2011 let output = steps[0].output.as_ref().unwrap();
2012 assert_eq!(output["issue_id"], 42);
2013 assert_eq!(output["project_id"], 123);
2014
2015 let input = steps[0].input.as_ref().unwrap();
2016 assert_eq!(input["project_id"], 123);
2017 assert_eq!(input["title"], "Bug report");
2018 }
2019
2020 #[tokio::test]
2021 async fn operation_step_failure_marks_run_failed() {
2022 let mut engine = create_test_engine();
2023 engine.register(FailingOperationWorkflow).unwrap();
2024
2025 let result = engine
2026 .run_handler("failing-operation-workflow", TriggerKind::Manual, json!({}))
2027 .await;
2028
2029 assert!(result.is_err());
2030 }
2031
2032 #[tokio::test]
2033 async fn operation_mixed_with_shell_steps() {
2034 let mut engine = create_test_engine();
2035 engine.register(MixedWorkflow).unwrap();
2036
2037 let run = engine
2038 .run_handler("mixed-workflow", TriggerKind::Manual, json!({}))
2039 .await
2040 .unwrap()
2041 .run;
2042
2043 assert_eq!(run.status.state, RunStatus::Completed);
2044
2045 let steps = engine.store().list_steps(run.id).await.unwrap();
2046
2047 assert_eq!(steps.len(), 2);
2048 assert_eq!(steps[0].kind, StepKind::Shell);
2049 assert_eq!(steps[1].kind, StepKind::Custom("gitlab".to_string()));
2050 assert_eq!(steps[0].position, 0);
2051 assert_eq!(steps[1].position, 1);
2052 }
2053
2054 use crate::config::ApprovalConfig;
2059
2060 struct SingleApprovalWorkflow;
2061
2062 impl WorkflowHandler for SingleApprovalWorkflow {
2063 fn name(&self) -> &str {
2064 "single-approval"
2065 }
2066
2067 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2068 Box::pin(async move {
2069 ctx.shell("build", ShellConfig::new("echo built")).await?;
2070 ctx.approval("gate", ApprovalConfig::new("OK?")).await?;
2071 ctx.shell("deploy", ShellConfig::new("echo deployed"))
2072 .await?;
2073 Ok(())
2074 })
2075 }
2076 }
2077
2078 struct DoubleApprovalWorkflow;
2079
2080 impl WorkflowHandler for DoubleApprovalWorkflow {
2081 fn name(&self) -> &str {
2082 "double-approval"
2083 }
2084
2085 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2086 Box::pin(async move {
2087 ctx.shell("build", ShellConfig::new("echo built")).await?;
2088 ctx.approval("staging-gate", ApprovalConfig::new("Deploy staging?"))
2089 .await?;
2090 ctx.shell("deploy-staging", ShellConfig::new("echo staging"))
2091 .await?;
2092 ctx.approval("prod-gate", ApprovalConfig::new("Deploy prod?"))
2093 .await?;
2094 ctx.shell("deploy-prod", ShellConfig::new("echo prod"))
2095 .await?;
2096 Ok(())
2097 })
2098 }
2099 }
2100
2101 #[tokio::test]
2102 async fn approval_pauses_run() {
2103 let mut engine = create_test_engine();
2104 engine.register(SingleApprovalWorkflow).unwrap();
2105
2106 let run = engine
2107 .run_handler("single-approval", TriggerKind::Manual, json!({}))
2108 .await
2109 .unwrap()
2110 .run;
2111
2112 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
2113
2114 let steps = engine.store().list_steps(run.id).await.unwrap();
2115 assert_eq!(steps.len(), 2); assert_eq!(steps[0].kind, StepKind::Shell);
2117 assert_eq!(steps[0].status.state, StepStatus::Completed);
2118 assert_eq!(steps[1].kind, StepKind::Approval);
2119 assert_eq!(steps[1].status.state, StepStatus::AwaitingApproval);
2120 }
2121
2122 #[tokio::test]
2123 async fn approval_resume_completes_run() {
2124 let mut engine = create_test_engine();
2125 engine.register(SingleApprovalWorkflow).unwrap();
2126
2127 let run = engine
2129 .run_handler("single-approval", TriggerKind::Manual, json!({}))
2130 .await
2131 .unwrap()
2132 .run;
2133 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
2134
2135 engine
2137 .store()
2138 .update_run_status(run.id, RunStatus::Running)
2139 .await
2140 .unwrap();
2141
2142 let resumed = engine.resume_run(run.id).await.unwrap().run;
2144 assert_eq!(resumed.status.state, RunStatus::Completed);
2145
2146 let steps = engine.store().list_steps(run.id).await.unwrap();
2147 assert_eq!(steps.len(), 3); assert_eq!(steps[0].name, "build");
2149 assert_eq!(steps[0].status.state, StepStatus::Completed);
2150 assert_eq!(steps[1].name, "gate");
2151 assert_eq!(steps[1].kind, StepKind::Approval);
2152 assert_eq!(steps[1].status.state, StepStatus::Completed);
2153 assert_eq!(steps[2].name, "deploy");
2154 assert_eq!(steps[2].status.state, StepStatus::Completed);
2155 }
2156
2157 #[tokio::test]
2158 async fn double_approval_two_resumes() {
2159 let mut engine = create_test_engine();
2160 engine.register(DoubleApprovalWorkflow).unwrap();
2161
2162 let run = engine
2164 .run_handler("double-approval", TriggerKind::Manual, json!({}))
2165 .await
2166 .unwrap()
2167 .run;
2168 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
2169
2170 let steps = engine.store().list_steps(run.id).await.unwrap();
2171 assert_eq!(steps.len(), 2); engine
2175 .store()
2176 .update_run_status(run.id, RunStatus::Running)
2177 .await
2178 .unwrap();
2179
2180 let resumed = engine.resume_run(run.id).await.unwrap().run;
2181 assert_eq!(resumed.status.state, RunStatus::AwaitingApproval);
2182
2183 let steps = engine.store().list_steps(run.id).await.unwrap();
2184 assert_eq!(steps.len(), 4); engine
2188 .store()
2189 .update_run_status(run.id, RunStatus::Running)
2190 .await
2191 .unwrap();
2192
2193 let final_run = engine.resume_run(run.id).await.unwrap().run;
2194 assert_eq!(final_run.status.state, RunStatus::Completed);
2195
2196 let steps = engine.store().list_steps(run.id).await.unwrap();
2197 assert_eq!(steps.len(), 5);
2198 assert_eq!(steps[0].name, "build");
2199 assert_eq!(steps[1].name, "staging-gate");
2200 assert_eq!(steps[2].name, "deploy-staging");
2201 assert_eq!(steps[3].name, "prod-gate");
2202 assert_eq!(steps[4].name, "deploy-prod");
2203
2204 for step in &steps {
2205 assert_eq!(step.status.state, StepStatus::Completed);
2206 }
2207 }
2208
2209 use ironflow_store::models::{NewStep, StepUpdate, step_trace_id};
2214
2215 async fn create_step_with_status(
2216 store: &Arc<dyn Store>,
2217 run_id: Uuid,
2218 name: &str,
2219 position: u32,
2220 status: StepStatus,
2221 ) -> ironflow_store::models::Step {
2222 let step = store
2223 .create_step(NewStep {
2224 run_id,
2225 trace_id: step_trace_id(run_id, name, position),
2226 name: name.to_string(),
2227 kind: StepKind::Shell,
2228 position,
2229 input: None,
2230 is_error_handler: false,
2231 })
2232 .await
2233 .unwrap();
2234
2235 match status {
2236 StepStatus::Pending => {}
2237 StepStatus::Running => {
2238 store
2239 .update_step(
2240 step.id,
2241 StepUpdate {
2242 status: Some(StepStatus::Running),
2243 ..StepUpdate::default()
2244 },
2245 )
2246 .await
2247 .unwrap();
2248 }
2249 StepStatus::Completed => {
2250 store
2251 .update_step(
2252 step.id,
2253 StepUpdate {
2254 status: Some(StepStatus::Running),
2255 ..StepUpdate::default()
2256 },
2257 )
2258 .await
2259 .unwrap();
2260 store
2261 .update_step(
2262 step.id,
2263 StepUpdate {
2264 status: Some(StepStatus::Completed),
2265 ..StepUpdate::default()
2266 },
2267 )
2268 .await
2269 .unwrap();
2270 }
2271 StepStatus::AwaitingApproval => {
2272 store
2273 .update_step(
2274 step.id,
2275 StepUpdate {
2276 status: Some(StepStatus::Running),
2277 ..StepUpdate::default()
2278 },
2279 )
2280 .await
2281 .unwrap();
2282 store
2283 .update_step(
2284 step.id,
2285 StepUpdate {
2286 status: Some(StepStatus::AwaitingApproval),
2287 ..StepUpdate::default()
2288 },
2289 )
2290 .await
2291 .unwrap();
2292 }
2293 _ => panic!("unsupported status for test helper: {status}"),
2294 }
2295
2296 store.get_step(step.id).await.unwrap().unwrap()
2297 }
2298
2299 #[tokio::test]
2300 async fn fail_orphaned_steps_marks_running_as_failed() {
2301 let engine = create_test_engine();
2302 let run = engine
2303 .store()
2304 .create_run(NewRun {
2305 created_by: None,
2306 workflow_name: "test".to_string(),
2307 trigger: TriggerKind::Manual,
2308 payload: json!({}),
2309 max_retries: 0,
2310 handler_version: None,
2311 labels: HashMap::new(),
2312 scheduled_at: None,
2313 idempotency_key: None,
2314 max_cost_usd: None,
2315 })
2316 .await
2317 .unwrap()
2318 .into_run();
2319
2320 let step = create_step_with_status(
2321 engine.store(),
2322 run.id,
2323 "running-step",
2324 0,
2325 StepStatus::Running,
2326 )
2327 .await;
2328
2329 engine
2330 .fail_orphaned_steps(run.id, "parent run timed out")
2331 .await
2332 .unwrap();
2333
2334 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2335 assert_eq!(updated.status.state, StepStatus::Failed);
2336 assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
2337 assert!(updated.completed_at.is_some());
2338 }
2339
2340 #[tokio::test]
2341 async fn fail_orphaned_steps_marks_pending_as_skipped() {
2342 let engine = create_test_engine();
2343 let run = engine
2344 .store()
2345 .create_run(NewRun {
2346 created_by: None,
2347 workflow_name: "test".to_string(),
2348 trigger: TriggerKind::Manual,
2349 payload: json!({}),
2350 max_retries: 0,
2351 handler_version: None,
2352 labels: HashMap::new(),
2353 scheduled_at: None,
2354 idempotency_key: None,
2355 max_cost_usd: None,
2356 })
2357 .await
2358 .unwrap()
2359 .into_run();
2360
2361 let step = create_step_with_status(
2362 engine.store(),
2363 run.id,
2364 "pending-step",
2365 0,
2366 StepStatus::Pending,
2367 )
2368 .await;
2369
2370 engine
2371 .fail_orphaned_steps(run.id, "parent run timed out")
2372 .await
2373 .unwrap();
2374
2375 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2376 assert_eq!(updated.status.state, StepStatus::Skipped);
2377 assert!(updated.error.is_none());
2378 assert!(updated.completed_at.is_some());
2379 }
2380
2381 #[tokio::test]
2382 async fn fail_orphaned_steps_marks_awaiting_approval_as_failed() {
2383 let engine = create_test_engine();
2384 let run = engine
2385 .store()
2386 .create_run(NewRun {
2387 created_by: None,
2388 workflow_name: "test".to_string(),
2389 trigger: TriggerKind::Manual,
2390 payload: json!({}),
2391 max_retries: 0,
2392 handler_version: None,
2393 labels: HashMap::new(),
2394 scheduled_at: None,
2395 idempotency_key: None,
2396 max_cost_usd: None,
2397 })
2398 .await
2399 .unwrap()
2400 .into_run();
2401
2402 let step = create_step_with_status(
2403 engine.store(),
2404 run.id,
2405 "approval-step",
2406 0,
2407 StepStatus::AwaitingApproval,
2408 )
2409 .await;
2410
2411 engine
2412 .fail_orphaned_steps(run.id, "parent run timed out")
2413 .await
2414 .unwrap();
2415
2416 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2417 assert_eq!(updated.status.state, StepStatus::Failed);
2418 assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
2419 assert!(updated.completed_at.is_some());
2420 }
2421
2422 #[tokio::test]
2423 async fn fail_orphaned_steps_skips_terminal_steps() {
2424 let engine = create_test_engine();
2425 let run = engine
2426 .store()
2427 .create_run(NewRun {
2428 created_by: None,
2429 workflow_name: "test".to_string(),
2430 trigger: TriggerKind::Manual,
2431 payload: json!({}),
2432 max_retries: 0,
2433 handler_version: None,
2434 labels: HashMap::new(),
2435 scheduled_at: None,
2436 idempotency_key: None,
2437 max_cost_usd: None,
2438 })
2439 .await
2440 .unwrap()
2441 .into_run();
2442
2443 let completed_step =
2444 create_step_with_status(engine.store(), run.id, "done", 0, StepStatus::Completed).await;
2445 let running_step =
2446 create_step_with_status(engine.store(), run.id, "in-flight", 1, StepStatus::Running)
2447 .await;
2448
2449 engine
2450 .fail_orphaned_steps(run.id, "parent run timed out")
2451 .await
2452 .unwrap();
2453
2454 let completed = engine
2455 .store()
2456 .get_step(completed_step.id)
2457 .await
2458 .unwrap()
2459 .unwrap();
2460 assert_eq!(completed.status.state, StepStatus::Completed);
2461
2462 let failed = engine
2463 .store()
2464 .get_step(running_step.id)
2465 .await
2466 .unwrap()
2467 .unwrap();
2468 assert_eq!(failed.status.state, StepStatus::Failed);
2469 }
2470
2471 #[tokio::test]
2472 async fn fail_orphaned_steps_mixed_states() {
2473 let engine = create_test_engine();
2474 let run = engine
2475 .store()
2476 .create_run(NewRun {
2477 created_by: None,
2478 workflow_name: "test".to_string(),
2479 trigger: TriggerKind::Manual,
2480 payload: json!({}),
2481 max_retries: 0,
2482 handler_version: None,
2483 labels: HashMap::new(),
2484 scheduled_at: None,
2485 idempotency_key: None,
2486 max_cost_usd: None,
2487 })
2488 .await
2489 .unwrap()
2490 .into_run();
2491
2492 let s_completed =
2493 create_step_with_status(engine.store(), run.id, "step-1", 0, StepStatus::Completed)
2494 .await;
2495 let s_running =
2496 create_step_with_status(engine.store(), run.id, "step-2", 1, StepStatus::Running).await;
2497 let s_pending =
2498 create_step_with_status(engine.store(), run.id, "step-3", 2, StepStatus::Pending).await;
2499
2500 engine.fail_orphaned_steps(run.id, "timeout").await.unwrap();
2501
2502 let r_completed = engine
2503 .store()
2504 .get_step(s_completed.id)
2505 .await
2506 .unwrap()
2507 .unwrap();
2508 assert_eq!(r_completed.status.state, StepStatus::Completed);
2509
2510 let r_running = engine
2511 .store()
2512 .get_step(s_running.id)
2513 .await
2514 .unwrap()
2515 .unwrap();
2516 assert_eq!(r_running.status.state, StepStatus::Failed);
2517 assert_eq!(r_running.error.as_deref(), Some("timeout"));
2518
2519 let r_pending = engine
2520 .store()
2521 .get_step(s_pending.id)
2522 .await
2523 .unwrap()
2524 .unwrap();
2525 assert_eq!(r_pending.status.state, StepStatus::Skipped);
2526 assert!(r_pending.error.is_none());
2527 }
2528
2529 #[tokio::test]
2530 async fn fail_orphaned_steps_no_steps_is_noop() {
2531 let engine = create_test_engine();
2532 let run = engine
2533 .store()
2534 .create_run(NewRun {
2535 created_by: None,
2536 workflow_name: "test".to_string(),
2537 trigger: TriggerKind::Manual,
2538 payload: json!({}),
2539 max_retries: 0,
2540 handler_version: None,
2541 labels: HashMap::new(),
2542 scheduled_at: None,
2543 idempotency_key: None,
2544 max_cost_usd: None,
2545 })
2546 .await
2547 .unwrap()
2548 .into_run();
2549
2550 let result = engine.fail_orphaned_steps(run.id, "timeout").await;
2551 assert!(result.is_ok());
2552 }
2553
2554 #[tokio::test]
2555 async fn fail_orphaned_steps_preserves_existing_error() {
2556 let engine = create_test_engine();
2557 let run = engine
2558 .store()
2559 .create_run(NewRun {
2560 created_by: None,
2561 workflow_name: "test".to_string(),
2562 trigger: TriggerKind::Manual,
2563 payload: json!({}),
2564 max_retries: 0,
2565 handler_version: None,
2566 labels: HashMap::new(),
2567 scheduled_at: None,
2568 idempotency_key: None,
2569 max_cost_usd: None,
2570 })
2571 .await
2572 .unwrap()
2573 .into_run();
2574
2575 let step_with_error = create_step_with_status(
2576 engine.store(),
2577 run.id,
2578 "already-errored",
2579 0,
2580 StepStatus::Running,
2581 )
2582 .await;
2583
2584 engine
2585 .store()
2586 .update_step(
2587 step_with_error.id,
2588 StepUpdate {
2589 error: Some("real error from provider".to_string()),
2590 ..StepUpdate::default()
2591 },
2592 )
2593 .await
2594 .unwrap();
2595
2596 let step_no_error = create_step_with_status(
2597 engine.store(),
2598 run.id,
2599 "no-error-yet",
2600 1,
2601 StepStatus::Running,
2602 )
2603 .await;
2604
2605 engine
2606 .fail_orphaned_steps(run.id, "parent run failed")
2607 .await
2608 .unwrap();
2609
2610 let updated_with = engine
2611 .store()
2612 .get_step(step_with_error.id)
2613 .await
2614 .unwrap()
2615 .unwrap();
2616 assert_eq!(updated_with.status.state, StepStatus::Failed);
2617 assert_eq!(
2618 updated_with.error.as_deref(),
2619 Some("real error from provider"),
2620 );
2621
2622 let updated_without = engine
2623 .store()
2624 .get_step(step_no_error.id)
2625 .await
2626 .unwrap()
2627 .unwrap();
2628 assert_eq!(updated_without.status.state, StepStatus::Failed);
2629 assert_eq!(updated_without.error.as_deref(), Some("parent run failed"),);
2630 }
2631}