1use std::collections::{HashMap, HashSet};
10use std::fmt;
11use std::sync::{Arc, Mutex};
12use std::time::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, StepStatus, StepUpdate,
33 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, StepResult};
46use crate::guard::{WorkflowGuardConfig, new_shared_guard_state};
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, SignalRejected, SignalResumed, received_output, validate_step_payload,
61};
62use ironflow_core::decision::DecisionProvider;
63
64#[derive(Debug, Clone)]
83pub struct WorkflowResult {
84 pub run: Run,
86 pub steps: Vec<StepResult>,
88}
89
90#[derive(Debug, Clone, Default)]
109pub struct EnqueueOptions {
110 pub max_retries: u32,
112 pub labels: HashMap<String, String>,
114 pub scheduled_at: Option<DateTime<Utc>>,
117 pub max_cost_usd: Option<Decimal>,
121 pub created_by: Option<RunActor>,
124 pub idempotency_key: Option<String>,
130 pub concurrency_key: Option<String>,
136 pub concurrency_limits: Vec<ConcurrencyLimit>,
144 pub priority: Option<i16>,
153 pub worker_tags: Vec<String>,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
172pub enum ExecutionMode {
173 #[default]
177 Local,
178 Workers,
181}
182
183pub struct Engine {
224 store: Arc<dyn Store>,
225 provider: Arc<dyn AgentProvider>,
226 handlers: HashMap<String, Arc<dyn WorkflowHandler>>,
227 event_publisher: EventPublisher,
228 log_sender: Option<LogSender>,
229 budget: BudgetConfig,
230 artifact_sink: Option<Arc<dyn ArtifactSink>>,
231 guard_config: Option<WorkflowGuardConfig>,
232 event_bus: Option<WorkflowEventBus>,
233 decision_provider: Option<Arc<dyn DecisionProvider>>,
234 step_interceptor: Option<Arc<dyn StepInterceptor>>,
235 execution_mode: ExecutionMode,
236 worker_tags: Option<Arc<Vec<String>>>,
237 active_runs: ActiveRuns,
238 local_resumes: ActiveRuns,
239}
240
241type ActiveRuns = Arc<Mutex<HashMap<Uuid, Arc<AsyncMutex<()>>>>>;
243
244struct ActiveRunGuard {
250 runs: ActiveRuns,
251 run_id: Uuid,
252 entry: Arc<AsyncMutex<()>>,
253 lock: Option<OwnedMutexGuard<()>>,
254}
255
256impl Drop for ActiveRunGuard {
257 fn drop(&mut self) {
258 self.lock.take();
259 prune_active_run(&self.runs, self.run_id, &self.entry);
260 }
261}
262
263async fn lock_run(runs: &ActiveRuns, run_id: Uuid) -> ActiveRunGuard {
265 let entry = {
266 let mut runs = runs.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
267 Arc::clone(runs.entry(run_id).or_default())
268 };
269 let lock = Arc::clone(&entry).lock_owned().await;
270 ActiveRunGuard {
271 runs: Arc::clone(runs),
272 run_id,
273 entry,
274 lock: Some(lock),
275 }
276}
277
278fn prune_active_run(runs: &ActiveRuns, run_id: Uuid, entry: &Arc<AsyncMutex<()>>) {
283 let mut runs = runs.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
284 let unused = runs
285 .get(&run_id)
286 .is_some_and(|current| Arc::ptr_eq(current, entry))
287 && Arc::strong_count(entry) == 2;
288 if unused {
289 runs.remove(&run_id);
290 }
291}
292
293fn validate_category(handler_name: &str, category: &str) -> Result<(), EngineError> {
303 let reject = |reason: &str| {
304 Err(EngineError::InvalidWorkflow(format!(
305 "handler '{handler_name}' has invalid category '{category}': {reason}"
306 )))
307 };
308
309 if category.is_empty() {
310 return reject("empty category");
311 }
312 if category.starts_with('/') {
313 return reject("leading '/'");
314 }
315 if category.ends_with('/') {
316 return reject("trailing '/'");
317 }
318 for segment in category.split('/') {
319 if segment.is_empty() {
320 return reject("empty segment (double '/')");
321 }
322 if segment.trim().is_empty() {
323 return reject("whitespace-only segment");
324 }
325 }
326 Ok(())
327}
328
329fn chain_label(run: &Run, key: &str) -> Option<Uuid> {
334 if !matches!(run.trigger, TriggerKind::Workflow) {
335 return None;
336 }
337 let id = Uuid::parse_str(run.labels.get(key)?).ok()?;
338 (id != run.id).then_some(id)
339}
340
341pub fn chain_root(run: &Run) -> Option<Uuid> {
361 chain_label(run, LABEL_ROOT_RUN_ID)
362}
363
364pub(crate) fn chain_parent(run: &Run) -> Option<Uuid> {
366 chain_label(run, PARENT_RUN_ID_LABEL)
367}
368
369impl Engine {
370 pub fn new(store: Arc<dyn Store>, provider: Arc<dyn AgentProvider>) -> Self {
386 Self {
387 store,
388 provider,
389 handlers: HashMap::new(),
390 event_publisher: EventPublisher::new(),
391 log_sender: None,
392 budget: BudgetConfig::new(),
393 artifact_sink: None,
394 guard_config: None,
395 event_bus: None,
396 decision_provider: None,
397 step_interceptor: None,
398 execution_mode: ExecutionMode::default(),
399 worker_tags: None,
400 active_runs: Arc::new(Mutex::new(HashMap::new())),
401 local_resumes: Arc::new(Mutex::new(HashMap::new())),
402 }
403 }
404
405 async fn track_execution(&self, run_id: Uuid) -> ActiveRunGuard {
408 lock_run(&self.active_runs, run_id).await
409 }
410
411 async fn wait_until_idle(&self, run_id: Uuid) {
413 let entry = {
414 let runs = self
415 .active_runs
416 .lock()
417 .unwrap_or_else(|poisoned| poisoned.into_inner());
418 runs.get(&run_id).map(Arc::clone)
419 };
420 if let Some(entry) = entry {
421 drop(Arc::clone(&entry).lock_owned().await);
422 prune_active_run(&self.active_runs, run_id, &entry);
423 }
424 }
425
426 pub(crate) fn is_executing(&self, run_id: Uuid) -> bool {
428 let runs = self
429 .active_runs
430 .lock()
431 .unwrap_or_else(|poisoned| poisoned.into_inner());
432 runs.get(&run_id)
433 .is_some_and(|entry| entry.try_lock().is_err())
434 }
435
436 pub fn with_decision_provider(mut self, provider: Arc<dyn DecisionProvider>) -> Self {
457 self.decision_provider = Some(provider);
458 self
459 }
460
461 pub fn with_step_interceptor(mut self, interceptor: Arc<dyn StepInterceptor>) -> Self {
485 self.step_interceptor = Some(interceptor);
486 self
487 }
488
489 pub fn step_interceptor(&self) -> Option<&Arc<dyn StepInterceptor>> {
491 self.step_interceptor.as_ref()
492 }
493
494 pub fn with_budget_config(mut self, budget: BudgetConfig) -> Self {
515 self.budget = budget;
516 self
517 }
518
519 pub fn budget_config(&self) -> &BudgetConfig {
521 &self.budget
522 }
523
524 pub fn with_guard_config(mut self, config: WorkflowGuardConfig) -> Self {
546 self.guard_config = Some(config);
547 self
548 }
549
550 pub fn guard_config(&self) -> Option<&WorkflowGuardConfig> {
552 self.guard_config.as_ref()
553 }
554
555 pub fn with_execution_mode(mut self, mode: ExecutionMode) -> Self {
576 self.execution_mode = mode;
577 self
578 }
579
580 pub fn execution_mode(&self) -> ExecutionMode {
582 self.execution_mode
583 }
584
585 pub fn set_worker_tags(&mut self, tags: Vec<String>) {
608 self.worker_tags = Some(Arc::new(tags));
609 }
610
611 pub fn worker_tags(&self) -> Option<&[String]> {
629 self.worker_tags.as_deref().map(Vec::as_slice)
630 }
631
632 pub fn set_log_sender(&mut self, sender: LogSender) {
638 self.log_sender = Some(sender);
639 }
640
641 pub fn set_artifact_sink(&mut self, sink: Arc<dyn ArtifactSink>) {
659 self.artifact_sink = Some(sink);
660 }
661
662 pub fn artifact_sink(&self) -> Option<&Arc<dyn ArtifactSink>> {
664 self.artifact_sink.as_ref()
665 }
666
667 pub fn set_event_bus(&mut self, bus: WorkflowEventBus) {
688 self.event_bus = Some(bus.clone());
689 self.event_publisher
690 .subscribe(EventBusReleaser::new(bus), &[Event::RUN_STATUS_CHANGED]);
691 }
692
693 pub fn event_bus(&self) -> Option<&WorkflowEventBus> {
695 self.event_bus.as_ref()
696 }
697
698 pub fn store(&self) -> &Arc<dyn Store> {
700 &self.store
701 }
702
703 pub fn provider(&self) -> &Arc<dyn AgentProvider> {
705 &self.provider
706 }
707
708 fn build_context(&self, run: &Run) -> WorkflowContext {
717 let handlers = self.handlers.clone();
718 let resolver: crate::context::HandlerResolver =
719 Arc::new(move |name: &str| handlers.get(name).cloned());
720 let mut ctx = WorkflowContext::with_handler_resolver(
721 run.id,
722 run.workflow_name.clone(),
723 self.store.clone(),
724 self.provider.clone(),
725 resolver,
726 );
727 ctx.carry_over_run_totals(run.retry_count + 1, run.cost_usd, run.duration_ms);
728 ctx.set_max_cost_usd(run.max_cost_usd);
729 ctx.set_run_created_at(run.created_at);
730 if let Some(ref sender) = self.log_sender {
731 ctx.set_log_sender(sender.clone());
732 }
733 if let Some(ref sink) = self.artifact_sink {
734 ctx.set_artifact_sink(sink.clone());
735 }
736 if let Some(ref bus) = self.event_bus {
737 ctx.set_event_bus(bus.clone());
738 }
739 if let Some(ref provider) = self.decision_provider {
740 ctx.set_decision_provider(provider.clone());
741 }
742 if let Some(ref interceptor) = self.step_interceptor {
743 ctx.set_step_interceptor(interceptor.clone());
744 }
745 if let Some(ref tags) = self.worker_tags {
746 ctx.set_worker_tags(tags.clone());
747 }
748 ctx
749 }
750
751 fn build_context_with_guard(
757 &self,
758 run: &Run,
759 handler: &dyn WorkflowHandler,
760 ) -> WorkflowContext {
761 let mut ctx = self.build_context(run);
762 let guard_config = handler.guard_config().or_else(|| self.guard_config.clone());
763 if let Some(config) = guard_config {
764 ctx.set_guard(config, new_shared_guard_state());
765 }
766 ctx
767 }
768
769 pub async fn check_monthly_quota(&self, workflow_name: &str) -> Result<(), EngineError> {
796 let Some(limit) = self.budget.monthly_cost_limit_usd else {
797 return Ok(());
798 };
799
800 let stats = self
801 .store
802 .get_stats(RunFilter {
803 created_after: Some(month_start(Utc::now())),
804 ..RunFilter::default()
805 })
806 .await?;
807
808 if stats.total_cost_usd < limit {
809 return Ok(());
810 }
811
812 warn!(
813 workflow = %workflow_name,
814 limit_usd = %limit,
815 spent_usd = %stats.total_cost_usd,
816 "monthly cost quota exhausted, refusing new run"
817 );
818
819 #[cfg(feature = "prometheus")]
820 counter!(
821 RUN_BUDGET_EXCEEDED_TOTAL,
822 "workflow" => workflow_name.to_string(),
823 "scope" => "monthly",
824 )
825 .increment(1);
826
827 Err(EngineError::MonthlyBudgetExceeded {
828 limit_usd: limit,
829 spent_usd: stats.total_cost_usd,
830 })
831 }
832
833 pub fn register(&mut self, handler: impl WorkflowHandler + 'static) -> Result<(), EngineError> {
877 let name = handler.name().to_string();
878 if self.handlers.contains_key(&name) {
879 return Err(EngineError::InvalidWorkflow(format!(
880 "handler '{}' already registered",
881 name
882 )));
883 }
884 if let Some(category) = handler.category() {
885 validate_category(&name, category)?;
886 }
887 self.handlers.insert(name, Arc::new(handler));
888 Ok(())
889 }
890
891 pub fn register_boxed(&mut self, handler: Box<dyn WorkflowHandler>) -> Result<(), EngineError> {
898 let name = handler.name().to_string();
899 if self.handlers.contains_key(&name) {
900 return Err(EngineError::InvalidWorkflow(format!(
901 "handler '{}' already registered",
902 name
903 )));
904 }
905 if let Some(category) = handler.category() {
906 validate_category(&name, category)?;
907 }
908 self.handlers.insert(name, Arc::from(handler));
909 Ok(())
910 }
911
912 pub fn get_handler(&self, name: &str) -> Option<&Arc<dyn WorkflowHandler>> {
914 self.handlers.get(name)
915 }
916
917 pub fn handler_names(&self) -> Vec<&str> {
919 self.handlers.keys().map(|s| s.as_str()).collect()
920 }
921
922 pub fn handler_info(&self, name: &str) -> Option<WorkflowInfo> {
924 self.handlers.get(name).map(|h| h.describe())
925 }
926
927 pub fn scheduled_handlers(&self) -> Vec<(&str, &CronSchedule)> {
951 self.handlers
952 .iter()
953 .filter_map(|(name, handler)| handler.schedule().map(|sched| (name.as_str(), sched)))
954 .collect()
955 }
956
957 pub fn subscribe(
982 &mut self,
983 subscriber: impl EventSubscriber + 'static,
984 event_types: &[&'static str],
985 ) {
986 self.event_publisher.subscribe(subscriber, event_types);
987 }
988
989 pub fn event_publisher(&self) -> &EventPublisher {
994 &self.event_publisher
995 }
996
997 #[tracing::instrument(name = "engine.run_handler", skip_all, fields(workflow = %handler_name))]
1033 pub async fn run_handler(
1034 &self,
1035 handler_name: &str,
1036 trigger: TriggerKind,
1037 payload: Value,
1038 ) -> Result<WorkflowResult, EngineError> {
1039 let handler = self
1040 .handlers
1041 .get(handler_name)
1042 .ok_or_else(|| {
1043 EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
1044 })?
1045 .clone();
1046
1047 self.check_monthly_quota(handler_name).await?;
1048
1049 let handler_version = handler.version().map(str::to_string);
1050 let max_cost_usd = self
1051 .budget
1052 .resolve_run_cap(None, handler.default_max_cost_usd());
1053 let run = self
1054 .store
1055 .create_run(NewRun {
1056 created_by: None,
1057 workflow_name: handler_name.to_string(),
1058 trigger,
1059 payload,
1060 max_retries: 0,
1061 handler_version,
1062 labels: handler.default_labels(),
1063 scheduled_at: None,
1064 idempotency_key: None,
1065 concurrency_key: None,
1066 priority: clamp_priority(handler.priority()),
1067 concurrency_limits: Vec::new(),
1068 max_cost_usd,
1069 worker_tags: normalize_worker_tags(handler.required_worker_tags()),
1070 })
1071 .await?
1072 .into_run();
1073
1074 let run_id = run.id;
1075 info!(run_id = %run_id, handler_version = run.handler_version.as_deref().unwrap_or(""), "run created");
1076
1077 if self.is_workflow_paused(handler_name).await? {
1078 info!(run_id = %run_id, workflow = %handler_name, "workflow paused, run left pending");
1079 return Ok(WorkflowResult {
1080 run,
1081 steps: Vec::new(),
1082 });
1083 }
1084
1085 self.store
1086 .update_run_status(run_id, RunStatus::Running)
1087 .await?;
1088
1089 #[cfg(feature = "prometheus")]
1090 gauge!(RUNS_ACTIVE, "workflow" => handler_name.to_string()).increment(1.0);
1091
1092 let run_start = Instant::now();
1093 let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
1094
1095 let result = handler.execute(&mut ctx).await;
1096 self.finalize_run(run_id, handler_name, result, &ctx, run_start, run.labels)
1097 .await
1098 }
1099
1100 #[tracing::instrument(name = "engine.plan_handler", skip_all, fields(workflow = %handler_name))]
1149 pub async fn plan_handler(
1150 &self,
1151 handler_name: &str,
1152 payload: Value,
1153 options: PlanOptions,
1154 ) -> Result<ExecutionPlan, EngineError> {
1155 if options.max_depth == 0 {
1156 return Err(EngineError::InvalidWorkflow(
1157 "max_depth must be at least 1".to_string(),
1158 ));
1159 }
1160
1161 let handler = self
1162 .handlers
1163 .get(handler_name)
1164 .ok_or_else(|| {
1165 EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
1166 })?
1167 .clone();
1168
1169 let estimates = if options.estimate_durations {
1170 estimate_durations(&self.store, handler_name, options.sample_runs).await?
1171 } else {
1172 HashMap::new()
1173 };
1174
1175 let shared: SharedPlanRecorder = Arc::new(Mutex::new(PlanRecorder::new(
1176 handler_name.to_string(),
1177 payload,
1178 options.max_depth,
1179 estimates,
1180 )));
1181
1182 let handlers = self.handlers.clone();
1185 let resolver: crate::context::HandlerResolver =
1186 Arc::new(move |name: &str| handlers.get(name).cloned());
1187 let mut ctx = WorkflowContext::with_handler_resolver(
1188 Uuid::now_v7(),
1189 handler_name.to_string(),
1190 self.store.clone(),
1191 self.provider.clone(),
1192 resolver,
1193 );
1194 ctx.set_plan(shared.clone());
1195
1196 match timeout(options.timeout, handler.execute(&mut ctx)).await {
1197 Ok(Ok(())) => {}
1198 Ok(Err(err)) => lock_plan(&shared).fail(err.to_string()),
1199 Err(_) => lock_plan(&shared).fail(format!(
1200 "plan timed out after {} s",
1201 options.timeout.as_secs_f64()
1202 )),
1203 }
1204 drop(ctx);
1205
1206 let plan = match Arc::try_unwrap(shared) {
1207 Ok(mutex) => mutex
1208 .into_inner()
1209 .unwrap_or_else(|poisoned| poisoned.into_inner())
1210 .into_plan(),
1211 Err(shared) => lock_plan(&shared).snapshot(),
1212 };
1213
1214 info!(
1215 workflow = %handler_name,
1216 steps = plan.steps.len(),
1217 truncated = plan.truncated,
1218 "execution plan built"
1219 );
1220
1221 Ok(plan)
1222 }
1223
1224 #[tracing::instrument(name = "engine.enqueue_handler", skip_all, fields(workflow = %handler_name))]
1235 pub async fn enqueue_handler(
1236 &self,
1237 handler_name: &str,
1238 trigger: TriggerKind,
1239 payload: Value,
1240 max_retries: u32,
1241 ) -> Result<Run, EngineError> {
1242 self.enqueue_handler_with_options(
1243 handler_name,
1244 trigger,
1245 payload,
1246 EnqueueOptions {
1247 max_retries,
1248 ..Default::default()
1249 },
1250 )
1251 .await
1252 .map(RunCreation::into_run)
1253 }
1254
1255 #[tracing::instrument(name = "engine.enqueue_handler_with_options", skip_all, fields(workflow = %handler_name))]
1307 pub async fn enqueue_handler_with_options(
1308 &self,
1309 handler_name: &str,
1310 trigger: TriggerKind,
1311 payload: Value,
1312 options: EnqueueOptions,
1313 ) -> Result<RunCreation, EngineError> {
1314 let EnqueueOptions {
1315 max_retries,
1316 labels,
1317 scheduled_at,
1318 max_cost_usd,
1319 created_by,
1320 idempotency_key,
1321 concurrency_key,
1322 concurrency_limits,
1323 priority,
1324 worker_tags,
1325 } = options;
1326
1327 validate_concurrency_limits(&concurrency_limits)
1330 .map_err(EngineError::InvalidConcurrencyLimit)?;
1331 if let Some(priority) = priority {
1332 validate_priority(priority).map_err(EngineError::InvalidPriority)?;
1333 }
1334 validate_worker_tags(&worker_tags).map_err(EngineError::InvalidWorkerTag)?;
1335
1336 let handler = self.handlers.get(handler_name).ok_or_else(|| {
1337 EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
1338 })?;
1339
1340 self.check_monthly_quota(handler_name).await?;
1341
1342 let handler_version = handler.version().map(str::to_string);
1343 let mut merged_labels = handler.default_labels();
1344 merged_labels.extend(labels);
1345 let resolved_cap = self
1346 .budget
1347 .resolve_run_cap(max_cost_usd, handler.default_max_cost_usd());
1348 let priority = priority.unwrap_or_else(|| clamp_priority(handler.priority()));
1349 let required_tags = normalize_worker_tags(
1350 handler
1351 .required_worker_tags()
1352 .into_iter()
1353 .chain(worker_tags),
1354 );
1355
1356 let creation = self
1357 .store
1358 .create_run(NewRun {
1359 workflow_name: handler_name.to_string(),
1360 trigger,
1361 payload,
1362 max_retries,
1363 handler_version,
1364 labels: merged_labels,
1365 scheduled_at,
1366 created_by,
1367 idempotency_key,
1368 concurrency_key,
1369 priority,
1370 concurrency_limits,
1371 max_cost_usd: resolved_cap,
1372 worker_tags: required_tags,
1373 })
1374 .await?;
1375
1376 match &creation {
1377 RunCreation::Created(run) => info!(
1378 run_id = %run.id,
1379 workflow = %handler_name,
1380 max_cost_usd = ?resolved_cap,
1381 "handler run enqueued"
1382 ),
1383 RunCreation::Existing(run) => info!(
1384 run_id = %run.id,
1385 workflow = %handler_name,
1386 "idempotent replay, nothing enqueued"
1387 ),
1388 }
1389
1390 Ok(creation)
1391 }
1392
1393 #[tracing::instrument(name = "engine.execute_handler_run", skip_all, fields(run_id = %run_id))]
1413 pub async fn execute_handler_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
1414 let run = self
1415 .store
1416 .get_run(run_id)
1417 .await?
1418 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
1419
1420 if let Some(root_run_id) = chain_root(&run) {
1421 return self.resume_chain(run, root_run_id).await;
1422 }
1423
1424 let _active = self.track_execution(run_id).await;
1425
1426 let handler = self
1427 .handlers
1428 .get(&run.workflow_name)
1429 .ok_or_else(|| {
1430 EngineError::InvalidWorkflow(format!(
1431 "no handler registered: {}",
1432 run.workflow_name
1433 ))
1434 })?
1435 .clone();
1436
1437 #[cfg(feature = "prometheus")]
1438 gauge!(RUNS_ACTIVE, "workflow" => run.workflow_name.clone()).increment(1.0);
1439
1440 let run_start = Instant::now();
1441 let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
1442
1443 let result = if handler.is_version_compatible(run.handler_version.as_deref()) {
1456 ctx.load_replay_steps().await?;
1457 self.release_then_execute(run_id, handler.as_ref(), &mut ctx)
1458 .await
1459 } else {
1460 Err(EngineError::HandlerVersionMismatch {
1461 run_id,
1462 workflow_name: run.workflow_name.clone(),
1463 run_version: run
1464 .handler_version
1465 .clone()
1466 .unwrap_or_else(|| "unknown".to_string()),
1467 current_version: handler
1468 .version()
1469 .map(str::to_string)
1470 .unwrap_or_else(|| "unknown".to_string()),
1471 })
1472 };
1473
1474 self.finalize_run(
1475 run_id,
1476 &run.workflow_name,
1477 result,
1478 &ctx,
1479 run_start,
1480 run.labels,
1481 )
1482 .await
1483 }
1484
1485 #[tracing::instrument(name = "engine.execute_run", skip_all, fields(run_id = %run_id))]
1493 pub async fn execute_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
1494 self.execute_handler_run(run_id).await
1495 }
1496
1497 #[tracing::instrument(name = "engine.resume_run", skip_all, fields(run_id = %run_id))]
1526 pub async fn resume_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
1527 let run = self
1528 .store
1529 .get_run(run_id)
1530 .await?
1531 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
1532
1533 if let Some(root_run_id) = chain_root(&run) {
1534 return self.resume_chain(run, root_run_id).await;
1535 }
1536
1537 self.resume_loaded_run(run).await
1538 }
1539
1540 async fn resume_chain(
1557 &self,
1558 child: Run,
1559 root_run_id: Uuid,
1560 ) -> Result<WorkflowResult, EngineError> {
1561 let child_run_id = child.id;
1562 let lease = child.worker_id.zip(child.lease_expires_at);
1563 match self
1564 .claim_chain_root(child_run_id, root_run_id, lease.as_ref())
1565 .await?
1566 {
1567 ChainClaim::Claimed => {}
1568 ChainClaim::Settled(root) => {
1569 return Ok(WorkflowResult {
1570 run: *root,
1571 steps: Vec::new(),
1572 });
1573 }
1574 }
1575
1576 if lease.is_some() {
1577 self.store
1578 .update_run(
1579 child_run_id,
1580 RunUpdate {
1581 lease: Some(LeaseUpdate::Release),
1582 ..RunUpdate::default()
1583 },
1584 )
1585 .await?;
1586 }
1587
1588 info!(
1589 run_id = %child_run_id,
1590 root_run_id = %root_run_id,
1591 lease_transferred = lease.is_some(),
1592 "child run resumed through its root run"
1593 );
1594
1595 let root = self
1596 .store
1597 .get_run(root_run_id)
1598 .await?
1599 .ok_or(EngineError::Store(StoreError::RunNotFound(root_run_id)))?;
1600 self.resume_loaded_run(root).await
1601 }
1602
1603 pub(crate) async fn move_root_to_running(
1609 &self,
1610 root_run_id: Uuid,
1611 lease: Option<&(String, DateTime<Utc>)>,
1612 ) -> Result<(), EngineError> {
1613 match lease {
1614 Some((worker_id, expires_at)) => {
1615 self.store
1616 .update_run(
1617 root_run_id,
1618 RunUpdate {
1619 status: Some(RunStatus::Running),
1620 lease: Some(LeaseUpdate::Set {
1621 worker_id: worker_id.clone(),
1622 expires_at: *expires_at,
1623 }),
1624 ..RunUpdate::default()
1625 },
1626 )
1627 .await?;
1628 }
1629 None => {
1630 self.store
1631 .update_run_status(root_run_id, RunStatus::Running)
1632 .await?;
1633 }
1634 }
1635 Ok(())
1636 }
1637
1638 async fn resume_loaded_run(&self, run: Run) -> Result<WorkflowResult, EngineError> {
1640 let run_id = run.id;
1641 let _active = self.track_execution(run_id).await;
1642 let handler = self
1643 .handlers
1644 .get(&run.workflow_name)
1645 .ok_or_else(|| {
1646 EngineError::InvalidWorkflow(format!(
1647 "no handler registered: {}",
1648 run.workflow_name
1649 ))
1650 })?
1651 .clone();
1652
1653 info!(run_id = %run_id, workflow = %run.workflow_name, "resuming run after approval");
1654
1655 let run_start = Instant::now();
1656 let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
1657
1658 let result = if handler.is_version_compatible(run.handler_version.as_deref()) {
1659 ctx.load_replay_steps().await?;
1660 self.release_then_execute(run_id, handler.as_ref(), &mut ctx)
1661 .await
1662 } else {
1663 Err(EngineError::HandlerVersionMismatch {
1664 run_id,
1665 workflow_name: run.workflow_name.clone(),
1666 run_version: run
1667 .handler_version
1668 .clone()
1669 .unwrap_or_else(|| "unknown".to_string()),
1670 current_version: handler
1671 .version()
1672 .map(str::to_string)
1673 .unwrap_or_else(|| "unknown".to_string()),
1674 })
1675 };
1676
1677 self.finalize_run(
1678 run_id,
1679 &run.workflow_name,
1680 result,
1681 &ctx,
1682 run_start,
1683 run.labels,
1684 )
1685 .await
1686 }
1687
1688 pub async fn deliver_signal(
1734 self: &Arc<Self>,
1735 signal: NewSignal,
1736 ) -> Result<SignalDelivery, EngineError> {
1737 if signal.name.trim().is_empty() {
1738 return Err(EngineError::InvalidSignal(
1739 "signal name must not be empty".to_string(),
1740 ));
1741 }
1742 if signal.key.trim().is_empty() {
1743 return Err(EngineError::InvalidSignal(
1744 "signal key must not be empty".to_string(),
1745 ));
1746 }
1747
1748 let stored = match self.store.insert_signal(signal).await? {
1749 SignalInsert::Created(stored) => stored,
1750 SignalInsert::Duplicate(existing) => {
1751 info!(
1752 signal_id = %existing.id,
1753 signal = %existing.name,
1754 key = %existing.key,
1755 "duplicate signal ignored"
1756 );
1757 return Ok(SignalDelivery {
1758 signal_id: existing.id,
1759 duplicate: true,
1760 resumed: Vec::new(),
1761 rejected: Vec::new(),
1762 });
1763 }
1764 };
1765
1766 let waiters = self
1767 .store
1768 .list_signal_waiters(&stored.name, &stored.key)
1769 .await?;
1770 let mut resumed = Vec::new();
1771 let mut rejected = Vec::new();
1772
1773 for step in waiters {
1774 if let Err(error) = validate_step_payload(step.input.as_ref(), &stored.payload) {
1775 rejected.push(SignalRejected {
1776 run_id: step.run_id,
1777 step_id: step.id,
1778 error,
1779 });
1780 continue;
1781 }
1782
1783 match self
1784 .store
1785 .resolve_signal_step(step.id, received_output(&stored))
1786 .await
1787 {
1788 Ok(SignalStepResolution::Resolved {
1789 run_id,
1790 run_resumed,
1791 }) => {
1792 resumed.push(SignalResumed {
1793 run_id,
1794 step_id: step.id,
1795 });
1796 if run_resumed && self.execution_mode == ExecutionMode::Local {
1797 self.spawn_local_resume(run_id);
1798 }
1799 }
1800 Ok(SignalStepResolution::NotWaiting { .. }) => {}
1802 Err(err) => {
1803 error!(
1804 run_id = %step.run_id,
1805 step_id = %step.id,
1806 error = %err,
1807 "failed to resolve a waiting signal step"
1808 );
1809 rejected.push(SignalRejected {
1810 run_id: step.run_id,
1811 step_id: step.id,
1812 error: err.to_string(),
1813 });
1814 }
1815 }
1816 }
1817
1818 info!(
1819 signal_id = %stored.id,
1820 signal = %stored.name,
1821 key = %stored.key,
1822 resumed = resumed.len(),
1823 rejected = rejected.len(),
1824 "signal received"
1825 );
1826 self.event_publisher
1827 .publish(Event::SignalReceived(SignalReceivedEvent {
1828 signal_id: stored.id,
1829 name: stored.name.clone(),
1830 key: stored.key.clone(),
1831 resumed_runs: resumed.iter().map(|r| r.run_id).collect(),
1832 at: stored.received_at,
1833 }));
1834
1835 Ok(SignalDelivery {
1836 signal_id: stored.id,
1837 duplicate: false,
1838 resumed,
1839 rejected,
1840 })
1841 }
1842
1843 pub async fn send_signal<S: Signal>(
1881 self: &Arc<Self>,
1882 signal: &S,
1883 key: &str,
1884 idempotency_id: Option<&str>,
1885 ) -> Result<SignalDelivery, EngineError> {
1886 let payload = to_value(signal)?;
1887 self.deliver_signal(NewSignal {
1888 name: S::NAME.to_string(),
1889 key: key.to_string(),
1890 payload,
1891 idempotency_id: idempotency_id.map(str::to_string),
1892 })
1893 .await
1894 }
1895
1896 pub(crate) fn spawn_local_resume(self: &Arc<Self>, run_id: Uuid) {
1921 let engine = Arc::clone(self);
1922 spawn(async move {
1923 let _resuming = lock_run(&engine.local_resumes, run_id).await;
1927 engine.wait_until_idle(run_id).await;
1928 let run = match engine.store.get_run(run_id).await {
1929 Ok(Some(run)) => run,
1930 Ok(None) => {
1931 error!(run_id = %run_id, "run to restart not found");
1932 return;
1933 }
1934 Err(err) => {
1935 error!(run_id = %run_id, error = %err, "failed to load a run to restart");
1936 return;
1937 }
1938 };
1939 match run.status.state {
1940 RunStatus::Pending => {
1941 match engine
1942 .store
1943 .update_run_status(run_id, RunStatus::Running)
1944 .await
1945 {
1946 Ok(()) => {}
1947 Err(StoreError::InvalidTransition { from, .. }) => {
1948 info!(
1949 run_id = %run_id,
1950 status = %from,
1951 "run claimed elsewhere, left to its worker"
1952 );
1953 return;
1954 }
1955 Err(err) => {
1956 error!(run_id = %run_id, error = %err, "failed to restart a woken run");
1957 return;
1958 }
1959 }
1960 }
1961 RunStatus::Running if run.worker_id.is_some() => {
1962 info!(
1963 run_id = %run_id,
1964 worker_id = ?run.worker_id,
1965 "run owned by a worker lease, local resume skipped"
1966 );
1967 return;
1968 }
1969 RunStatus::Running => {
1970 if engine.is_executing(run_id) {
1971 info!(run_id = %run_id, "run already executing in this process, resume skipped");
1972 return;
1973 }
1974 }
1975 status => {
1976 info!(
1977 run_id = %run_id,
1978 status = %status,
1979 "run no longer waiting to restart, resume skipped"
1980 );
1981 return;
1982 }
1983 }
1984 if let Err(err) = engine.resume_run(run_id).await {
1985 error!(run_id = %run_id, error = %err, "failed to resume a woken run");
1986 }
1987 });
1988 }
1989
1990 pub async fn fail_or_schedule_retry(
2041 &self,
2042 run_id: Uuid,
2043 error: &str,
2044 retryable: bool,
2045 cost_usd: Option<Decimal>,
2046 duration_ms: Option<u64>,
2047 ) -> Result<RunStatus, EngineError> {
2048 let run = self
2049 .store
2050 .get_run(run_id)
2051 .await?
2052 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
2053
2054 if run.status.state == RunStatus::Paused {
2057 info!(run_id = %run_id, error = %error, "run paused, failure not recorded");
2058 return Ok(RunStatus::Paused);
2059 }
2060
2061 let has_attempts_left = run.retry_count < run.max_retries;
2062 let update = if retryable && has_attempts_left {
2063 let backoff = backoff_for_retry(run.retry_count);
2064 let scheduled_at = Utc::now() + TimeDelta::milliseconds(backoff.as_millis() as i64);
2065
2066 info!(
2067 run_id = %run_id,
2068 workflow = %run.workflow_name,
2069 attempt = run.retry_count + 1,
2070 max_retries = run.max_retries,
2071 backoff_secs = backoff.as_secs(),
2072 scheduled_at = %scheduled_at,
2073 "run failed, scheduling retry"
2074 );
2075
2076 RunUpdate {
2077 status: Some(RunStatus::Retrying),
2078 error: Some(error.to_string()),
2079 increment_retry: true,
2080 cost_usd,
2081 duration_ms,
2082 scheduled_at: Some(scheduled_at),
2083 ..RunUpdate::default()
2084 }
2085 } else {
2086 RunUpdate {
2087 status: Some(RunStatus::Failed),
2088 error: Some(error.to_string()),
2089 cost_usd,
2090 duration_ms,
2091 completed_at: Some(Utc::now()),
2092 ..RunUpdate::default()
2093 }
2094 };
2095
2096 let status = update.status.unwrap_or(RunStatus::Failed);
2097 self.store.update_run(run_id, update).await?;
2098 self.fail_orphaned_steps(run_id, error).await?;
2099 self.cancel_descendants_of_stopped_run(run_id, error).await;
2102
2103 Ok(status)
2104 }
2105
2106 pub async fn interrupt_running_steps(&self, run_id: Uuid) -> Result<(), EngineError> {
2136 interrupt_running_steps(self.store.as_ref(), run_id).await
2137 }
2138
2139 pub async fn fail_orphaned_steps(
2153 &self,
2154 run_id: Uuid,
2155 error_message: &str,
2156 ) -> Result<(), EngineError> {
2157 let steps = self.store.list_steps(run_id).await?;
2158 let now = Utc::now();
2159
2160 for step in steps {
2161 if step.status.state.is_terminal() {
2162 continue;
2163 }
2164
2165 let (target_status, error) = match step.status.state {
2166 StepStatus::Running | StepStatus::AwaitingApproval => {
2167 let err = if step.error.is_some() {
2168 None
2169 } else {
2170 Some(error_message.to_string())
2171 };
2172 (StepStatus::Failed, err)
2173 }
2174 StepStatus::Pending => (StepStatus::Skipped, None),
2175 _ => continue,
2176 };
2177
2178 if let Err(e) = self
2179 .store
2180 .update_step(
2181 step.id,
2182 StepUpdate {
2183 status: Some(target_status),
2184 error,
2185 completed_at: Some(now),
2186 ..StepUpdate::default()
2187 },
2188 )
2189 .await
2190 {
2191 warn!(
2192 run_id = %run_id,
2193 step_id = %step.id,
2194 step_name = %step.name,
2195 error = %e,
2196 "failed to cleanup orphaned step"
2197 );
2198 } else {
2199 info!(
2200 run_id = %run_id,
2201 step_id = %step.id,
2202 step_name = %step.name,
2203 from = %step.status.state,
2204 to = %target_status,
2205 "cleaned up orphaned step"
2206 );
2207 }
2208 }
2209
2210 Ok(())
2211 }
2212
2213 async fn release_then_execute(
2218 &self,
2219 run_id: Uuid,
2220 handler: &dyn WorkflowHandler,
2221 ctx: &mut WorkflowContext,
2222 ) -> Result<(), EngineError> {
2223 match self.provider.release_run(&run_id.to_string()).await {
2224 Ok(()) => handler.execute(ctx).await,
2225 Err(e) => Err(EngineError::Operation(OperationError::Agent(e))),
2226 }
2227 }
2228
2229 async fn finalize_run(
2235 &self,
2236 run_id: Uuid,
2237 workflow_name: &str,
2238 result: Result<(), EngineError>,
2239 ctx: &WorkflowContext,
2240 run_start: Instant,
2241 run_labels: HashMap<String, String>,
2242 ) -> Result<WorkflowResult, EngineError> {
2243 let total_duration = ctx.carried_duration_ms() + run_start.elapsed().as_millis() as u64;
2246 let completed_at = Utc::now();
2247
2248 if let Some(run) = self.store.get_run(run_id).await?
2252 && run.status.state == RunStatus::Paused
2253 {
2254 let run = self
2255 .store
2256 .update_run_returning(
2257 run_id,
2258 RunUpdate {
2259 cost_usd: Some(ctx.total_cost_usd()),
2260 duration_ms: Some(total_duration),
2261 ..RunUpdate::default()
2262 },
2263 )
2264 .await?;
2265 info!(
2266 run_id = %run_id,
2267 outcome = ?result.err().map(|err| err.to_string()),
2268 "run paused, execution stopped"
2269 );
2270 return Ok(WorkflowResult {
2271 run,
2272 steps: ctx.step_results().to_vec(),
2273 });
2274 }
2275
2276 if matches!(result, Err(EngineError::RunPaused { .. })) {
2279 let run = self
2280 .store
2281 .get_run(run_id)
2282 .await?
2283 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
2284 info!(
2285 run_id = %run_id,
2286 status = %run.status.state,
2287 "run resumed after the pause, execution stopped"
2288 );
2289 return Ok(WorkflowResult {
2290 run,
2291 steps: ctx.step_results().to_vec(),
2292 });
2293 }
2294
2295 let final_status;
2296 let final_run;
2297
2298 match result {
2299 Ok(()) => {
2300 final_status = if ctx.has_allowed_failure() {
2301 RunStatus::Warning
2302 } else {
2303 RunStatus::Completed
2304 };
2305 final_run = self
2306 .store
2307 .update_run_returning(
2308 run_id,
2309 RunUpdate {
2310 status: Some(final_status),
2311 cost_usd: Some(ctx.total_cost_usd()),
2312 duration_ms: Some(total_duration),
2313 completed_at: Some(completed_at),
2314 output: ctx.output().cloned(),
2315 ..RunUpdate::default()
2316 },
2317 )
2318 .await?;
2319
2320 info!(
2321 run_id = %run_id,
2322 status = %final_status,
2323 cost_usd = %ctx.total_cost_usd(),
2324 duration_ms = total_duration,
2325 "run completed"
2326 );
2327 }
2328 Err(EngineError::ApprovalRequired {
2329 run_id: approval_run_id,
2330 step_id,
2331 ref message,
2332 }) => {
2333 final_status = RunStatus::AwaitingApproval;
2334 final_run = self
2335 .store
2336 .update_run_returning(
2337 run_id,
2338 RunUpdate {
2339 status: Some(RunStatus::AwaitingApproval),
2340 cost_usd: Some(ctx.total_cost_usd()),
2341 duration_ms: Some(total_duration),
2342 ..RunUpdate::default()
2343 },
2344 )
2345 .await?;
2346
2347 info!(
2348 run_id = %approval_run_id,
2349 step_id = %step_id,
2350 message = %message,
2351 "run awaiting approval"
2352 );
2353
2354 self.publish_approval_requested(approval_run_id, step_id, message)
2355 .await?;
2356 }
2357 Err(EngineError::ChildSuspended {
2358 run_id: child_run_id,
2359 ref cause,
2360 }) => {
2361 final_status = cause.suspension_status();
2362 final_run = self
2366 .store
2367 .update_run_returning(
2368 run_id,
2369 RunUpdate {
2370 status: Some(final_status),
2371 cost_usd: Some(ctx.total_cost_usd()),
2372 duration_ms: Some(total_duration),
2373 ..RunUpdate::default()
2374 },
2375 )
2376 .await?;
2377
2378 let leaf = cause.suspension_leaf();
2379 info!(
2380 run_id = %run_id,
2381 child_run_id = %child_run_id,
2382 status = %final_status,
2383 cause = %leaf,
2384 "run suspended with its child run"
2385 );
2386
2387 self.publish_suspension(leaf).await?;
2388 for sibling in ctx.sibling_suspensions() {
2390 self.publish_suspension(sibling.suspension_leaf()).await?;
2391 }
2392 }
2393 Err(EngineError::HumanInputRequired {
2394 run_id: input_run_id,
2395 step_id,
2396 ref message,
2397 }) => {
2398 final_status = RunStatus::AwaitingApproval;
2399 final_run = self
2400 .store
2401 .update_run_returning(
2402 run_id,
2403 RunUpdate {
2404 status: Some(RunStatus::AwaitingApproval),
2405 cost_usd: Some(ctx.total_cost_usd()),
2406 duration_ms: Some(total_duration),
2407 ..RunUpdate::default()
2408 },
2409 )
2410 .await?;
2411
2412 info!(
2414 run_id = %input_run_id,
2415 step_id = %step_id,
2416 message = %message,
2417 "run awaiting human input"
2418 );
2419 }
2420 Err(EngineError::DelaySleeping {
2421 run_id: delay_run_id,
2422 step_id,
2423 wake_at,
2424 }) => {
2425 final_status = RunStatus::Sleeping;
2426 final_run = self
2427 .store
2428 .update_run_returning(
2429 run_id,
2430 RunUpdate {
2431 status: Some(RunStatus::Sleeping),
2432 cost_usd: Some(ctx.total_cost_usd()),
2433 duration_ms: Some(total_duration),
2434 scheduled_at: Some(wake_at),
2435 ..RunUpdate::default()
2436 },
2437 )
2438 .await?;
2439
2440 info!(
2441 run_id = %delay_run_id,
2442 step_id = %step_id,
2443 wake_at = %wake_at,
2444 "run sleeping until delay elapses"
2445 );
2446 }
2447 Err(EngineError::CapacitySleeping {
2448 run_id: capacity_run_id,
2449 step_id,
2450 ref kind,
2451 wake_at,
2452 }) => {
2453 final_status = RunStatus::Sleeping;
2454 final_run = self
2455 .store
2456 .update_run_returning(
2457 run_id,
2458 RunUpdate {
2459 status: Some(RunStatus::Sleeping),
2460 cost_usd: Some(ctx.total_cost_usd()),
2461 duration_ms: Some(total_duration),
2462 scheduled_at: Some(wake_at),
2463 capacity_wait_kind: Some(ProviderKind::new(kind.as_str())),
2464 ..RunUpdate::default()
2465 },
2466 )
2467 .await?;
2468
2469 info!(
2470 run_id = %capacity_run_id,
2471 step_id = %step_id,
2472 kind = %kind,
2473 wake_at = %wake_at,
2474 "run sleeping until provider capacity returns"
2475 );
2476 }
2477 Err(EngineError::SignalWaiting {
2478 run_id: wait_run_id,
2479 step_id,
2480 ref step_name,
2481 ref name,
2482 ref key,
2483 deadline_at,
2484 }) => {
2485 final_status = RunStatus::Sleeping;
2486 let waiting = self
2490 .store
2491 .suspend_run_on_signal(run_id, step_id, deadline_at)
2492 .await?;
2493 final_run = self
2494 .store
2495 .update_run_returning(
2496 run_id,
2497 RunUpdate {
2498 cost_usd: Some(ctx.total_cost_usd()),
2499 duration_ms: Some(total_duration),
2500 ..RunUpdate::default()
2501 },
2502 )
2503 .await?;
2504
2505 if waiting {
2506 self.event_publisher
2507 .publish(Event::SignalAwaited(SignalAwaitedEvent {
2508 run_id: wait_run_id,
2509 step_id,
2510 step_name: step_name.clone(),
2511 name: name.clone(),
2512 key: key.clone(),
2513 deadline_at,
2514 at: Utc::now(),
2515 }));
2516 }
2517
2518 info!(
2519 run_id = %wait_run_id,
2520 step_id = %step_id,
2521 signal = %name,
2522 key = %key,
2523 deadline_at = %deadline_at,
2524 waiting,
2525 "run sleeping until a signal arrives"
2526 );
2527 }
2528 Err(err) => {
2529 let guardrail_stop = matches!(
2533 err,
2534 EngineError::RunBudgetExceeded { .. } | EngineError::WorkflowGuardRejected(_)
2535 );
2536
2537 final_status = if guardrail_stop {
2538 if let Err(store_err) = self
2539 .store
2540 .update_run(
2541 run_id,
2542 RunUpdate {
2543 status: Some(RunStatus::Cancelled),
2544 error: Some(err.to_string()),
2545 cost_usd: Some(ctx.total_cost_usd()),
2546 duration_ms: Some(total_duration),
2547 completed_at: Some(completed_at),
2548 output: ctx.output().cloned(),
2549 ..RunUpdate::default()
2550 },
2551 )
2552 .await
2553 {
2554 error!(run_id = %run_id, store_error = %store_err, "failed to persist run cancellation");
2555 }
2556 if let Err(cleanup_err) = self
2557 .fail_orphaned_steps(run_id, "run stopped: guardrail limit reached")
2558 .await
2559 {
2560 error!(run_id = %run_id, store_error = %cleanup_err, "failed to cleanup orphaned steps");
2561 }
2562 RunStatus::Cancelled
2563 } else {
2564 if let Some(output) = ctx.output()
2567 && let Err(store_err) = self
2568 .store
2569 .update_run(
2570 run_id,
2571 RunUpdate {
2572 output: Some(output.clone()),
2573 ..RunUpdate::default()
2574 },
2575 )
2576 .await
2577 {
2578 error!(run_id = %run_id, store_error = %store_err, "failed to persist run output");
2579 }
2580 self.fail_or_schedule_retry(
2581 run_id,
2582 &err.to_string(),
2583 is_run_retryable(&err),
2584 Some(ctx.total_cost_usd()),
2585 Some(total_duration),
2586 )
2587 .await
2588 .unwrap_or_else(|store_err| {
2589 error!(run_id = %run_id, store_error = %store_err, "failed to persist run failure");
2590 RunStatus::Failed
2591 })
2592 };
2593
2594 if matches!(err, EngineError::RunBudgetExceeded { .. }) {
2595 self.on_run_budget_exceeded(workflow_name, run_id, &err);
2596 }
2597
2598 error!(run_id = %run_id, status = %final_status, error = %err, "run stopped");
2599
2600 self.publish_run_status_changed(
2601 workflow_name,
2602 run_id,
2603 final_status,
2604 Some(err.to_string()),
2605 ctx,
2606 total_duration,
2607 run_labels,
2608 );
2609
2610 #[cfg(feature = "prometheus")]
2611 self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
2612
2613 return Err(err);
2614 }
2615 }
2616
2617 self.publish_run_status_changed(
2618 workflow_name,
2619 run_id,
2620 final_status,
2621 None,
2622 ctx,
2623 total_duration,
2624 run_labels,
2625 );
2626
2627 #[cfg(feature = "prometheus")]
2628 self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
2629
2630 Ok(WorkflowResult {
2631 run: final_run,
2632 steps: ctx.step_results().to_vec(),
2633 })
2634 }
2635
2636 pub(crate) async fn publish_approval_requested(
2639 &self,
2640 run_id: Uuid,
2641 step_id: Uuid,
2642 message: &str,
2643 ) -> Result<(), EngineError> {
2644 let requirement = self
2645 .store
2646 .get_step(step_id)
2647 .await?
2648 .and_then(|s| s.approval_requirement);
2649 self.event_publisher
2650 .publish(Event::ApprovalRequested(ApprovalRequestedEvent {
2651 run_id,
2652 step_id,
2653 message: message.to_string(),
2654 requirement,
2655 at: Utc::now(),
2656 }));
2657 Ok(())
2658 }
2659
2660 pub async fn fail_ancestors(&self, run_id: Uuid, reason: &str) -> Result<(), EngineError> {
2693 let mut current = self
2694 .store
2695 .get_run(run_id)
2696 .await?
2697 .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
2698 let mut visited = HashSet::from([run_id]);
2700
2701 while let Some(parent_id) = chain_parent(¤t) {
2702 if !visited.insert(parent_id) {
2703 break;
2704 }
2705 let status = self
2706 .fail_or_schedule_retry(parent_id, reason, false, None, None)
2707 .await?;
2708 if status == RunStatus::Paused {
2711 self.requeue_paused_root(¤t).await?;
2712 break;
2713 }
2714 info!(
2715 run_id = %run_id,
2716 ancestor_run_id = %parent_id,
2717 status = %status,
2718 "ancestor run failed with its child"
2719 );
2720 current = self
2721 .store
2722 .get_run(parent_id)
2723 .await?
2724 .ok_or(EngineError::Store(StoreError::RunNotFound(parent_id)))?;
2725 }
2726
2727 Ok(())
2728 }
2729
2730 #[cfg(feature = "prometheus")]
2732 fn emit_run_metrics(
2733 &self,
2734 workflow_name: &str,
2735 status: RunStatus,
2736 duration_ms: u64,
2737 ctx: &WorkflowContext,
2738 ) {
2739 let status_str = status.to_string();
2740 let wf = workflow_name.to_string();
2741
2742 counter!(RUNS_TOTAL, "workflow" => wf.clone(), "status" => status_str.clone()).increment(1);
2743 histogram!(RUN_DURATION_SECONDS, "workflow" => wf.clone(), "status" => status_str)
2744 .record(duration_ms as f64 / 1000.0);
2745 histogram!(RUN_COST_USD, "workflow" => wf.clone()).record(
2746 ctx.total_cost_usd()
2747 .to_string()
2748 .parse::<f64>()
2749 .unwrap_or(0.0),
2750 );
2751 gauge!(RUNS_ACTIVE, "workflow" => wf).decrement(1.0);
2752 }
2753
2754 fn on_run_budget_exceeded(&self, workflow_name: &str, run_id: Uuid, err: &EngineError) {
2760 let EngineError::RunBudgetExceeded {
2761 limit_usd,
2762 spent_usd,
2763 step_budget_usd,
2764 ..
2765 } = err
2766 else {
2767 return;
2768 };
2769
2770 #[cfg(feature = "prometheus")]
2771 counter!(
2772 RUN_BUDGET_EXCEEDED_TOTAL,
2773 "workflow" => workflow_name.to_string(),
2774 "scope" => "run",
2775 )
2776 .increment(1);
2777
2778 self.event_publisher
2779 .publish(Event::RunBudgetExceeded(RunBudgetExceededEvent {
2780 run_id,
2781 workflow_name: workflow_name.to_string(),
2782 limit_usd: *limit_usd,
2783 spent_usd: *spent_usd,
2784 step_budget_usd: *step_budget_usd,
2785 at: Utc::now(),
2786 }));
2787 }
2788
2789 #[allow(clippy::too_many_arguments)]
2794 fn publish_run_status_changed(
2795 &self,
2796 workflow_name: &str,
2797 run_id: Uuid,
2798 to: RunStatus,
2799 error: Option<String>,
2800 ctx: &WorkflowContext,
2801 duration_ms: u64,
2802 labels: HashMap<String, String>,
2803 ) {
2804 let now = Utc::now();
2805 let cost_usd = ctx.total_cost_usd();
2806 let wf = workflow_name.to_string();
2807
2808 self.event_publisher
2809 .publish(Event::RunStatusChanged(RunStatusChangedEvent {
2810 run_id,
2811 workflow_name: wf.clone(),
2812 from: RunStatus::Running,
2813 to,
2814 error: error.clone(),
2815 cost_usd,
2816 duration_ms,
2817 labels: labels.clone(),
2818 at: now,
2819 }));
2820
2821 if to == RunStatus::Failed {
2822 self.event_publisher
2823 .publish(Event::RunFailed(RunFailedEvent {
2824 run_id,
2825 workflow_name: wf,
2826 error,
2827 cost_usd,
2828 duration_ms,
2829 labels,
2830 at: now,
2831 }));
2832 }
2833 }
2834}
2835
2836impl fmt::Debug for Engine {
2837 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2838 f.debug_struct("Engine")
2839 .field("handlers", &self.handlers.keys().collect::<Vec<_>>())
2840 .finish_non_exhaustive()
2841 }
2842}
2843
2844#[cfg(test)]
2845mod tests {
2846 use super::*;
2847 use crate::config::ShellConfig;
2848 use crate::handler::{HandlerFuture, WorkflowHandler};
2849 use ironflow_core::providers::claude::ClaudeCodeProvider;
2850 use ironflow_core::providers::record_replay::RecordReplayProvider;
2851 use ironflow_store::memory::InMemoryStore;
2852 use ironflow_store::models::{MAX_PRIORITY, MIN_PRIORITY, StepApproval, StepStatus};
2853 use serde_json::json;
2854
2855 struct EchoWorkflow;
2857
2858 impl WorkflowHandler for EchoWorkflow {
2859 fn name(&self) -> &str {
2860 "echo-workflow"
2861 }
2862
2863 fn describe(&self) -> WorkflowInfo {
2864 WorkflowInfo {
2865 description: "A simple workflow that echoes hello".to_string(),
2866 source_code: None,
2867 sub_workflows: Vec::new(),
2868 category: None,
2869 version: self.version().map(str::to_string),
2870 compatible_versions: Vec::new(),
2871 input_schema: None,
2872 default_labels: HashMap::new(),
2873 schedule: self.schedule().cloned(),
2874 default_max_cost_usd: self.default_max_cost_usd(),
2875 priority: self.priority(),
2876 }
2877 }
2878
2879 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2880 Box::pin(async move {
2881 ctx.shell("greet", ShellConfig::new("echo hello")).await?;
2882 Ok(())
2883 })
2884 }
2885 }
2886
2887 struct FailingWorkflow;
2889
2890 impl WorkflowHandler for FailingWorkflow {
2891 fn name(&self) -> &str {
2892 "failing-workflow"
2893 }
2894
2895 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2896 Box::pin(async move {
2897 ctx.shell("fail", ShellConfig::new("exit 1")).await?;
2898 Ok(())
2899 })
2900 }
2901 }
2902
2903 fn create_test_engine() -> Engine {
2904 let store = Arc::new(InMemoryStore::new());
2905 let inner = ClaudeCodeProvider::new();
2906 let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
2907 inner,
2908 "/tmp/ironflow-fixtures",
2909 ));
2910 Engine::new(store, provider)
2911 }
2912
2913 #[test]
2914 fn engine_new_creates_instance() {
2915 let engine = create_test_engine();
2916 assert_eq!(engine.handler_names().len(), 0);
2917 }
2918
2919 #[test]
2920 fn execution_mode_defaults_to_local() {
2921 let engine = create_test_engine();
2922 assert_eq!(engine.execution_mode(), ExecutionMode::Local);
2923 }
2924
2925 #[test]
2926 fn with_execution_mode_overrides_the_default() {
2927 let engine = create_test_engine().with_execution_mode(ExecutionMode::Workers);
2928 assert_eq!(engine.execution_mode(), ExecutionMode::Workers);
2929 }
2930
2931 #[test]
2932 fn engine_register_handler() {
2933 let mut engine = create_test_engine();
2934 let result = engine.register(EchoWorkflow);
2935 assert!(result.is_ok());
2936 assert_eq!(engine.handler_names().len(), 1);
2937 assert!(engine.handler_names().contains(&"echo-workflow"));
2938 }
2939
2940 #[test]
2941 fn engine_register_duplicate_returns_error() {
2942 let mut engine = create_test_engine();
2943 engine.register(EchoWorkflow).unwrap();
2944 let result = engine.register(EchoWorkflow);
2945 assert!(result.is_err());
2946 }
2947
2948 #[test]
2949 fn engine_get_handler_found() {
2950 let mut engine = create_test_engine();
2951 engine.register(EchoWorkflow).unwrap();
2952 let handler = engine.get_handler("echo-workflow");
2953 assert!(handler.is_some());
2954 }
2955
2956 #[test]
2957 fn engine_get_handler_not_found() {
2958 let engine = create_test_engine();
2959 let handler = engine.get_handler("nonexistent");
2960 assert!(handler.is_none());
2961 }
2962
2963 #[test]
2964 fn engine_handler_names_lists_all() {
2965 let mut engine = create_test_engine();
2966 engine.register(EchoWorkflow).unwrap();
2967 engine.register(FailingWorkflow).unwrap();
2968 let names = engine.handler_names();
2969 assert_eq!(names.len(), 2);
2970 assert!(names.contains(&"echo-workflow"));
2971 assert!(names.contains(&"failing-workflow"));
2972 }
2973
2974 #[test]
2975 fn engine_handler_info_returns_description() {
2976 let mut engine = create_test_engine();
2977 engine.register(EchoWorkflow).unwrap();
2978 let info = engine.handler_info("echo-workflow");
2979 assert!(info.is_some());
2980 let info = info.unwrap();
2981 assert_eq!(info.description, "A simple workflow that echoes hello");
2982 }
2983
2984 struct CategorizedWorkflow;
2985
2986 impl WorkflowHandler for CategorizedWorkflow {
2987 fn name(&self) -> &str {
2988 "categorized"
2989 }
2990 fn category(&self) -> Option<&str> {
2991 Some("data/etl")
2992 }
2993 fn execute<'a>(
2994 &'a self,
2995 _ctx: &'a mut WorkflowContext,
2996 ) -> crate::handler::HandlerFuture<'a> {
2997 Box::pin(async move { Ok(()) })
2998 }
2999 }
3000
3001 #[test]
3002 fn engine_default_describe_propagates_category() {
3003 let mut engine = create_test_engine();
3004 engine.register(CategorizedWorkflow).unwrap();
3005 let info = engine.handler_info("categorized").unwrap();
3006 assert_eq!(info.category.as_deref(), Some("data/etl"));
3007 }
3008
3009 #[test]
3010 fn engine_default_describe_without_category() {
3011 let mut engine = create_test_engine();
3012 engine.register(EchoWorkflow).unwrap();
3013 let info = engine.handler_info("echo-workflow").unwrap();
3014 assert!(info.category.is_none());
3015 }
3016
3017 struct ScheduledWorkflow {
3022 schedule: CronSchedule,
3023 }
3024
3025 impl ScheduledWorkflow {
3026 fn new() -> Self {
3027 Self {
3028 schedule: CronSchedule::new("0 0 * * * *").unwrap(),
3029 }
3030 }
3031 }
3032
3033 impl WorkflowHandler for ScheduledWorkflow {
3034 fn name(&self) -> &str {
3035 "scheduled"
3036 }
3037 fn schedule(&self) -> Option<&CronSchedule> {
3038 Some(&self.schedule)
3039 }
3040 fn execute<'a>(
3041 &'a self,
3042 _ctx: &'a mut WorkflowContext,
3043 ) -> crate::handler::HandlerFuture<'a> {
3044 Box::pin(async move { Ok(()) })
3045 }
3046 }
3047
3048 #[test]
3049 fn engine_default_describe_propagates_schedule() {
3050 let mut engine = create_test_engine();
3051 engine.register(ScheduledWorkflow::new()).unwrap();
3052 let info = engine.handler_info("scheduled").unwrap();
3053 assert_eq!(
3054 info.schedule.as_ref().map(|s| s.as_str()),
3055 Some("0 0 * * * *")
3056 );
3057 }
3058
3059 #[test]
3060 fn engine_default_describe_without_schedule() {
3061 let mut engine = create_test_engine();
3062 engine.register(EchoWorkflow).unwrap();
3063 let info = engine.handler_info("echo-workflow").unwrap();
3064 assert!(info.schedule.is_none());
3065 }
3066
3067 #[test]
3068 fn scheduled_handlers_returns_only_scheduled() {
3069 let mut engine = create_test_engine();
3070 engine.register(EchoWorkflow).unwrap();
3071 engine.register(ScheduledWorkflow::new()).unwrap();
3072 engine.register(FailingWorkflow).unwrap();
3073
3074 let scheduled = engine.scheduled_handlers();
3075 assert_eq!(scheduled.len(), 1);
3076 assert_eq!(scheduled[0].0, "scheduled");
3077 assert_eq!(scheduled[0].1.as_str(), "0 0 * * * *");
3078 }
3079
3080 #[test]
3081 fn scheduled_handlers_empty_when_none_scheduled() {
3082 let mut engine = create_test_engine();
3083 engine.register(EchoWorkflow).unwrap();
3084 engine.register(FailingWorkflow).unwrap();
3085
3086 let scheduled = engine.scheduled_handlers();
3087 assert!(scheduled.is_empty());
3088 }
3089
3090 struct BadCategoryWorkflow(&'static str);
3091
3092 impl WorkflowHandler for BadCategoryWorkflow {
3093 fn name(&self) -> &str {
3094 "bad-category"
3095 }
3096 fn category(&self) -> Option<&str> {
3097 Some(self.0)
3098 }
3099 fn execute<'a>(
3100 &'a self,
3101 _ctx: &'a mut WorkflowContext,
3102 ) -> crate::handler::HandlerFuture<'a> {
3103 Box::pin(async move { Ok(()) })
3104 }
3105 }
3106
3107 #[test]
3108 fn engine_register_rejects_empty_category() {
3109 let mut engine = create_test_engine();
3110 let err = engine.register(BadCategoryWorkflow("")).unwrap_err();
3111 match err {
3112 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty category")),
3113 other => panic!("expected InvalidWorkflow, got {other:?}"),
3114 }
3115 }
3116
3117 #[test]
3118 fn engine_register_rejects_leading_slash_category() {
3119 let mut engine = create_test_engine();
3120 let err = engine
3121 .register(BadCategoryWorkflow("/data/etl"))
3122 .unwrap_err();
3123 match err {
3124 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("leading '/'")),
3125 other => panic!("expected InvalidWorkflow, got {other:?}"),
3126 }
3127 }
3128
3129 #[test]
3130 fn engine_register_rejects_trailing_slash_category() {
3131 let mut engine = create_test_engine();
3132 let err = engine
3133 .register(BadCategoryWorkflow("data/etl/"))
3134 .unwrap_err();
3135 match err {
3136 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("trailing '/'")),
3137 other => panic!("expected InvalidWorkflow, got {other:?}"),
3138 }
3139 }
3140
3141 #[test]
3142 fn engine_register_rejects_double_slash_category() {
3143 let mut engine = create_test_engine();
3144 let err = engine
3145 .register(BadCategoryWorkflow("data//etl"))
3146 .unwrap_err();
3147 match err {
3148 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty segment")),
3149 other => panic!("expected InvalidWorkflow, got {other:?}"),
3150 }
3151 }
3152
3153 #[test]
3154 fn engine_register_rejects_whitespace_only_segment_category() {
3155 let mut engine = create_test_engine();
3156 let err = engine
3157 .register(BadCategoryWorkflow("data/ /etl"))
3158 .unwrap_err();
3159 match err {
3160 EngineError::InvalidWorkflow(msg) => assert!(msg.contains("whitespace-only segment")),
3161 other => panic!("expected InvalidWorkflow, got {other:?}"),
3162 }
3163 }
3164
3165 #[test]
3166 fn engine_register_accepts_valid_nested_category() {
3167 let mut engine = create_test_engine();
3168 assert!(engine.register(CategorizedWorkflow).is_ok());
3169 }
3170
3171 #[tokio::test]
3172 async fn engine_unknown_workflow_returns_error() {
3173 let engine = create_test_engine();
3174 let result = engine
3175 .run_handler("unknown", TriggerKind::Manual, json!({}))
3176 .await;
3177 assert!(result.is_err());
3178 match result {
3179 Err(EngineError::InvalidWorkflow(msg)) => {
3180 assert!(msg.contains("no handler registered"));
3181 }
3182 _ => panic!("expected InvalidWorkflow error"),
3183 }
3184 }
3185
3186 #[tokio::test]
3187 async fn engine_enqueue_handler_creates_pending_run() {
3188 let mut engine = create_test_engine();
3189 engine.register(EchoWorkflow).unwrap();
3190
3191 let run = engine
3192 .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
3193 .await
3194 .unwrap();
3195 assert_eq!(run.status.state, RunStatus::Pending);
3196 assert_eq!(run.workflow_name, "echo-workflow");
3197 }
3198
3199 #[tokio::test]
3200 async fn enqueue_handler_leaves_the_run_unattributed() {
3201 let mut engine = create_test_engine();
3202 engine.register(EchoWorkflow).unwrap();
3203
3204 let run = engine
3205 .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
3206 .await
3207 .unwrap();
3208
3209 assert!(run.created_by.is_none());
3210 }
3211
3212 #[tokio::test]
3213 async fn enqueue_handler_with_options_records_the_author() {
3214 let mut engine = create_test_engine();
3215 engine.register(EchoWorkflow).unwrap();
3216 let actor = RunActor::User {
3217 user_id: Uuid::now_v7(),
3218 };
3219
3220 let run = engine
3221 .enqueue_handler_with_options(
3222 "echo-workflow",
3223 TriggerKind::Api,
3224 json!({}),
3225 EnqueueOptions {
3226 created_by: Some(actor.clone()),
3227 ..Default::default()
3228 },
3229 )
3230 .await
3231 .unwrap()
3232 .into_run();
3233
3234 assert_eq!(run.created_by, Some(actor));
3235 }
3236
3237 #[tokio::test]
3238 async fn enqueue_handler_with_options_accepts_no_author() {
3239 let mut engine = create_test_engine();
3240 engine.register(EchoWorkflow).unwrap();
3241
3242 let run = engine
3243 .enqueue_handler_with_options(
3244 "echo-workflow",
3245 TriggerKind::Cron {
3246 schedule: "0 * * * * *".to_string(),
3247 schedule_id: None,
3248 scheduled_for: None,
3249 },
3250 json!({}),
3251 EnqueueOptions::default(),
3252 )
3253 .await
3254 .unwrap()
3255 .into_run();
3256
3257 assert!(run.created_by.is_none());
3258 }
3259
3260 #[tokio::test]
3261 async fn enqueue_handler_with_options_stores_concurrency_limits() {
3262 let mut engine = create_test_engine();
3263 engine.register(EchoWorkflow).unwrap();
3264 let limits = vec![
3265 ConcurrencyLimit::new("repo:acme", 2),
3266 ConcurrencyLimit::new("tenant:42", 5),
3267 ];
3268
3269 let run = engine
3270 .enqueue_handler_with_options(
3271 "echo-workflow",
3272 TriggerKind::Api,
3273 json!({}),
3274 EnqueueOptions {
3275 concurrency_limits: limits.clone(),
3276 ..Default::default()
3277 },
3278 )
3279 .await
3280 .unwrap()
3281 .into_run();
3282
3283 assert_eq!(run.concurrency_limits, limits);
3284 }
3285
3286 #[tokio::test]
3287 async fn enqueue_rejects_invalid_concurrency_limits() {
3288 let mut engine = create_test_engine();
3289 engine.register(EchoWorkflow).unwrap();
3290
3291 let invalid = [
3292 vec![ConcurrencyLimit::new("repo:acme", 0)],
3293 vec![ConcurrencyLimit::new("", 1)],
3294 vec![
3295 ConcurrencyLimit::new("repo:acme", 1),
3296 ConcurrencyLimit::new("repo:acme", 2),
3297 ],
3298 ];
3299 for concurrency_limits in invalid {
3300 let err = engine
3301 .enqueue_handler_with_options(
3302 "echo-workflow",
3303 TriggerKind::Api,
3304 json!({}),
3305 EnqueueOptions {
3306 concurrency_limits,
3307 ..Default::default()
3308 },
3309 )
3310 .await
3311 .unwrap_err();
3312 assert!(
3313 matches!(err, EngineError::InvalidConcurrencyLimit(_)),
3314 "{err:?}"
3315 );
3316 }
3317
3318 let err = engine
3320 .enqueue_handler_with_options(
3321 "not-registered",
3322 TriggerKind::Api,
3323 json!({}),
3324 EnqueueOptions {
3325 concurrency_limits: vec![ConcurrencyLimit::new("repo:acme", 0)],
3326 ..Default::default()
3327 },
3328 )
3329 .await
3330 .unwrap_err();
3331 assert!(
3332 matches!(err, EngineError::InvalidConcurrencyLimit(_)),
3333 "{err:?}"
3334 );
3335
3336 let page = engine
3337 .store()
3338 .list_runs(RunFilter::default(), 1, 10)
3339 .await
3340 .unwrap();
3341 assert_eq!(page.total, 0, "no run may be created");
3342 }
3343
3344 struct UrgentWorkflow;
3345
3346 impl WorkflowHandler for UrgentWorkflow {
3347 fn name(&self) -> &str {
3348 "urgent-workflow"
3349 }
3350
3351 fn priority(&self) -> i16 {
3352 60
3353 }
3354
3355 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3356 Box::pin(async { Ok(()) })
3357 }
3358 }
3359
3360 #[tokio::test]
3361 async fn enqueue_priority_defaults_to_the_handler_priority() {
3362 let mut engine = create_test_engine();
3363 engine.register(EchoWorkflow).unwrap();
3364 engine.register(UrgentWorkflow).unwrap();
3365
3366 let echo = engine
3367 .enqueue_handler_with_options(
3368 "echo-workflow",
3369 TriggerKind::Api,
3370 json!({}),
3371 EnqueueOptions::default(),
3372 )
3373 .await
3374 .unwrap()
3375 .into_run();
3376 assert_eq!(echo.priority, 0);
3377
3378 let urgent = engine
3379 .enqueue_handler_with_options(
3380 "urgent-workflow",
3381 TriggerKind::Api,
3382 json!({}),
3383 EnqueueOptions::default(),
3384 )
3385 .await
3386 .unwrap()
3387 .into_run();
3388 assert_eq!(urgent.priority, 60);
3389 }
3390
3391 #[tokio::test]
3392 async fn enqueue_priority_explicit_value_overrides_the_handler() {
3393 let mut engine = create_test_engine();
3394 engine.register(UrgentWorkflow).unwrap();
3395
3396 let run = engine
3397 .enqueue_handler_with_options(
3398 "urgent-workflow",
3399 TriggerKind::Api,
3400 json!({}),
3401 EnqueueOptions {
3402 priority: Some(-20),
3403 ..Default::default()
3404 },
3405 )
3406 .await
3407 .unwrap()
3408 .into_run();
3409 assert_eq!(run.priority, -20);
3410
3411 let stored = engine.store().get_run(run.id).await.unwrap().unwrap();
3412 assert_eq!(stored.priority, -20);
3413 }
3414
3415 #[tokio::test]
3416 async fn enqueue_priority_out_of_range_is_rejected() {
3417 let mut engine = create_test_engine();
3418 engine.register(EchoWorkflow).unwrap();
3419
3420 for priority in [MAX_PRIORITY + 1, MIN_PRIORITY - 1] {
3421 let err = engine
3422 .enqueue_handler_with_options(
3423 "echo-workflow",
3424 TriggerKind::Api,
3425 json!({}),
3426 EnqueueOptions {
3427 priority: Some(priority),
3428 ..Default::default()
3429 },
3430 )
3431 .await
3432 .unwrap_err();
3433 assert!(matches!(err, EngineError::InvalidPriority(_)), "{err:?}");
3434 }
3435
3436 let err = engine
3438 .enqueue_handler_with_options(
3439 "not-registered",
3440 TriggerKind::Api,
3441 json!({}),
3442 EnqueueOptions {
3443 priority: Some(MAX_PRIORITY + 1),
3444 ..Default::default()
3445 },
3446 )
3447 .await
3448 .unwrap_err();
3449 assert!(matches!(err, EngineError::InvalidPriority(_)), "{err:?}");
3450
3451 let page = engine
3452 .store()
3453 .list_runs(RunFilter::default(), 1, 10)
3454 .await
3455 .unwrap();
3456 assert_eq!(page.total, 0, "no run may be created");
3457 }
3458
3459 #[tokio::test]
3460 async fn enqueue_priority_bounds_are_accepted() {
3461 let mut engine = create_test_engine();
3462 engine.register(EchoWorkflow).unwrap();
3463
3464 for priority in [MIN_PRIORITY, MAX_PRIORITY] {
3465 let run = engine
3466 .enqueue_handler_with_options(
3467 "echo-workflow",
3468 TriggerKind::Api,
3469 json!({}),
3470 EnqueueOptions {
3471 priority: Some(priority),
3472 ..Default::default()
3473 },
3474 )
3475 .await
3476 .unwrap()
3477 .into_run();
3478 assert_eq!(run.priority, priority);
3479 }
3480 }
3481
3482 struct GpuWorkflow;
3483
3484 impl WorkflowHandler for GpuWorkflow {
3485 fn name(&self) -> &str {
3486 "gpu-workflow"
3487 }
3488
3489 fn required_worker_tags(&self) -> Vec<String> {
3490 vec!["gpu".to_string()]
3491 }
3492
3493 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3494 Box::pin(async move { Ok(()) })
3495 }
3496 }
3497
3498 #[tokio::test]
3499 async fn enqueue_merges_handler_and_request_worker_tags() {
3500 let mut engine = create_test_engine();
3501 engine.register(GpuWorkflow).unwrap();
3502
3503 let run = engine
3504 .enqueue_handler_with_options(
3505 "gpu-workflow",
3506 TriggerKind::Api,
3507 json!({}),
3508 EnqueueOptions {
3509 worker_tags: vec!["region:eu".to_string(), "gpu".to_string()],
3510 ..Default::default()
3511 },
3512 )
3513 .await
3514 .unwrap()
3515 .into_run();
3516
3517 assert_eq!(
3518 run.worker_tags,
3519 vec!["gpu".to_string(), "region:eu".to_string()]
3520 );
3521 }
3522
3523 #[tokio::test]
3524 async fn enqueue_without_worker_tags_keeps_handler_tags() {
3525 let mut engine = create_test_engine();
3526 engine.register(GpuWorkflow).unwrap();
3527 engine.register(EchoWorkflow).unwrap();
3528
3529 let gpu = engine
3530 .enqueue_handler("gpu-workflow", TriggerKind::Api, json!({}), 0)
3531 .await
3532 .unwrap();
3533 assert_eq!(gpu.worker_tags, vec!["gpu".to_string()]);
3534
3535 let echo = engine
3536 .enqueue_handler("echo-workflow", TriggerKind::Api, json!({}), 0)
3537 .await
3538 .unwrap();
3539 assert!(echo.worker_tags.is_empty());
3540 }
3541
3542 #[tokio::test]
3543 async fn enqueue_rejects_invalid_worker_tags() {
3544 let mut engine = create_test_engine();
3545 engine.register(EchoWorkflow).unwrap();
3546
3547 for worker_tags in [
3548 vec!["bad,tag".to_string()],
3549 vec![" ".to_string()],
3550 vec!["x".repeat(65)],
3551 ] {
3552 let err = engine
3553 .enqueue_handler_with_options(
3554 "echo-workflow",
3555 TriggerKind::Api,
3556 json!({}),
3557 EnqueueOptions {
3558 worker_tags,
3559 ..Default::default()
3560 },
3561 )
3562 .await
3563 .unwrap_err();
3564 assert!(matches!(err, EngineError::InvalidWorkerTag(_)), "{err:?}");
3565 }
3566
3567 let err = engine
3569 .enqueue_handler_with_options(
3570 "not-registered",
3571 TriggerKind::Api,
3572 json!({}),
3573 EnqueueOptions {
3574 worker_tags: vec!["bad,tag".to_string()],
3575 ..Default::default()
3576 },
3577 )
3578 .await
3579 .unwrap_err();
3580 assert!(matches!(err, EngineError::InvalidWorkerTag(_)), "{err:?}");
3581 }
3582
3583 #[test]
3584 fn worker_tags_are_unset_by_default() {
3585 let engine = create_test_engine();
3586 assert!(engine.worker_tags().is_none());
3587 }
3588
3589 #[test]
3590 fn set_worker_tags_stores_the_tags() {
3591 let mut engine = create_test_engine();
3592 engine.set_worker_tags(vec!["gpu".to_string()]);
3593 assert_eq!(engine.worker_tags(), Some(&["gpu".to_string()][..]));
3594
3595 engine.set_worker_tags(Vec::new());
3596 assert_eq!(engine.worker_tags(), Some(&[][..]));
3597 }
3598
3599 #[tokio::test]
3600 async fn run_handler_records_handler_worker_tags() {
3601 let mut engine = create_test_engine();
3602 engine.register(GpuWorkflow).unwrap();
3603
3604 let result = engine
3605 .run_handler("gpu-workflow", TriggerKind::Manual, json!({}))
3606 .await
3607 .unwrap();
3608 assert_eq!(result.run.worker_tags, vec!["gpu".to_string()]);
3609 }
3610
3611 #[tokio::test]
3612 async fn run_handler_leaves_the_run_unattributed() {
3613 let mut engine = create_test_engine();
3614 engine.register(EchoWorkflow).unwrap();
3615
3616 let run = engine
3617 .run_handler("echo-workflow", TriggerKind::Manual, json!({}))
3618 .await
3619 .unwrap()
3620 .run;
3621
3622 assert!(run.created_by.is_none());
3623 }
3624
3625 #[tokio::test]
3626 async fn run_handler_priority_comes_from_the_handler() {
3627 let mut engine = create_test_engine();
3628 engine.register(UrgentWorkflow).unwrap();
3629
3630 let run = engine
3631 .run_handler("urgent-workflow", TriggerKind::Manual, json!({}))
3632 .await
3633 .unwrap()
3634 .run;
3635
3636 assert_eq!(run.priority, 60);
3637 }
3638
3639 #[tokio::test]
3640 async fn engine_register_boxed() {
3641 let mut engine = create_test_engine();
3642 let handler: Box<dyn WorkflowHandler> = Box::new(EchoWorkflow);
3643 let result = engine.register_boxed(handler);
3644 assert!(result.is_ok());
3645 assert_eq!(engine.handler_names().len(), 1);
3646 }
3647
3648 #[tokio::test]
3649 async fn engine_store_and_provider_accessors() {
3650 let store = Arc::new(InMemoryStore::new());
3651 let inner = ClaudeCodeProvider::new();
3652 let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
3653 inner,
3654 "/tmp/ironflow-fixtures",
3655 ));
3656 let engine = Engine::new(store.clone(), provider.clone());
3657
3658 let _ = engine.store();
3660 let _ = engine.provider();
3661 }
3662
3663 use crate::operation::{Operation, OperationContext};
3668 use async_trait::async_trait;
3669 use ironflow_core::error::OperationError;
3670 use ironflow_store::models::StepKind;
3671
3672 struct FakeGitlabOp {
3673 project_id: u64,
3674 title: String,
3675 }
3676
3677 #[async_trait]
3678 impl Operation for FakeGitlabOp {
3679 fn kind(&self) -> &str {
3680 "gitlab"
3681 }
3682
3683 async fn execute(&self, _ctx: &OperationContext) -> Result<Value, OperationError> {
3684 Ok(json!({
3685 "issue_id": 42,
3686 "project_id": self.project_id,
3687 "title": self.title,
3688 }))
3689 }
3690
3691 fn input(&self) -> Option<Value> {
3692 Some(json!({
3693 "project_id": self.project_id,
3694 "title": self.title,
3695 }))
3696 }
3697 }
3698
3699 struct FailingOp;
3700
3701 #[async_trait]
3702 impl Operation for FailingOp {
3703 fn kind(&self) -> &str {
3704 "broken-service"
3705 }
3706
3707 async fn execute(&self, _ctx: &OperationContext) -> Result<Value, OperationError> {
3708 Err(OperationError::Http {
3709 status: None,
3710 message: "service unavailable".to_string(),
3711 })
3712 }
3713 }
3714
3715 struct OperationWorkflow;
3716
3717 impl WorkflowHandler for OperationWorkflow {
3718 fn name(&self) -> &str {
3719 "operation-workflow"
3720 }
3721
3722 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3723 Box::pin(async move {
3724 let op = FakeGitlabOp {
3725 project_id: 123,
3726 title: "Bug report".to_string(),
3727 };
3728 ctx.operation("create-issue", &op).await?;
3729 Ok(())
3730 })
3731 }
3732 }
3733
3734 struct FailingOperationWorkflow;
3735
3736 impl WorkflowHandler for FailingOperationWorkflow {
3737 fn name(&self) -> &str {
3738 "failing-operation-workflow"
3739 }
3740
3741 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3742 Box::pin(async move {
3743 ctx.operation("broken-call", &FailingOp).await?;
3744 Ok(())
3745 })
3746 }
3747 }
3748
3749 struct MixedWorkflow;
3750
3751 impl WorkflowHandler for MixedWorkflow {
3752 fn name(&self) -> &str {
3753 "mixed-workflow"
3754 }
3755
3756 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3757 Box::pin(async move {
3758 ctx.shell("build", ShellConfig::new("echo built")).await?;
3759 let op = FakeGitlabOp {
3760 project_id: 456,
3761 title: "Deploy done".to_string(),
3762 };
3763 let result = ctx.operation("notify-gitlab", &op).await?;
3764 assert_eq!(result.output["issue_id"], 42);
3765 Ok(())
3766 })
3767 }
3768 }
3769
3770 #[tokio::test]
3771 async fn operation_step_happy_path() {
3772 let mut engine = create_test_engine();
3773 engine.register(OperationWorkflow).unwrap();
3774
3775 let run = engine
3776 .run_handler("operation-workflow", TriggerKind::Manual, json!({}))
3777 .await
3778 .unwrap()
3779 .run;
3780
3781 assert_eq!(run.status.state, RunStatus::Completed);
3782
3783 let steps = engine.store().list_steps(run.id).await.unwrap();
3784
3785 assert_eq!(steps.len(), 1);
3786 assert_eq!(steps[0].name, "create-issue");
3787 assert_eq!(steps[0].kind, StepKind::Custom("gitlab".to_string()));
3788 assert_eq!(
3789 steps[0].status.state,
3790 ironflow_store::models::StepStatus::Completed
3791 );
3792
3793 let output = steps[0].output.as_ref().unwrap();
3794 assert_eq!(output["issue_id"], 42);
3795 assert_eq!(output["project_id"], 123);
3796
3797 let input = steps[0].input.as_ref().unwrap();
3798 assert_eq!(input["project_id"], 123);
3799 assert_eq!(input["title"], "Bug report");
3800 }
3801
3802 #[tokio::test]
3803 async fn operation_step_failure_marks_run_failed() {
3804 let mut engine = create_test_engine();
3805 engine.register(FailingOperationWorkflow).unwrap();
3806
3807 let result = engine
3808 .run_handler("failing-operation-workflow", TriggerKind::Manual, json!({}))
3809 .await;
3810
3811 assert!(result.is_err());
3812 }
3813
3814 #[tokio::test]
3815 async fn operation_mixed_with_shell_steps() {
3816 let mut engine = create_test_engine();
3817 engine.register(MixedWorkflow).unwrap();
3818
3819 let run = engine
3820 .run_handler("mixed-workflow", TriggerKind::Manual, json!({}))
3821 .await
3822 .unwrap()
3823 .run;
3824
3825 assert_eq!(run.status.state, RunStatus::Completed);
3826
3827 let steps = engine.store().list_steps(run.id).await.unwrap();
3828
3829 assert_eq!(steps.len(), 2);
3830 assert_eq!(steps[0].kind, StepKind::Shell);
3831 assert_eq!(steps[1].kind, StepKind::Custom("gitlab".to_string()));
3832 assert_eq!(steps[0].position, 0);
3833 assert_eq!(steps[1].position, 1);
3834 }
3835
3836 use crate::config::ApprovalConfig;
3841
3842 struct SingleApprovalWorkflow;
3843
3844 impl WorkflowHandler for SingleApprovalWorkflow {
3845 fn name(&self) -> &str {
3846 "single-approval"
3847 }
3848
3849 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3850 Box::pin(async move {
3851 ctx.shell("build", ShellConfig::new("echo built")).await?;
3852 ctx.approval("gate", ApprovalConfig::new("OK?")).await?;
3853 ctx.shell("deploy", ShellConfig::new("echo deployed"))
3854 .await?;
3855 Ok(())
3856 })
3857 }
3858 }
3859
3860 struct DoubleApprovalWorkflow;
3861
3862 impl WorkflowHandler for DoubleApprovalWorkflow {
3863 fn name(&self) -> &str {
3864 "double-approval"
3865 }
3866
3867 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3868 Box::pin(async move {
3869 ctx.shell("build", ShellConfig::new("echo built")).await?;
3870 ctx.approval("staging-gate", ApprovalConfig::new("Deploy staging?"))
3871 .await?;
3872 ctx.shell("deploy-staging", ShellConfig::new("echo staging"))
3873 .await?;
3874 ctx.approval("prod-gate", ApprovalConfig::new("Deploy prod?"))
3875 .await?;
3876 ctx.shell("deploy-prod", ShellConfig::new("echo prod"))
3877 .await?;
3878 Ok(())
3879 })
3880 }
3881 }
3882
3883 #[tokio::test]
3884 async fn approval_pauses_run() {
3885 let mut engine = create_test_engine();
3886 engine.register(SingleApprovalWorkflow).unwrap();
3887
3888 let run = engine
3889 .run_handler("single-approval", TriggerKind::Manual, json!({}))
3890 .await
3891 .unwrap()
3892 .run;
3893
3894 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
3895
3896 let steps = engine.store().list_steps(run.id).await.unwrap();
3897 assert_eq!(steps.len(), 2); assert_eq!(steps[0].kind, StepKind::Shell);
3899 assert_eq!(steps[0].status.state, StepStatus::Completed);
3900 assert_eq!(steps[1].kind, StepKind::Approval);
3901 assert_eq!(steps[1].status.state, StepStatus::AwaitingApproval);
3902 }
3903
3904 async fn approve_open_gate(store: &Arc<dyn Store>, run_id: Uuid) {
3907 let gate = store
3908 .list_steps(run_id)
3909 .await
3910 .unwrap()
3911 .into_iter()
3912 .find(|step| step.status.state == StepStatus::AwaitingApproval)
3913 .expect("an open approval gate");
3914 store
3915 .record_step_approval(
3916 gate.id,
3917 StepApproval {
3918 user_id: Uuid::now_v7(),
3919 approved_by: "tester".to_string(),
3920 at: Utc::now(),
3921 },
3922 )
3923 .await
3924 .unwrap();
3925 store
3926 .update_run_status(run_id, RunStatus::Running)
3927 .await
3928 .unwrap();
3929 }
3930
3931 #[tokio::test]
3932 async fn approval_resume_completes_run() {
3933 let mut engine = create_test_engine();
3934 engine.register(SingleApprovalWorkflow).unwrap();
3935
3936 let run = engine
3938 .run_handler("single-approval", TriggerKind::Manual, json!({}))
3939 .await
3940 .unwrap()
3941 .run;
3942 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
3943
3944 approve_open_gate(engine.store(), run.id).await;
3946
3947 let resumed = engine.resume_run(run.id).await.unwrap().run;
3949 assert_eq!(resumed.status.state, RunStatus::Completed);
3950
3951 let steps = engine.store().list_steps(run.id).await.unwrap();
3952 assert_eq!(steps.len(), 3); assert_eq!(steps[0].name, "build");
3954 assert_eq!(steps[0].status.state, StepStatus::Completed);
3955 assert_eq!(steps[1].name, "gate");
3956 assert_eq!(steps[1].kind, StepKind::Approval);
3957 assert_eq!(steps[1].status.state, StepStatus::Completed);
3958 assert_eq!(steps[2].name, "deploy");
3959 assert_eq!(steps[2].status.state, StepStatus::Completed);
3960 }
3961
3962 #[tokio::test]
3963 async fn double_approval_two_resumes() {
3964 let mut engine = create_test_engine();
3965 engine.register(DoubleApprovalWorkflow).unwrap();
3966
3967 let run = engine
3969 .run_handler("double-approval", TriggerKind::Manual, json!({}))
3970 .await
3971 .unwrap()
3972 .run;
3973 assert_eq!(run.status.state, RunStatus::AwaitingApproval);
3974
3975 let steps = engine.store().list_steps(run.id).await.unwrap();
3976 assert_eq!(steps.len(), 2); approve_open_gate(engine.store(), run.id).await;
3980
3981 let resumed = engine.resume_run(run.id).await.unwrap().run;
3982 assert_eq!(resumed.status.state, RunStatus::AwaitingApproval);
3983
3984 let steps = engine.store().list_steps(run.id).await.unwrap();
3985 assert_eq!(steps.len(), 4); approve_open_gate(engine.store(), run.id).await;
3989
3990 let final_run = engine.resume_run(run.id).await.unwrap().run;
3991 assert_eq!(final_run.status.state, RunStatus::Completed);
3992
3993 let steps = engine.store().list_steps(run.id).await.unwrap();
3994 assert_eq!(steps.len(), 5);
3995 assert_eq!(steps[0].name, "build");
3996 assert_eq!(steps[1].name, "staging-gate");
3997 assert_eq!(steps[2].name, "deploy-staging");
3998 assert_eq!(steps[3].name, "prod-gate");
3999 assert_eq!(steps[4].name, "deploy-prod");
4000
4001 for step in &steps {
4002 assert_eq!(step.status.state, StepStatus::Completed);
4003 }
4004 }
4005
4006 use ironflow_store::models::{NewStep, StepUpdate, step_trace_id};
4011
4012 async fn create_step_with_status(
4013 store: &Arc<dyn Store>,
4014 run_id: Uuid,
4015 name: &str,
4016 position: u32,
4017 status: StepStatus,
4018 ) -> ironflow_store::models::Step {
4019 let step = store
4020 .create_step(NewStep {
4021 run_id,
4022 trace_id: step_trace_id(run_id, name, position),
4023 name: name.to_string(),
4024 kind: StepKind::Shell,
4025 position,
4026 input: None,
4027 is_error_handler: false,
4028 })
4029 .await
4030 .unwrap();
4031
4032 match status {
4033 StepStatus::Pending => {}
4034 StepStatus::Running => {
4035 store
4036 .update_step(
4037 step.id,
4038 StepUpdate {
4039 status: Some(StepStatus::Running),
4040 ..StepUpdate::default()
4041 },
4042 )
4043 .await
4044 .unwrap();
4045 }
4046 StepStatus::Completed => {
4047 store
4048 .update_step(
4049 step.id,
4050 StepUpdate {
4051 status: Some(StepStatus::Running),
4052 ..StepUpdate::default()
4053 },
4054 )
4055 .await
4056 .unwrap();
4057 store
4058 .update_step(
4059 step.id,
4060 StepUpdate {
4061 status: Some(StepStatus::Completed),
4062 ..StepUpdate::default()
4063 },
4064 )
4065 .await
4066 .unwrap();
4067 }
4068 StepStatus::AwaitingApproval => {
4069 store
4070 .update_step(
4071 step.id,
4072 StepUpdate {
4073 status: Some(StepStatus::Running),
4074 ..StepUpdate::default()
4075 },
4076 )
4077 .await
4078 .unwrap();
4079 store
4080 .update_step(
4081 step.id,
4082 StepUpdate {
4083 status: Some(StepStatus::AwaitingApproval),
4084 ..StepUpdate::default()
4085 },
4086 )
4087 .await
4088 .unwrap();
4089 }
4090 _ => panic!("unsupported status for test helper: {status}"),
4091 }
4092
4093 store.get_step(step.id).await.unwrap().unwrap()
4094 }
4095
4096 #[tokio::test]
4097 async fn fail_orphaned_steps_marks_running_as_failed() {
4098 let engine = create_test_engine();
4099 let run = engine
4100 .store()
4101 .create_run(NewRun {
4102 created_by: None,
4103 workflow_name: "test".to_string(),
4104 trigger: TriggerKind::Manual,
4105 payload: json!({}),
4106 max_retries: 0,
4107 handler_version: None,
4108 labels: HashMap::new(),
4109 scheduled_at: None,
4110 idempotency_key: None,
4111 concurrency_key: None,
4112 priority: 0,
4113 concurrency_limits: Vec::new(),
4114 max_cost_usd: None,
4115 worker_tags: Vec::new(),
4116 })
4117 .await
4118 .unwrap()
4119 .into_run();
4120
4121 let step = create_step_with_status(
4122 engine.store(),
4123 run.id,
4124 "running-step",
4125 0,
4126 StepStatus::Running,
4127 )
4128 .await;
4129
4130 engine
4131 .fail_orphaned_steps(run.id, "parent run timed out")
4132 .await
4133 .unwrap();
4134
4135 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
4136 assert_eq!(updated.status.state, StepStatus::Failed);
4137 assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
4138 assert!(updated.completed_at.is_some());
4139 }
4140
4141 #[tokio::test]
4142 async fn fail_orphaned_steps_marks_pending_as_skipped() {
4143 let engine = create_test_engine();
4144 let run = engine
4145 .store()
4146 .create_run(NewRun {
4147 created_by: None,
4148 workflow_name: "test".to_string(),
4149 trigger: TriggerKind::Manual,
4150 payload: json!({}),
4151 max_retries: 0,
4152 handler_version: None,
4153 labels: HashMap::new(),
4154 scheduled_at: None,
4155 idempotency_key: None,
4156 concurrency_key: None,
4157 priority: 0,
4158 concurrency_limits: Vec::new(),
4159 max_cost_usd: None,
4160 worker_tags: Vec::new(),
4161 })
4162 .await
4163 .unwrap()
4164 .into_run();
4165
4166 let step = create_step_with_status(
4167 engine.store(),
4168 run.id,
4169 "pending-step",
4170 0,
4171 StepStatus::Pending,
4172 )
4173 .await;
4174
4175 engine
4176 .fail_orphaned_steps(run.id, "parent run timed out")
4177 .await
4178 .unwrap();
4179
4180 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
4181 assert_eq!(updated.status.state, StepStatus::Skipped);
4182 assert!(updated.error.is_none());
4183 assert!(updated.completed_at.is_some());
4184 }
4185
4186 #[tokio::test]
4187 async fn fail_orphaned_steps_marks_awaiting_approval_as_failed() {
4188 let engine = create_test_engine();
4189 let run = engine
4190 .store()
4191 .create_run(NewRun {
4192 created_by: None,
4193 workflow_name: "test".to_string(),
4194 trigger: TriggerKind::Manual,
4195 payload: json!({}),
4196 max_retries: 0,
4197 handler_version: None,
4198 labels: HashMap::new(),
4199 scheduled_at: None,
4200 idempotency_key: None,
4201 concurrency_key: None,
4202 priority: 0,
4203 concurrency_limits: Vec::new(),
4204 max_cost_usd: None,
4205 worker_tags: Vec::new(),
4206 })
4207 .await
4208 .unwrap()
4209 .into_run();
4210
4211 let step = create_step_with_status(
4212 engine.store(),
4213 run.id,
4214 "approval-step",
4215 0,
4216 StepStatus::AwaitingApproval,
4217 )
4218 .await;
4219
4220 engine
4221 .fail_orphaned_steps(run.id, "parent run timed out")
4222 .await
4223 .unwrap();
4224
4225 let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
4226 assert_eq!(updated.status.state, StepStatus::Failed);
4227 assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
4228 assert!(updated.completed_at.is_some());
4229 }
4230
4231 #[tokio::test]
4232 async fn fail_orphaned_steps_skips_terminal_steps() {
4233 let engine = create_test_engine();
4234 let run = engine
4235 .store()
4236 .create_run(NewRun {
4237 created_by: None,
4238 workflow_name: "test".to_string(),
4239 trigger: TriggerKind::Manual,
4240 payload: json!({}),
4241 max_retries: 0,
4242 handler_version: None,
4243 labels: HashMap::new(),
4244 scheduled_at: None,
4245 idempotency_key: None,
4246 concurrency_key: None,
4247 priority: 0,
4248 concurrency_limits: Vec::new(),
4249 max_cost_usd: None,
4250 worker_tags: Vec::new(),
4251 })
4252 .await
4253 .unwrap()
4254 .into_run();
4255
4256 let completed_step =
4257 create_step_with_status(engine.store(), run.id, "done", 0, StepStatus::Completed).await;
4258 let running_step =
4259 create_step_with_status(engine.store(), run.id, "in-flight", 1, StepStatus::Running)
4260 .await;
4261
4262 engine
4263 .fail_orphaned_steps(run.id, "parent run timed out")
4264 .await
4265 .unwrap();
4266
4267 let completed = engine
4268 .store()
4269 .get_step(completed_step.id)
4270 .await
4271 .unwrap()
4272 .unwrap();
4273 assert_eq!(completed.status.state, StepStatus::Completed);
4274
4275 let failed = engine
4276 .store()
4277 .get_step(running_step.id)
4278 .await
4279 .unwrap()
4280 .unwrap();
4281 assert_eq!(failed.status.state, StepStatus::Failed);
4282 }
4283
4284 #[tokio::test]
4285 async fn fail_orphaned_steps_mixed_states() {
4286 let engine = create_test_engine();
4287 let run = engine
4288 .store()
4289 .create_run(NewRun {
4290 created_by: None,
4291 workflow_name: "test".to_string(),
4292 trigger: TriggerKind::Manual,
4293 payload: json!({}),
4294 max_retries: 0,
4295 handler_version: None,
4296 labels: HashMap::new(),
4297 scheduled_at: None,
4298 idempotency_key: None,
4299 concurrency_key: None,
4300 priority: 0,
4301 concurrency_limits: Vec::new(),
4302 max_cost_usd: None,
4303 worker_tags: Vec::new(),
4304 })
4305 .await
4306 .unwrap()
4307 .into_run();
4308
4309 let s_completed =
4310 create_step_with_status(engine.store(), run.id, "step-1", 0, StepStatus::Completed)
4311 .await;
4312 let s_running =
4313 create_step_with_status(engine.store(), run.id, "step-2", 1, StepStatus::Running).await;
4314 let s_pending =
4315 create_step_with_status(engine.store(), run.id, "step-3", 2, StepStatus::Pending).await;
4316
4317 engine.fail_orphaned_steps(run.id, "timeout").await.unwrap();
4318
4319 let r_completed = engine
4320 .store()
4321 .get_step(s_completed.id)
4322 .await
4323 .unwrap()
4324 .unwrap();
4325 assert_eq!(r_completed.status.state, StepStatus::Completed);
4326
4327 let r_running = engine
4328 .store()
4329 .get_step(s_running.id)
4330 .await
4331 .unwrap()
4332 .unwrap();
4333 assert_eq!(r_running.status.state, StepStatus::Failed);
4334 assert_eq!(r_running.error.as_deref(), Some("timeout"));
4335
4336 let r_pending = engine
4337 .store()
4338 .get_step(s_pending.id)
4339 .await
4340 .unwrap()
4341 .unwrap();
4342 assert_eq!(r_pending.status.state, StepStatus::Skipped);
4343 assert!(r_pending.error.is_none());
4344 }
4345
4346 #[tokio::test]
4347 async fn fail_orphaned_steps_no_steps_is_noop() {
4348 let engine = create_test_engine();
4349 let run = engine
4350 .store()
4351 .create_run(NewRun {
4352 created_by: None,
4353 workflow_name: "test".to_string(),
4354 trigger: TriggerKind::Manual,
4355 payload: json!({}),
4356 max_retries: 0,
4357 handler_version: None,
4358 labels: HashMap::new(),
4359 scheduled_at: None,
4360 idempotency_key: None,
4361 concurrency_key: None,
4362 priority: 0,
4363 concurrency_limits: Vec::new(),
4364 max_cost_usd: None,
4365 worker_tags: Vec::new(),
4366 })
4367 .await
4368 .unwrap()
4369 .into_run();
4370
4371 let result = engine.fail_orphaned_steps(run.id, "timeout").await;
4372 assert!(result.is_ok());
4373 }
4374
4375 #[tokio::test]
4376 async fn fail_orphaned_steps_preserves_existing_error() {
4377 let engine = create_test_engine();
4378 let run = engine
4379 .store()
4380 .create_run(NewRun {
4381 created_by: None,
4382 workflow_name: "test".to_string(),
4383 trigger: TriggerKind::Manual,
4384 payload: json!({}),
4385 max_retries: 0,
4386 handler_version: None,
4387 labels: HashMap::new(),
4388 scheduled_at: None,
4389 idempotency_key: None,
4390 concurrency_key: None,
4391 priority: 0,
4392 concurrency_limits: Vec::new(),
4393 max_cost_usd: None,
4394 worker_tags: Vec::new(),
4395 })
4396 .await
4397 .unwrap()
4398 .into_run();
4399
4400 let step_with_error = create_step_with_status(
4401 engine.store(),
4402 run.id,
4403 "already-errored",
4404 0,
4405 StepStatus::Running,
4406 )
4407 .await;
4408
4409 engine
4410 .store()
4411 .update_step(
4412 step_with_error.id,
4413 StepUpdate {
4414 error: Some("real error from provider".to_string()),
4415 ..StepUpdate::default()
4416 },
4417 )
4418 .await
4419 .unwrap();
4420
4421 let step_no_error = create_step_with_status(
4422 engine.store(),
4423 run.id,
4424 "no-error-yet",
4425 1,
4426 StepStatus::Running,
4427 )
4428 .await;
4429
4430 engine
4431 .fail_orphaned_steps(run.id, "parent run failed")
4432 .await
4433 .unwrap();
4434
4435 let updated_with = engine
4436 .store()
4437 .get_step(step_with_error.id)
4438 .await
4439 .unwrap()
4440 .unwrap();
4441 assert_eq!(updated_with.status.state, StepStatus::Failed);
4442 assert_eq!(
4443 updated_with.error.as_deref(),
4444 Some("real error from provider"),
4445 );
4446
4447 let updated_without = engine
4448 .store()
4449 .get_step(step_no_error.id)
4450 .await
4451 .unwrap()
4452 .unwrap();
4453 assert_eq!(updated_without.status.state, StepStatus::Failed);
4454 assert_eq!(updated_without.error.as_deref(), Some("parent run failed"),);
4455 }
4456
4457 use std::sync::atomic::{AtomicUsize, Ordering};
4462 use std::time::Duration;
4463
4464 use ironflow_store::models::LeaseRequest;
4465 use tokio::time::sleep;
4466
4467 struct CountingWorkflow {
4469 executions: Arc<AtomicUsize>,
4470 }
4471
4472 impl WorkflowHandler for CountingWorkflow {
4473 fn name(&self) -> &str {
4474 "local-resume-counter"
4475 }
4476
4477 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
4478 Box::pin(async move {
4479 self.executions.fetch_add(1, Ordering::SeqCst);
4480 Ok(())
4481 })
4482 }
4483 }
4484
4485 async fn engine_with_pending_counter() -> (Arc<Engine>, Arc<AtomicUsize>, Uuid) {
4487 let executions = Arc::new(AtomicUsize::new(0));
4488 let mut engine = create_test_engine();
4489 engine
4490 .register(CountingWorkflow {
4491 executions: executions.clone(),
4492 })
4493 .unwrap();
4494 let run = engine
4495 .enqueue_handler("local-resume-counter", TriggerKind::Manual, json!({}), 0)
4496 .await
4497 .unwrap();
4498 (Arc::new(engine), executions, run.id)
4499 }
4500
4501 #[tokio::test]
4502 async fn local_resume_leaves_a_run_claimed_by_a_worker_alone() {
4503 let (engine, executions, run_id) = engine_with_pending_counter().await;
4504 let lease = LeaseRequest {
4505 worker_id: "worker-1".to_string(),
4506 ttl: Duration::from_secs(30),
4507 };
4508 let picked = engine
4509 .store()
4510 .pick_next_pending_for(Some(lease), None)
4511 .await
4512 .unwrap()
4513 .expect("the run is due");
4514 assert_eq!(picked.id, run_id);
4515
4516 engine.spawn_local_resume(run_id);
4517 sleep(Duration::from_millis(200)).await;
4518
4519 assert_eq!(executions.load(Ordering::SeqCst), 0);
4520 let run = engine.store().get_run(run_id).await.unwrap().unwrap();
4521 assert_eq!(run.status.state, RunStatus::Running);
4522 assert_eq!(run.worker_id.as_deref(), Some("worker-1"));
4523 }
4524
4525 #[tokio::test]
4526 async fn local_resume_claims_a_pending_run_once() {
4527 let (engine, executions, run_id) = engine_with_pending_counter().await;
4528
4529 engine.spawn_local_resume(run_id);
4530 engine.spawn_local_resume(run_id);
4531 sleep(Duration::from_millis(200)).await;
4532
4533 assert_eq!(executions.load(Ordering::SeqCst), 1);
4534 let run = engine.store().get_run(run_id).await.unwrap().unwrap();
4535 assert_eq!(run.status.state, RunStatus::Completed);
4536 }
4537
4538 #[tokio::test]
4539 async fn local_resume_of_a_running_run_executes_it_once() {
4540 let (engine, executions, run_id) = engine_with_pending_counter().await;
4541 engine
4542 .store()
4543 .update_run_status(run_id, RunStatus::Running)
4544 .await
4545 .unwrap();
4546
4547 engine.spawn_local_resume(run_id);
4548 engine.spawn_local_resume(run_id);
4549 sleep(Duration::from_millis(200)).await;
4550
4551 assert_eq!(executions.load(Ordering::SeqCst), 1);
4552 let run = engine.store().get_run(run_id).await.unwrap().unwrap();
4553 assert_eq!(run.status.state, RunStatus::Completed);
4554 }
4555}