1use std::collections::{HashMap, HashSet};
10use std::fmt;
11use std::sync::{Arc, Mutex};
12use std::time::{Duration, Instant};
13
14use chrono::{DateTime, TimeDelta, Utc};
15use rust_decimal::Decimal;
16use serde_json::{Value, to_value};
17use tokio::spawn;
18use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
19use tokio::time::timeout;
20use tracing::{error, info, warn};
21use uuid::Uuid;
22
23use ironflow_core::error::OperationError;
24#[cfg(feature = "prometheus")]
25use ironflow_core::metric_names::{
26 RUN_BUDGET_EXCEEDED_TOTAL, RUN_COST_USD, RUN_DURATION_SECONDS, RUNS_ACTIVE, RUNS_TOTAL,
27};
28use ironflow_core::provider::{AgentProvider, LABEL_ROOT_RUN_ID};
29use ironflow_store::error::StoreError;
30use ironflow_store::models::{
31 ConcurrencyLimit, LeaseUpdate, NewRun, NewSignal, ProviderKind, Run, RunActor, RunCreation,
32 RunFilter, RunStatus, RunUpdate, SignalInsert, SignalStepResolution, StepKind, StepStatus,
33 StepUpdate, TriggerKind, normalize_worker_tags, validate_concurrency_limits, validate_priority,
34 validate_worker_tags,
35};
36use ironflow_store::store::Store;
37#[cfg(feature = "prometheus")]
38use metrics::{counter, gauge, histogram};
39
40use crate::artifact::ArtifactSink;
41use crate::budget::{BudgetConfig, month_start};
42use crate::chain::ChainClaim;
43use crate::context::{PARENT_RUN_ID_LABEL, WorkflowContext, interrupt_running_steps};
44use crate::error::EngineError;
45use crate::executor::{StepInterceptor, StepOutput, StepResult};
46use crate::guard::{WorkflowGuardConfig, WorkflowGuardState};
47use crate::handler::{WorkflowHandler, WorkflowInfo, clamp_priority};
48use crate::log_sender::LogSender;
49use crate::notify::{
50 ApprovalRequestedEvent, Event, EventBusReleaser, EventPublisher, EventSubscriber,
51 RunBudgetExceededEvent, RunFailedEvent, RunStatusChangedEvent, SignalAwaitedEvent,
52 SignalReceivedEvent, WorkflowEventBus,
53};
54use crate::plan::{
55 ExecutionPlan, PlanOptions, PlanRecorder, SharedPlanRecorder, estimate_durations, lock_plan,
56};
57use crate::retry_policy::{backoff_for_retry, is_run_retryable};
58use crate::schedule::CronSchedule;
59use crate::signal::{
60 Signal, SignalDelivery, SignalOrigin, SignalRejected, SignalResumed, SignalSendFuture,
61 SignalSender, received_output, validate_signal, validate_step_payload,
62};
63use ironflow_core::decision::DecisionProvider;
64
65const LIST_RUNS_PAGE_SIZE: u32 = 100;
67
68#[derive(Debug, Clone)]
87pub struct WorkflowResult {
88 pub run: Run,
90 pub steps: Vec<StepResult>,
92}
93
94#[derive(Debug, Clone, Default)]
113pub struct EnqueueOptions {
114 pub max_retries: u32,
116 pub labels: HashMap<String, String>,
118 pub scheduled_at: Option<DateTime<Utc>>,
121 pub max_cost_usd: Option<Decimal>,
125 pub created_by: Option<RunActor>,
128 pub idempotency_key: Option<String>,
134 pub concurrency_key: Option<String>,
140 pub concurrency_limits: Vec<ConcurrencyLimit>,
148 pub priority: Option<i16>,
157 pub worker_tags: Vec<String>,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
176pub enum ExecutionMode {
177 #[default]
181 Local,
182 Workers,
185}
186
187pub struct Engine {
228 store: Arc<dyn Store>,
229 provider: Arc<dyn AgentProvider>,
230 handlers: HashMap<String, Arc<dyn WorkflowHandler>>,
231 event_publisher: EventPublisher,
232 log_sender: Option<LogSender>,
233 budget: BudgetConfig,
234 artifact_sink: Option<Arc<dyn ArtifactSink>>,
235 guard_config: Option<WorkflowGuardConfig>,
236 event_bus: Option<WorkflowEventBus>,
237 decision_provider: Option<Arc<dyn DecisionProvider>>,
238 step_interceptor: Option<Arc<dyn StepInterceptor>>,
239 execution_mode: ExecutionMode,
240 worker_tags: Option<Arc<Vec<String>>>,
241 signal_sender: Option<Arc<dyn SignalSender>>,
243 active_runs: ActiveRuns,
244 local_resumes: ActiveRuns,
245}
246
247type ActiveRuns = Arc<Mutex<HashMap<Uuid, Arc<AsyncMutex<()>>>>>;
249
250struct ActiveRunGuard {
256 runs: ActiveRuns,
257 run_id: Uuid,
258 entry: Arc<AsyncMutex<()>>,
259 lock: Option<OwnedMutexGuard<()>>,
260}
261
262impl Drop for ActiveRunGuard {
263 fn drop(&mut self) {
264 self.lock.take();
265 prune_active_run(&self.runs, self.run_id, &self.entry);
266 }
267}
268
269async fn lock_run(runs: &ActiveRuns, run_id: Uuid) -> ActiveRunGuard {
271 let entry = {
272 let mut runs = runs.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
273 Arc::clone(runs.entry(run_id).or_default())
274 };
275 let lock = Arc::clone(&entry).lock_owned().await;
276 ActiveRunGuard {
277 runs: Arc::clone(runs),
278 run_id,
279 entry,
280 lock: Some(lock),
281 }
282}
283
284fn prune_active_run(runs: &ActiveRuns, run_id: Uuid, entry: &Arc<AsyncMutex<()>>) {
289 let mut runs = runs.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
290 let unused = runs
291 .get(&run_id)
292 .is_some_and(|current| Arc::ptr_eq(current, entry))
293 && Arc::strong_count(entry) == 2;
294 if unused {
295 runs.remove(&run_id);
296 }
297}
298
299struct InProcessSignalSender(Arc<Engine>);
301
302impl SignalSender for InProcessSignalSender {
303 fn send(&self, signal: NewSignal, origin: SignalOrigin) -> SignalSendFuture<'_> {
304 Box::pin(self.0.deliver_signal_from(signal, Some(origin)))
305 }
306}
307
308fn validate_category(handler_name: &str, category: &str) -> Result<(), EngineError> {
318 let reject = |reason: &str| {
319 Err(EngineError::InvalidWorkflow(format!(
320 "handler '{handler_name}' has invalid category '{category}': {reason}"
321 )))
322 };
323
324 if category.is_empty() {
325 return reject("empty category");
326 }
327 if category.starts_with('/') {
328 return reject("leading '/'");
329 }
330 if category.ends_with('/') {
331 return reject("trailing '/'");
332 }
333 for segment in category.split('/') {
334 if segment.is_empty() {
335 return reject("empty segment (double '/')");
336 }
337 if segment.trim().is_empty() {
338 return reject("whitespace-only segment");
339 }
340 }
341 Ok(())
342}
343
344fn chain_label(run: &Run, key: &str) -> Option<Uuid> {
349 if !matches!(run.trigger, TriggerKind::Workflow) {
350 return None;
351 }
352 let id = Uuid::parse_str(run.labels.get(key)?).ok()?;
353 (id != run.id).then_some(id)
354}
355
356pub fn chain_root(run: &Run) -> Option<Uuid> {
376 chain_label(run, LABEL_ROOT_RUN_ID)
377}
378
379pub(crate) fn chain_parent(run: &Run) -> Option<Uuid> {
381 chain_label(run, PARENT_RUN_ID_LABEL)
382}
383
384impl Engine {
385 pub fn new(store: Arc<dyn Store>, provider: Arc<dyn AgentProvider>) -> Self {
401 Self {
402 store,
403 provider,
404 handlers: HashMap::new(),
405 event_publisher: EventPublisher::new(),
406 log_sender: None,
407 budget: BudgetConfig::new(),
408 artifact_sink: None,
409 guard_config: None,
410 event_bus: None,
411 decision_provider: None,
412 step_interceptor: None,
413 execution_mode: ExecutionMode::default(),
414 worker_tags: None,
415 signal_sender: None,
416 active_runs: Arc::new(Mutex::new(HashMap::new())),
417 local_resumes: Arc::new(Mutex::new(HashMap::new())),
418 }
419 }
420
421 async fn track_execution(&self, run_id: Uuid) -> ActiveRunGuard {
424 lock_run(&self.active_runs, run_id).await
425 }
426
427 async fn wait_until_idle(&self, run_id: Uuid) {
429 let entry = {
430 let runs = self
431 .active_runs
432 .lock()
433 .unwrap_or_else(|poisoned| poisoned.into_inner());
434 runs.get(&run_id).map(Arc::clone)
435 };
436 if let Some(entry) = entry {
437 drop(Arc::clone(&entry).lock_owned().await);
438 prune_active_run(&self.active_runs, run_id, &entry);
439 }
440 }
441
442 pub(crate) fn is_executing(&self, run_id: Uuid) -> bool {
444 let runs = self
445 .active_runs
446 .lock()
447 .unwrap_or_else(|poisoned| poisoned.into_inner());
448 runs.get(&run_id)
449 .is_some_and(|entry| entry.try_lock().is_err())
450 }
451
452 pub fn with_signal_sender(mut self, sender: Arc<dyn SignalSender>) -> Self {
471 self.signal_sender = Some(sender);
472 self
473 }
474
475 pub fn with_decision_provider(mut self, provider: Arc<dyn DecisionProvider>) -> Self {
496 self.decision_provider = Some(provider);
497 self
498 }
499
500 pub fn with_step_interceptor(mut self, interceptor: Arc<dyn StepInterceptor>) -> Self {
524 self.step_interceptor = Some(interceptor);
525 self
526 }
527
528 pub fn step_interceptor(&self) -> Option<&Arc<dyn StepInterceptor>> {
530 self.step_interceptor.as_ref()
531 }
532
533 pub fn with_budget_config(mut self, budget: BudgetConfig) -> Self {
554 self.budget = budget;
555 self
556 }
557
558 pub fn budget_config(&self) -> &BudgetConfig {
560 &self.budget
561 }
562
563 pub fn with_guard_config(mut self, config: WorkflowGuardConfig) -> Self {
585 self.guard_config = Some(config);
586 self
587 }
588
589 pub fn guard_config(&self) -> Option<&WorkflowGuardConfig> {
591 self.guard_config.as_ref()
592 }
593
594 pub fn with_execution_mode(mut self, mode: ExecutionMode) -> Self {
615 self.execution_mode = mode;
616 self
617 }
618
619 pub fn execution_mode(&self) -> ExecutionMode {
621 self.execution_mode
622 }
623
624 pub fn set_worker_tags(&mut self, tags: Vec<String>) {
647 self.worker_tags = Some(Arc::new(tags));
648 }
649
650 pub fn worker_tags(&self) -> Option<&[String]> {
668 self.worker_tags.as_deref().map(Vec::as_slice)
669 }
670
671 pub fn set_log_sender(&mut self, sender: LogSender) {
677 self.log_sender = Some(sender);
678 }
679
680 pub fn set_artifact_sink(&mut self, sink: Arc<dyn ArtifactSink>) {
698 self.artifact_sink = Some(sink);
699 }
700
701 pub fn artifact_sink(&self) -> Option<&Arc<dyn ArtifactSink>> {
703 self.artifact_sink.as_ref()
704 }
705
706 pub fn set_event_bus(&mut self, bus: WorkflowEventBus) {
727 self.event_bus = Some(bus.clone());
728 self.event_publisher
729 .subscribe(EventBusReleaser::new(bus), &[Event::RUN_STATUS_CHANGED]);
730 }
731
732 pub fn event_bus(&self) -> Option<&WorkflowEventBus> {
734 self.event_bus.as_ref()
735 }
736
737 pub fn store(&self) -> &Arc<dyn Store> {
739 &self.store
740 }
741
742 pub fn provider(&self) -> &Arc<dyn AgentProvider> {
744 &self.provider
745 }
746
747 fn build_context(&self, run: &Run) -> WorkflowContext {
756 let handlers = self.handlers.clone();
757 let resolver: crate::context::HandlerResolver =
758 Arc::new(move |name: &str| handlers.get(name).cloned());
759 let mut ctx = WorkflowContext::with_handler_resolver(
760 run.id,
761 run.workflow_name.clone(),
762 self.store.clone(),
763 self.provider.clone(),
764 resolver,
765 );
766 ctx.carry_over_run_totals(run.retry_count + 1, run.cost_usd, run.duration_ms);
767 ctx.set_max_cost_usd(run.max_cost_usd);
768 ctx.set_run_created_at(run.created_at);
769 if let Some(ref sender) = self.log_sender {
770 ctx.set_log_sender(sender.clone());
771 }
772 if let Some(ref sink) = self.artifact_sink {
773 ctx.set_artifact_sink(sink.clone());
774 }
775 if let Some(ref bus) = self.event_bus {
776 ctx.set_event_bus(bus.clone());
777 }
778 if let Some(ref provider) = self.decision_provider {
779 ctx.set_decision_provider(provider.clone());
780 }
781 if let Some(ref interceptor) = self.step_interceptor {
782 ctx.set_step_interceptor(interceptor.clone());
783 }
784 if let Some(ref tags) = self.worker_tags {
785 ctx.set_worker_tags(tags.clone());
786 }
787 let sender = self
790 .signal_sender
791 .clone()
792 .unwrap_or_else(|| Arc::new(InProcessSignalSender(Arc::new(self.shared_copy()))));
793 ctx.set_signal_sender(sender);
794 ctx
795 }
796
797 fn shared_copy(&self) -> Engine {
800 Engine {
801 store: self.store.clone(),
802 provider: self.provider.clone(),
803 handlers: self.handlers.clone(),
804 event_publisher: self.event_publisher.clone(),
805 log_sender: self.log_sender.clone(),
806 budget: self.budget.clone(),
807 artifact_sink: self.artifact_sink.clone(),
808 guard_config: self.guard_config.clone(),
809 event_bus: self.event_bus.clone(),
810 decision_provider: self.decision_provider.clone(),
811 step_interceptor: self.step_interceptor.clone(),
812 execution_mode: self.execution_mode,
813 worker_tags: self.worker_tags.clone(),
814 signal_sender: self.signal_sender.clone(),
815 active_runs: self.active_runs.clone(),
816 local_resumes: self.local_resumes.clone(),
817 }
818 }
819
820 pub(crate) fn effective_guard_config(
823 &self,
824 handler: &dyn WorkflowHandler,
825 ) -> Option<WorkflowGuardConfig> {
826 handler.guard_config().or_else(|| self.guard_config.clone())
827 }
828
829 async fn build_context_with_guard(
848 &self,
849 run: &Run,
850 handler: &dyn WorkflowHandler,
851 ) -> Result<WorkflowContext, EngineError> {
852 let mut ctx = self.build_context(run);
853 if let Some(config) = self.effective_guard_config(handler) {
854 let mut state = WorkflowGuardState::new()
855 .with_tokens_used(self.tokens_used_by_run_tree(run).await?)
856 .with_elapsed(Duration::from_millis(run.duration_ms));
857 if let Some(at) = config.deadline_for(run.created_at) {
858 state = state.with_deadline_at(at);
859 }
860 ctx.set_guard(config, Arc::new(Mutex::new(state)));
861 }
862 Ok(ctx)
863 }
864
865 pub(crate) async fn list_all_runs(&self, filter: RunFilter) -> Result<Vec<Run>, EngineError> {
874 let mut runs = Vec::new();
875 let mut page = 1;
876 loop {
877 let result = self
878 .store
879 .list_runs(filter.clone(), page, LIST_RUNS_PAGE_SIZE)
880 .await?;
881 if result.items.is_empty() {
882 break;
883 }
884 runs.extend(result.items);
885 if runs.len() as u64 >= result.total {
886 break;
887 }
888 page += 1;
889 }
890 Ok(runs)
891 }
892
893 async fn tokens_used_by_run_tree(&self, root: &Run) -> Result<u64, EngineError> {
902 let tree_label = HashMap::from([(LABEL_ROOT_RUN_ID.to_string(), root.id.to_string())]);
903 let filter = RunFilter {
904 labels: Some(tree_label),
905 ..RunFilter::default()
906 };
907 let mut run_ids = vec![root.id];
908 run_ids.extend(
909 self.list_all_runs(filter)
910 .await?
911 .into_iter()
912 .map(|run| run.id),
913 );
914
915 let mut tokens: u64 = 0;
916 for run_id in run_ids {
917 for step in self.store.list_steps(run_id).await? {
918 if step.kind == StepKind::Agent {
919 let step_tokens = StepOutput::from(&step).total_tokens();
920 tokens = tokens.saturating_add(step_tokens);
921 }
922 }
923 }
924 Ok(tokens)
925 }
926
927 pub async fn check_monthly_quota(&self, workflow_name: &str) -> Result<(), EngineError> {
954 let Some(limit) = self.budget.monthly_cost_limit_usd else {
955 return Ok(());
956 };
957
958 let stats = self
959 .store
960 .get_stats(RunFilter {
961 created_after: Some(month_start(Utc::now())),
962 ..RunFilter::default()
963 })
964 .await?;
965
966 if stats.total_cost_usd < limit {
967 return Ok(());
968 }
969
970 warn!(
971 workflow = %workflow_name,
972 limit_usd = %limit,
973 spent_usd = %stats.total_cost_usd,
974 "monthly cost quota exhausted, refusing new run"
975 );
976
977 #[cfg(feature = "prometheus")]
978 counter!(
979 RUN_BUDGET_EXCEEDED_TOTAL,
980 "workflow" => workflow_name.to_string(),
981 "scope" => "monthly",
982 )
983 .increment(1);
984
985 Err(EngineError::MonthlyBudgetExceeded {
986 limit_usd: limit,
987 spent_usd: stats.total_cost_usd,
988 })
989 }
990
991 pub fn register(&mut self, handler: impl WorkflowHandler + 'static) -> Result<(), EngineError> {
1035 let name = handler.name().to_string();
1036 if self.handlers.contains_key(&name) {
1037 return Err(EngineError::InvalidWorkflow(format!(
1038 "handler '{}' already registered",
1039 name
1040 )));
1041 }
1042 if let Some(category) = handler.category() {
1043 validate_category(&name, category)?;
1044 }
1045 self.handlers.insert(name, Arc::new(handler));
1046 Ok(())
1047 }
1048
1049 pub fn register_boxed(&mut self, handler: Box<dyn WorkflowHandler>) -> Result<(), EngineError> {
1056 let name = handler.name().to_string();
1057 if self.handlers.contains_key(&name) {
1058 return Err(EngineError::InvalidWorkflow(format!(
1059 "handler '{}' already registered",
1060 name
1061 )));
1062 }
1063 if let Some(category) = handler.category() {
1064 validate_category(&name, category)?;
1065 }
1066 self.handlers.insert(name, Arc::from(handler));
1067 Ok(())
1068 }
1069
1070 pub fn get_handler(&self, name: &str) -> Option<&Arc<dyn WorkflowHandler>> {
1072 self.handlers.get(name)
1073 }
1074
1075 pub fn handler_names(&self) -> Vec<&str> {
1077 self.handlers.keys().map(|s| s.as_str()).collect()
1078 }
1079
1080 pub fn handler_info(&self, name: &str) -> Option<WorkflowInfo> {
1082 self.handlers.get(name).map(|h| h.describe())
1083 }
1084
1085 pub fn scheduled_handlers(&self) -> Vec<(&str, &CronSchedule)> {
1109 self.handlers
1110 .iter()
1111 .filter_map(|(name, handler)| handler.schedule().map(|sched| (name.as_str(), sched)))
1112 .collect()
1113 }
1114
1115 pub fn subscribe(
1140 &mut self,
1141 subscriber: impl EventSubscriber + 'static,
1142 event_types: &[&'static str],
1143 ) {
1144 self.event_publisher.subscribe(subscriber, event_types);
1145 }
1146
1147 pub fn event_publisher(&self) -> &EventPublisher {
1152 &self.event_publisher
1153 }
1154
1155 #[tracing::instrument(name = "engine.run_handler", skip_all, fields(workflow = %handler_name))]
1191 pub async fn run_handler(
1192 &self,
1193 handler_name: &str,
1194 trigger: TriggerKind,
1195 payload: Value,
1196 ) -> Result<WorkflowResult, EngineError> {
1197 let handler = self
1198 .handlers
1199 .get(handler_name)
1200 .ok_or_else(|| {
1201 EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
1202 })?
1203 .clone();
1204
1205 self.check_monthly_quota(handler_name).await?;
1206
1207 let handler_version = handler.version().map(str::to_string);
1208 let max_cost_usd = self
1209 .budget
1210 .resolve_run_cap(None, handler.default_max_cost_usd());
1211 let run = self
1212 .store
1213 .create_run(NewRun {
1214 created_by: None,
1215 workflow_name: handler_name.to_string(),
1216 trigger,
1217 payload,
1218 max_retries: 0,
1219 handler_version,
1220 labels: handler.default_labels(),
1221 scheduled_at: None,
1222 idempotency_key: None,
1223 concurrency_key: None,
1224 priority: clamp_priority(handler.priority()),
1225 concurrency_limits: Vec::new(),
1226 max_cost_usd,
1227 worker_tags: normalize_worker_tags(handler.required_worker_tags()),
1228 })
1229 .await?
1230 .into_run();
1231
1232 let run_id = run.id;
1233 info!(run_id = %run_id, handler_version = run.handler_version.as_deref().unwrap_or(""), "run created");
1234
1235 if self.is_workflow_paused(handler_name).await? {
1236 info!(run_id = %run_id, workflow = %handler_name, "workflow paused, run left pending");
1237 return Ok(WorkflowResult {
1238 run,
1239 steps: Vec::new(),
1240 });
1241 }
1242
1243 let mut ctx = self
1247 .build_context_with_guard(&run, handler.as_ref())
1248 .await?;
1249
1250 self.store
1251 .update_run_status(run_id, RunStatus::Running)
1252 .await?;
1253
1254 #[cfg(feature = "prometheus")]
1255 gauge!(RUNS_ACTIVE, "workflow" => handler_name.to_string()).increment(1.0);
1256
1257 let run_start = Instant::now();
1258 let result = handler.execute(&mut ctx).await;
1259 self.finalize_run(run_id, handler_name, result, &ctx, run_start, run.labels)
1260 .await
1261 }
1262
1263 #[tracing::instrument(name = "engine.plan_handler", skip_all, fields(workflow = %handler_name))]
1312 pub async fn plan_handler(
1313 &self,
1314 handler_name: &str,
1315 payload: Value,
1316 options: PlanOptions,
1317 ) -> Result<ExecutionPlan, EngineError> {
1318 if options.max_depth == 0 {
1319 return Err(EngineError::InvalidWorkflow(
1320 "max_depth must be at least 1".to_string(),
1321 ));
1322 }
1323
1324 let handler = self
1325 .handlers
1326 .get(handler_name)
1327 .ok_or_else(|| {
1328 EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
1329 })?
1330 .clone();
1331
1332 let estimates = if options.estimate_durations {
1333 estimate_durations(&self.store, handler_name, options.sample_runs).await?
1334 } else {
1335 HashMap::new()
1336 };
1337
1338 let shared: SharedPlanRecorder = Arc::new(Mutex::new(PlanRecorder::new(
1339 handler_name.to_string(),
1340 payload,
1341 options.max_depth,
1342 estimates,
1343 )));
1344
1345 let handlers = self.handlers.clone();
1348 let resolver: crate::context::HandlerResolver =
1349 Arc::new(move |name: &str| handlers.get(name).cloned());
1350 let mut ctx = WorkflowContext::with_handler_resolver(
1351 Uuid::now_v7(),
1352 handler_name.to_string(),
1353 self.store.clone(),
1354 self.provider.clone(),
1355 resolver,
1356 );
1357 ctx.set_plan(shared.clone());
1358
1359 match timeout(options.timeout, handler.execute(&mut ctx)).await {
1360 Ok(Ok(())) => {}
1361 Ok(Err(err)) => lock_plan(&shared).fail(err.to_string()),
1362 Err(_) => lock_plan(&shared).fail(format!(
1363 "plan timed out after {} s",
1364 options.timeout.as_secs_f64()
1365 )),
1366 }
1367 drop(ctx);
1368
1369 let plan = match Arc::try_unwrap(shared) {
1370 Ok(mutex) => mutex
1371 .into_inner()
1372 .unwrap_or_else(|poisoned| poisoned.into_inner())
1373 .into_plan(),
1374 Err(shared) => lock_plan(&shared).snapshot(),
1375 };
1376
1377 info!(
1378 workflow = %handler_name,
1379 steps = plan.steps.len(),
1380 truncated = plan.truncated,
1381 "execution plan built"
1382 );
1383
1384 Ok(plan)
1385 }
1386
1387 #[tracing::instrument(name = "engine.enqueue_handler", skip_all, fields(workflow = %handler_name))]
1398 pub async fn enqueue_handler(
1399 &self,
1400 handler_name: &str,
1401 trigger: TriggerKind,
1402 payload: Value,
1403 max_retries: u32,
1404 ) -> Result<Run, EngineError> {
1405 self.enqueue_handler_with_options(
1406 handler_name,
1407 trigger,
1408 payload,
1409 EnqueueOptions {
1410 max_retries,
1411 ..Default::default()
1412 },
1413 )
1414 .await
1415 .map(RunCreation::into_run)
1416 }
1417
1418 #[tracing::instrument(name = "engine.enqueue_handler_with_options", skip_all, fields(workflow = %handler_name))]
1470 pub async fn enqueue_handler_with_options(
1471 &self,
1472 handler_name: &str,
1473 trigger: TriggerKind,
1474 payload: Value,
1475 options: EnqueueOptions,
1476 ) -> Result<RunCreation, EngineError> {
1477 let EnqueueOptions {
1478 max_retries,
1479 labels,
1480 scheduled_at,
1481 max_cost_usd,
1482 created_by,
1483 idempotency_key,
1484 concurrency_key,
1485 concurrency_limits,
1486 priority,
1487 worker_tags,
1488 } = options;
1489
1490 validate_concurrency_limits(&concurrency_limits)
1493 .map_err(EngineError::InvalidConcurrencyLimit)?;
1494 if let Some(priority) = priority {
1495 validate_priority(priority).map_err(EngineError::InvalidPriority)?;
1496 }
1497 validate_worker_tags(&worker_tags).map_err(EngineError::InvalidWorkerTag)?;
1498
1499 let handler = self.handlers.get(handler_name).ok_or_else(|| {
1500 EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
1501 })?;
1502
1503 self.check_monthly_quota(handler_name).await?;
1504
1505 let handler_version = handler.version().map(str::to_string);
1506 let mut merged_labels = handler.default_labels();
1507 merged_labels.extend(labels);
1508 let resolved_cap = self
1509 .budget
1510 .resolve_run_cap(max_cost_usd, handler.default_max_cost_usd());
1511 let priority = priority.unwrap_or_else(|| clamp_priority(handler.priority()));
1512 let required_tags = normalize_worker_tags(
1513 handler
1514 .required_worker_tags()
1515 .into_iter()
1516 .chain(worker_tags),
1517 );
1518
1519 let creation = self
1520 .store
1521 .create_run(NewRun {
1522 workflow_name: handler_name.to_string(),
1523 trigger,
1524 payload,
1525 max_retries,
1526 handler_version,
1527 labels: merged_labels,
1528 scheduled_at,
1529 created_by,
1530 idempotency_key,
1531 concurrency_key,
1532 priority,
1533 concurrency_limits,
1534 max_cost_usd: resolved_cap,
1535 worker_tags: required_tags,
1536 })
1537 .await?;
1538
1539 match &creation {
1540 RunCreation::Created(run) => info!(
1541 run_id = %run.id,
1542 workflow = %handler_name,
1543 max_cost_usd = ?resolved_cap,
1544 "handler run enqueued"
1545 ),
1546 RunCreation::Existing(run) => info!(
1547 run_id = %run.id,
1548 workflow = %handler_name,
1549 "idempotent replay, nothing enqueued"
1550 ),
1551 }
1552
1553 Ok(creation)
1554 }
1555
1556 #[tracing::instrument(name = "engine.execute_handler_run", skip_all, fields(run_id = %run_id))]
1576 pub async fn execute_handler_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
1577 let run = self
1578 .store
1579 .get_run(run_id)
1580 .await?
1581 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
1582
1583 if let Some(root_run_id) = chain_root(&run) {
1584 return self.resume_chain(run, root_run_id).await;
1585 }
1586
1587 let _active = self.track_execution(run_id).await;
1588
1589 let handler = self
1590 .handlers
1591 .get(&run.workflow_name)
1592 .ok_or_else(|| {
1593 EngineError::InvalidWorkflow(format!(
1594 "no handler registered: {}",
1595 run.workflow_name
1596 ))
1597 })?
1598 .clone();
1599
1600 #[cfg(feature = "prometheus")]
1601 gauge!(RUNS_ACTIVE, "workflow" => run.workflow_name.clone()).increment(1.0);
1602
1603 let run_start = Instant::now();
1604 let mut ctx = self
1605 .build_context_with_guard(&run, handler.as_ref())
1606 .await?;
1607
1608 let result = if handler.is_version_compatible(run.handler_version.as_deref()) {
1621 ctx.load_replay_steps().await?;
1622 self.release_then_execute(run_id, handler.as_ref(), &mut ctx)
1623 .await
1624 } else {
1625 Err(EngineError::HandlerVersionMismatch {
1626 run_id,
1627 workflow_name: run.workflow_name.clone(),
1628 run_version: run
1629 .handler_version
1630 .clone()
1631 .unwrap_or_else(|| "unknown".to_string()),
1632 current_version: handler
1633 .version()
1634 .map(str::to_string)
1635 .unwrap_or_else(|| "unknown".to_string()),
1636 })
1637 };
1638
1639 self.finalize_run(
1640 run_id,
1641 &run.workflow_name,
1642 result,
1643 &ctx,
1644 run_start,
1645 run.labels,
1646 )
1647 .await
1648 }
1649
1650 #[tracing::instrument(name = "engine.execute_run", skip_all, fields(run_id = %run_id))]
1658 pub async fn execute_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
1659 self.execute_handler_run(run_id).await
1660 }
1661
1662 #[tracing::instrument(name = "engine.resume_run", skip_all, fields(run_id = %run_id))]
1691 pub async fn resume_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
1692 let run = self
1693 .store
1694 .get_run(run_id)
1695 .await?
1696 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
1697
1698 if let Some(root_run_id) = chain_root(&run) {
1699 return self.resume_chain(run, root_run_id).await;
1700 }
1701
1702 self.resume_loaded_run(run).await
1703 }
1704
1705 async fn resume_chain(
1722 &self,
1723 child: Run,
1724 root_run_id: Uuid,
1725 ) -> Result<WorkflowResult, EngineError> {
1726 let child_run_id = child.id;
1727 let lease = child.worker_id.zip(child.lease_expires_at);
1728 match self
1729 .claim_chain_root(child_run_id, root_run_id, lease.as_ref())
1730 .await?
1731 {
1732 ChainClaim::Claimed => {}
1733 ChainClaim::Settled(root) => {
1734 return Ok(WorkflowResult {
1735 run: *root,
1736 steps: Vec::new(),
1737 });
1738 }
1739 }
1740
1741 if lease.is_some() {
1742 self.store
1743 .update_run(
1744 child_run_id,
1745 RunUpdate {
1746 lease: Some(LeaseUpdate::Release),
1747 ..RunUpdate::default()
1748 },
1749 )
1750 .await?;
1751 }
1752
1753 info!(
1754 run_id = %child_run_id,
1755 root_run_id = %root_run_id,
1756 lease_transferred = lease.is_some(),
1757 "child run resumed through its root run"
1758 );
1759
1760 let root = self
1761 .store
1762 .get_run(root_run_id)
1763 .await?
1764 .ok_or(EngineError::Store(StoreError::RunNotFound(root_run_id)))?;
1765 self.resume_loaded_run(root).await
1766 }
1767
1768 pub(crate) async fn move_root_to_running(
1774 &self,
1775 root_run_id: Uuid,
1776 lease: Option<&(String, DateTime<Utc>)>,
1777 ) -> Result<(), EngineError> {
1778 match lease {
1779 Some((worker_id, expires_at)) => {
1780 self.store
1781 .update_run(
1782 root_run_id,
1783 RunUpdate {
1784 status: Some(RunStatus::Running),
1785 lease: Some(LeaseUpdate::Set {
1786 worker_id: worker_id.clone(),
1787 expires_at: *expires_at,
1788 }),
1789 ..RunUpdate::default()
1790 },
1791 )
1792 .await?;
1793 }
1794 None => {
1795 self.store
1796 .update_run_status(root_run_id, RunStatus::Running)
1797 .await?;
1798 }
1799 }
1800 Ok(())
1801 }
1802
1803 async fn resume_loaded_run(&self, run: Run) -> Result<WorkflowResult, EngineError> {
1805 let run_id = run.id;
1806 let _active = self.track_execution(run_id).await;
1807 let handler = self
1808 .handlers
1809 .get(&run.workflow_name)
1810 .ok_or_else(|| {
1811 EngineError::InvalidWorkflow(format!(
1812 "no handler registered: {}",
1813 run.workflow_name
1814 ))
1815 })?
1816 .clone();
1817
1818 info!(run_id = %run_id, workflow = %run.workflow_name, "resuming run after approval");
1819
1820 let run_start = Instant::now();
1821 let mut ctx = self
1822 .build_context_with_guard(&run, handler.as_ref())
1823 .await?;
1824
1825 let result = if handler.is_version_compatible(run.handler_version.as_deref()) {
1826 ctx.load_replay_steps().await?;
1827 self.release_then_execute(run_id, handler.as_ref(), &mut ctx)
1828 .await
1829 } else {
1830 Err(EngineError::HandlerVersionMismatch {
1831 run_id,
1832 workflow_name: run.workflow_name.clone(),
1833 run_version: run
1834 .handler_version
1835 .clone()
1836 .unwrap_or_else(|| "unknown".to_string()),
1837 current_version: handler
1838 .version()
1839 .map(str::to_string)
1840 .unwrap_or_else(|| "unknown".to_string()),
1841 })
1842 };
1843
1844 self.finalize_run(
1845 run_id,
1846 &run.workflow_name,
1847 result,
1848 &ctx,
1849 run_start,
1850 run.labels,
1851 )
1852 .await
1853 }
1854
1855 pub async fn deliver_signal(
1903 self: &Arc<Self>,
1904 signal: NewSignal,
1905 ) -> Result<SignalDelivery, EngineError> {
1906 self.deliver_signal_from(signal, None).await
1907 }
1908
1909 pub async fn deliver_signal_from(
1939 self: &Arc<Self>,
1940 signal: NewSignal,
1941 origin: Option<SignalOrigin>,
1942 ) -> Result<SignalDelivery, EngineError> {
1943 validate_signal(&signal)?;
1944
1945 let stored = match self.store.insert_signal(signal).await? {
1946 SignalInsert::Created(stored) => stored,
1947 SignalInsert::Duplicate(existing) => {
1948 info!(
1949 signal_id = %existing.id,
1950 signal = %existing.name,
1951 key = %existing.key,
1952 "duplicate signal ignored"
1953 );
1954 return Ok(SignalDelivery {
1955 signal_id: existing.id,
1956 duplicate: true,
1957 resumed: Vec::new(),
1958 rejected: Vec::new(),
1959 });
1960 }
1961 };
1962
1963 let waiters = self
1964 .store
1965 .list_signal_waiters(&stored.name, &stored.key)
1966 .await?;
1967 let mut resumed = Vec::new();
1968 let mut rejected = Vec::new();
1969
1970 for step in waiters {
1971 if let Err(error) = validate_step_payload(step.input.as_ref(), &stored.payload) {
1972 rejected.push(SignalRejected {
1973 run_id: step.run_id,
1974 step_id: step.id,
1975 error,
1976 });
1977 continue;
1978 }
1979
1980 match self
1981 .store
1982 .resolve_signal_step(step.id, received_output(&stored))
1983 .await
1984 {
1985 Ok(SignalStepResolution::Resolved {
1986 run_id,
1987 run_resumed,
1988 }) => {
1989 resumed.push(SignalResumed {
1990 run_id,
1991 step_id: step.id,
1992 });
1993 if run_resumed && self.execution_mode == ExecutionMode::Local {
1994 self.spawn_local_resume(run_id);
1995 }
1996 }
1997 Ok(SignalStepResolution::NotWaiting { .. }) => {}
1999 Err(err) => {
2000 error!(
2001 run_id = %step.run_id,
2002 step_id = %step.id,
2003 error = %err,
2004 "failed to resolve a waiting signal step"
2005 );
2006 rejected.push(SignalRejected {
2007 run_id: step.run_id,
2008 step_id: step.id,
2009 error: err.to_string(),
2010 });
2011 }
2012 }
2013 }
2014
2015 info!(
2016 signal_id = %stored.id,
2017 signal = %stored.name,
2018 key = %stored.key,
2019 resumed = resumed.len(),
2020 rejected = rejected.len(),
2021 "signal received"
2022 );
2023 self.event_publisher
2024 .publish(Event::SignalReceived(SignalReceivedEvent {
2025 signal_id: stored.id,
2026 name: stored.name.clone(),
2027 key: stored.key.clone(),
2028 resumed_runs: resumed.iter().map(|r| r.run_id).collect(),
2029 sender_run_id: origin.map(|o| o.run_id),
2030 sender_step_id: origin.map(|o| o.step_id),
2031 at: stored.received_at,
2032 }));
2033
2034 Ok(SignalDelivery {
2035 signal_id: stored.id,
2036 duplicate: false,
2037 resumed,
2038 rejected,
2039 })
2040 }
2041
2042 pub async fn send_signal<S: Signal>(
2080 self: &Arc<Self>,
2081 signal: &S,
2082 key: &str,
2083 idempotency_id: Option<&str>,
2084 ) -> Result<SignalDelivery, EngineError> {
2085 let payload = to_value(signal)?;
2086 self.deliver_signal(NewSignal {
2087 name: S::NAME.to_string(),
2088 key: key.to_string(),
2089 payload,
2090 idempotency_id: idempotency_id.map(str::to_string),
2091 })
2092 .await
2093 }
2094
2095 pub(crate) fn spawn_local_resume(self: &Arc<Self>, run_id: Uuid) {
2120 let engine = Arc::clone(self);
2121 spawn(async move {
2122 let _resuming = lock_run(&engine.local_resumes, run_id).await;
2126 engine.wait_until_idle(run_id).await;
2127 let run = match engine.store.get_run(run_id).await {
2128 Ok(Some(run)) => run,
2129 Ok(None) => {
2130 error!(run_id = %run_id, "run to restart not found");
2131 return;
2132 }
2133 Err(err) => {
2134 error!(run_id = %run_id, error = %err, "failed to load a run to restart");
2135 return;
2136 }
2137 };
2138 match run.status.state {
2139 RunStatus::Pending => {
2140 match engine
2141 .store
2142 .update_run_status(run_id, RunStatus::Running)
2143 .await
2144 {
2145 Ok(()) => {}
2146 Err(StoreError::InvalidTransition { from, .. }) => {
2147 info!(
2148 run_id = %run_id,
2149 status = %from,
2150 "run claimed elsewhere, left to its worker"
2151 );
2152 return;
2153 }
2154 Err(err) => {
2155 error!(run_id = %run_id, error = %err, "failed to restart a woken run");
2156 return;
2157 }
2158 }
2159 }
2160 RunStatus::Running if run.worker_id.is_some() => {
2161 info!(
2162 run_id = %run_id,
2163 worker_id = ?run.worker_id,
2164 "run owned by a worker lease, local resume skipped"
2165 );
2166 return;
2167 }
2168 RunStatus::Running => {
2169 if engine.is_executing(run_id) {
2170 info!(run_id = %run_id, "run already executing in this process, resume skipped");
2171 return;
2172 }
2173 }
2174 status => {
2175 info!(
2176 run_id = %run_id,
2177 status = %status,
2178 "run no longer waiting to restart, resume skipped"
2179 );
2180 return;
2181 }
2182 }
2183 if let Err(err) = engine.resume_run(run_id).await {
2184 error!(run_id = %run_id, error = %err, "failed to resume a woken run");
2185 }
2186 });
2187 }
2188
2189 pub async fn fail_or_schedule_retry(
2240 &self,
2241 run_id: Uuid,
2242 error: &str,
2243 retryable: bool,
2244 cost_usd: Option<Decimal>,
2245 duration_ms: Option<u64>,
2246 ) -> Result<RunStatus, EngineError> {
2247 let run = self
2248 .store
2249 .get_run(run_id)
2250 .await?
2251 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
2252
2253 if run.status.state == RunStatus::Paused {
2256 info!(run_id = %run_id, error = %error, "run paused, failure not recorded");
2257 return Ok(RunStatus::Paused);
2258 }
2259
2260 let has_attempts_left = run.retry_count < run.max_retries;
2261 let update = if retryable && has_attempts_left {
2262 let backoff = backoff_for_retry(run.retry_count);
2263 let scheduled_at = Utc::now() + TimeDelta::milliseconds(backoff.as_millis() as i64);
2264
2265 info!(
2266 run_id = %run_id,
2267 workflow = %run.workflow_name,
2268 attempt = run.retry_count + 1,
2269 max_retries = run.max_retries,
2270 backoff_secs = backoff.as_secs(),
2271 scheduled_at = %scheduled_at,
2272 "run failed, scheduling retry"
2273 );
2274
2275 RunUpdate {
2276 status: Some(RunStatus::Retrying),
2277 error: Some(error.to_string()),
2278 increment_retry: true,
2279 cost_usd,
2280 duration_ms,
2281 scheduled_at: Some(scheduled_at),
2282 ..RunUpdate::default()
2283 }
2284 } else {
2285 RunUpdate {
2286 status: Some(RunStatus::Failed),
2287 error: Some(error.to_string()),
2288 cost_usd,
2289 duration_ms,
2290 completed_at: Some(Utc::now()),
2291 ..RunUpdate::default()
2292 }
2293 };
2294
2295 let status = update.status.unwrap_or(RunStatus::Failed);
2296 self.store.update_run(run_id, update).await?;
2297 self.fail_orphaned_steps(run_id, error).await?;
2298 self.cancel_descendants_of_stopped_run(run_id, error).await;
2301
2302 Ok(status)
2303 }
2304
2305 pub async fn interrupt_running_steps(&self, run_id: Uuid) -> Result<(), EngineError> {
2335 interrupt_running_steps(self.store.as_ref(), run_id).await
2336 }
2337
2338 pub async fn fail_orphaned_steps(
2352 &self,
2353 run_id: Uuid,
2354 error_message: &str,
2355 ) -> Result<(), EngineError> {
2356 let steps = self.store.list_steps(run_id).await?;
2357 let now = Utc::now();
2358
2359 for step in steps {
2360 if step.status.state.is_terminal() {
2361 continue;
2362 }
2363
2364 let (target_status, error) = match step.status.state {
2365 StepStatus::Running | StepStatus::AwaitingApproval => {
2366 let err = if step.error.is_some() {
2367 None
2368 } else {
2369 Some(error_message.to_string())
2370 };
2371 (StepStatus::Failed, err)
2372 }
2373 StepStatus::Pending => (StepStatus::Skipped, None),
2374 _ => continue,
2375 };
2376
2377 if let Err(e) = self
2378 .store
2379 .update_step(
2380 step.id,
2381 StepUpdate {
2382 status: Some(target_status),
2383 error,
2384 completed_at: Some(now),
2385 ..StepUpdate::default()
2386 },
2387 )
2388 .await
2389 {
2390 warn!(
2391 run_id = %run_id,
2392 step_id = %step.id,
2393 step_name = %step.name,
2394 error = %e,
2395 "failed to cleanup orphaned step"
2396 );
2397 } else {
2398 info!(
2399 run_id = %run_id,
2400 step_id = %step.id,
2401 step_name = %step.name,
2402 from = %step.status.state,
2403 to = %target_status,
2404 "cleaned up orphaned step"
2405 );
2406 }
2407 }
2408
2409 Ok(())
2410 }
2411
2412 async fn release_then_execute(
2417 &self,
2418 run_id: Uuid,
2419 handler: &dyn WorkflowHandler,
2420 ctx: &mut WorkflowContext,
2421 ) -> Result<(), EngineError> {
2422 match self.provider.release_run(&run_id.to_string()).await {
2423 Ok(()) => handler.execute(ctx).await,
2424 Err(e) => Err(EngineError::Operation(OperationError::Agent(e))),
2425 }
2426 }
2427
2428 async fn finalize_run(
2434 &self,
2435 run_id: Uuid,
2436 workflow_name: &str,
2437 result: Result<(), EngineError>,
2438 ctx: &WorkflowContext,
2439 run_start: Instant,
2440 run_labels: HashMap<String, String>,
2441 ) -> Result<WorkflowResult, EngineError> {
2442 let total_duration = ctx.carried_duration_ms() + run_start.elapsed().as_millis() as u64;
2445 let completed_at = Utc::now();
2446
2447 if let Some(run) = self.store.get_run(run_id).await?
2451 && run.status.state == RunStatus::Paused
2452 {
2453 let run = self
2454 .store
2455 .update_run_returning(
2456 run_id,
2457 RunUpdate {
2458 cost_usd: Some(ctx.total_cost_usd()),
2459 duration_ms: Some(total_duration),
2460 ..RunUpdate::default()
2461 },
2462 )
2463 .await?;
2464 info!(
2465 run_id = %run_id,
2466 outcome = ?result.err().map(|err| err.to_string()),
2467 "run paused, execution stopped"
2468 );
2469 return Ok(WorkflowResult {
2470 run,
2471 steps: ctx.step_results().to_vec(),
2472 });
2473 }
2474
2475 if matches!(result, Err(EngineError::RunPaused { .. })) {
2478 let run = self
2479 .store
2480 .get_run(run_id)
2481 .await?
2482 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
2483 info!(
2484 run_id = %run_id,
2485 status = %run.status.state,
2486 "run resumed after the pause, execution stopped"
2487 );
2488 return Ok(WorkflowResult {
2489 run,
2490 steps: ctx.step_results().to_vec(),
2491 });
2492 }
2493
2494 let final_status;
2495 let final_run;
2496
2497 match result {
2498 Ok(()) => {
2499 final_status = if ctx.has_allowed_failure() {
2500 RunStatus::Warning
2501 } else {
2502 RunStatus::Completed
2503 };
2504 final_run = self
2505 .store
2506 .update_run_returning(
2507 run_id,
2508 RunUpdate {
2509 status: Some(final_status),
2510 cost_usd: Some(ctx.total_cost_usd()),
2511 duration_ms: Some(total_duration),
2512 completed_at: Some(completed_at),
2513 output: ctx.output().cloned(),
2514 ..RunUpdate::default()
2515 },
2516 )
2517 .await?;
2518
2519 info!(
2520 run_id = %run_id,
2521 status = %final_status,
2522 cost_usd = %ctx.total_cost_usd(),
2523 duration_ms = total_duration,
2524 "run completed"
2525 );
2526 }
2527 Err(EngineError::ApprovalRequired {
2528 run_id: approval_run_id,
2529 step_id,
2530 ref message,
2531 }) => {
2532 final_status = RunStatus::AwaitingApproval;
2533 final_run = self
2534 .store
2535 .update_run_returning(
2536 run_id,
2537 RunUpdate {
2538 status: Some(RunStatus::AwaitingApproval),
2539 cost_usd: Some(ctx.total_cost_usd()),
2540 duration_ms: Some(total_duration),
2541 ..RunUpdate::default()
2542 },
2543 )
2544 .await?;
2545
2546 info!(
2547 run_id = %approval_run_id,
2548 step_id = %step_id,
2549 message = %message,
2550 "run awaiting approval"
2551 );
2552
2553 self.publish_approval_requested(approval_run_id, step_id, message)
2554 .await?;
2555 }
2556 Err(EngineError::ChildSuspended {
2557 run_id: child_run_id,
2558 ref cause,
2559 }) => {
2560 final_status = cause.suspension_status();
2561 final_run = self
2565 .store
2566 .update_run_returning(
2567 run_id,
2568 RunUpdate {
2569 status: Some(final_status),
2570 cost_usd: Some(ctx.total_cost_usd()),
2571 duration_ms: Some(total_duration),
2572 ..RunUpdate::default()
2573 },
2574 )
2575 .await?;
2576
2577 let leaf = cause.suspension_leaf();
2578 info!(
2579 run_id = %run_id,
2580 child_run_id = %child_run_id,
2581 status = %final_status,
2582 cause = %leaf,
2583 "run suspended with its child run"
2584 );
2585
2586 self.publish_suspension(leaf).await?;
2587 for sibling in ctx.sibling_suspensions() {
2589 self.publish_suspension(sibling.suspension_leaf()).await?;
2590 }
2591 }
2592 Err(EngineError::HumanInputRequired {
2593 run_id: input_run_id,
2594 step_id,
2595 ref message,
2596 }) => {
2597 final_status = RunStatus::AwaitingApproval;
2598 final_run = self
2599 .store
2600 .update_run_returning(
2601 run_id,
2602 RunUpdate {
2603 status: Some(RunStatus::AwaitingApproval),
2604 cost_usd: Some(ctx.total_cost_usd()),
2605 duration_ms: Some(total_duration),
2606 ..RunUpdate::default()
2607 },
2608 )
2609 .await?;
2610
2611 info!(
2613 run_id = %input_run_id,
2614 step_id = %step_id,
2615 message = %message,
2616 "run awaiting human input"
2617 );
2618 }
2619 Err(EngineError::DelaySleeping {
2620 run_id: delay_run_id,
2621 step_id,
2622 wake_at,
2623 }) => {
2624 final_status = RunStatus::Sleeping;
2625 final_run = self
2626 .store
2627 .update_run_returning(
2628 run_id,
2629 RunUpdate {
2630 status: Some(RunStatus::Sleeping),
2631 cost_usd: Some(ctx.total_cost_usd()),
2632 duration_ms: Some(total_duration),
2633 scheduled_at: Some(wake_at),
2634 ..RunUpdate::default()
2635 },
2636 )
2637 .await?;
2638
2639 info!(
2640 run_id = %delay_run_id,
2641 step_id = %step_id,
2642 wake_at = %wake_at,
2643 "run sleeping until delay elapses"
2644 );
2645 }
2646 Err(EngineError::CapacitySleeping {
2647 run_id: capacity_run_id,
2648 step_id,
2649 ref kind,
2650 wake_at,
2651 }) => {
2652 final_status = RunStatus::Sleeping;
2653 final_run = self
2654 .store
2655 .update_run_returning(
2656 run_id,
2657 RunUpdate {
2658 status: Some(RunStatus::Sleeping),
2659 cost_usd: Some(ctx.total_cost_usd()),
2660 duration_ms: Some(total_duration),
2661 scheduled_at: Some(wake_at),
2662 capacity_wait_kind: Some(ProviderKind::new(kind.as_str())),
2663 ..RunUpdate::default()
2664 },
2665 )
2666 .await?;
2667
2668 info!(
2669 run_id = %capacity_run_id,
2670 step_id = %step_id,
2671 kind = %kind,
2672 wake_at = %wake_at,
2673 "run sleeping until provider capacity returns"
2674 );
2675 }
2676 Err(EngineError::SignalWaiting {
2677 run_id: wait_run_id,
2678 step_id,
2679 ref step_name,
2680 ref name,
2681 ref key,
2682 deadline_at,
2683 }) => {
2684 final_status = RunStatus::Sleeping;
2685 let waiting = self
2689 .store
2690 .suspend_run_on_signal(run_id, step_id, deadline_at)
2691 .await?;
2692 final_run = self
2693 .store
2694 .update_run_returning(
2695 run_id,
2696 RunUpdate {
2697 cost_usd: Some(ctx.total_cost_usd()),
2698 duration_ms: Some(total_duration),
2699 ..RunUpdate::default()
2700 },
2701 )
2702 .await?;
2703
2704 if waiting {
2705 self.event_publisher
2706 .publish(Event::SignalAwaited(SignalAwaitedEvent {
2707 run_id: wait_run_id,
2708 step_id,
2709 step_name: step_name.clone(),
2710 name: name.clone(),
2711 key: key.clone(),
2712 deadline_at,
2713 at: Utc::now(),
2714 }));
2715 }
2716
2717 info!(
2718 run_id = %wait_run_id,
2719 step_id = %step_id,
2720 signal = %name,
2721 key = %key,
2722 deadline_at = %deadline_at,
2723 waiting,
2724 "run sleeping until a signal arrives"
2725 );
2726 }
2727 Err(err) => {
2728 let guardrail_stop = matches!(
2732 err,
2733 EngineError::RunBudgetExceeded { .. } | EngineError::WorkflowGuardRejected(_)
2734 );
2735
2736 final_status = if guardrail_stop {
2737 if let Err(store_err) = self
2738 .store
2739 .update_run(
2740 run_id,
2741 RunUpdate {
2742 status: Some(RunStatus::Cancelled),
2743 error: Some(err.to_string()),
2744 cost_usd: Some(ctx.total_cost_usd()),
2745 duration_ms: Some(total_duration),
2746 completed_at: Some(completed_at),
2747 output: ctx.output().cloned(),
2748 ..RunUpdate::default()
2749 },
2750 )
2751 .await
2752 {
2753 error!(run_id = %run_id, store_error = %store_err, "failed to persist run cancellation");
2754 }
2755 if let Err(cleanup_err) = self
2756 .fail_orphaned_steps(run_id, "run stopped: guardrail limit reached")
2757 .await
2758 {
2759 error!(run_id = %run_id, store_error = %cleanup_err, "failed to cleanup orphaned steps");
2760 }
2761 RunStatus::Cancelled
2762 } else {
2763 if let Some(output) = ctx.output()
2766 && let Err(store_err) = self
2767 .store
2768 .update_run(
2769 run_id,
2770 RunUpdate {
2771 output: Some(output.clone()),
2772 ..RunUpdate::default()
2773 },
2774 )
2775 .await
2776 {
2777 error!(run_id = %run_id, store_error = %store_err, "failed to persist run output");
2778 }
2779 self.fail_or_schedule_retry(
2780 run_id,
2781 &err.to_string(),
2782 is_run_retryable(&err),
2783 Some(ctx.total_cost_usd()),
2784 Some(total_duration),
2785 )
2786 .await
2787 .unwrap_or_else(|store_err| {
2788 error!(run_id = %run_id, store_error = %store_err, "failed to persist run failure");
2789 RunStatus::Failed
2790 })
2791 };
2792
2793 if matches!(err, EngineError::RunBudgetExceeded { .. }) {
2794 self.on_run_budget_exceeded(workflow_name, run_id, &err);
2795 }
2796
2797 error!(run_id = %run_id, status = %final_status, error = %err, "run stopped");
2798
2799 self.publish_run_status_changed(
2800 workflow_name,
2801 run_id,
2802 final_status,
2803 Some(err.to_string()),
2804 ctx,
2805 total_duration,
2806 run_labels,
2807 );
2808
2809 #[cfg(feature = "prometheus")]
2810 self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
2811
2812 return Err(err);
2813 }
2814 }
2815
2816 self.publish_run_status_changed(
2817 workflow_name,
2818 run_id,
2819 final_status,
2820 None,
2821 ctx,
2822 total_duration,
2823 run_labels,
2824 );
2825
2826 #[cfg(feature = "prometheus")]
2827 self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
2828
2829 Ok(WorkflowResult {
2830 run: final_run,
2831 steps: ctx.step_results().to_vec(),
2832 })
2833 }
2834
2835 pub(crate) async fn publish_approval_requested(
2838 &self,
2839 run_id: Uuid,
2840 step_id: Uuid,
2841 message: &str,
2842 ) -> Result<(), EngineError> {
2843 let requirement = self
2844 .store
2845 .get_step(step_id)
2846 .await?
2847 .and_then(|s| s.approval_requirement);
2848 self.event_publisher
2849 .publish(Event::ApprovalRequested(ApprovalRequestedEvent {
2850 run_id,
2851 step_id,
2852 message: message.to_string(),
2853 requirement,
2854 at: Utc::now(),
2855 }));
2856 Ok(())
2857 }
2858
2859 pub async fn fail_ancestors(&self, run_id: Uuid, reason: &str) -> Result<(), EngineError> {
2892 let mut current = self
2893 .store
2894 .get_run(run_id)
2895 .await?
2896 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
2897 let mut visited = HashSet::from([run_id]);
2899
2900 while let Some(parent_id) = chain_parent(¤t) {
2901 if !visited.insert(parent_id) {
2902 break;
2903 }
2904 let status = self
2905 .fail_or_schedule_retry(parent_id, reason, false, None, None)
2906 .await?;
2907 if status == RunStatus::Paused {
2910 self.requeue_paused_root(¤t).await?;
2911 break;
2912 }
2913 info!(
2914 run_id = %run_id,
2915 ancestor_run_id = %parent_id,
2916 status = %status,
2917 "ancestor run failed with its child"
2918 );
2919 current = self
2920 .store
2921 .get_run(parent_id)
2922 .await?
2923 .ok_or(EngineError::Store(StoreError::RunNotFound(parent_id)))?;
2924 }
2925
2926 Ok(())
2927 }
2928
2929 #[cfg(feature = "prometheus")]
2931 fn emit_run_metrics(
2932 &self,
2933 workflow_name: &str,
2934 status: RunStatus,
2935 duration_ms: u64,
2936 ctx: &WorkflowContext,
2937 ) {
2938 let status_str = status.to_string();
2939 let wf = workflow_name.to_string();
2940
2941 counter!(RUNS_TOTAL, "workflow" => wf.clone(), "status" => status_str.clone()).increment(1);
2942 histogram!(RUN_DURATION_SECONDS, "workflow" => wf.clone(), "status" => status_str)
2943 .record(duration_ms as f64 / 1000.0);
2944 histogram!(RUN_COST_USD, "workflow" => wf.clone()).record(
2945 ctx.total_cost_usd()
2946 .to_string()
2947 .parse::<f64>()
2948 .unwrap_or(0.0),
2949 );
2950 gauge!(RUNS_ACTIVE, "workflow" => wf).decrement(1.0);
2951 }
2952
2953 fn on_run_budget_exceeded(&self, workflow_name: &str, run_id: Uuid, err: &EngineError) {
2959 let EngineError::RunBudgetExceeded {
2960 limit_usd,
2961 spent_usd,
2962 step_budget_usd,
2963 ..
2964 } = err
2965 else {
2966 return;
2967 };
2968
2969 #[cfg(feature = "prometheus")]
2970 counter!(
2971 RUN_BUDGET_EXCEEDED_TOTAL,
2972 "workflow" => workflow_name.to_string(),
2973 "scope" => "run",
2974 )
2975 .increment(1);
2976
2977 self.event_publisher
2978 .publish(Event::RunBudgetExceeded(RunBudgetExceededEvent {
2979 run_id,
2980 workflow_name: workflow_name.to_string(),
2981 limit_usd: *limit_usd,
2982 spent_usd: *spent_usd,
2983 step_budget_usd: *step_budget_usd,
2984 at: Utc::now(),
2985 }));
2986 }
2987
2988 #[allow(clippy::too_many_arguments)]
2993 fn publish_run_status_changed(
2994 &self,
2995 workflow_name: &str,
2996 run_id: Uuid,
2997 to: RunStatus,
2998 error: Option<String>,
2999 ctx: &WorkflowContext,
3000 duration_ms: u64,
3001 labels: HashMap<String, String>,
3002 ) {
3003 let now = Utc::now();
3004 let cost_usd = ctx.total_cost_usd();
3005 let wf = workflow_name.to_string();
3006
3007 self.event_publisher
3008 .publish(Event::RunStatusChanged(RunStatusChangedEvent {
3009 run_id,
3010 workflow_name: wf.clone(),
3011 from: RunStatus::Running,
3012 to,
3013 error: error.clone(),
3014 cost_usd,
3015 duration_ms,
3016 labels: labels.clone(),
3017 at: now,
3018 }));
3019
3020 if to == RunStatus::Failed {
3021 self.event_publisher
3022 .publish(Event::RunFailed(RunFailedEvent {
3023 run_id,
3024 workflow_name: wf,
3025 error,
3026 cost_usd,
3027 duration_ms,
3028 labels,
3029 at: now,
3030 }));
3031 }
3032 }
3033}
3034
3035impl fmt::Debug for Engine {
3036 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3037 f.debug_struct("Engine")
3038 .field("handlers", &self.handlers.keys().collect::<Vec<_>>())
3039 .finish_non_exhaustive()
3040 }
3041}
3042
3043#[cfg(test)]
3044mod tests {
3045 use super::*;
3046 use crate::config::ShellConfig;
3047 use crate::handler::{HandlerFuture, WorkflowHandler};
3048 use ironflow_core::providers::claude::ClaudeCodeProvider;
3049 use ironflow_core::providers::record_replay::RecordReplayProvider;
3050 use ironflow_store::memory::InMemoryStore;
3051 use ironflow_store::models::{MAX_PRIORITY, MIN_PRIORITY, StepApproval, StepStatus};
3052 use serde_json::json;
3053
3054 struct EchoWorkflow;
3056
3057 impl WorkflowHandler for EchoWorkflow {
3058 fn name(&self) -> &str {
3059 "echo-workflow"
3060 }
3061
3062 fn describe(&self) -> WorkflowInfo {
3063 WorkflowInfo {
3064 description: "A simple workflow that echoes hello".to_string(),
3065 source_code: None,
3066 sub_workflows: Vec::new(),
3067 category: None,
3068 version: self.version().map(str::to_string),
3069 compatible_versions: Vec::new(),
3070 input_schema: None,
3071 default_labels: HashMap::new(),
3072 schedule: self.schedule().cloned(),
3073 default_max_cost_usd: self.default_max_cost_usd(),
3074 priority: self.priority(),
3075 }
3076 }
3077
3078 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3079 Box::pin(async move {
3080 ctx.shell("greet", ShellConfig::new("echo hello")).await?;
3081 Ok(())
3082 })
3083 }
3084 }
3085
3086 struct FailingWorkflow;
3088
3089 impl WorkflowHandler for FailingWorkflow {
3090 fn name(&self) -> &str {
3091 "failing-workflow"
3092 }
3093
3094 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3095 Box::pin(async move {
3096 ctx.shell("fail", ShellConfig::new("exit 1")).await?;
3097 Ok(())
3098 })
3099 }
3100 }
3101
3102 fn create_test_engine() -> Engine {
3103 let store = Arc::new(InMemoryStore::new());
3104 let inner = ClaudeCodeProvider::new();
3105 let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
3106 inner,
3107 "/tmp/ironflow-fixtures",
3108 ));
3109 Engine::new(store, provider)
3110 }
3111
3112 #[test]
3113 fn engine_new_creates_instance() {
3114 let engine = create_test_engine();
3115 assert_eq!(engine.handler_names().len(), 0);
3116 }
3117
3118 #[test]
3119 fn execution_mode_defaults_to_local() {
3120 let engine = create_test_engine();
3121 assert_eq!(engine.execution_mode(), ExecutionMode::Local);
3122 }
3123
3124 #[test]
3125 fn with_execution_mode_overrides_the_default() {
3126 let engine = create_test_engine().with_execution_mode(ExecutionMode::Workers);
3127 assert_eq!(engine.execution_mode(), ExecutionMode::Workers);
3128 }
3129
3130 #[test]
3131 fn engine_register_handler() {
3132 let mut engine = create_test_engine();
3133 let result = engine.register(EchoWorkflow);
3134 assert!(result.is_ok());
3135 assert_eq!(engine.handler_names().len(), 1);
3136 assert!(engine.handler_names().contains(&"echo-workflow"));
3137 }
3138
3139 #[test]
3140 fn engine_register_duplicate_returns_error() {
3141 let mut engine = create_test_engine();
3142 engine.register(EchoWorkflow).unwrap();
3143 let result = engine.register(EchoWorkflow);
3144 assert!(result.is_err());
3145 }
3146
3147 #[test]
3148 fn engine_get_handler_found() {
3149 let mut engine = create_test_engine();
3150 engine.register(EchoWorkflow).unwrap();
3151 let handler = engine.get_handler("echo-workflow");
3152 assert!(handler.is_some());
3153 }
3154
3155 #[test]
3156 fn engine_get_handler_not_found() {
3157 let engine = create_test_engine();
3158 let handler = engine.get_handler("nonexistent");
3159 assert!(handler.is_none());
3160 }
3161
3162 #[test]
3163 fn engine_handler_names_lists_all() {
3164 let mut engine = create_test_engine();
3165 engine.register(EchoWorkflow).unwrap();
3166 engine.register(FailingWorkflow).unwrap();
3167 let names = engine.handler_names();
3168 assert_eq!(names.len(), 2);
3169 assert!(names.contains(&"echo-workflow"));
3170 assert!(names.contains(&"failing-workflow"));
3171 }
3172
3173 #[test]
3174 fn engine_handler_info_returns_description() {
3175 let mut engine = create_test_engine();
3176 engine.register(EchoWorkflow).unwrap();
3177 let info = engine.handler_info("echo-workflow");
3178 assert!(info.is_some());
3179 let info = info.unwrap();
3180 assert_eq!(info.description, "A simple workflow that echoes hello");
3181 }
3182
3183 struct CategorizedWorkflow;
3184
3185 impl WorkflowHandler for CategorizedWorkflow {
3186 fn name(&self) -> &str {
3187 "categorized"
3188 }
3189 fn category(&self) -> Option<&str> {
3190 Some("data/etl")
3191 }
3192 fn execute<'a>(
3193 &'a self,
3194 _ctx: &'a mut WorkflowContext,
3195 ) -> crate::handler::HandlerFuture<'a> {
3196 Box::pin(async move { Ok(()) })
3197 }
3198 }
3199
3200 #[test]
3201 fn engine_default_describe_propagates_category() {
3202 let mut engine = create_test_engine();
3203 engine.register(CategorizedWorkflow).unwrap();
3204 let info = engine.handler_info("categorized").unwrap();
3205 assert_eq!(info.category.as_deref(), Some("data/etl"));
3206 }
3207
3208 #[test]
3209 fn engine_default_describe_without_category() {
3210 let mut engine = create_test_engine();
3211 engine.register(EchoWorkflow).unwrap();
3212 let info = engine.handler_info("echo-workflow").unwrap();
3213 assert!(info.category.is_none());
3214 }
3215
3216 struct ScheduledWorkflow {
3221 schedule: CronSchedule,
3222 }
3223
3224 impl ScheduledWorkflow {
3225 fn new() -> Self {
3226 Self {
3227 schedule: CronSchedule::new("0 0 * * * *").unwrap(),
3228 }
3229 }
3230 }
3231
3232 impl WorkflowHandler for ScheduledWorkflow {
3233 fn name(&self) -> &str {
3234 "scheduled"
3235 }
3236 fn schedule(&self) -> Option<&CronSchedule> {
3237 Some(&self.schedule)
3238 }
3239 fn execute<'a>(
3240 &'a self,
3241 _ctx: &'a mut WorkflowContext,
3242 ) -> crate::handler::HandlerFuture<'a> {
3243 Box::pin(async move { Ok(()) })
3244 }
3245 }
3246
3247 #[test]
3248 fn engine_default_describe_propagates_schedule() {
3249 let mut engine = create_test_engine();
3250 engine.register(ScheduledWorkflow::new()).unwrap();
3251 let info = engine.handler_info("scheduled").unwrap();
3252 assert_eq!(
3253 info.schedule.as_ref().map(|s| s.as_str()),
3254 Some("0 0 * * * *")
3255 );
3256 }
3257
3258 #[test]
3259 fn engine_default_describe_without_schedule() {
3260 let mut engine = create_test_engine();
3261 engine.register(EchoWorkflow).unwrap();
3262 let info = engine.handler_info("echo-workflow").unwrap();
3263 assert!(info.schedule.is_none());
3264 }
3265
3266 #[test]
3267 fn scheduled_handlers_returns_only_scheduled() {
3268 let mut engine = create_test_engine();
3269 engine.register(EchoWorkflow).unwrap();
3270 engine.register(ScheduledWorkflow::new()).unwrap();
3271 engine.register(FailingWorkflow).unwrap();
3272
3273 let scheduled = engine.scheduled_handlers();
3274 assert_eq!(scheduled.len(), 1);
3275 assert_eq!(scheduled[0].0, "scheduled");
3276 assert_eq!(scheduled[0].1.as_str(), "0 0 * * * *");
3277 }
3278
3279 #[test]
3280 fn scheduled_handlers_empty_when_none_scheduled() {
3281 let mut engine = create_test_engine();
3282 engine.register(EchoWorkflow).unwrap();
3283 engine.register(FailingWorkflow).unwrap();
3284
3285 let scheduled = engine.scheduled_handlers();
3286 assert!(scheduled.is_empty());
3287 }
3288
3289 struct BadCategoryWorkflow(&'static str);
3290
3291 impl WorkflowHandler for BadCategoryWorkflow {
3292 fn name(&self) -> &str {
3293 "bad-category"
3294 }
3295 fn category(&self) -> Option<&str> {
3296 Some(self.0)
3297 }
3298 fn execute<'a>(
3299 &'a self,
3300 _ctx: &'a mut WorkflowContext,
3301 ) -> crate::handler::HandlerFuture<'a> {
3302 Box::pin(async move { Ok(()) })
3303 }
3304 }
3305
3306 #[test]
3307 fn engine_register_rejects_empty_category() {
3308 let mut engine = create_test_engine();
3309 let err = engine.register(BadCategoryWorkflow("")).unwrap_err();
3310 match err {
3311 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty category")),
3312 other => panic!("expected InvalidWorkflow, got {other:?}"),
3313 }
3314 }
3315
3316 #[test]
3317 fn engine_register_rejects_leading_slash_category() {
3318 let mut engine = create_test_engine();
3319 let err = engine
3320 .register(BadCategoryWorkflow("/data/etl"))
3321 .unwrap_err();
3322 match err {
3323 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("leading '/'")),
3324 other => panic!("expected InvalidWorkflow, got {other:?}"),
3325 }
3326 }
3327
3328 #[test]
3329 fn engine_register_rejects_trailing_slash_category() {
3330 let mut engine = create_test_engine();
3331 let err = engine
3332 .register(BadCategoryWorkflow("data/etl/"))
3333 .unwrap_err();
3334 match err {
3335 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("trailing '/'")),
3336 other => panic!("expected InvalidWorkflow, got {other:?}"),
3337 }
3338 }
3339
3340 #[test]
3341 fn engine_register_rejects_double_slash_category() {
3342 let mut engine = create_test_engine();
3343 let err = engine
3344 .register(BadCategoryWorkflow("data//etl"))
3345 .unwrap_err();
3346 match err {
3347 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty segment")),
3348 other => panic!("expected InvalidWorkflow, got {other:?}"),
3349 }
3350 }
3351
3352 #[test]
3353 fn engine_register_rejects_whitespace_only_segment_category() {
3354 let mut engine = create_test_engine();
3355 let err = engine
3356 .register(BadCategoryWorkflow("data/ /etl"))
3357 .unwrap_err();
3358 match err {
3359 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("whitespace-only segment")),
3360 other => panic!("expected InvalidWorkflow, got {other:?}"),
3361 }
3362 }
3363
3364 #[test]
3365 fn engine_register_accepts_valid_nested_category() {
3366 let mut engine = create_test_engine();
3367 assert!(engine.register(CategorizedWorkflow).is_ok());
3368 }
3369
3370 #[tokio::test]
3371 async fn engine_unknown_workflow_returns_error() {
3372 let engine = create_test_engine();
3373 let result = engine
3374 .run_handler("unknown", TriggerKind::Manual, json!({}))
3375 .await;
3376 assert!(result.is_err());
3377 match result {
3378 Err(EngineError::InvalidWorkflow(msg)) => {
3379 assert!(msg.contains("no handler registered"));
3380 }
3381 _ => panic!("expected InvalidWorkflow error"),
3382 }
3383 }
3384
3385 #[tokio::test]
3386 async fn engine_enqueue_handler_creates_pending_run() {
3387 let mut engine = create_test_engine();
3388 engine.register(EchoWorkflow).unwrap();
3389
3390 let run = engine
3391 .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
3392 .await
3393 .unwrap();
3394 assert_eq!(run.status.state, RunStatus::Pending);
3395 assert_eq!(run.workflow_name, "echo-workflow");
3396 }
3397
3398 #[tokio::test]
3399 async fn enqueue_handler_leaves_the_run_unattributed() {
3400 let mut engine = create_test_engine();
3401 engine.register(EchoWorkflow).unwrap();
3402
3403 let run = engine
3404 .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
3405 .await
3406 .unwrap();
3407
3408 assert!(run.created_by.is_none());
3409 }
3410
3411 #[tokio::test]
3412 async fn enqueue_handler_with_options_records_the_author() {
3413 let mut engine = create_test_engine();
3414 engine.register(EchoWorkflow).unwrap();
3415 let actor = RunActor::User {
3416 user_id: Uuid::now_v7(),
3417 };
3418
3419 let run = engine
3420 .enqueue_handler_with_options(
3421 "echo-workflow",
3422 TriggerKind::Api,
3423 json!({}),
3424 EnqueueOptions {
3425 created_by: Some(actor.clone()),
3426 ..Default::default()
3427 },
3428 )
3429 .await
3430 .unwrap()
3431 .into_run();
3432
3433 assert_eq!(run.created_by, Some(actor));
3434 }
3435
3436 #[tokio::test]
3437 async fn enqueue_handler_with_options_accepts_no_author() {
3438 let mut engine = create_test_engine();
3439 engine.register(EchoWorkflow).unwrap();
3440
3441 let run = engine
3442 .enqueue_handler_with_options(
3443 "echo-workflow",
3444 TriggerKind::Cron {
3445 schedule: "0 * * * * *".to_string(),
3446 schedule_id: None,
3447 scheduled_for: None,
3448 },
3449 json!({}),
3450 EnqueueOptions::default(),
3451 )
3452 .await
3453 .unwrap()
3454 .into_run();
3455
3456 assert!(run.created_by.is_none());
3457 }
3458
3459 #[tokio::test]
3460 async fn enqueue_handler_with_options_stores_concurrency_limits() {
3461 let mut engine = create_test_engine();
3462 engine.register(EchoWorkflow).unwrap();
3463 let limits = vec![
3464 ConcurrencyLimit::new("repo:acme", 2),
3465 ConcurrencyLimit::new("tenant:42", 5),
3466 ];
3467
3468 let run = engine
3469 .enqueue_handler_with_options(
3470 "echo-workflow",
3471 TriggerKind::Api,
3472 json!({}),
3473 EnqueueOptions {
3474 concurrency_limits: limits.clone(),
3475 ..Default::default()
3476 },
3477 )
3478 .await
3479 .unwrap()
3480 .into_run();
3481
3482 assert_eq!(run.concurrency_limits, limits);
3483 }
3484
3485 #[tokio::test]
3486 async fn enqueue_rejects_invalid_concurrency_limits() {
3487 let mut engine = create_test_engine();
3488 engine.register(EchoWorkflow).unwrap();
3489
3490 let invalid = [
3491 vec![ConcurrencyLimit::new("repo:acme", 0)],
3492 vec![ConcurrencyLimit::new("", 1)],
3493 vec![
3494 ConcurrencyLimit::new("repo:acme", 1),
3495 ConcurrencyLimit::new("repo:acme", 2),
3496 ],
3497 ];
3498 for concurrency_limits in invalid {
3499 let err = engine
3500 .enqueue_handler_with_options(
3501 "echo-workflow",
3502 TriggerKind::Api,
3503 json!({}),
3504 EnqueueOptions {
3505 concurrency_limits,
3506 ..Default::default()
3507 },
3508 )
3509 .await
3510 .unwrap_err();
3511 assert!(
3512 matches!(err, EngineError::InvalidConcurrencyLimit(_)),
3513 "{err:?}"
3514 );
3515 }
3516
3517 let err = engine
3519 .enqueue_handler_with_options(
3520 "not-registered",
3521 TriggerKind::Api,
3522 json!({}),
3523 EnqueueOptions {
3524 concurrency_limits: vec![ConcurrencyLimit::new("repo:acme", 0)],
3525 ..Default::default()
3526 },
3527 )
3528 .await
3529 .unwrap_err();
3530 assert!(
3531 matches!(err, EngineError::InvalidConcurrencyLimit(_)),
3532 "{err:?}"
3533 );
3534
3535 let page = engine
3536 .store()
3537 .list_runs(RunFilter::default(), 1, 10)
3538 .await
3539 .unwrap();
3540 assert_eq!(page.total, 0, "no run may be created");
3541 }
3542
3543 struct UrgentWorkflow;
3544
3545 impl WorkflowHandler for UrgentWorkflow {
3546 fn name(&self) -> &str {
3547 "urgent-workflow"
3548 }
3549
3550 fn priority(&self) -> i16 {
3551 60
3552 }
3553
3554 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3555 Box::pin(async { Ok(()) })
3556 }
3557 }
3558
3559 #[tokio::test]
3560 async fn enqueue_priority_defaults_to_the_handler_priority() {
3561 let mut engine = create_test_engine();
3562 engine.register(EchoWorkflow).unwrap();
3563 engine.register(UrgentWorkflow).unwrap();
3564
3565 let echo = engine
3566 .enqueue_handler_with_options(
3567 "echo-workflow",
3568 TriggerKind::Api,
3569 json!({}),
3570 EnqueueOptions::default(),
3571 )
3572 .await
3573 .unwrap()
3574 .into_run();
3575 assert_eq!(echo.priority, 0);
3576
3577 let urgent = engine
3578 .enqueue_handler_with_options(
3579 "urgent-workflow",
3580 TriggerKind::Api,
3581 json!({}),
3582 EnqueueOptions::default(),
3583 )
3584 .await
3585 .unwrap()
3586 .into_run();
3587 assert_eq!(urgent.priority, 60);
3588 }
3589
3590 #[tokio::test]
3591 async fn enqueue_priority_explicit_value_overrides_the_handler() {
3592 let mut engine = create_test_engine();
3593 engine.register(UrgentWorkflow).unwrap();
3594
3595 let run = engine
3596 .enqueue_handler_with_options(
3597 "urgent-workflow",
3598 TriggerKind::Api,
3599 json!({}),
3600 EnqueueOptions {
3601 priority: Some(-20),
3602 ..Default::default()
3603 },
3604 )
3605 .await
3606 .unwrap()
3607 .into_run();
3608 assert_eq!(run.priority, -20);
3609
3610 let stored = engine.store().get_run(run.id).await.unwrap().unwrap();
3611 assert_eq!(stored.priority, -20);
3612 }
3613
3614 #[tokio::test]
3615 async fn enqueue_priority_out_of_range_is_rejected() {
3616 let mut engine = create_test_engine();
3617 engine.register(EchoWorkflow).unwrap();
3618
3619 for priority in [MAX_PRIORITY + 1, MIN_PRIORITY - 1] {
3620 let err = engine
3621 .enqueue_handler_with_options(
3622 "echo-workflow",
3623 TriggerKind::Api,
3624 json!({}),
3625 EnqueueOptions {
3626 priority: Some(priority),
3627 ..Default::default()
3628 },
3629 )
3630 .await
3631 .unwrap_err();
3632 assert!(matches!(err, EngineError::InvalidPriority(_)), "{err:?}");
3633 }
3634
3635 let err = engine
3637 .enqueue_handler_with_options(
3638 "not-registered",
3639 TriggerKind::Api,
3640 json!({}),
3641 EnqueueOptions {
3642 priority: Some(MAX_PRIORITY + 1),
3643 ..Default::default()
3644 },
3645 )
3646 .await
3647 .unwrap_err();
3648 assert!(matches!(err, EngineError::InvalidPriority(_)), "{err:?}");
3649
3650 let page = engine
3651 .store()
3652 .list_runs(RunFilter::default(), 1, 10)
3653 .await
3654 .unwrap();
3655 assert_eq!(page.total, 0, "no run may be created");
3656 }
3657
3658 #[tokio::test]
3659 async fn enqueue_priority_bounds_are_accepted() {
3660 let mut engine = create_test_engine();
3661 engine.register(EchoWorkflow).unwrap();
3662
3663 for priority in [MIN_PRIORITY, MAX_PRIORITY] {
3664 let run = engine
3665 .enqueue_handler_with_options(
3666 "echo-workflow",
3667 TriggerKind::Api,
3668 json!({}),
3669 EnqueueOptions {
3670 priority: Some(priority),
3671 ..Default::default()
3672 },
3673 )
3674 .await
3675 .unwrap()
3676 .into_run();
3677 assert_eq!(run.priority, priority);
3678 }
3679 }
3680
3681 struct GpuWorkflow;
3682
3683 impl WorkflowHandler for GpuWorkflow {
3684 fn name(&self) -> &str {
3685 "gpu-workflow"
3686 }
3687
3688 fn required_worker_tags(&self) -> Vec<String> {
3689 vec!["gpu".to_string()]
3690 }
3691
3692 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3693 Box::pin(async move { Ok(()) })
3694 }
3695 }
3696
3697 #[tokio::test]
3698 async fn enqueue_merges_handler_and_request_worker_tags() {
3699 let mut engine = create_test_engine();
3700 engine.register(GpuWorkflow).unwrap();
3701
3702 let run = engine
3703 .enqueue_handler_with_options(
3704 "gpu-workflow",
3705 TriggerKind::Api,
3706 json!({}),
3707 EnqueueOptions {
3708 worker_tags: vec!["region:eu".to_string(), "gpu".to_string()],
3709 ..Default::default()
3710 },
3711 )
3712 .await
3713 .unwrap()
3714 .into_run();
3715
3716 assert_eq!(
3717 run.worker_tags,
3718 vec!["gpu".to_string(), "region:eu".to_string()]
3719 );
3720 }
3721
3722 #[tokio::test]
3723 async fn enqueue_without_worker_tags_keeps_handler_tags() {
3724 let mut engine = create_test_engine();
3725 engine.register(GpuWorkflow).unwrap();
3726 engine.register(EchoWorkflow).unwrap();
3727
3728 let gpu = engine
3729 .enqueue_handler("gpu-workflow", TriggerKind::Api, json!({}), 0)
3730 .await
3731 .unwrap();
3732 assert_eq!(gpu.worker_tags, vec!["gpu".to_string()]);
3733
3734 let echo = engine
3735 .enqueue_handler("echo-workflow", TriggerKind::Api, json!({}), 0)
3736 .await
3737 .unwrap();
3738 assert!(echo.worker_tags.is_empty());
3739 }
3740
3741 #[tokio::test]
3742 async fn enqueue_rejects_invalid_worker_tags() {
3743 let mut engine = create_test_engine();
3744 engine.register(EchoWorkflow).unwrap();
3745
3746 for worker_tags in [
3747 vec!["bad,tag".to_string()],
3748 vec![" ".to_string()],
3749 vec!["x".repeat(65)],
3750 ] {
3751 let err = engine
3752 .enqueue_handler_with_options(
3753 "echo-workflow",
3754 TriggerKind::Api,
3755 json!({}),
3756 EnqueueOptions {
3757 worker_tags,
3758 ..Default::default()
3759 },
3760 )
3761 .await
3762 .unwrap_err();
3763 assert!(matches!(err, EngineError::InvalidWorkerTag(_)), "{err:?}");
3764 }
3765
3766 let err = engine
3768 .enqueue_handler_with_options(
3769 "not-registered",
3770 TriggerKind::Api,
3771 json!({}),
3772 EnqueueOptions {
3773 worker_tags: vec!["bad,tag".to_string()],
3774 ..Default::default()
3775 },
3776 )
3777 .await
3778 .unwrap_err();
3779 assert!(matches!(err, EngineError::InvalidWorkerTag(_)), "{err:?}");
3780 }
3781
3782 #[test]
3783 fn worker_tags_are_unset_by_default() {
3784 let engine = create_test_engine();
3785 assert!(engine.worker_tags().is_none());
3786 }
3787
3788 #[test]
3789 fn set_worker_tags_stores_the_tags() {
3790 let mut engine = create_test_engine();
3791 engine.set_worker_tags(vec!["gpu".to_string()]);
3792 assert_eq!(engine.worker_tags(), Some(&["gpu".to_string()][..]));
3793
3794 engine.set_worker_tags(Vec::new());
3795 assert_eq!(engine.worker_tags(), Some(&[][..]));
3796 }
3797
3798 #[tokio::test]
3799 async fn run_handler_records_handler_worker_tags() {
3800 let mut engine = create_test_engine();
3801 engine.register(GpuWorkflow).unwrap();
3802
3803 let result = engine
3804 .run_handler("gpu-workflow", TriggerKind::Manual, json!({}))
3805 .await
3806 .unwrap();
3807 assert_eq!(result.run.worker_tags, vec!["gpu".to_string()]);
3808 }
3809
3810 #[tokio::test]
3811 async fn run_handler_leaves_the_run_unattributed() {
3812 let mut engine = create_test_engine();
3813 engine.register(EchoWorkflow).unwrap();
3814
3815 let run = engine
3816 .run_handler("echo-workflow", TriggerKind::Manual, json!({}))
3817 .await
3818 .unwrap()
3819 .run;
3820
3821 assert!(run.created_by.is_none());
3822 }
3823
3824 #[tokio::test]
3825 async fn run_handler_priority_comes_from_the_handler() {
3826 let mut engine = create_test_engine();
3827 engine.register(UrgentWorkflow).unwrap();
3828
3829 let run = engine
3830 .run_handler("urgent-workflow", TriggerKind::Manual, json!({}))
3831 .await
3832 .unwrap()
3833 .run;
3834
3835 assert_eq!(run.priority, 60);
3836 }
3837
3838 #[tokio::test]
3839 async fn engine_register_boxed() {
3840 let mut engine = create_test_engine();
3841 let handler: Box<dyn WorkflowHandler> = Box::new(EchoWorkflow);
3842 let result = engine.register_boxed(handler);
3843 assert!(result.is_ok());
3844 assert_eq!(engine.handler_names().len(), 1);
3845 }
3846
3847 #[tokio::test]
3848 async fn engine_store_and_provider_accessors() {
3849 let store = Arc::new(InMemoryStore::new());
3850 let inner = ClaudeCodeProvider::new();
3851 let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
3852 inner,
3853 "/tmp/ironflow-fixtures",
3854 ));
3855 let engine = Engine::new(store.clone(), provider.clone());
3856
3857 let _ = engine.store();
3859 let _ = engine.provider();
3860 }
3861
3862 use crate::operation::{Operation, OperationContext};
3867 use async_trait::async_trait;
3868 use ironflow_core::error::OperationError;
3869 use ironflow_store::models::StepKind;
3870
3871 struct FakeGitlabOp {
3872 project_id: u64,
3873 title: String,
3874 }
3875
3876 #[async_trait]
3877 impl Operation for FakeGitlabOp {
3878 fn kind(&self) -> &str {
3879 "gitlab"
3880 }
3881
3882 async fn execute(&self, _ctx: &OperationContext) -> Result<Value, OperationError> {
3883 Ok(json!({
3884 "issue_id": 42,
3885 "project_id": self.project_id,
3886 "title": self.title,
3887 }))
3888 }
3889
3890 fn input(&self) -> Option<Value> {
3891 Some(json!({
3892 "project_id": self.project_id,
3893 "title": self.title,
3894 }))
3895 }
3896 }
3897
3898 struct FailingOp;
3899
3900 #[async_trait]
3901 impl Operation for FailingOp {
3902 fn kind(&self) -> &str {
3903 "broken-service"
3904 }
3905
3906 async fn execute(&self, _ctx: &OperationContext) -> Result<Value, OperationError> {
3907 Err(OperationError::Http {
3908 status: None,
3909 message: "service unavailable".to_string(),
3910 })
3911 }
3912 }
3913
3914 struct OperationWorkflow;
3915
3916 impl WorkflowHandler for OperationWorkflow {
3917 fn name(&self) -> &str {
3918 "operation-workflow"
3919 }
3920
3921 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3922 Box::pin(async move {
3923 let op = FakeGitlabOp {
3924 project_id: 123,
3925 title: "Bug report".to_string(),
3926 };
3927 ctx.operation("create-issue", &op).await?;
3928 Ok(())
3929 })
3930 }
3931 }
3932
3933 struct FailingOperationWorkflow;
3934
3935 impl WorkflowHandler for FailingOperationWorkflow {
3936 fn name(&self) -> &str {
3937 "failing-operation-workflow"
3938 }
3939
3940 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3941 Box::pin(async move {
3942 ctx.operation("broken-call", &FailingOp).await?;
3943 Ok(())
3944 })
3945 }
3946 }
3947
3948 struct MixedWorkflow;
3949
3950 impl WorkflowHandler for MixedWorkflow {
3951 fn name(&self) -> &str {
3952 "mixed-workflow"
3953 }
3954
3955 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3956 Box::pin(async move {
3957 ctx.shell("build", ShellConfig::new("echo built")).await?;
3958 let op = FakeGitlabOp {
3959 project_id: 456,
3960 title: "Deploy done".to_string(),
3961 };
3962 let result = ctx.operation("notify-gitlab", &op).await?;
3963 assert_eq!(result.output["issue_id"], 42);
3964 Ok(())
3965 })
3966 }
3967 }
3968
3969 #[tokio::test]
3970 async fn operation_step_happy_path() {
3971 let mut engine = create_test_engine();
3972 engine.register(OperationWorkflow).unwrap();
3973
3974 let run = engine
3975 .run_handler("operation-workflow", TriggerKind::Manual, json!({}))
3976 .await
3977 .unwrap()
3978 .run;
3979
3980 assert_eq!(run.status.state, RunStatus::Completed);
3981
3982 let steps = engine.store().list_steps(run.id).await.unwrap();
3983
3984 assert_eq!(steps.len(), 1);
3985 assert_eq!(steps[0].name, "create-issue");
3986 assert_eq!(steps[0].kind, StepKind::Custom("gitlab".to_string()));
3987 assert_eq!(
3988 steps[0].status.state,
3989 ironflow_store::models::StepStatus::Completed
3990 );
3991
3992 let output = steps[0].output.as_ref().unwrap();
3993 assert_eq!(output["issue_id"], 42);
3994 assert_eq!(output["project_id"], 123);
3995
3996 let input = steps[0].input.as_ref().unwrap();
3997 assert_eq!(input["project_id"], 123);
3998 assert_eq!(input["title"], "Bug report");
3999 }
4000
4001 #[tokio::test]
4002 async fn operation_step_failure_marks_run_failed() {
4003 let mut engine = create_test_engine();
4004 engine.register(FailingOperationWorkflow).unwrap();
4005
4006 let result = engine
4007 .run_handler("failing-operation-workflow", TriggerKind::Manual, json!({}))
4008 .await;
4009
4010 assert!(result.is_err());
4011 }
4012
4013 #[tokio::test]
4014 async fn operation_mixed_with_shell_steps() {
4015 let mut engine = create_test_engine();
4016 engine.register(MixedWorkflow).unwrap();
4017
4018 let run = engine
4019 .run_handler("mixed-workflow", TriggerKind::Manual, json!({}))
4020 .await
4021 .unwrap()
4022 .run;
4023
4024 assert_eq!(run.status.state, RunStatus::Completed);
4025
4026 let steps = engine.store().list_steps(run.id).await.unwrap();
4027
4028 assert_eq!(steps.len(), 2);
4029 assert_eq!(steps[0].kind, StepKind::Shell);
4030 assert_eq!(steps[1].kind, StepKind::Custom("gitlab".to_string()));
4031 assert_eq!(steps[0].position, 0);
4032 assert_eq!(steps[1].position, 1);
4033 }
4034
4035 use crate::config::ApprovalConfig;
4040
4041 struct SingleApprovalWorkflow;
4042
4043 impl WorkflowHandler for SingleApprovalWorkflow {
4044 fn name(&self) -> &str {
4045 "single-approval"
4046 }
4047
4048 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
4049 Box::pin(async move {
4050 ctx.shell("build", ShellConfig::new("echo built")).await?;
4051 ctx.approval("gate", ApprovalConfig::new("OK?")).await?;
4052 ctx.shell("deploy", ShellConfig::new("echo deployed"))
4053 .await?;
4054 Ok(())
4055 })
4056 }
4057 }
4058
4059 struct DoubleApprovalWorkflow;
4060
4061 impl WorkflowHandler for DoubleApprovalWorkflow {
4062 fn name(&self) -> &str {
4063 "double-approval"
4064 }
4065
4066 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
4067 Box::pin(async move {
4068 ctx.shell("build", ShellConfig::new("echo built")).await?;
4069 ctx.approval("staging-gate", ApprovalConfig::new("Deploy staging?"))
4070 .await?;
4071 ctx.shell("deploy-staging", ShellConfig::new("echo staging"))
4072 .await?;
4073 ctx.approval("prod-gate", ApprovalConfig::new("Deploy prod?"))
4074 .await?;
4075 ctx.shell("deploy-prod", ShellConfig::new("echo prod"))
4076 .await?;
4077 Ok(())
4078 })
4079 }
4080 }
4081
4082 #[tokio::test]
4083 async fn approval_pauses_run() {
4084 let mut engine = create_test_engine();
4085 engine.register(SingleApprovalWorkflow).unwrap();
4086
4087 let run = engine
4088 .run_handler("single-approval", TriggerKind::Manual, json!({}))
4089 .await
4090 .unwrap()
4091 .run;
4092
4093 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
4094
4095 let steps = engine.store().list_steps(run.id).await.unwrap();
4096 assert_eq!(steps.len(), 2); assert_eq!(steps[0].kind, StepKind::Shell);
4098 assert_eq!(steps[0].status.state, StepStatus::Completed);
4099 assert_eq!(steps[1].kind, StepKind::Approval);
4100 assert_eq!(steps[1].status.state, StepStatus::AwaitingApproval);
4101 }
4102
4103 async fn approve_open_gate(store: &Arc<dyn Store>, run_id: Uuid) {
4106 let gate = store
4107 .list_steps(run_id)
4108 .await
4109 .unwrap()
4110 .into_iter()
4111 .find(|step| step.status.state == StepStatus::AwaitingApproval)
4112 .expect("an open approval gate");
4113 store
4114 .record_step_approval(
4115 gate.id,
4116 StepApproval {
4117 user_id: Uuid::now_v7(),
4118 approved_by: "tester".to_string(),
4119 at: Utc::now(),
4120 },
4121 )
4122 .await
4123 .unwrap();
4124 store
4125 .update_run_status(run_id, RunStatus::Running)
4126 .await
4127 .unwrap();
4128 }
4129
4130 #[tokio::test]
4131 async fn approval_resume_completes_run() {
4132 let mut engine = create_test_engine();
4133 engine.register(SingleApprovalWorkflow).unwrap();
4134
4135 let run = engine
4137 .run_handler("single-approval", TriggerKind::Manual, json!({}))
4138 .await
4139 .unwrap()
4140 .run;
4141 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
4142
4143 approve_open_gate(engine.store(), run.id).await;
4145
4146 let resumed = engine.resume_run(run.id).await.unwrap().run;
4148 assert_eq!(resumed.status.state, RunStatus::Completed);
4149
4150 let steps = engine.store().list_steps(run.id).await.unwrap();
4151 assert_eq!(steps.len(), 3); assert_eq!(steps[0].name, "build");
4153 assert_eq!(steps[0].status.state, StepStatus::Completed);
4154 assert_eq!(steps[1].name, "gate");
4155 assert_eq!(steps[1].kind, StepKind::Approval);
4156 assert_eq!(steps[1].status.state, StepStatus::Completed);
4157 assert_eq!(steps[2].name, "deploy");
4158 assert_eq!(steps[2].status.state, StepStatus::Completed);
4159 }
4160
4161 #[tokio::test]
4162 async fn double_approval_two_resumes() {
4163 let mut engine = create_test_engine();
4164 engine.register(DoubleApprovalWorkflow).unwrap();
4165
4166 let run = engine
4168 .run_handler("double-approval", TriggerKind::Manual, json!({}))
4169 .await
4170 .unwrap()
4171 .run;
4172 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
4173
4174 let steps = engine.store().list_steps(run.id).await.unwrap();
4175 assert_eq!(steps.len(), 2); approve_open_gate(engine.store(), run.id).await;
4179
4180 let resumed = engine.resume_run(run.id).await.unwrap().run;
4181 assert_eq!(resumed.status.state, RunStatus::AwaitingApproval);
4182
4183 let steps = engine.store().list_steps(run.id).await.unwrap();
4184 assert_eq!(steps.len(), 4); approve_open_gate(engine.store(), run.id).await;
4188
4189 let final_run = engine.resume_run(run.id).await.unwrap().run;
4190 assert_eq!(final_run.status.state, RunStatus::Completed);
4191
4192 let steps = engine.store().list_steps(run.id).await.unwrap();
4193 assert_eq!(steps.len(), 5);
4194 assert_eq!(steps[0].name, "build");
4195 assert_eq!(steps[1].name, "staging-gate");
4196 assert_eq!(steps[2].name, "deploy-staging");
4197 assert_eq!(steps[3].name, "prod-gate");
4198 assert_eq!(steps[4].name, "deploy-prod");
4199
4200 for step in &steps {
4201 assert_eq!(step.status.state, StepStatus::Completed);
4202 }
4203 }
4204
4205 use ironflow_store::models::{NewStep, StepUpdate, step_trace_id};
4210
4211 async fn create_step_with_status(
4212 store: &Arc<dyn Store>,
4213 run_id: Uuid,
4214 name: &str,
4215 position: u32,
4216 status: StepStatus,
4217 ) -> ironflow_store::models::Step {
4218 let step = store
4219 .create_step(NewStep {
4220 run_id,
4221 trace_id: step_trace_id(run_id, name, position),
4222 name: name.to_string(),
4223 kind: StepKind::Shell,
4224 position,
4225 input: None,
4226 is_error_handler: false,
4227 })
4228 .await
4229 .unwrap();
4230
4231 match status {
4232 StepStatus::Pending => {}
4233 StepStatus::Running => {
4234 store
4235 .update_step(
4236 step.id,
4237 StepUpdate {
4238 status: Some(StepStatus::Running),
4239 ..StepUpdate::default()
4240 },
4241 )
4242 .await
4243 .unwrap();
4244 }
4245 StepStatus::Completed => {
4246 store
4247 .update_step(
4248 step.id,
4249 StepUpdate {
4250 status: Some(StepStatus::Running),
4251 ..StepUpdate::default()
4252 },
4253 )
4254 .await
4255 .unwrap();
4256 store
4257 .update_step(
4258 step.id,
4259 StepUpdate {
4260 status: Some(StepStatus::Completed),
4261 ..StepUpdate::default()
4262 },
4263 )
4264 .await
4265 .unwrap();
4266 }
4267 StepStatus::AwaitingApproval => {
4268 store
4269 .update_step(
4270 step.id,
4271 StepUpdate {
4272 status: Some(StepStatus::Running),
4273 ..StepUpdate::default()
4274 },
4275 )
4276 .await
4277 .unwrap();
4278 store
4279 .update_step(
4280 step.id,
4281 StepUpdate {
4282 status: Some(StepStatus::AwaitingApproval),
4283 ..StepUpdate::default()
4284 },
4285 )
4286 .await
4287 .unwrap();
4288 }
4289 _ => panic!("unsupported status for test helper: {status}"),
4290 }
4291
4292 store.get_step(step.id).await.unwrap().unwrap()
4293 }
4294
4295 #[tokio::test]
4296 async fn fail_orphaned_steps_marks_running_as_failed() {
4297 let engine = create_test_engine();
4298 let run = engine
4299 .store()
4300 .create_run(NewRun {
4301 created_by: None,
4302 workflow_name: "test".to_string(),
4303 trigger: TriggerKind::Manual,
4304 payload: json!({}),
4305 max_retries: 0,
4306 handler_version: None,
4307 labels: HashMap::new(),
4308 scheduled_at: None,
4309 idempotency_key: None,
4310 concurrency_key: None,
4311 priority: 0,
4312 concurrency_limits: Vec::new(),
4313 max_cost_usd: None,
4314 worker_tags: Vec::new(),
4315 })
4316 .await
4317 .unwrap()
4318 .into_run();
4319
4320 let step = create_step_with_status(
4321 engine.store(),
4322 run.id,
4323 "running-step",
4324 0,
4325 StepStatus::Running,
4326 )
4327 .await;
4328
4329 engine
4330 .fail_orphaned_steps(run.id, "parent run timed out")
4331 .await
4332 .unwrap();
4333
4334 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
4335 assert_eq!(updated.status.state, StepStatus::Failed);
4336 assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
4337 assert!(updated.completed_at.is_some());
4338 }
4339
4340 #[tokio::test]
4341 async fn fail_orphaned_steps_marks_pending_as_skipped() {
4342 let engine = create_test_engine();
4343 let run = engine
4344 .store()
4345 .create_run(NewRun {
4346 created_by: None,
4347 workflow_name: "test".to_string(),
4348 trigger: TriggerKind::Manual,
4349 payload: json!({}),
4350 max_retries: 0,
4351 handler_version: None,
4352 labels: HashMap::new(),
4353 scheduled_at: None,
4354 idempotency_key: None,
4355 concurrency_key: None,
4356 priority: 0,
4357 concurrency_limits: Vec::new(),
4358 max_cost_usd: None,
4359 worker_tags: Vec::new(),
4360 })
4361 .await
4362 .unwrap()
4363 .into_run();
4364
4365 let step = create_step_with_status(
4366 engine.store(),
4367 run.id,
4368 "pending-step",
4369 0,
4370 StepStatus::Pending,
4371 )
4372 .await;
4373
4374 engine
4375 .fail_orphaned_steps(run.id, "parent run timed out")
4376 .await
4377 .unwrap();
4378
4379 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
4380 assert_eq!(updated.status.state, StepStatus::Skipped);
4381 assert!(updated.error.is_none());
4382 assert!(updated.completed_at.is_some());
4383 }
4384
4385 #[tokio::test]
4386 async fn fail_orphaned_steps_marks_awaiting_approval_as_failed() {
4387 let engine = create_test_engine();
4388 let run = engine
4389 .store()
4390 .create_run(NewRun {
4391 created_by: None,
4392 workflow_name: "test".to_string(),
4393 trigger: TriggerKind::Manual,
4394 payload: json!({}),
4395 max_retries: 0,
4396 handler_version: None,
4397 labels: HashMap::new(),
4398 scheduled_at: None,
4399 idempotency_key: None,
4400 concurrency_key: None,
4401 priority: 0,
4402 concurrency_limits: Vec::new(),
4403 max_cost_usd: None,
4404 worker_tags: Vec::new(),
4405 })
4406 .await
4407 .unwrap()
4408 .into_run();
4409
4410 let step = create_step_with_status(
4411 engine.store(),
4412 run.id,
4413 "approval-step",
4414 0,
4415 StepStatus::AwaitingApproval,
4416 )
4417 .await;
4418
4419 engine
4420 .fail_orphaned_steps(run.id, "parent run timed out")
4421 .await
4422 .unwrap();
4423
4424 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
4425 assert_eq!(updated.status.state, StepStatus::Failed);
4426 assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
4427 assert!(updated.completed_at.is_some());
4428 }
4429
4430 #[tokio::test]
4431 async fn fail_orphaned_steps_skips_terminal_steps() {
4432 let engine = create_test_engine();
4433 let run = engine
4434 .store()
4435 .create_run(NewRun {
4436 created_by: None,
4437 workflow_name: "test".to_string(),
4438 trigger: TriggerKind::Manual,
4439 payload: json!({}),
4440 max_retries: 0,
4441 handler_version: None,
4442 labels: HashMap::new(),
4443 scheduled_at: None,
4444 idempotency_key: None,
4445 concurrency_key: None,
4446 priority: 0,
4447 concurrency_limits: Vec::new(),
4448 max_cost_usd: None,
4449 worker_tags: Vec::new(),
4450 })
4451 .await
4452 .unwrap()
4453 .into_run();
4454
4455 let completed_step =
4456 create_step_with_status(engine.store(), run.id, "done", 0, StepStatus::Completed).await;
4457 let running_step =
4458 create_step_with_status(engine.store(), run.id, "in-flight", 1, StepStatus::Running)
4459 .await;
4460
4461 engine
4462 .fail_orphaned_steps(run.id, "parent run timed out")
4463 .await
4464 .unwrap();
4465
4466 let completed = engine
4467 .store()
4468 .get_step(completed_step.id)
4469 .await
4470 .unwrap()
4471 .unwrap();
4472 assert_eq!(completed.status.state, StepStatus::Completed);
4473
4474 let failed = engine
4475 .store()
4476 .get_step(running_step.id)
4477 .await
4478 .unwrap()
4479 .unwrap();
4480 assert_eq!(failed.status.state, StepStatus::Failed);
4481 }
4482
4483 #[tokio::test]
4484 async fn fail_orphaned_steps_mixed_states() {
4485 let engine = create_test_engine();
4486 let run = engine
4487 .store()
4488 .create_run(NewRun {
4489 created_by: None,
4490 workflow_name: "test".to_string(),
4491 trigger: TriggerKind::Manual,
4492 payload: json!({}),
4493 max_retries: 0,
4494 handler_version: None,
4495 labels: HashMap::new(),
4496 scheduled_at: None,
4497 idempotency_key: None,
4498 concurrency_key: None,
4499 priority: 0,
4500 concurrency_limits: Vec::new(),
4501 max_cost_usd: None,
4502 worker_tags: Vec::new(),
4503 })
4504 .await
4505 .unwrap()
4506 .into_run();
4507
4508 let s_completed =
4509 create_step_with_status(engine.store(), run.id, "step-1", 0, StepStatus::Completed)
4510 .await;
4511 let s_running =
4512 create_step_with_status(engine.store(), run.id, "step-2", 1, StepStatus::Running).await;
4513 let s_pending =
4514 create_step_with_status(engine.store(), run.id, "step-3", 2, StepStatus::Pending).await;
4515
4516 engine.fail_orphaned_steps(run.id, "timeout").await.unwrap();
4517
4518 let r_completed = engine
4519 .store()
4520 .get_step(s_completed.id)
4521 .await
4522 .unwrap()
4523 .unwrap();
4524 assert_eq!(r_completed.status.state, StepStatus::Completed);
4525
4526 let r_running = engine
4527 .store()
4528 .get_step(s_running.id)
4529 .await
4530 .unwrap()
4531 .unwrap();
4532 assert_eq!(r_running.status.state, StepStatus::Failed);
4533 assert_eq!(r_running.error.as_deref(), Some("timeout"));
4534
4535 let r_pending = engine
4536 .store()
4537 .get_step(s_pending.id)
4538 .await
4539 .unwrap()
4540 .unwrap();
4541 assert_eq!(r_pending.status.state, StepStatus::Skipped);
4542 assert!(r_pending.error.is_none());
4543 }
4544
4545 #[tokio::test]
4546 async fn fail_orphaned_steps_no_steps_is_noop() {
4547 let engine = create_test_engine();
4548 let run = engine
4549 .store()
4550 .create_run(NewRun {
4551 created_by: None,
4552 workflow_name: "test".to_string(),
4553 trigger: TriggerKind::Manual,
4554 payload: json!({}),
4555 max_retries: 0,
4556 handler_version: None,
4557 labels: HashMap::new(),
4558 scheduled_at: None,
4559 idempotency_key: None,
4560 concurrency_key: None,
4561 priority: 0,
4562 concurrency_limits: Vec::new(),
4563 max_cost_usd: None,
4564 worker_tags: Vec::new(),
4565 })
4566 .await
4567 .unwrap()
4568 .into_run();
4569
4570 let result = engine.fail_orphaned_steps(run.id, "timeout").await;
4571 assert!(result.is_ok());
4572 }
4573
4574 #[tokio::test]
4575 async fn fail_orphaned_steps_preserves_existing_error() {
4576 let engine = create_test_engine();
4577 let run = engine
4578 .store()
4579 .create_run(NewRun {
4580 created_by: None,
4581 workflow_name: "test".to_string(),
4582 trigger: TriggerKind::Manual,
4583 payload: json!({}),
4584 max_retries: 0,
4585 handler_version: None,
4586 labels: HashMap::new(),
4587 scheduled_at: None,
4588 idempotency_key: None,
4589 concurrency_key: None,
4590 priority: 0,
4591 concurrency_limits: Vec::new(),
4592 max_cost_usd: None,
4593 worker_tags: Vec::new(),
4594 })
4595 .await
4596 .unwrap()
4597 .into_run();
4598
4599 let step_with_error = create_step_with_status(
4600 engine.store(),
4601 run.id,
4602 "already-errored",
4603 0,
4604 StepStatus::Running,
4605 )
4606 .await;
4607
4608 engine
4609 .store()
4610 .update_step(
4611 step_with_error.id,
4612 StepUpdate {
4613 error: Some("real error from provider".to_string()),
4614 ..StepUpdate::default()
4615 },
4616 )
4617 .await
4618 .unwrap();
4619
4620 let step_no_error = create_step_with_status(
4621 engine.store(),
4622 run.id,
4623 "no-error-yet",
4624 1,
4625 StepStatus::Running,
4626 )
4627 .await;
4628
4629 engine
4630 .fail_orphaned_steps(run.id, "parent run failed")
4631 .await
4632 .unwrap();
4633
4634 let updated_with = engine
4635 .store()
4636 .get_step(step_with_error.id)
4637 .await
4638 .unwrap()
4639 .unwrap();
4640 assert_eq!(updated_with.status.state, StepStatus::Failed);
4641 assert_eq!(
4642 updated_with.error.as_deref(),
4643 Some("real error from provider"),
4644 );
4645
4646 let updated_without = engine
4647 .store()
4648 .get_step(step_no_error.id)
4649 .await
4650 .unwrap()
4651 .unwrap();
4652 assert_eq!(updated_without.status.state, StepStatus::Failed);
4653 assert_eq!(updated_without.error.as_deref(), Some("parent run failed"),);
4654 }
4655
4656 use std::sync::atomic::{AtomicUsize, Ordering};
4661 use std::time::Duration;
4662
4663 use ironflow_store::models::LeaseRequest;
4664 use tokio::time::sleep;
4665
4666 struct CountingWorkflow {
4668 executions: Arc<AtomicUsize>,
4669 }
4670
4671 impl WorkflowHandler for CountingWorkflow {
4672 fn name(&self) -> &str {
4673 "local-resume-counter"
4674 }
4675
4676 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
4677 Box::pin(async move {
4678 self.executions.fetch_add(1, Ordering::SeqCst);
4679 Ok(())
4680 })
4681 }
4682 }
4683
4684 async fn engine_with_pending_counter() -> (Arc<Engine>, Arc<AtomicUsize>, Uuid) {
4686 let executions = Arc::new(AtomicUsize::new(0));
4687 let mut engine = create_test_engine();
4688 engine
4689 .register(CountingWorkflow {
4690 executions: executions.clone(),
4691 })
4692 .unwrap();
4693 let run = engine
4694 .enqueue_handler("local-resume-counter", TriggerKind::Manual, json!({}), 0)
4695 .await
4696 .unwrap();
4697 (Arc::new(engine), executions, run.id)
4698 }
4699
4700 #[tokio::test]
4701 async fn local_resume_leaves_a_run_claimed_by_a_worker_alone() {
4702 let (engine, executions, run_id) = engine_with_pending_counter().await;
4703 let lease = LeaseRequest {
4704 worker_id: "worker-1".to_string(),
4705 ttl: Duration::from_secs(30),
4706 };
4707 let picked = engine
4708 .store()
4709 .pick_next_pending_for(Some(lease), None)
4710 .await
4711 .unwrap()
4712 .expect("the run is due");
4713 assert_eq!(picked.id, run_id);
4714
4715 engine.spawn_local_resume(run_id);
4716 sleep(Duration::from_millis(200)).await;
4717
4718 assert_eq!(executions.load(Ordering::SeqCst), 0);
4719 let run = engine.store().get_run(run_id).await.unwrap().unwrap();
4720 assert_eq!(run.status.state, RunStatus::Running);
4721 assert_eq!(run.worker_id.as_deref(), Some("worker-1"));
4722 }
4723
4724 #[tokio::test]
4725 async fn local_resume_claims_a_pending_run_once() {
4726 let (engine, executions, run_id) = engine_with_pending_counter().await;
4727
4728 engine.spawn_local_resume(run_id);
4729 engine.spawn_local_resume(run_id);
4730 sleep(Duration::from_millis(200)).await;
4731
4732 assert_eq!(executions.load(Ordering::SeqCst), 1);
4733 let run = engine.store().get_run(run_id).await.unwrap().unwrap();
4734 assert_eq!(run.status.state, RunStatus::Completed);
4735 }
4736
4737 #[tokio::test]
4738 async fn local_resume_of_a_running_run_executes_it_once() {
4739 let (engine, executions, run_id) = engine_with_pending_counter().await;
4740 engine
4741 .store()
4742 .update_run_status(run_id, RunStatus::Running)
4743 .await
4744 .unwrap();
4745
4746 engine.spawn_local_resume(run_id);
4747 engine.spawn_local_resume(run_id);
4748 sleep(Duration::from_millis(200)).await;
4749
4750 assert_eq!(executions.load(Ordering::SeqCst), 1);
4751 let run = engine.store().get_run(run_id).await.unwrap().unwrap();
4752 assert_eq!(run.status.state, RunStatus::Completed);
4753 }
4754}