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