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