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::handler::{WorkflowHandler, WorkflowInfo};
39use crate::log_sender::LogSender;
40use crate::notify::{Event, EventPublisher, EventSubscriber};
41use crate::retry_policy::{backoff_for_retry, is_run_retryable};
42use crate::schedule::CronSchedule;
43
44#[derive(Debug, Clone, Default)]
63pub struct EnqueueOptions {
64 pub max_retries: u32,
66 pub labels: HashMap<String, String>,
68 pub scheduled_at: Option<DateTime<Utc>>,
71 pub max_cost_usd: Option<Decimal>,
75 pub created_by: Option<RunActor>,
78 pub idempotency_key: Option<String>,
84}
85
86pub struct Engine {
127 store: Arc<dyn Store>,
128 provider: Arc<dyn AgentProvider>,
129 handlers: HashMap<String, Arc<dyn WorkflowHandler>>,
130 event_publisher: EventPublisher,
131 log_sender: Option<LogSender>,
132 budget: BudgetConfig,
133 artifact_sink: Option<Arc<dyn ArtifactSink>>,
134}
135
136fn validate_category(handler_name: &str, category: &str) -> Result<(), EngineError> {
146 let reject = |reason: &str| {
147 Err(EngineError::InvalidWorkflow(format!(
148 "handler '{handler_name}' has invalid category '{category}': {reason}"
149 )))
150 };
151
152 if category.is_empty() {
153 return reject("empty category");
154 }
155 if category.starts_with('/') {
156 return reject("leading '/'");
157 }
158 if category.ends_with('/') {
159 return reject("trailing '/'");
160 }
161 for segment in category.split('/') {
162 if segment.is_empty() {
163 return reject("empty segment (double '/')");
164 }
165 if segment.trim().is_empty() {
166 return reject("whitespace-only segment");
167 }
168 }
169 Ok(())
170}
171
172impl Engine {
173 pub fn new(store: Arc<dyn Store>, provider: Arc<dyn AgentProvider>) -> Self {
189 Self {
190 store,
191 provider,
192 handlers: HashMap::new(),
193 event_publisher: EventPublisher::new(),
194 log_sender: None,
195 budget: BudgetConfig::new(),
196 artifact_sink: None,
197 }
198 }
199
200 pub fn with_budget_config(mut self, budget: BudgetConfig) -> Self {
221 self.budget = budget;
222 self
223 }
224
225 pub fn budget_config(&self) -> &BudgetConfig {
227 &self.budget
228 }
229
230 pub fn set_log_sender(&mut self, sender: LogSender) {
236 self.log_sender = Some(sender);
237 }
238
239 pub fn set_artifact_sink(&mut self, sink: Arc<dyn ArtifactSink>) {
257 self.artifact_sink = Some(sink);
258 }
259
260 pub fn artifact_sink(&self) -> Option<&Arc<dyn ArtifactSink>> {
262 self.artifact_sink.as_ref()
263 }
264
265 pub fn store(&self) -> &Arc<dyn Store> {
267 &self.store
268 }
269
270 pub fn provider(&self) -> &Arc<dyn AgentProvider> {
272 &self.provider
273 }
274
275 fn build_context(&self, run: &Run) -> WorkflowContext {
284 let handlers = self.handlers.clone();
285 let resolver: crate::context::HandlerResolver =
286 Arc::new(move |name: &str| handlers.get(name).cloned());
287 let mut ctx = WorkflowContext::with_handler_resolver(
288 run.id,
289 self.store.clone(),
290 self.provider.clone(),
291 resolver,
292 );
293 ctx.carry_over_run_totals(run.retry_count + 1, run.cost_usd, run.duration_ms);
294 ctx.set_max_cost_usd(run.max_cost_usd);
295 if let Some(ref sender) = self.log_sender {
296 ctx.set_log_sender(sender.clone());
297 }
298 if let Some(ref sink) = self.artifact_sink {
299 ctx.set_artifact_sink(sink.clone());
300 }
301 ctx
302 }
303
304 async fn check_monthly_quota(&self, workflow_name: &str) -> Result<(), EngineError> {
315 let Some(limit) = self.budget.monthly_cost_limit_usd else {
316 return Ok(());
317 };
318
319 let stats = self
320 .store
321 .get_stats(RunFilter {
322 created_after: Some(month_start(Utc::now())),
323 ..RunFilter::default()
324 })
325 .await?;
326
327 if stats.total_cost_usd < limit {
328 return Ok(());
329 }
330
331 warn!(
332 workflow = %workflow_name,
333 limit_usd = %limit,
334 spent_usd = %stats.total_cost_usd,
335 "monthly cost quota exhausted, refusing new run"
336 );
337
338 #[cfg(feature = "prometheus")]
339 counter!(
340 RUN_BUDGET_EXCEEDED_TOTAL,
341 "workflow" => workflow_name.to_string(),
342 "scope" => "monthly",
343 )
344 .increment(1);
345
346 Err(EngineError::MonthlyBudgetExceeded {
347 limit_usd: limit,
348 spent_usd: stats.total_cost_usd,
349 })
350 }
351
352 pub fn register(&mut self, handler: impl WorkflowHandler + 'static) -> Result<(), EngineError> {
396 let name = handler.name().to_string();
397 if self.handlers.contains_key(&name) {
398 return Err(EngineError::InvalidWorkflow(format!(
399 "handler '{}' already registered",
400 name
401 )));
402 }
403 if let Some(category) = handler.category() {
404 validate_category(&name, category)?;
405 }
406 self.handlers.insert(name, Arc::new(handler));
407 Ok(())
408 }
409
410 pub fn register_boxed(&mut self, handler: Box<dyn WorkflowHandler>) -> Result<(), EngineError> {
417 let name = handler.name().to_string();
418 if self.handlers.contains_key(&name) {
419 return Err(EngineError::InvalidWorkflow(format!(
420 "handler '{}' already registered",
421 name
422 )));
423 }
424 if let Some(category) = handler.category() {
425 validate_category(&name, category)?;
426 }
427 self.handlers.insert(name, Arc::from(handler));
428 Ok(())
429 }
430
431 pub fn get_handler(&self, name: &str) -> Option<&Arc<dyn WorkflowHandler>> {
433 self.handlers.get(name)
434 }
435
436 pub fn handler_names(&self) -> Vec<&str> {
438 self.handlers.keys().map(|s| s.as_str()).collect()
439 }
440
441 pub fn handler_info(&self, name: &str) -> Option<WorkflowInfo> {
443 self.handlers.get(name).map(|h| h.describe())
444 }
445
446 pub fn scheduled_handlers(&self) -> Vec<(&str, &CronSchedule)> {
470 self.handlers
471 .iter()
472 .filter_map(|(name, handler)| handler.schedule().map(|sched| (name.as_str(), sched)))
473 .collect()
474 }
475
476 pub fn subscribe(
501 &mut self,
502 subscriber: impl EventSubscriber + 'static,
503 event_types: &[&'static str],
504 ) {
505 self.event_publisher.subscribe(subscriber, event_types);
506 }
507
508 pub fn event_publisher(&self) -> &EventPublisher {
513 &self.event_publisher
514 }
515
516 #[tracing::instrument(name = "engine.run_handler", skip_all, fields(workflow = %handler_name))]
546 pub async fn run_handler(
547 &self,
548 handler_name: &str,
549 trigger: TriggerKind,
550 payload: Value,
551 ) -> Result<Run, EngineError> {
552 let handler = self
553 .handlers
554 .get(handler_name)
555 .ok_or_else(|| {
556 EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
557 })?
558 .clone();
559
560 self.check_monthly_quota(handler_name).await?;
561
562 let handler_version = handler.version().map(str::to_string);
563 let max_cost_usd = self
564 .budget
565 .resolve_run_cap(None, handler.default_max_cost_usd());
566 let run = self
567 .store
568 .create_run(NewRun {
569 created_by: None,
570 workflow_name: handler_name.to_string(),
571 trigger,
572 payload,
573 max_retries: 0,
574 handler_version,
575 labels: handler.default_labels(),
576 scheduled_at: None,
577 idempotency_key: None,
578 max_cost_usd,
579 })
580 .await?
581 .into_run();
582
583 let run_id = run.id;
584 info!(run_id = %run_id, handler_version = run.handler_version.as_deref().unwrap_or(""), "run created");
585
586 self.store
587 .update_run_status(run_id, RunStatus::Running)
588 .await?;
589
590 #[cfg(feature = "prometheus")]
591 gauge!(RUNS_ACTIVE, "workflow" => handler_name.to_string()).increment(1.0);
592
593 let run_start = Instant::now();
594 let mut ctx = self.build_context(&run);
595
596 let result = handler.execute(&mut ctx).await;
597 self.finalize_run(run_id, handler_name, result, &ctx, run_start)
598 .await
599 }
600
601 #[tracing::instrument(name = "engine.enqueue_handler", skip_all, fields(workflow = %handler_name))]
612 pub async fn enqueue_handler(
613 &self,
614 handler_name: &str,
615 trigger: TriggerKind,
616 payload: Value,
617 max_retries: u32,
618 ) -> Result<Run, EngineError> {
619 self.enqueue_handler_with_options(
620 handler_name,
621 trigger,
622 payload,
623 EnqueueOptions {
624 max_retries,
625 ..Default::default()
626 },
627 )
628 .await
629 .map(RunCreation::into_run)
630 }
631
632 #[tracing::instrument(name = "engine.enqueue_handler_with_options", skip_all, fields(workflow = %handler_name))]
677 pub async fn enqueue_handler_with_options(
678 &self,
679 handler_name: &str,
680 trigger: TriggerKind,
681 payload: Value,
682 options: EnqueueOptions,
683 ) -> Result<RunCreation, EngineError> {
684 let EnqueueOptions {
685 max_retries,
686 labels,
687 scheduled_at,
688 max_cost_usd,
689 created_by,
690 idempotency_key,
691 } = options;
692
693 let handler = self.handlers.get(handler_name).ok_or_else(|| {
694 EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
695 })?;
696
697 self.check_monthly_quota(handler_name).await?;
698
699 let handler_version = handler.version().map(str::to_string);
700 let mut merged_labels = handler.default_labels();
701 merged_labels.extend(labels);
702 let resolved_cap = self
703 .budget
704 .resolve_run_cap(max_cost_usd, handler.default_max_cost_usd());
705
706 let creation = self
707 .store
708 .create_run(NewRun {
709 workflow_name: handler_name.to_string(),
710 trigger,
711 payload,
712 max_retries,
713 handler_version,
714 labels: merged_labels,
715 scheduled_at,
716 created_by,
717 idempotency_key,
718 max_cost_usd: resolved_cap,
719 })
720 .await?;
721
722 match &creation {
723 RunCreation::Created(run) => info!(
724 run_id = %run.id,
725 workflow = %handler_name,
726 max_cost_usd = ?resolved_cap,
727 "handler run enqueued"
728 ),
729 RunCreation::Existing(run) => info!(
730 run_id = %run.id,
731 workflow = %handler_name,
732 "idempotent replay, nothing enqueued"
733 ),
734 }
735
736 Ok(creation)
737 }
738
739 #[tracing::instrument(name = "engine.execute_handler_run", skip_all, fields(run_id = %run_id))]
748 pub async fn execute_handler_run(&self, run_id: Uuid) -> Result<Run, EngineError> {
749 let run = self
750 .store
751 .get_run(run_id)
752 .await?
753 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
754
755 let handler = self
756 .handlers
757 .get(&run.workflow_name)
758 .ok_or_else(|| {
759 EngineError::InvalidWorkflow(format!(
760 "no handler registered: {}",
761 run.workflow_name
762 ))
763 })?
764 .clone();
765
766 #[cfg(feature = "prometheus")]
767 gauge!(RUNS_ACTIVE, "workflow" => run.workflow_name.clone()).increment(1.0);
768
769 let run_start = Instant::now();
770 let mut ctx = self.build_context(&run);
771
772 if run.retry_count > 0 {
775 ctx.load_replay_steps().await?;
776 }
777
778 let result = handler.execute(&mut ctx).await;
779 self.finalize_run(run_id, &run.workflow_name, result, &ctx, run_start)
780 .await
781 }
782
783 #[tracing::instrument(name = "engine.execute_run", skip_all, fields(run_id = %run_id))]
791 pub async fn execute_run(&self, run_id: Uuid) -> Result<Run, EngineError> {
792 self.execute_handler_run(run_id).await
793 }
794
795 #[tracing::instrument(name = "engine.resume_run", skip_all, fields(run_id = %run_id))]
809 pub async fn resume_run(&self, run_id: Uuid) -> Result<Run, EngineError> {
810 let run = self
811 .store
812 .get_run(run_id)
813 .await?
814 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
815
816 let handler = self
817 .handlers
818 .get(&run.workflow_name)
819 .ok_or_else(|| {
820 EngineError::InvalidWorkflow(format!(
821 "no handler registered: {}",
822 run.workflow_name
823 ))
824 })?
825 .clone();
826
827 info!(run_id = %run_id, workflow = %run.workflow_name, "resuming run after approval");
828
829 let run_start = Instant::now();
830 let mut ctx = self.build_context(&run);
831 ctx.load_replay_steps().await?;
832
833 let result = handler.execute(&mut ctx).await;
834 self.finalize_run(run_id, &run.workflow_name, result, &ctx, run_start)
835 .await
836 }
837
838 pub async fn fail_or_schedule_retry(
883 &self,
884 run_id: Uuid,
885 error: &str,
886 retryable: bool,
887 cost_usd: Option<Decimal>,
888 duration_ms: Option<u64>,
889 ) -> Result<RunStatus, EngineError> {
890 let run = self
891 .store
892 .get_run(run_id)
893 .await?
894 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
895
896 let has_attempts_left = run.retry_count < run.max_retries;
897 let update = if retryable && has_attempts_left {
898 let backoff = backoff_for_retry(run.retry_count);
899 let scheduled_at = Utc::now() + TimeDelta::milliseconds(backoff.as_millis() as i64);
900
901 info!(
902 run_id = %run_id,
903 workflow = %run.workflow_name,
904 attempt = run.retry_count + 1,
905 max_retries = run.max_retries,
906 backoff_secs = backoff.as_secs(),
907 scheduled_at = %scheduled_at,
908 "run failed, scheduling retry"
909 );
910
911 RunUpdate {
912 status: Some(RunStatus::Retrying),
913 error: Some(error.to_string()),
914 increment_retry: true,
915 cost_usd,
916 duration_ms,
917 scheduled_at: Some(scheduled_at),
918 ..RunUpdate::default()
919 }
920 } else {
921 RunUpdate {
922 status: Some(RunStatus::Failed),
923 error: Some(error.to_string()),
924 cost_usd,
925 duration_ms,
926 completed_at: Some(Utc::now()),
927 ..RunUpdate::default()
928 }
929 };
930
931 let status = update.status.unwrap_or(RunStatus::Failed);
932 self.store.update_run(run_id, update).await?;
933 self.fail_orphaned_steps(run_id, error).await?;
934
935 Ok(status)
936 }
937
938 pub async fn fail_orphaned_steps(
952 &self,
953 run_id: Uuid,
954 error_message: &str,
955 ) -> Result<(), EngineError> {
956 let steps = self.store.list_steps(run_id).await?;
957 let now = Utc::now();
958
959 for step in steps {
960 if step.status.state.is_terminal() {
961 continue;
962 }
963
964 let (target_status, error) = match step.status.state {
965 StepStatus::Running | StepStatus::AwaitingApproval => {
966 let err = if step.error.is_some() {
967 None
968 } else {
969 Some(error_message.to_string())
970 };
971 (StepStatus::Failed, err)
972 }
973 StepStatus::Pending => (StepStatus::Skipped, None),
974 _ => continue,
975 };
976
977 if let Err(e) = self
978 .store
979 .update_step(
980 step.id,
981 StepUpdate {
982 status: Some(target_status),
983 error,
984 completed_at: Some(now),
985 ..StepUpdate::default()
986 },
987 )
988 .await
989 {
990 warn!(
991 run_id = %run_id,
992 step_id = %step.id,
993 step_name = %step.name,
994 error = %e,
995 "failed to cleanup orphaned step"
996 );
997 } else {
998 info!(
999 run_id = %run_id,
1000 step_id = %step.id,
1001 step_name = %step.name,
1002 from = %step.status.state,
1003 to = %target_status,
1004 "cleaned up orphaned step"
1005 );
1006 }
1007 }
1008
1009 Ok(())
1010 }
1011
1012 async fn finalize_run(
1018 &self,
1019 run_id: Uuid,
1020 workflow_name: &str,
1021 result: Result<(), EngineError>,
1022 ctx: &WorkflowContext,
1023 run_start: Instant,
1024 ) -> Result<Run, EngineError> {
1025 let total_duration = ctx.carried_duration_ms() + run_start.elapsed().as_millis() as u64;
1028 let completed_at = Utc::now();
1029
1030 let final_status;
1031 let final_run;
1032
1033 match result {
1034 Ok(()) => {
1035 final_status = RunStatus::Completed;
1036 final_run = self
1037 .store
1038 .update_run_returning(
1039 run_id,
1040 RunUpdate {
1041 status: Some(RunStatus::Completed),
1042 cost_usd: Some(ctx.total_cost_usd()),
1043 duration_ms: Some(total_duration),
1044 completed_at: Some(completed_at),
1045 ..RunUpdate::default()
1046 },
1047 )
1048 .await?;
1049
1050 info!(
1051 run_id = %run_id,
1052 cost_usd = %ctx.total_cost_usd(),
1053 duration_ms = total_duration,
1054 "run completed"
1055 );
1056 }
1057 Err(EngineError::ApprovalRequired {
1058 run_id: approval_run_id,
1059 step_id,
1060 ref message,
1061 }) => {
1062 final_status = RunStatus::AwaitingApproval;
1063 final_run = self
1064 .store
1065 .update_run_returning(
1066 run_id,
1067 RunUpdate {
1068 status: Some(RunStatus::AwaitingApproval),
1069 cost_usd: Some(ctx.total_cost_usd()),
1070 duration_ms: Some(total_duration),
1071 ..RunUpdate::default()
1072 },
1073 )
1074 .await?;
1075
1076 info!(
1077 run_id = %approval_run_id,
1078 step_id = %step_id,
1079 message = %message,
1080 "run awaiting approval"
1081 );
1082 }
1083 Err(err) => {
1084 let budget_exceeded = matches!(err, EngineError::RunBudgetExceeded { .. });
1088
1089 final_status = if budget_exceeded {
1090 if let Err(store_err) = self
1091 .store
1092 .update_run(
1093 run_id,
1094 RunUpdate {
1095 status: Some(RunStatus::Cancelled),
1096 error: Some(err.to_string()),
1097 cost_usd: Some(ctx.total_cost_usd()),
1098 duration_ms: Some(total_duration),
1099 completed_at: Some(completed_at),
1100 ..RunUpdate::default()
1101 },
1102 )
1103 .await
1104 {
1105 error!(run_id = %run_id, store_error = %store_err, "failed to persist run cancellation");
1106 }
1107 if let Err(cleanup_err) = self
1108 .fail_orphaned_steps(run_id, "run stopped: cost cap reached")
1109 .await
1110 {
1111 error!(run_id = %run_id, store_error = %cleanup_err, "failed to cleanup orphaned steps");
1112 }
1113 RunStatus::Cancelled
1114 } else {
1115 self.fail_or_schedule_retry(
1116 run_id,
1117 &err.to_string(),
1118 is_run_retryable(&err),
1119 Some(ctx.total_cost_usd()),
1120 Some(total_duration),
1121 )
1122 .await
1123 .unwrap_or_else(|store_err| {
1124 error!(run_id = %run_id, store_error = %store_err, "failed to persist run failure");
1125 RunStatus::Failed
1126 })
1127 };
1128
1129 if budget_exceeded {
1130 self.on_run_budget_exceeded(workflow_name, run_id, &err);
1131 }
1132
1133 error!(run_id = %run_id, status = %final_status, error = %err, "run stopped");
1134
1135 self.publish_run_status_changed(
1136 workflow_name,
1137 run_id,
1138 final_status,
1139 Some(err.to_string()),
1140 ctx,
1141 total_duration,
1142 );
1143
1144 #[cfg(feature = "prometheus")]
1145 self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
1146
1147 return Err(err);
1148 }
1149 }
1150
1151 self.publish_run_status_changed(
1152 workflow_name,
1153 run_id,
1154 final_status,
1155 None,
1156 ctx,
1157 total_duration,
1158 );
1159
1160 #[cfg(feature = "prometheus")]
1161 self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
1162
1163 Ok(final_run)
1164 }
1165
1166 #[cfg(feature = "prometheus")]
1168 fn emit_run_metrics(
1169 &self,
1170 workflow_name: &str,
1171 status: RunStatus,
1172 duration_ms: u64,
1173 ctx: &WorkflowContext,
1174 ) {
1175 let status_str = status.to_string();
1176 let wf = workflow_name.to_string();
1177
1178 counter!(RUNS_TOTAL, "workflow" => wf.clone(), "status" => status_str.clone()).increment(1);
1179 histogram!(RUN_DURATION_SECONDS, "workflow" => wf.clone(), "status" => status_str)
1180 .record(duration_ms as f64 / 1000.0);
1181 histogram!(RUN_COST_USD, "workflow" => wf.clone()).record(
1182 ctx.total_cost_usd()
1183 .to_string()
1184 .parse::<f64>()
1185 .unwrap_or(0.0),
1186 );
1187 gauge!(RUNS_ACTIVE, "workflow" => wf).decrement(1.0);
1188 }
1189
1190 fn on_run_budget_exceeded(&self, workflow_name: &str, run_id: Uuid, err: &EngineError) {
1196 let EngineError::RunBudgetExceeded {
1197 limit_usd,
1198 spent_usd,
1199 step_budget_usd,
1200 ..
1201 } = err
1202 else {
1203 return;
1204 };
1205
1206 #[cfg(feature = "prometheus")]
1207 counter!(
1208 RUN_BUDGET_EXCEEDED_TOTAL,
1209 "workflow" => workflow_name.to_string(),
1210 "scope" => "run",
1211 )
1212 .increment(1);
1213
1214 self.event_publisher.publish(Event::RunBudgetExceeded {
1215 run_id,
1216 workflow_name: workflow_name.to_string(),
1217 limit_usd: *limit_usd,
1218 spent_usd: *spent_usd,
1219 step_budget_usd: *step_budget_usd,
1220 at: Utc::now(),
1221 });
1222 }
1223
1224 fn publish_run_status_changed(
1229 &self,
1230 workflow_name: &str,
1231 run_id: Uuid,
1232 to: RunStatus,
1233 error: Option<String>,
1234 ctx: &WorkflowContext,
1235 duration_ms: u64,
1236 ) {
1237 let now = Utc::now();
1238 let cost_usd = ctx.total_cost_usd();
1239 let wf = workflow_name.to_string();
1240
1241 self.event_publisher.publish(Event::RunStatusChanged {
1242 run_id,
1243 workflow_name: wf.clone(),
1244 from: RunStatus::Running,
1245 to,
1246 error: error.clone(),
1247 cost_usd,
1248 duration_ms,
1249 at: now,
1250 });
1251
1252 if to == RunStatus::Failed {
1253 self.event_publisher.publish(Event::RunFailed {
1254 run_id,
1255 workflow_name: wf,
1256 error,
1257 cost_usd,
1258 duration_ms,
1259 at: now,
1260 });
1261 }
1262 }
1263}
1264
1265impl fmt::Debug for Engine {
1266 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1267 f.debug_struct("Engine")
1268 .field("handlers", &self.handlers.keys().collect::<Vec<_>>())
1269 .finish_non_exhaustive()
1270 }
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275 use super::*;
1276 use crate::config::ShellConfig;
1277 use crate::handler::{HandlerFuture, WorkflowHandler};
1278 use ironflow_core::providers::claude::ClaudeCodeProvider;
1279 use ironflow_core::providers::record_replay::RecordReplayProvider;
1280 use ironflow_store::memory::InMemoryStore;
1281 use ironflow_store::models::StepStatus;
1282 use serde_json::json;
1283
1284 struct EchoWorkflow;
1286
1287 impl WorkflowHandler for EchoWorkflow {
1288 fn name(&self) -> &str {
1289 "echo-workflow"
1290 }
1291
1292 fn describe(&self) -> WorkflowInfo {
1293 WorkflowInfo {
1294 description: "A simple workflow that echoes hello".to_string(),
1295 source_code: None,
1296 sub_workflows: Vec::new(),
1297 category: None,
1298 version: self.version().map(str::to_string),
1299 input_schema: None,
1300 default_labels: HashMap::new(),
1301 schedule: self.schedule().cloned(),
1302 default_max_cost_usd: self.default_max_cost_usd(),
1303 }
1304 }
1305
1306 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1307 Box::pin(async move {
1308 ctx.shell("greet", ShellConfig::new("echo hello")).await?;
1309 Ok(())
1310 })
1311 }
1312 }
1313
1314 struct FailingWorkflow;
1316
1317 impl WorkflowHandler for FailingWorkflow {
1318 fn name(&self) -> &str {
1319 "failing-workflow"
1320 }
1321
1322 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1323 Box::pin(async move {
1324 ctx.shell("fail", ShellConfig::new("exit 1")).await?;
1325 Ok(())
1326 })
1327 }
1328 }
1329
1330 fn create_test_engine() -> Engine {
1331 let store = Arc::new(InMemoryStore::new());
1332 let inner = ClaudeCodeProvider::new();
1333 let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
1334 inner,
1335 "/tmp/ironflow-fixtures",
1336 ));
1337 Engine::new(store, provider)
1338 }
1339
1340 #[test]
1341 fn engine_new_creates_instance() {
1342 let engine = create_test_engine();
1343 assert_eq!(engine.handler_names().len(), 0);
1344 }
1345
1346 #[test]
1347 fn engine_register_handler() {
1348 let mut engine = create_test_engine();
1349 let result = engine.register(EchoWorkflow);
1350 assert!(result.is_ok());
1351 assert_eq!(engine.handler_names().len(), 1);
1352 assert!(engine.handler_names().contains(&"echo-workflow"));
1353 }
1354
1355 #[test]
1356 fn engine_register_duplicate_returns_error() {
1357 let mut engine = create_test_engine();
1358 engine.register(EchoWorkflow).unwrap();
1359 let result = engine.register(EchoWorkflow);
1360 assert!(result.is_err());
1361 }
1362
1363 #[test]
1364 fn engine_get_handler_found() {
1365 let mut engine = create_test_engine();
1366 engine.register(EchoWorkflow).unwrap();
1367 let handler = engine.get_handler("echo-workflow");
1368 assert!(handler.is_some());
1369 }
1370
1371 #[test]
1372 fn engine_get_handler_not_found() {
1373 let engine = create_test_engine();
1374 let handler = engine.get_handler("nonexistent");
1375 assert!(handler.is_none());
1376 }
1377
1378 #[test]
1379 fn engine_handler_names_lists_all() {
1380 let mut engine = create_test_engine();
1381 engine.register(EchoWorkflow).unwrap();
1382 engine.register(FailingWorkflow).unwrap();
1383 let names = engine.handler_names();
1384 assert_eq!(names.len(), 2);
1385 assert!(names.contains(&"echo-workflow"));
1386 assert!(names.contains(&"failing-workflow"));
1387 }
1388
1389 #[test]
1390 fn engine_handler_info_returns_description() {
1391 let mut engine = create_test_engine();
1392 engine.register(EchoWorkflow).unwrap();
1393 let info = engine.handler_info("echo-workflow");
1394 assert!(info.is_some());
1395 let info = info.unwrap();
1396 assert_eq!(info.description, "A simple workflow that echoes hello");
1397 }
1398
1399 struct CategorizedWorkflow;
1400
1401 impl WorkflowHandler for CategorizedWorkflow {
1402 fn name(&self) -> &str {
1403 "categorized"
1404 }
1405 fn category(&self) -> Option<&str> {
1406 Some("data/etl")
1407 }
1408 fn execute<'a>(
1409 &'a self,
1410 _ctx: &'a mut WorkflowContext,
1411 ) -> crate::handler::HandlerFuture<'a> {
1412 Box::pin(async move { Ok(()) })
1413 }
1414 }
1415
1416 #[test]
1417 fn engine_default_describe_propagates_category() {
1418 let mut engine = create_test_engine();
1419 engine.register(CategorizedWorkflow).unwrap();
1420 let info = engine.handler_info("categorized").unwrap();
1421 assert_eq!(info.category.as_deref(), Some("data/etl"));
1422 }
1423
1424 #[test]
1425 fn engine_default_describe_without_category() {
1426 let mut engine = create_test_engine();
1427 engine.register(EchoWorkflow).unwrap();
1428 let info = engine.handler_info("echo-workflow").unwrap();
1429 assert!(info.category.is_none());
1430 }
1431
1432 struct ScheduledWorkflow {
1437 schedule: CronSchedule,
1438 }
1439
1440 impl ScheduledWorkflow {
1441 fn new() -> Self {
1442 Self {
1443 schedule: CronSchedule::new("0 0 * * * *").unwrap(),
1444 }
1445 }
1446 }
1447
1448 impl WorkflowHandler for ScheduledWorkflow {
1449 fn name(&self) -> &str {
1450 "scheduled"
1451 }
1452 fn schedule(&self) -> Option<&CronSchedule> {
1453 Some(&self.schedule)
1454 }
1455 fn execute<'a>(
1456 &'a self,
1457 _ctx: &'a mut WorkflowContext,
1458 ) -> crate::handler::HandlerFuture<'a> {
1459 Box::pin(async move { Ok(()) })
1460 }
1461 }
1462
1463 #[test]
1464 fn engine_default_describe_propagates_schedule() {
1465 let mut engine = create_test_engine();
1466 engine.register(ScheduledWorkflow::new()).unwrap();
1467 let info = engine.handler_info("scheduled").unwrap();
1468 assert_eq!(
1469 info.schedule.as_ref().map(|s| s.as_str()),
1470 Some("0 0 * * * *")
1471 );
1472 }
1473
1474 #[test]
1475 fn engine_default_describe_without_schedule() {
1476 let mut engine = create_test_engine();
1477 engine.register(EchoWorkflow).unwrap();
1478 let info = engine.handler_info("echo-workflow").unwrap();
1479 assert!(info.schedule.is_none());
1480 }
1481
1482 #[test]
1483 fn scheduled_handlers_returns_only_scheduled() {
1484 let mut engine = create_test_engine();
1485 engine.register(EchoWorkflow).unwrap();
1486 engine.register(ScheduledWorkflow::new()).unwrap();
1487 engine.register(FailingWorkflow).unwrap();
1488
1489 let scheduled = engine.scheduled_handlers();
1490 assert_eq!(scheduled.len(), 1);
1491 assert_eq!(scheduled[0].0, "scheduled");
1492 assert_eq!(scheduled[0].1.as_str(), "0 0 * * * *");
1493 }
1494
1495 #[test]
1496 fn scheduled_handlers_empty_when_none_scheduled() {
1497 let mut engine = create_test_engine();
1498 engine.register(EchoWorkflow).unwrap();
1499 engine.register(FailingWorkflow).unwrap();
1500
1501 let scheduled = engine.scheduled_handlers();
1502 assert!(scheduled.is_empty());
1503 }
1504
1505 struct BadCategoryWorkflow(&'static str);
1506
1507 impl WorkflowHandler for BadCategoryWorkflow {
1508 fn name(&self) -> &str {
1509 "bad-category"
1510 }
1511 fn category(&self) -> Option<&str> {
1512 Some(self.0)
1513 }
1514 fn execute<'a>(
1515 &'a self,
1516 _ctx: &'a mut WorkflowContext,
1517 ) -> crate::handler::HandlerFuture<'a> {
1518 Box::pin(async move { Ok(()) })
1519 }
1520 }
1521
1522 #[test]
1523 fn engine_register_rejects_empty_category() {
1524 let mut engine = create_test_engine();
1525 let err = engine.register(BadCategoryWorkflow("")).unwrap_err();
1526 match err {
1527 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty category")),
1528 other => panic!("expected InvalidWorkflow, got {other:?}"),
1529 }
1530 }
1531
1532 #[test]
1533 fn engine_register_rejects_leading_slash_category() {
1534 let mut engine = create_test_engine();
1535 let err = engine
1536 .register(BadCategoryWorkflow("/data/etl"))
1537 .unwrap_err();
1538 match err {
1539 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("leading '/'")),
1540 other => panic!("expected InvalidWorkflow, got {other:?}"),
1541 }
1542 }
1543
1544 #[test]
1545 fn engine_register_rejects_trailing_slash_category() {
1546 let mut engine = create_test_engine();
1547 let err = engine
1548 .register(BadCategoryWorkflow("data/etl/"))
1549 .unwrap_err();
1550 match err {
1551 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("trailing '/'")),
1552 other => panic!("expected InvalidWorkflow, got {other:?}"),
1553 }
1554 }
1555
1556 #[test]
1557 fn engine_register_rejects_double_slash_category() {
1558 let mut engine = create_test_engine();
1559 let err = engine
1560 .register(BadCategoryWorkflow("data//etl"))
1561 .unwrap_err();
1562 match err {
1563 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty segment")),
1564 other => panic!("expected InvalidWorkflow, got {other:?}"),
1565 }
1566 }
1567
1568 #[test]
1569 fn engine_register_rejects_whitespace_only_segment_category() {
1570 let mut engine = create_test_engine();
1571 let err = engine
1572 .register(BadCategoryWorkflow("data/ /etl"))
1573 .unwrap_err();
1574 match err {
1575 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("whitespace-only segment")),
1576 other => panic!("expected InvalidWorkflow, got {other:?}"),
1577 }
1578 }
1579
1580 #[test]
1581 fn engine_register_accepts_valid_nested_category() {
1582 let mut engine = create_test_engine();
1583 assert!(engine.register(CategorizedWorkflow).is_ok());
1584 }
1585
1586 #[tokio::test]
1587 async fn engine_unknown_workflow_returns_error() {
1588 let engine = create_test_engine();
1589 let result = engine
1590 .run_handler("unknown", TriggerKind::Manual, json!({}))
1591 .await;
1592 assert!(result.is_err());
1593 match result {
1594 Err(EngineError::InvalidWorkflow(msg)) => {
1595 assert!(msg.contains("no handler registered"));
1596 }
1597 _ => panic!("expected InvalidWorkflow error"),
1598 }
1599 }
1600
1601 #[tokio::test]
1602 async fn engine_enqueue_handler_creates_pending_run() {
1603 let mut engine = create_test_engine();
1604 engine.register(EchoWorkflow).unwrap();
1605
1606 let run = engine
1607 .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
1608 .await
1609 .unwrap();
1610 assert_eq!(run.status.state, RunStatus::Pending);
1611 assert_eq!(run.workflow_name, "echo-workflow");
1612 }
1613
1614 #[tokio::test]
1615 async fn enqueue_handler_leaves_the_run_unattributed() {
1616 let mut engine = create_test_engine();
1617 engine.register(EchoWorkflow).unwrap();
1618
1619 let run = engine
1620 .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
1621 .await
1622 .unwrap();
1623
1624 assert!(run.created_by.is_none());
1625 }
1626
1627 #[tokio::test]
1628 async fn enqueue_handler_with_options_records_the_author() {
1629 let mut engine = create_test_engine();
1630 engine.register(EchoWorkflow).unwrap();
1631 let actor = RunActor::User {
1632 user_id: Uuid::now_v7(),
1633 };
1634
1635 let run = engine
1636 .enqueue_handler_with_options(
1637 "echo-workflow",
1638 TriggerKind::Api,
1639 json!({}),
1640 EnqueueOptions {
1641 created_by: Some(actor.clone()),
1642 ..Default::default()
1643 },
1644 )
1645 .await
1646 .unwrap()
1647 .into_run();
1648
1649 assert_eq!(run.created_by, Some(actor));
1650 }
1651
1652 #[tokio::test]
1653 async fn enqueue_handler_with_options_accepts_no_author() {
1654 let mut engine = create_test_engine();
1655 engine.register(EchoWorkflow).unwrap();
1656
1657 let run = engine
1658 .enqueue_handler_with_options(
1659 "echo-workflow",
1660 TriggerKind::Cron {
1661 schedule: "0 * * * * *".to_string(),
1662 },
1663 json!({}),
1664 EnqueueOptions::default(),
1665 )
1666 .await
1667 .unwrap()
1668 .into_run();
1669
1670 assert!(run.created_by.is_none());
1671 }
1672
1673 #[tokio::test]
1674 async fn run_handler_leaves_the_run_unattributed() {
1675 let mut engine = create_test_engine();
1676 engine.register(EchoWorkflow).unwrap();
1677
1678 let run = engine
1679 .run_handler("echo-workflow", TriggerKind::Manual, json!({}))
1680 .await
1681 .unwrap();
1682
1683 assert!(run.created_by.is_none());
1684 }
1685
1686 #[tokio::test]
1687 async fn engine_register_boxed() {
1688 let mut engine = create_test_engine();
1689 let handler: Box<dyn WorkflowHandler> = Box::new(EchoWorkflow);
1690 let result = engine.register_boxed(handler);
1691 assert!(result.is_ok());
1692 assert_eq!(engine.handler_names().len(), 1);
1693 }
1694
1695 #[tokio::test]
1696 async fn engine_store_and_provider_accessors() {
1697 let store = Arc::new(InMemoryStore::new());
1698 let inner = ClaudeCodeProvider::new();
1699 let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
1700 inner,
1701 "/tmp/ironflow-fixtures",
1702 ));
1703 let engine = Engine::new(store.clone(), provider.clone());
1704
1705 let _ = engine.store();
1707 let _ = engine.provider();
1708 }
1709
1710 use crate::operation::Operation;
1715 use ironflow_store::models::StepKind;
1716 use std::future::Future;
1717 use std::pin::Pin;
1718
1719 struct FakeGitlabOp {
1720 project_id: u64,
1721 title: String,
1722 }
1723
1724 impl Operation for FakeGitlabOp {
1725 fn kind(&self) -> &str {
1726 "gitlab"
1727 }
1728
1729 fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
1730 Box::pin(async move {
1731 Ok(json!({
1732 "issue_id": 42,
1733 "project_id": self.project_id,
1734 "title": self.title,
1735 }))
1736 })
1737 }
1738
1739 fn input(&self) -> Option<Value> {
1740 Some(json!({
1741 "project_id": self.project_id,
1742 "title": self.title,
1743 }))
1744 }
1745 }
1746
1747 struct FailingOp;
1748
1749 impl Operation for FailingOp {
1750 fn kind(&self) -> &str {
1751 "broken-service"
1752 }
1753
1754 fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
1755 Box::pin(async move { Err(EngineError::StepConfig("service unavailable".to_string())) })
1756 }
1757 }
1758
1759 struct OperationWorkflow;
1760
1761 impl WorkflowHandler for OperationWorkflow {
1762 fn name(&self) -> &str {
1763 "operation-workflow"
1764 }
1765
1766 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1767 Box::pin(async move {
1768 let op = FakeGitlabOp {
1769 project_id: 123,
1770 title: "Bug report".to_string(),
1771 };
1772 ctx.operation("create-issue", &op).await?;
1773 Ok(())
1774 })
1775 }
1776 }
1777
1778 struct FailingOperationWorkflow;
1779
1780 impl WorkflowHandler for FailingOperationWorkflow {
1781 fn name(&self) -> &str {
1782 "failing-operation-workflow"
1783 }
1784
1785 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1786 Box::pin(async move {
1787 ctx.operation("broken-call", &FailingOp).await?;
1788 Ok(())
1789 })
1790 }
1791 }
1792
1793 struct MixedWorkflow;
1794
1795 impl WorkflowHandler for MixedWorkflow {
1796 fn name(&self) -> &str {
1797 "mixed-workflow"
1798 }
1799
1800 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1801 Box::pin(async move {
1802 ctx.shell("build", ShellConfig::new("echo built")).await?;
1803 let op = FakeGitlabOp {
1804 project_id: 456,
1805 title: "Deploy done".to_string(),
1806 };
1807 let result = ctx.operation("notify-gitlab", &op).await?;
1808 assert_eq!(result.output["issue_id"], 42);
1809 Ok(())
1810 })
1811 }
1812 }
1813
1814 #[tokio::test]
1815 async fn operation_step_happy_path() {
1816 let mut engine = create_test_engine();
1817 engine.register(OperationWorkflow).unwrap();
1818
1819 let run = engine
1820 .run_handler("operation-workflow", TriggerKind::Manual, json!({}))
1821 .await
1822 .unwrap();
1823
1824 assert_eq!(run.status.state, RunStatus::Completed);
1825
1826 let steps = engine.store().list_steps(run.id).await.unwrap();
1827
1828 assert_eq!(steps.len(), 1);
1829 assert_eq!(steps[0].name, "create-issue");
1830 assert_eq!(steps[0].kind, StepKind::Custom("gitlab".to_string()));
1831 assert_eq!(
1832 steps[0].status.state,
1833 ironflow_store::models::StepStatus::Completed
1834 );
1835
1836 let output = steps[0].output.as_ref().unwrap();
1837 assert_eq!(output["issue_id"], 42);
1838 assert_eq!(output["project_id"], 123);
1839
1840 let input = steps[0].input.as_ref().unwrap();
1841 assert_eq!(input["project_id"], 123);
1842 assert_eq!(input["title"], "Bug report");
1843 }
1844
1845 #[tokio::test]
1846 async fn operation_step_failure_marks_run_failed() {
1847 let mut engine = create_test_engine();
1848 engine.register(FailingOperationWorkflow).unwrap();
1849
1850 let result = engine
1851 .run_handler("failing-operation-workflow", TriggerKind::Manual, json!({}))
1852 .await;
1853
1854 assert!(result.is_err());
1855 }
1856
1857 #[tokio::test]
1858 async fn operation_mixed_with_shell_steps() {
1859 let mut engine = create_test_engine();
1860 engine.register(MixedWorkflow).unwrap();
1861
1862 let run = engine
1863 .run_handler("mixed-workflow", TriggerKind::Manual, json!({}))
1864 .await
1865 .unwrap();
1866
1867 assert_eq!(run.status.state, RunStatus::Completed);
1868
1869 let steps = engine.store().list_steps(run.id).await.unwrap();
1870
1871 assert_eq!(steps.len(), 2);
1872 assert_eq!(steps[0].kind, StepKind::Shell);
1873 assert_eq!(steps[1].kind, StepKind::Custom("gitlab".to_string()));
1874 assert_eq!(steps[0].position, 0);
1875 assert_eq!(steps[1].position, 1);
1876 }
1877
1878 use crate::config::ApprovalConfig;
1883
1884 struct SingleApprovalWorkflow;
1885
1886 impl WorkflowHandler for SingleApprovalWorkflow {
1887 fn name(&self) -> &str {
1888 "single-approval"
1889 }
1890
1891 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1892 Box::pin(async move {
1893 ctx.shell("build", ShellConfig::new("echo built")).await?;
1894 ctx.approval("gate", ApprovalConfig::new("OK?")).await?;
1895 ctx.shell("deploy", ShellConfig::new("echo deployed"))
1896 .await?;
1897 Ok(())
1898 })
1899 }
1900 }
1901
1902 struct DoubleApprovalWorkflow;
1903
1904 impl WorkflowHandler for DoubleApprovalWorkflow {
1905 fn name(&self) -> &str {
1906 "double-approval"
1907 }
1908
1909 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1910 Box::pin(async move {
1911 ctx.shell("build", ShellConfig::new("echo built")).await?;
1912 ctx.approval("staging-gate", ApprovalConfig::new("Deploy staging?"))
1913 .await?;
1914 ctx.shell("deploy-staging", ShellConfig::new("echo staging"))
1915 .await?;
1916 ctx.approval("prod-gate", ApprovalConfig::new("Deploy prod?"))
1917 .await?;
1918 ctx.shell("deploy-prod", ShellConfig::new("echo prod"))
1919 .await?;
1920 Ok(())
1921 })
1922 }
1923 }
1924
1925 #[tokio::test]
1926 async fn approval_pauses_run() {
1927 let mut engine = create_test_engine();
1928 engine.register(SingleApprovalWorkflow).unwrap();
1929
1930 let run = engine
1931 .run_handler("single-approval", TriggerKind::Manual, json!({}))
1932 .await
1933 .unwrap();
1934
1935 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
1936
1937 let steps = engine.store().list_steps(run.id).await.unwrap();
1938 assert_eq!(steps.len(), 2); assert_eq!(steps[0].kind, StepKind::Shell);
1940 assert_eq!(steps[0].status.state, StepStatus::Completed);
1941 assert_eq!(steps[1].kind, StepKind::Approval);
1942 assert_eq!(steps[1].status.state, StepStatus::AwaitingApproval);
1943 }
1944
1945 #[tokio::test]
1946 async fn approval_resume_completes_run() {
1947 let mut engine = create_test_engine();
1948 engine.register(SingleApprovalWorkflow).unwrap();
1949
1950 let run = engine
1952 .run_handler("single-approval", TriggerKind::Manual, json!({}))
1953 .await
1954 .unwrap();
1955 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
1956
1957 engine
1959 .store()
1960 .update_run_status(run.id, RunStatus::Running)
1961 .await
1962 .unwrap();
1963
1964 let resumed = engine.resume_run(run.id).await.unwrap();
1966 assert_eq!(resumed.status.state, RunStatus::Completed);
1967
1968 let steps = engine.store().list_steps(run.id).await.unwrap();
1969 assert_eq!(steps.len(), 3); assert_eq!(steps[0].name, "build");
1971 assert_eq!(steps[0].status.state, StepStatus::Completed);
1972 assert_eq!(steps[1].name, "gate");
1973 assert_eq!(steps[1].kind, StepKind::Approval);
1974 assert_eq!(steps[1].status.state, StepStatus::Completed);
1975 assert_eq!(steps[2].name, "deploy");
1976 assert_eq!(steps[2].status.state, StepStatus::Completed);
1977 }
1978
1979 #[tokio::test]
1980 async fn double_approval_two_resumes() {
1981 let mut engine = create_test_engine();
1982 engine.register(DoubleApprovalWorkflow).unwrap();
1983
1984 let run = engine
1986 .run_handler("double-approval", TriggerKind::Manual, json!({}))
1987 .await
1988 .unwrap();
1989 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
1990
1991 let steps = engine.store().list_steps(run.id).await.unwrap();
1992 assert_eq!(steps.len(), 2); engine
1996 .store()
1997 .update_run_status(run.id, RunStatus::Running)
1998 .await
1999 .unwrap();
2000
2001 let resumed = engine.resume_run(run.id).await.unwrap();
2002 assert_eq!(resumed.status.state, RunStatus::AwaitingApproval);
2003
2004 let steps = engine.store().list_steps(run.id).await.unwrap();
2005 assert_eq!(steps.len(), 4); engine
2009 .store()
2010 .update_run_status(run.id, RunStatus::Running)
2011 .await
2012 .unwrap();
2013
2014 let final_run = engine.resume_run(run.id).await.unwrap();
2015 assert_eq!(final_run.status.state, RunStatus::Completed);
2016
2017 let steps = engine.store().list_steps(run.id).await.unwrap();
2018 assert_eq!(steps.len(), 5);
2019 assert_eq!(steps[0].name, "build");
2020 assert_eq!(steps[1].name, "staging-gate");
2021 assert_eq!(steps[2].name, "deploy-staging");
2022 assert_eq!(steps[3].name, "prod-gate");
2023 assert_eq!(steps[4].name, "deploy-prod");
2024
2025 for step in &steps {
2026 assert_eq!(step.status.state, StepStatus::Completed);
2027 }
2028 }
2029
2030 use ironflow_store::models::{NewStep, StepUpdate};
2035
2036 async fn create_step_with_status(
2037 store: &Arc<dyn Store>,
2038 run_id: Uuid,
2039 name: &str,
2040 position: u32,
2041 status: StepStatus,
2042 ) -> ironflow_store::models::Step {
2043 let step = store
2044 .create_step(NewStep {
2045 run_id,
2046 name: name.to_string(),
2047 kind: StepKind::Shell,
2048 position,
2049 input: None,
2050 })
2051 .await
2052 .unwrap();
2053
2054 match status {
2055 StepStatus::Pending => {}
2056 StepStatus::Running => {
2057 store
2058 .update_step(
2059 step.id,
2060 StepUpdate {
2061 status: Some(StepStatus::Running),
2062 ..StepUpdate::default()
2063 },
2064 )
2065 .await
2066 .unwrap();
2067 }
2068 StepStatus::Completed => {
2069 store
2070 .update_step(
2071 step.id,
2072 StepUpdate {
2073 status: Some(StepStatus::Running),
2074 ..StepUpdate::default()
2075 },
2076 )
2077 .await
2078 .unwrap();
2079 store
2080 .update_step(
2081 step.id,
2082 StepUpdate {
2083 status: Some(StepStatus::Completed),
2084 ..StepUpdate::default()
2085 },
2086 )
2087 .await
2088 .unwrap();
2089 }
2090 StepStatus::AwaitingApproval => {
2091 store
2092 .update_step(
2093 step.id,
2094 StepUpdate {
2095 status: Some(StepStatus::Running),
2096 ..StepUpdate::default()
2097 },
2098 )
2099 .await
2100 .unwrap();
2101 store
2102 .update_step(
2103 step.id,
2104 StepUpdate {
2105 status: Some(StepStatus::AwaitingApproval),
2106 ..StepUpdate::default()
2107 },
2108 )
2109 .await
2110 .unwrap();
2111 }
2112 _ => panic!("unsupported status for test helper: {status}"),
2113 }
2114
2115 store.get_step(step.id).await.unwrap().unwrap()
2116 }
2117
2118 #[tokio::test]
2119 async fn fail_orphaned_steps_marks_running_as_failed() {
2120 let engine = create_test_engine();
2121 let run = engine
2122 .store()
2123 .create_run(NewRun {
2124 created_by: None,
2125 workflow_name: "test".to_string(),
2126 trigger: TriggerKind::Manual,
2127 payload: json!({}),
2128 max_retries: 0,
2129 handler_version: None,
2130 labels: HashMap::new(),
2131 scheduled_at: None,
2132 idempotency_key: None,
2133 max_cost_usd: None,
2134 })
2135 .await
2136 .unwrap()
2137 .into_run();
2138
2139 let step = create_step_with_status(
2140 engine.store(),
2141 run.id,
2142 "running-step",
2143 0,
2144 StepStatus::Running,
2145 )
2146 .await;
2147
2148 engine
2149 .fail_orphaned_steps(run.id, "parent run timed out")
2150 .await
2151 .unwrap();
2152
2153 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2154 assert_eq!(updated.status.state, StepStatus::Failed);
2155 assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
2156 assert!(updated.completed_at.is_some());
2157 }
2158
2159 #[tokio::test]
2160 async fn fail_orphaned_steps_marks_pending_as_skipped() {
2161 let engine = create_test_engine();
2162 let run = engine
2163 .store()
2164 .create_run(NewRun {
2165 created_by: None,
2166 workflow_name: "test".to_string(),
2167 trigger: TriggerKind::Manual,
2168 payload: json!({}),
2169 max_retries: 0,
2170 handler_version: None,
2171 labels: HashMap::new(),
2172 scheduled_at: None,
2173 idempotency_key: None,
2174 max_cost_usd: None,
2175 })
2176 .await
2177 .unwrap()
2178 .into_run();
2179
2180 let step = create_step_with_status(
2181 engine.store(),
2182 run.id,
2183 "pending-step",
2184 0,
2185 StepStatus::Pending,
2186 )
2187 .await;
2188
2189 engine
2190 .fail_orphaned_steps(run.id, "parent run timed out")
2191 .await
2192 .unwrap();
2193
2194 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2195 assert_eq!(updated.status.state, StepStatus::Skipped);
2196 assert!(updated.error.is_none());
2197 assert!(updated.completed_at.is_some());
2198 }
2199
2200 #[tokio::test]
2201 async fn fail_orphaned_steps_marks_awaiting_approval_as_failed() {
2202 let engine = create_test_engine();
2203 let run = engine
2204 .store()
2205 .create_run(NewRun {
2206 created_by: None,
2207 workflow_name: "test".to_string(),
2208 trigger: TriggerKind::Manual,
2209 payload: json!({}),
2210 max_retries: 0,
2211 handler_version: None,
2212 labels: HashMap::new(),
2213 scheduled_at: None,
2214 idempotency_key: None,
2215 max_cost_usd: None,
2216 })
2217 .await
2218 .unwrap()
2219 .into_run();
2220
2221 let step = create_step_with_status(
2222 engine.store(),
2223 run.id,
2224 "approval-step",
2225 0,
2226 StepStatus::AwaitingApproval,
2227 )
2228 .await;
2229
2230 engine
2231 .fail_orphaned_steps(run.id, "parent run timed out")
2232 .await
2233 .unwrap();
2234
2235 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2236 assert_eq!(updated.status.state, StepStatus::Failed);
2237 assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
2238 assert!(updated.completed_at.is_some());
2239 }
2240
2241 #[tokio::test]
2242 async fn fail_orphaned_steps_skips_terminal_steps() {
2243 let engine = create_test_engine();
2244 let run = engine
2245 .store()
2246 .create_run(NewRun {
2247 created_by: None,
2248 workflow_name: "test".to_string(),
2249 trigger: TriggerKind::Manual,
2250 payload: json!({}),
2251 max_retries: 0,
2252 handler_version: None,
2253 labels: HashMap::new(),
2254 scheduled_at: None,
2255 idempotency_key: None,
2256 max_cost_usd: None,
2257 })
2258 .await
2259 .unwrap()
2260 .into_run();
2261
2262 let completed_step =
2263 create_step_with_status(engine.store(), run.id, "done", 0, StepStatus::Completed).await;
2264 let running_step =
2265 create_step_with_status(engine.store(), run.id, "in-flight", 1, StepStatus::Running)
2266 .await;
2267
2268 engine
2269 .fail_orphaned_steps(run.id, "parent run timed out")
2270 .await
2271 .unwrap();
2272
2273 let completed = engine
2274 .store()
2275 .get_step(completed_step.id)
2276 .await
2277 .unwrap()
2278 .unwrap();
2279 assert_eq!(completed.status.state, StepStatus::Completed);
2280
2281 let failed = engine
2282 .store()
2283 .get_step(running_step.id)
2284 .await
2285 .unwrap()
2286 .unwrap();
2287 assert_eq!(failed.status.state, StepStatus::Failed);
2288 }
2289
2290 #[tokio::test]
2291 async fn fail_orphaned_steps_mixed_states() {
2292 let engine = create_test_engine();
2293 let run = engine
2294 .store()
2295 .create_run(NewRun {
2296 created_by: None,
2297 workflow_name: "test".to_string(),
2298 trigger: TriggerKind::Manual,
2299 payload: json!({}),
2300 max_retries: 0,
2301 handler_version: None,
2302 labels: HashMap::new(),
2303 scheduled_at: None,
2304 idempotency_key: None,
2305 max_cost_usd: None,
2306 })
2307 .await
2308 .unwrap()
2309 .into_run();
2310
2311 let s_completed =
2312 create_step_with_status(engine.store(), run.id, "step-1", 0, StepStatus::Completed)
2313 .await;
2314 let s_running =
2315 create_step_with_status(engine.store(), run.id, "step-2", 1, StepStatus::Running).await;
2316 let s_pending =
2317 create_step_with_status(engine.store(), run.id, "step-3", 2, StepStatus::Pending).await;
2318
2319 engine.fail_orphaned_steps(run.id, "timeout").await.unwrap();
2320
2321 let r_completed = engine
2322 .store()
2323 .get_step(s_completed.id)
2324 .await
2325 .unwrap()
2326 .unwrap();
2327 assert_eq!(r_completed.status.state, StepStatus::Completed);
2328
2329 let r_running = engine
2330 .store()
2331 .get_step(s_running.id)
2332 .await
2333 .unwrap()
2334 .unwrap();
2335 assert_eq!(r_running.status.state, StepStatus::Failed);
2336 assert_eq!(r_running.error.as_deref(), Some("timeout"));
2337
2338 let r_pending = engine
2339 .store()
2340 .get_step(s_pending.id)
2341 .await
2342 .unwrap()
2343 .unwrap();
2344 assert_eq!(r_pending.status.state, StepStatus::Skipped);
2345 assert!(r_pending.error.is_none());
2346 }
2347
2348 #[tokio::test]
2349 async fn fail_orphaned_steps_no_steps_is_noop() {
2350 let engine = create_test_engine();
2351 let run = engine
2352 .store()
2353 .create_run(NewRun {
2354 created_by: None,
2355 workflow_name: "test".to_string(),
2356 trigger: TriggerKind::Manual,
2357 payload: json!({}),
2358 max_retries: 0,
2359 handler_version: None,
2360 labels: HashMap::new(),
2361 scheduled_at: None,
2362 idempotency_key: None,
2363 max_cost_usd: None,
2364 })
2365 .await
2366 .unwrap()
2367 .into_run();
2368
2369 let result = engine.fail_orphaned_steps(run.id, "timeout").await;
2370 assert!(result.is_ok());
2371 }
2372
2373 #[tokio::test]
2374 async fn fail_orphaned_steps_preserves_existing_error() {
2375 let engine = create_test_engine();
2376 let run = engine
2377 .store()
2378 .create_run(NewRun {
2379 created_by: None,
2380 workflow_name: "test".to_string(),
2381 trigger: TriggerKind::Manual,
2382 payload: json!({}),
2383 max_retries: 0,
2384 handler_version: None,
2385 labels: HashMap::new(),
2386 scheduled_at: None,
2387 idempotency_key: None,
2388 max_cost_usd: None,
2389 })
2390 .await
2391 .unwrap()
2392 .into_run();
2393
2394 let step_with_error = create_step_with_status(
2395 engine.store(),
2396 run.id,
2397 "already-errored",
2398 0,
2399 StepStatus::Running,
2400 )
2401 .await;
2402
2403 engine
2404 .store()
2405 .update_step(
2406 step_with_error.id,
2407 StepUpdate {
2408 error: Some("real error from provider".to_string()),
2409 ..StepUpdate::default()
2410 },
2411 )
2412 .await
2413 .unwrap();
2414
2415 let step_no_error = create_step_with_status(
2416 engine.store(),
2417 run.id,
2418 "no-error-yet",
2419 1,
2420 StepStatus::Running,
2421 )
2422 .await;
2423
2424 engine
2425 .fail_orphaned_steps(run.id, "parent run failed")
2426 .await
2427 .unwrap();
2428
2429 let updated_with = engine
2430 .store()
2431 .get_step(step_with_error.id)
2432 .await
2433 .unwrap()
2434 .unwrap();
2435 assert_eq!(updated_with.status.state, StepStatus::Failed);
2436 assert_eq!(
2437 updated_with.error.as_deref(),
2438 Some("real error from provider"),
2439 );
2440
2441 let updated_without = engine
2442 .store()
2443 .get_step(step_no_error.id)
2444 .await
2445 .unwrap()
2446 .unwrap();
2447 assert_eq!(updated_without.status.state, StepStatus::Failed);
2448 assert_eq!(updated_without.error.as_deref(), Some("parent run failed"),);
2449 }
2450}