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 compatible_versions: Vec::new(),
1300 input_schema: None,
1301 default_labels: HashMap::new(),
1302 schedule: self.schedule().cloned(),
1303 default_max_cost_usd: self.default_max_cost_usd(),
1304 }
1305 }
1306
1307 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1308 Box::pin(async move {
1309 ctx.shell("greet", ShellConfig::new("echo hello")).await?;
1310 Ok(())
1311 })
1312 }
1313 }
1314
1315 struct FailingWorkflow;
1317
1318 impl WorkflowHandler for FailingWorkflow {
1319 fn name(&self) -> &str {
1320 "failing-workflow"
1321 }
1322
1323 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1324 Box::pin(async move {
1325 ctx.shell("fail", ShellConfig::new("exit 1")).await?;
1326 Ok(())
1327 })
1328 }
1329 }
1330
1331 fn create_test_engine() -> Engine {
1332 let store = Arc::new(InMemoryStore::new());
1333 let inner = ClaudeCodeProvider::new();
1334 let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
1335 inner,
1336 "/tmp/ironflow-fixtures",
1337 ));
1338 Engine::new(store, provider)
1339 }
1340
1341 #[test]
1342 fn engine_new_creates_instance() {
1343 let engine = create_test_engine();
1344 assert_eq!(engine.handler_names().len(), 0);
1345 }
1346
1347 #[test]
1348 fn engine_register_handler() {
1349 let mut engine = create_test_engine();
1350 let result = engine.register(EchoWorkflow);
1351 assert!(result.is_ok());
1352 assert_eq!(engine.handler_names().len(), 1);
1353 assert!(engine.handler_names().contains(&"echo-workflow"));
1354 }
1355
1356 #[test]
1357 fn engine_register_duplicate_returns_error() {
1358 let mut engine = create_test_engine();
1359 engine.register(EchoWorkflow).unwrap();
1360 let result = engine.register(EchoWorkflow);
1361 assert!(result.is_err());
1362 }
1363
1364 #[test]
1365 fn engine_get_handler_found() {
1366 let mut engine = create_test_engine();
1367 engine.register(EchoWorkflow).unwrap();
1368 let handler = engine.get_handler("echo-workflow");
1369 assert!(handler.is_some());
1370 }
1371
1372 #[test]
1373 fn engine_get_handler_not_found() {
1374 let engine = create_test_engine();
1375 let handler = engine.get_handler("nonexistent");
1376 assert!(handler.is_none());
1377 }
1378
1379 #[test]
1380 fn engine_handler_names_lists_all() {
1381 let mut engine = create_test_engine();
1382 engine.register(EchoWorkflow).unwrap();
1383 engine.register(FailingWorkflow).unwrap();
1384 let names = engine.handler_names();
1385 assert_eq!(names.len(), 2);
1386 assert!(names.contains(&"echo-workflow"));
1387 assert!(names.contains(&"failing-workflow"));
1388 }
1389
1390 #[test]
1391 fn engine_handler_info_returns_description() {
1392 let mut engine = create_test_engine();
1393 engine.register(EchoWorkflow).unwrap();
1394 let info = engine.handler_info("echo-workflow");
1395 assert!(info.is_some());
1396 let info = info.unwrap();
1397 assert_eq!(info.description, "A simple workflow that echoes hello");
1398 }
1399
1400 struct CategorizedWorkflow;
1401
1402 impl WorkflowHandler for CategorizedWorkflow {
1403 fn name(&self) -> &str {
1404 "categorized"
1405 }
1406 fn category(&self) -> Option<&str> {
1407 Some("data/etl")
1408 }
1409 fn execute<'a>(
1410 &'a self,
1411 _ctx: &'a mut WorkflowContext,
1412 ) -> crate::handler::HandlerFuture<'a> {
1413 Box::pin(async move { Ok(()) })
1414 }
1415 }
1416
1417 #[test]
1418 fn engine_default_describe_propagates_category() {
1419 let mut engine = create_test_engine();
1420 engine.register(CategorizedWorkflow).unwrap();
1421 let info = engine.handler_info("categorized").unwrap();
1422 assert_eq!(info.category.as_deref(), Some("data/etl"));
1423 }
1424
1425 #[test]
1426 fn engine_default_describe_without_category() {
1427 let mut engine = create_test_engine();
1428 engine.register(EchoWorkflow).unwrap();
1429 let info = engine.handler_info("echo-workflow").unwrap();
1430 assert!(info.category.is_none());
1431 }
1432
1433 struct ScheduledWorkflow {
1438 schedule: CronSchedule,
1439 }
1440
1441 impl ScheduledWorkflow {
1442 fn new() -> Self {
1443 Self {
1444 schedule: CronSchedule::new("0 0 * * * *").unwrap(),
1445 }
1446 }
1447 }
1448
1449 impl WorkflowHandler for ScheduledWorkflow {
1450 fn name(&self) -> &str {
1451 "scheduled"
1452 }
1453 fn schedule(&self) -> Option<&CronSchedule> {
1454 Some(&self.schedule)
1455 }
1456 fn execute<'a>(
1457 &'a self,
1458 _ctx: &'a mut WorkflowContext,
1459 ) -> crate::handler::HandlerFuture<'a> {
1460 Box::pin(async move { Ok(()) })
1461 }
1462 }
1463
1464 #[test]
1465 fn engine_default_describe_propagates_schedule() {
1466 let mut engine = create_test_engine();
1467 engine.register(ScheduledWorkflow::new()).unwrap();
1468 let info = engine.handler_info("scheduled").unwrap();
1469 assert_eq!(
1470 info.schedule.as_ref().map(|s| s.as_str()),
1471 Some("0 0 * * * *")
1472 );
1473 }
1474
1475 #[test]
1476 fn engine_default_describe_without_schedule() {
1477 let mut engine = create_test_engine();
1478 engine.register(EchoWorkflow).unwrap();
1479 let info = engine.handler_info("echo-workflow").unwrap();
1480 assert!(info.schedule.is_none());
1481 }
1482
1483 #[test]
1484 fn scheduled_handlers_returns_only_scheduled() {
1485 let mut engine = create_test_engine();
1486 engine.register(EchoWorkflow).unwrap();
1487 engine.register(ScheduledWorkflow::new()).unwrap();
1488 engine.register(FailingWorkflow).unwrap();
1489
1490 let scheduled = engine.scheduled_handlers();
1491 assert_eq!(scheduled.len(), 1);
1492 assert_eq!(scheduled[0].0, "scheduled");
1493 assert_eq!(scheduled[0].1.as_str(), "0 0 * * * *");
1494 }
1495
1496 #[test]
1497 fn scheduled_handlers_empty_when_none_scheduled() {
1498 let mut engine = create_test_engine();
1499 engine.register(EchoWorkflow).unwrap();
1500 engine.register(FailingWorkflow).unwrap();
1501
1502 let scheduled = engine.scheduled_handlers();
1503 assert!(scheduled.is_empty());
1504 }
1505
1506 struct BadCategoryWorkflow(&'static str);
1507
1508 impl WorkflowHandler for BadCategoryWorkflow {
1509 fn name(&self) -> &str {
1510 "bad-category"
1511 }
1512 fn category(&self) -> Option<&str> {
1513 Some(self.0)
1514 }
1515 fn execute<'a>(
1516 &'a self,
1517 _ctx: &'a mut WorkflowContext,
1518 ) -> crate::handler::HandlerFuture<'a> {
1519 Box::pin(async move { Ok(()) })
1520 }
1521 }
1522
1523 #[test]
1524 fn engine_register_rejects_empty_category() {
1525 let mut engine = create_test_engine();
1526 let err = engine.register(BadCategoryWorkflow("")).unwrap_err();
1527 match err {
1528 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty category")),
1529 other => panic!("expected InvalidWorkflow, got {other:?}"),
1530 }
1531 }
1532
1533 #[test]
1534 fn engine_register_rejects_leading_slash_category() {
1535 let mut engine = create_test_engine();
1536 let err = engine
1537 .register(BadCategoryWorkflow("/data/etl"))
1538 .unwrap_err();
1539 match err {
1540 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("leading '/'")),
1541 other => panic!("expected InvalidWorkflow, got {other:?}"),
1542 }
1543 }
1544
1545 #[test]
1546 fn engine_register_rejects_trailing_slash_category() {
1547 let mut engine = create_test_engine();
1548 let err = engine
1549 .register(BadCategoryWorkflow("data/etl/"))
1550 .unwrap_err();
1551 match err {
1552 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("trailing '/'")),
1553 other => panic!("expected InvalidWorkflow, got {other:?}"),
1554 }
1555 }
1556
1557 #[test]
1558 fn engine_register_rejects_double_slash_category() {
1559 let mut engine = create_test_engine();
1560 let err = engine
1561 .register(BadCategoryWorkflow("data//etl"))
1562 .unwrap_err();
1563 match err {
1564 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty segment")),
1565 other => panic!("expected InvalidWorkflow, got {other:?}"),
1566 }
1567 }
1568
1569 #[test]
1570 fn engine_register_rejects_whitespace_only_segment_category() {
1571 let mut engine = create_test_engine();
1572 let err = engine
1573 .register(BadCategoryWorkflow("data/ /etl"))
1574 .unwrap_err();
1575 match err {
1576 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("whitespace-only segment")),
1577 other => panic!("expected InvalidWorkflow, got {other:?}"),
1578 }
1579 }
1580
1581 #[test]
1582 fn engine_register_accepts_valid_nested_category() {
1583 let mut engine = create_test_engine();
1584 assert!(engine.register(CategorizedWorkflow).is_ok());
1585 }
1586
1587 #[tokio::test]
1588 async fn engine_unknown_workflow_returns_error() {
1589 let engine = create_test_engine();
1590 let result = engine
1591 .run_handler("unknown", TriggerKind::Manual, json!({}))
1592 .await;
1593 assert!(result.is_err());
1594 match result {
1595 Err(EngineError::InvalidWorkflow(msg)) => {
1596 assert!(msg.contains("no handler registered"));
1597 }
1598 _ => panic!("expected InvalidWorkflow error"),
1599 }
1600 }
1601
1602 #[tokio::test]
1603 async fn engine_enqueue_handler_creates_pending_run() {
1604 let mut engine = create_test_engine();
1605 engine.register(EchoWorkflow).unwrap();
1606
1607 let run = engine
1608 .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
1609 .await
1610 .unwrap();
1611 assert_eq!(run.status.state, RunStatus::Pending);
1612 assert_eq!(run.workflow_name, "echo-workflow");
1613 }
1614
1615 #[tokio::test]
1616 async fn enqueue_handler_leaves_the_run_unattributed() {
1617 let mut engine = create_test_engine();
1618 engine.register(EchoWorkflow).unwrap();
1619
1620 let run = engine
1621 .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
1622 .await
1623 .unwrap();
1624
1625 assert!(run.created_by.is_none());
1626 }
1627
1628 #[tokio::test]
1629 async fn enqueue_handler_with_options_records_the_author() {
1630 let mut engine = create_test_engine();
1631 engine.register(EchoWorkflow).unwrap();
1632 let actor = RunActor::User {
1633 user_id: Uuid::now_v7(),
1634 };
1635
1636 let run = engine
1637 .enqueue_handler_with_options(
1638 "echo-workflow",
1639 TriggerKind::Api,
1640 json!({}),
1641 EnqueueOptions {
1642 created_by: Some(actor.clone()),
1643 ..Default::default()
1644 },
1645 )
1646 .await
1647 .unwrap()
1648 .into_run();
1649
1650 assert_eq!(run.created_by, Some(actor));
1651 }
1652
1653 #[tokio::test]
1654 async fn enqueue_handler_with_options_accepts_no_author() {
1655 let mut engine = create_test_engine();
1656 engine.register(EchoWorkflow).unwrap();
1657
1658 let run = engine
1659 .enqueue_handler_with_options(
1660 "echo-workflow",
1661 TriggerKind::Cron {
1662 schedule: "0 * * * * *".to_string(),
1663 },
1664 json!({}),
1665 EnqueueOptions::default(),
1666 )
1667 .await
1668 .unwrap()
1669 .into_run();
1670
1671 assert!(run.created_by.is_none());
1672 }
1673
1674 #[tokio::test]
1675 async fn run_handler_leaves_the_run_unattributed() {
1676 let mut engine = create_test_engine();
1677 engine.register(EchoWorkflow).unwrap();
1678
1679 let run = engine
1680 .run_handler("echo-workflow", TriggerKind::Manual, json!({}))
1681 .await
1682 .unwrap();
1683
1684 assert!(run.created_by.is_none());
1685 }
1686
1687 #[tokio::test]
1688 async fn engine_register_boxed() {
1689 let mut engine = create_test_engine();
1690 let handler: Box<dyn WorkflowHandler> = Box::new(EchoWorkflow);
1691 let result = engine.register_boxed(handler);
1692 assert!(result.is_ok());
1693 assert_eq!(engine.handler_names().len(), 1);
1694 }
1695
1696 #[tokio::test]
1697 async fn engine_store_and_provider_accessors() {
1698 let store = Arc::new(InMemoryStore::new());
1699 let inner = ClaudeCodeProvider::new();
1700 let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
1701 inner,
1702 "/tmp/ironflow-fixtures",
1703 ));
1704 let engine = Engine::new(store.clone(), provider.clone());
1705
1706 let _ = engine.store();
1708 let _ = engine.provider();
1709 }
1710
1711 use crate::operation::Operation;
1716 use ironflow_store::models::StepKind;
1717 use std::future::Future;
1718 use std::pin::Pin;
1719
1720 struct FakeGitlabOp {
1721 project_id: u64,
1722 title: String,
1723 }
1724
1725 impl Operation for FakeGitlabOp {
1726 fn kind(&self) -> &str {
1727 "gitlab"
1728 }
1729
1730 fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
1731 Box::pin(async move {
1732 Ok(json!({
1733 "issue_id": 42,
1734 "project_id": self.project_id,
1735 "title": self.title,
1736 }))
1737 })
1738 }
1739
1740 fn input(&self) -> Option<Value> {
1741 Some(json!({
1742 "project_id": self.project_id,
1743 "title": self.title,
1744 }))
1745 }
1746 }
1747
1748 struct FailingOp;
1749
1750 impl Operation for FailingOp {
1751 fn kind(&self) -> &str {
1752 "broken-service"
1753 }
1754
1755 fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
1756 Box::pin(async move { Err(EngineError::StepConfig("service unavailable".to_string())) })
1757 }
1758 }
1759
1760 struct OperationWorkflow;
1761
1762 impl WorkflowHandler for OperationWorkflow {
1763 fn name(&self) -> &str {
1764 "operation-workflow"
1765 }
1766
1767 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1768 Box::pin(async move {
1769 let op = FakeGitlabOp {
1770 project_id: 123,
1771 title: "Bug report".to_string(),
1772 };
1773 ctx.operation("create-issue", &op).await?;
1774 Ok(())
1775 })
1776 }
1777 }
1778
1779 struct FailingOperationWorkflow;
1780
1781 impl WorkflowHandler for FailingOperationWorkflow {
1782 fn name(&self) -> &str {
1783 "failing-operation-workflow"
1784 }
1785
1786 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1787 Box::pin(async move {
1788 ctx.operation("broken-call", &FailingOp).await?;
1789 Ok(())
1790 })
1791 }
1792 }
1793
1794 struct MixedWorkflow;
1795
1796 impl WorkflowHandler for MixedWorkflow {
1797 fn name(&self) -> &str {
1798 "mixed-workflow"
1799 }
1800
1801 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1802 Box::pin(async move {
1803 ctx.shell("build", ShellConfig::new("echo built")).await?;
1804 let op = FakeGitlabOp {
1805 project_id: 456,
1806 title: "Deploy done".to_string(),
1807 };
1808 let result = ctx.operation("notify-gitlab", &op).await?;
1809 assert_eq!(result.output["issue_id"], 42);
1810 Ok(())
1811 })
1812 }
1813 }
1814
1815 #[tokio::test]
1816 async fn operation_step_happy_path() {
1817 let mut engine = create_test_engine();
1818 engine.register(OperationWorkflow).unwrap();
1819
1820 let run = engine
1821 .run_handler("operation-workflow", TriggerKind::Manual, json!({}))
1822 .await
1823 .unwrap();
1824
1825 assert_eq!(run.status.state, RunStatus::Completed);
1826
1827 let steps = engine.store().list_steps(run.id).await.unwrap();
1828
1829 assert_eq!(steps.len(), 1);
1830 assert_eq!(steps[0].name, "create-issue");
1831 assert_eq!(steps[0].kind, StepKind::Custom("gitlab".to_string()));
1832 assert_eq!(
1833 steps[0].status.state,
1834 ironflow_store::models::StepStatus::Completed
1835 );
1836
1837 let output = steps[0].output.as_ref().unwrap();
1838 assert_eq!(output["issue_id"], 42);
1839 assert_eq!(output["project_id"], 123);
1840
1841 let input = steps[0].input.as_ref().unwrap();
1842 assert_eq!(input["project_id"], 123);
1843 assert_eq!(input["title"], "Bug report");
1844 }
1845
1846 #[tokio::test]
1847 async fn operation_step_failure_marks_run_failed() {
1848 let mut engine = create_test_engine();
1849 engine.register(FailingOperationWorkflow).unwrap();
1850
1851 let result = engine
1852 .run_handler("failing-operation-workflow", TriggerKind::Manual, json!({}))
1853 .await;
1854
1855 assert!(result.is_err());
1856 }
1857
1858 #[tokio::test]
1859 async fn operation_mixed_with_shell_steps() {
1860 let mut engine = create_test_engine();
1861 engine.register(MixedWorkflow).unwrap();
1862
1863 let run = engine
1864 .run_handler("mixed-workflow", TriggerKind::Manual, json!({}))
1865 .await
1866 .unwrap();
1867
1868 assert_eq!(run.status.state, RunStatus::Completed);
1869
1870 let steps = engine.store().list_steps(run.id).await.unwrap();
1871
1872 assert_eq!(steps.len(), 2);
1873 assert_eq!(steps[0].kind, StepKind::Shell);
1874 assert_eq!(steps[1].kind, StepKind::Custom("gitlab".to_string()));
1875 assert_eq!(steps[0].position, 0);
1876 assert_eq!(steps[1].position, 1);
1877 }
1878
1879 use crate::config::ApprovalConfig;
1884
1885 struct SingleApprovalWorkflow;
1886
1887 impl WorkflowHandler for SingleApprovalWorkflow {
1888 fn name(&self) -> &str {
1889 "single-approval"
1890 }
1891
1892 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1893 Box::pin(async move {
1894 ctx.shell("build", ShellConfig::new("echo built")).await?;
1895 ctx.approval("gate", ApprovalConfig::new("OK?")).await?;
1896 ctx.shell("deploy", ShellConfig::new("echo deployed"))
1897 .await?;
1898 Ok(())
1899 })
1900 }
1901 }
1902
1903 struct DoubleApprovalWorkflow;
1904
1905 impl WorkflowHandler for DoubleApprovalWorkflow {
1906 fn name(&self) -> &str {
1907 "double-approval"
1908 }
1909
1910 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1911 Box::pin(async move {
1912 ctx.shell("build", ShellConfig::new("echo built")).await?;
1913 ctx.approval("staging-gate", ApprovalConfig::new("Deploy staging?"))
1914 .await?;
1915 ctx.shell("deploy-staging", ShellConfig::new("echo staging"))
1916 .await?;
1917 ctx.approval("prod-gate", ApprovalConfig::new("Deploy prod?"))
1918 .await?;
1919 ctx.shell("deploy-prod", ShellConfig::new("echo prod"))
1920 .await?;
1921 Ok(())
1922 })
1923 }
1924 }
1925
1926 #[tokio::test]
1927 async fn approval_pauses_run() {
1928 let mut engine = create_test_engine();
1929 engine.register(SingleApprovalWorkflow).unwrap();
1930
1931 let run = engine
1932 .run_handler("single-approval", TriggerKind::Manual, json!({}))
1933 .await
1934 .unwrap();
1935
1936 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
1937
1938 let steps = engine.store().list_steps(run.id).await.unwrap();
1939 assert_eq!(steps.len(), 2); assert_eq!(steps[0].kind, StepKind::Shell);
1941 assert_eq!(steps[0].status.state, StepStatus::Completed);
1942 assert_eq!(steps[1].kind, StepKind::Approval);
1943 assert_eq!(steps[1].status.state, StepStatus::AwaitingApproval);
1944 }
1945
1946 #[tokio::test]
1947 async fn approval_resume_completes_run() {
1948 let mut engine = create_test_engine();
1949 engine.register(SingleApprovalWorkflow).unwrap();
1950
1951 let run = engine
1953 .run_handler("single-approval", TriggerKind::Manual, json!({}))
1954 .await
1955 .unwrap();
1956 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
1957
1958 engine
1960 .store()
1961 .update_run_status(run.id, RunStatus::Running)
1962 .await
1963 .unwrap();
1964
1965 let resumed = engine.resume_run(run.id).await.unwrap();
1967 assert_eq!(resumed.status.state, RunStatus::Completed);
1968
1969 let steps = engine.store().list_steps(run.id).await.unwrap();
1970 assert_eq!(steps.len(), 3); assert_eq!(steps[0].name, "build");
1972 assert_eq!(steps[0].status.state, StepStatus::Completed);
1973 assert_eq!(steps[1].name, "gate");
1974 assert_eq!(steps[1].kind, StepKind::Approval);
1975 assert_eq!(steps[1].status.state, StepStatus::Completed);
1976 assert_eq!(steps[2].name, "deploy");
1977 assert_eq!(steps[2].status.state, StepStatus::Completed);
1978 }
1979
1980 #[tokio::test]
1981 async fn double_approval_two_resumes() {
1982 let mut engine = create_test_engine();
1983 engine.register(DoubleApprovalWorkflow).unwrap();
1984
1985 let run = engine
1987 .run_handler("double-approval", TriggerKind::Manual, json!({}))
1988 .await
1989 .unwrap();
1990 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
1991
1992 let steps = engine.store().list_steps(run.id).await.unwrap();
1993 assert_eq!(steps.len(), 2); engine
1997 .store()
1998 .update_run_status(run.id, RunStatus::Running)
1999 .await
2000 .unwrap();
2001
2002 let resumed = engine.resume_run(run.id).await.unwrap();
2003 assert_eq!(resumed.status.state, RunStatus::AwaitingApproval);
2004
2005 let steps = engine.store().list_steps(run.id).await.unwrap();
2006 assert_eq!(steps.len(), 4); engine
2010 .store()
2011 .update_run_status(run.id, RunStatus::Running)
2012 .await
2013 .unwrap();
2014
2015 let final_run = engine.resume_run(run.id).await.unwrap();
2016 assert_eq!(final_run.status.state, RunStatus::Completed);
2017
2018 let steps = engine.store().list_steps(run.id).await.unwrap();
2019 assert_eq!(steps.len(), 5);
2020 assert_eq!(steps[0].name, "build");
2021 assert_eq!(steps[1].name, "staging-gate");
2022 assert_eq!(steps[2].name, "deploy-staging");
2023 assert_eq!(steps[3].name, "prod-gate");
2024 assert_eq!(steps[4].name, "deploy-prod");
2025
2026 for step in &steps {
2027 assert_eq!(step.status.state, StepStatus::Completed);
2028 }
2029 }
2030
2031 use ironflow_store::models::{NewStep, StepUpdate};
2036
2037 async fn create_step_with_status(
2038 store: &Arc<dyn Store>,
2039 run_id: Uuid,
2040 name: &str,
2041 position: u32,
2042 status: StepStatus,
2043 ) -> ironflow_store::models::Step {
2044 let step = store
2045 .create_step(NewStep {
2046 run_id,
2047 name: name.to_string(),
2048 kind: StepKind::Shell,
2049 position,
2050 input: None,
2051 is_error_handler: false,
2052 })
2053 .await
2054 .unwrap();
2055
2056 match status {
2057 StepStatus::Pending => {}
2058 StepStatus::Running => {
2059 store
2060 .update_step(
2061 step.id,
2062 StepUpdate {
2063 status: Some(StepStatus::Running),
2064 ..StepUpdate::default()
2065 },
2066 )
2067 .await
2068 .unwrap();
2069 }
2070 StepStatus::Completed => {
2071 store
2072 .update_step(
2073 step.id,
2074 StepUpdate {
2075 status: Some(StepStatus::Running),
2076 ..StepUpdate::default()
2077 },
2078 )
2079 .await
2080 .unwrap();
2081 store
2082 .update_step(
2083 step.id,
2084 StepUpdate {
2085 status: Some(StepStatus::Completed),
2086 ..StepUpdate::default()
2087 },
2088 )
2089 .await
2090 .unwrap();
2091 }
2092 StepStatus::AwaitingApproval => {
2093 store
2094 .update_step(
2095 step.id,
2096 StepUpdate {
2097 status: Some(StepStatus::Running),
2098 ..StepUpdate::default()
2099 },
2100 )
2101 .await
2102 .unwrap();
2103 store
2104 .update_step(
2105 step.id,
2106 StepUpdate {
2107 status: Some(StepStatus::AwaitingApproval),
2108 ..StepUpdate::default()
2109 },
2110 )
2111 .await
2112 .unwrap();
2113 }
2114 _ => panic!("unsupported status for test helper: {status}"),
2115 }
2116
2117 store.get_step(step.id).await.unwrap().unwrap()
2118 }
2119
2120 #[tokio::test]
2121 async fn fail_orphaned_steps_marks_running_as_failed() {
2122 let engine = create_test_engine();
2123 let run = engine
2124 .store()
2125 .create_run(NewRun {
2126 created_by: None,
2127 workflow_name: "test".to_string(),
2128 trigger: TriggerKind::Manual,
2129 payload: json!({}),
2130 max_retries: 0,
2131 handler_version: None,
2132 labels: HashMap::new(),
2133 scheduled_at: None,
2134 idempotency_key: None,
2135 max_cost_usd: None,
2136 })
2137 .await
2138 .unwrap()
2139 .into_run();
2140
2141 let step = create_step_with_status(
2142 engine.store(),
2143 run.id,
2144 "running-step",
2145 0,
2146 StepStatus::Running,
2147 )
2148 .await;
2149
2150 engine
2151 .fail_orphaned_steps(run.id, "parent run timed out")
2152 .await
2153 .unwrap();
2154
2155 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2156 assert_eq!(updated.status.state, StepStatus::Failed);
2157 assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
2158 assert!(updated.completed_at.is_some());
2159 }
2160
2161 #[tokio::test]
2162 async fn fail_orphaned_steps_marks_pending_as_skipped() {
2163 let engine = create_test_engine();
2164 let run = engine
2165 .store()
2166 .create_run(NewRun {
2167 created_by: None,
2168 workflow_name: "test".to_string(),
2169 trigger: TriggerKind::Manual,
2170 payload: json!({}),
2171 max_retries: 0,
2172 handler_version: None,
2173 labels: HashMap::new(),
2174 scheduled_at: None,
2175 idempotency_key: None,
2176 max_cost_usd: None,
2177 })
2178 .await
2179 .unwrap()
2180 .into_run();
2181
2182 let step = create_step_with_status(
2183 engine.store(),
2184 run.id,
2185 "pending-step",
2186 0,
2187 StepStatus::Pending,
2188 )
2189 .await;
2190
2191 engine
2192 .fail_orphaned_steps(run.id, "parent run timed out")
2193 .await
2194 .unwrap();
2195
2196 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2197 assert_eq!(updated.status.state, StepStatus::Skipped);
2198 assert!(updated.error.is_none());
2199 assert!(updated.completed_at.is_some());
2200 }
2201
2202 #[tokio::test]
2203 async fn fail_orphaned_steps_marks_awaiting_approval_as_failed() {
2204 let engine = create_test_engine();
2205 let run = engine
2206 .store()
2207 .create_run(NewRun {
2208 created_by: None,
2209 workflow_name: "test".to_string(),
2210 trigger: TriggerKind::Manual,
2211 payload: json!({}),
2212 max_retries: 0,
2213 handler_version: None,
2214 labels: HashMap::new(),
2215 scheduled_at: None,
2216 idempotency_key: None,
2217 max_cost_usd: None,
2218 })
2219 .await
2220 .unwrap()
2221 .into_run();
2222
2223 let step = create_step_with_status(
2224 engine.store(),
2225 run.id,
2226 "approval-step",
2227 0,
2228 StepStatus::AwaitingApproval,
2229 )
2230 .await;
2231
2232 engine
2233 .fail_orphaned_steps(run.id, "parent run timed out")
2234 .await
2235 .unwrap();
2236
2237 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2238 assert_eq!(updated.status.state, StepStatus::Failed);
2239 assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
2240 assert!(updated.completed_at.is_some());
2241 }
2242
2243 #[tokio::test]
2244 async fn fail_orphaned_steps_skips_terminal_steps() {
2245 let engine = create_test_engine();
2246 let run = engine
2247 .store()
2248 .create_run(NewRun {
2249 created_by: None,
2250 workflow_name: "test".to_string(),
2251 trigger: TriggerKind::Manual,
2252 payload: json!({}),
2253 max_retries: 0,
2254 handler_version: None,
2255 labels: HashMap::new(),
2256 scheduled_at: None,
2257 idempotency_key: None,
2258 max_cost_usd: None,
2259 })
2260 .await
2261 .unwrap()
2262 .into_run();
2263
2264 let completed_step =
2265 create_step_with_status(engine.store(), run.id, "done", 0, StepStatus::Completed).await;
2266 let running_step =
2267 create_step_with_status(engine.store(), run.id, "in-flight", 1, StepStatus::Running)
2268 .await;
2269
2270 engine
2271 .fail_orphaned_steps(run.id, "parent run timed out")
2272 .await
2273 .unwrap();
2274
2275 let completed = engine
2276 .store()
2277 .get_step(completed_step.id)
2278 .await
2279 .unwrap()
2280 .unwrap();
2281 assert_eq!(completed.status.state, StepStatus::Completed);
2282
2283 let failed = engine
2284 .store()
2285 .get_step(running_step.id)
2286 .await
2287 .unwrap()
2288 .unwrap();
2289 assert_eq!(failed.status.state, StepStatus::Failed);
2290 }
2291
2292 #[tokio::test]
2293 async fn fail_orphaned_steps_mixed_states() {
2294 let engine = create_test_engine();
2295 let run = engine
2296 .store()
2297 .create_run(NewRun {
2298 created_by: None,
2299 workflow_name: "test".to_string(),
2300 trigger: TriggerKind::Manual,
2301 payload: json!({}),
2302 max_retries: 0,
2303 handler_version: None,
2304 labels: HashMap::new(),
2305 scheduled_at: None,
2306 idempotency_key: None,
2307 max_cost_usd: None,
2308 })
2309 .await
2310 .unwrap()
2311 .into_run();
2312
2313 let s_completed =
2314 create_step_with_status(engine.store(), run.id, "step-1", 0, StepStatus::Completed)
2315 .await;
2316 let s_running =
2317 create_step_with_status(engine.store(), run.id, "step-2", 1, StepStatus::Running).await;
2318 let s_pending =
2319 create_step_with_status(engine.store(), run.id, "step-3", 2, StepStatus::Pending).await;
2320
2321 engine.fail_orphaned_steps(run.id, "timeout").await.unwrap();
2322
2323 let r_completed = engine
2324 .store()
2325 .get_step(s_completed.id)
2326 .await
2327 .unwrap()
2328 .unwrap();
2329 assert_eq!(r_completed.status.state, StepStatus::Completed);
2330
2331 let r_running = engine
2332 .store()
2333 .get_step(s_running.id)
2334 .await
2335 .unwrap()
2336 .unwrap();
2337 assert_eq!(r_running.status.state, StepStatus::Failed);
2338 assert_eq!(r_running.error.as_deref(), Some("timeout"));
2339
2340 let r_pending = engine
2341 .store()
2342 .get_step(s_pending.id)
2343 .await
2344 .unwrap()
2345 .unwrap();
2346 assert_eq!(r_pending.status.state, StepStatus::Skipped);
2347 assert!(r_pending.error.is_none());
2348 }
2349
2350 #[tokio::test]
2351 async fn fail_orphaned_steps_no_steps_is_noop() {
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 result = engine.fail_orphaned_steps(run.id, "timeout").await;
2372 assert!(result.is_ok());
2373 }
2374
2375 #[tokio::test]
2376 async fn fail_orphaned_steps_preserves_existing_error() {
2377 let engine = create_test_engine();
2378 let run = engine
2379 .store()
2380 .create_run(NewRun {
2381 created_by: None,
2382 workflow_name: "test".to_string(),
2383 trigger: TriggerKind::Manual,
2384 payload: json!({}),
2385 max_retries: 0,
2386 handler_version: None,
2387 labels: HashMap::new(),
2388 scheduled_at: None,
2389 idempotency_key: None,
2390 max_cost_usd: None,
2391 })
2392 .await
2393 .unwrap()
2394 .into_run();
2395
2396 let step_with_error = create_step_with_status(
2397 engine.store(),
2398 run.id,
2399 "already-errored",
2400 0,
2401 StepStatus::Running,
2402 )
2403 .await;
2404
2405 engine
2406 .store()
2407 .update_step(
2408 step_with_error.id,
2409 StepUpdate {
2410 error: Some("real error from provider".to_string()),
2411 ..StepUpdate::default()
2412 },
2413 )
2414 .await
2415 .unwrap();
2416
2417 let step_no_error = create_step_with_status(
2418 engine.store(),
2419 run.id,
2420 "no-error-yet",
2421 1,
2422 StepStatus::Running,
2423 )
2424 .await;
2425
2426 engine
2427 .fail_orphaned_steps(run.id, "parent run failed")
2428 .await
2429 .unwrap();
2430
2431 let updated_with = engine
2432 .store()
2433 .get_step(step_with_error.id)
2434 .await
2435 .unwrap()
2436 .unwrap();
2437 assert_eq!(updated_with.status.state, StepStatus::Failed);
2438 assert_eq!(
2439 updated_with.error.as_deref(),
2440 Some("real error from provider"),
2441 );
2442
2443 let updated_without = engine
2444 .store()
2445 .get_step(step_no_error.id)
2446 .await
2447 .unwrap()
2448 .unwrap();
2449 assert_eq!(updated_without.status.state, StepStatus::Failed);
2450 assert_eq!(updated_without.error.as_deref(), Some("parent run failed"),);
2451 }
2452}