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