Skip to main content

ironflow_engine/
engine.rs

1//! The core [`Engine`] -- orchestrates workflow execution and persistence.
2//!
3//! The engine ties together a `RunStore` for persistence, an [`AgentProvider`]
4//! for AI operations, and a registry of [`WorkflowHandler`]s.
5//!
6//! Handlers are Rust-native: steps can reference previous outputs, use native
7//! `if`/`else`/`match` for conditional branching, and execute in parallel.
8
9use 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/// Result of a workflow execution, carrying the final [`Run`] and per-step
66/// metrics collected during execution.
67///
68/// Returned by [`Engine::run_handler`], [`Engine::execute_handler_run`],
69/// [`Engine::execute_run`], and [`Engine::resume_run`].
70///
71/// # Examples
72///
73/// ```no_run
74/// use ironflow_engine::engine::WorkflowResult;
75///
76/// # fn example(result: WorkflowResult) {
77/// println!("run {} finished with {} steps", result.run.id, result.steps.len());
78/// for step in &result.steps {
79///     println!("  {} ({:?}): {}ms", step.name, step.status, step.duration_ms);
80/// }
81/// # }
82/// ```
83#[derive(Debug, Clone)]
84pub struct WorkflowResult {
85    /// The finalized run record.
86    pub run: Run,
87    /// Per-step results in execution order.
88    pub steps: Vec<StepResult>,
89}
90
91/// Optional settings for [`Engine::enqueue_handler_with_options`].
92///
93/// All fields fall back to handler or server defaults when left at their
94/// [`Default`] value.
95///
96/// # Examples
97///
98/// ```
99/// use ironflow_engine::engine::EnqueueOptions;
100/// use rust_decimal::Decimal;
101///
102/// let options = EnqueueOptions {
103///     max_retries: 3,
104///     max_cost_usd: Some(Decimal::new(50, 2)),
105///     ..Default::default()
106/// };
107/// assert_eq!(options.max_retries, 3);
108/// ```
109#[derive(Debug, Clone, Default)]
110pub struct EnqueueOptions {
111    /// Number of automatic retries granted to the run.
112    pub max_retries: u32,
113    /// Labels merged on top of the handler's default labels.
114    pub labels: HashMap<String, String>,
115    /// Defer execution until this instant instead of running as soon as a
116    /// worker picks the run up.
117    pub scheduled_at: Option<DateTime<Utc>>,
118    /// Cost cap for the run. Overrides both the handler default and the server
119    /// default. `None` falls back to
120    /// [`BudgetConfig::resolve_run_cap`](crate::budget::BudgetConfig::resolve_run_cap).
121    pub max_cost_usd: Option<Decimal>,
122    /// Authenticated principal that triggered the run. `None` for cron,
123    /// webhook, and programmatic triggers.
124    pub created_by: Option<RunActor>,
125    /// Idempotency key binding this enqueue to a single run.
126    ///
127    /// When set and already bound to a run created within
128    /// [`IDEMPOTENCY_WINDOW`](ironflow_store::entities::IDEMPOTENCY_WINDOW),
129    /// nothing is enqueued and the original run is replayed.
130    pub idempotency_key: Option<String>,
131    /// Concurrency key making the run exclusive.
132    ///
133    /// When set, the run is refused with [`EngineError::ConcurrencyConflict`]
134    /// while another non-terminal run holds the same key. The key is released
135    /// once the holder reaches a terminal state.
136    pub concurrency_key: Option<String>,
137    /// Concurrency groups the run belongs to, each with the maximum number of
138    /// root runs of that group allowed to execute at once.
139    ///
140    /// Unlike [`concurrency_key`](Self::concurrency_key), the run is always
141    /// created: it stays pending until every group is under its limit. Empty
142    /// means no limit. Invalid limits are refused with
143    /// [`EngineError::InvalidConcurrencyLimit`].
144    pub concurrency_limits: Vec<ConcurrencyLimit>,
145    /// Queue priority of the run, overriding
146    /// [`WorkflowHandler::priority`].
147    ///
148    /// Workers pick the pending run with the highest priority first, then the
149    /// oldest among equal priorities. `None` falls back to the handler's
150    /// priority, clamped to the accepted range. An explicit value outside
151    /// [`MIN_PRIORITY`](ironflow_store::entities::MIN_PRIORITY)`..=`[`MAX_PRIORITY`](ironflow_store::entities::MAX_PRIORITY)
152    /// is refused with [`EngineError::InvalidPriority`].
153    pub priority: Option<i16>,
154    /// Worker tags the run requires, merged with the handler's
155    /// [`required_worker_tags`](WorkflowHandler::required_worker_tags).
156    ///
157    /// Only a worker carrying every tag picks the run. Invalid tags are
158    /// refused with [`EngineError::InvalidWorkerTag`].
159    pub worker_tags: Vec<String>,
160}
161
162/// Where a run resumes once an approval, a human input or an escalation
163/// resolves the gate it was suspended on.
164///
165/// # Examples
166///
167/// ```
168/// use ironflow_engine::engine::ExecutionMode;
169///
170/// assert_eq!(ExecutionMode::default(), ExecutionMode::Local);
171/// ```
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
173pub enum ExecutionMode {
174    /// Resume in-process via [`Engine::resume_run`]. Used by
175    /// [`crate::testing::TestEngine`] and single-process deployments where
176    /// the API also registers the handlers.
177    #[default]
178    Local,
179    /// Requeue the run to `Pending` instead. A worker's `pick_next_pending`
180    /// claims it and finishes it via [`Engine::execute_handler_run`].
181    Workers,
182}
183
184/// The workflow orchestration engine.
185///
186/// Holds references to the store, agent provider, and a registry of
187/// [`WorkflowHandler`]s.
188///
189/// # Examples
190///
191/// ```no_run
192/// use std::sync::Arc;
193/// use ironflow_engine::engine::Engine;
194/// use ironflow_engine::config::ShellConfig;
195/// use ironflow_engine::handler::{WorkflowHandler, HandlerFuture, WorkflowInfo};
196/// use ironflow_engine::context::WorkflowContext;
197/// use ironflow_store::memory::InMemoryStore;
198/// use ironflow_store::models::TriggerKind;
199/// use ironflow_core::providers::claude::ClaudeCodeProvider;
200/// use serde_json::json;
201///
202/// struct CiWorkflow;
203/// impl WorkflowHandler for CiWorkflow {
204///     fn name(&self) -> &str { "ci" }
205///     fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
206///         Box::pin(async move {
207///             ctx.shell("test", ShellConfig::new("cargo test")).await?;
208///             Ok(())
209///         })
210///     }
211/// }
212///
213/// # async fn example() -> Result<(), ironflow_engine::error::EngineError> {
214/// let store = Arc::new(InMemoryStore::new());
215/// let provider = Arc::new(ClaudeCodeProvider::new());
216/// let mut engine = Engine::new(store, provider);
217/// engine.register(CiWorkflow)?;
218///
219/// let result = engine.run_handler("ci", TriggerKind::Manual, json!({})).await?;
220/// tracing::info!(run_id = %result.run.id, status = ?result.run.status, steps = result.steps.len(), "run completed");
221/// # Ok(())
222/// # }
223/// ```
224pub 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
242/// Per-run locks of this process, keyed by run id.
243type ActiveRuns = Arc<Mutex<HashMap<Uuid, Arc<AsyncMutex<()>>>>>;
244
245/// Marks a run as executing in this process, or as being resumed locally,
246/// until it is dropped.
247///
248/// Holds the run's lock, so a resume of the same run waits for the execution
249/// to end ([`Engine::wait_until_idle`]) instead of replaying it in parallel.
250struct 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
264/// Take the lock of `run_id` in `runs`, waiting for its current holder.
265async 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
279/// Remove the lock of `run_id` from `runs` once nobody holds or awaits it.
280///
281/// `entry` is the caller's own clone: the map plus that clone make two
282/// references, any more is a waiter that still needs the lock in the map.
283fn 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
294/// Validate a workflow category path.
295///
296/// A category is a `/`-separated list of non-empty segments. This function
297/// rejects empty paths, leading or trailing `/`, consecutive `/`, and
298/// segments containing only whitespace.
299///
300/// # Errors
301///
302/// Returns [`EngineError::InvalidWorkflow`] when the category is malformed.
303fn 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
330/// Read a run id from the label `key` of a sub-workflow child run.
331///
332/// `None` when the run was not started by a `Workflow` step, when the label
333/// is missing or invalid, or when it points back at the run itself.
334fn 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
342/// The root run of the chain a sub-workflow child run belongs to.
343///
344/// Resuming a child resumes this root instead: the root replays its steps
345/// and re-enters the same child run through its open `Workflow` step. The
346/// worker uses it to follow its lease from a child to the root it resumed.
347///
348/// `None` when `run` is not a sub-workflow child run.
349///
350/// # Examples
351///
352/// ```no_run
353/// use ironflow_engine::engine::chain_root;
354/// use ironflow_store::entities::Run;
355/// use uuid::Uuid;
356///
357/// fn lease_target(run: &Run) -> Uuid {
358///     chain_root(run).unwrap_or(run.id)
359/// }
360/// ```
361pub fn chain_root(run: &Run) -> Option<Uuid> {
362    chain_label(run, LABEL_ROOT_RUN_ID)
363}
364
365/// The run whose `Workflow` step started this sub-workflow child run.
366pub(crate) fn chain_parent(run: &Run) -> Option<Uuid> {
367    chain_label(run, PARENT_RUN_ID_LABEL)
368}
369
370impl Engine {
371    /// Create a new engine with the given store and agent provider.
372    ///
373    /// The provider is wrapped in a [`RoleCostProvider`]: every agent step of a
374    /// named `AgentRole` is checked against the cost cap of its role.
375    ///
376    /// # Examples
377    ///
378    /// ```no_run
379    /// use std::sync::Arc;
380    /// use ironflow_engine::engine::Engine;
381    /// use ironflow_store::memory::InMemoryStore;
382    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
383    ///
384    /// let engine = Engine::new(
385    ///     Arc::new(InMemoryStore::new()),
386    ///     Arc::new(ClaudeCodeProvider::new()),
387    /// );
388    /// ```
389    pub fn new(store: Arc<dyn Store>, provider: Arc<dyn AgentProvider>) -> Self {
390        Self {
391            // Every engine enforces agent role cost caps: no wiring can forget it.
392            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    /// Mark `run_id` as executing in this process, waiting for a previous
411    /// execution of the same run to end first.
412    async fn track_execution(&self, run_id: Uuid) -> ActiveRunGuard {
413        lock_run(&self.active_runs, run_id).await
414    }
415
416    /// Wait until no execution of `run_id` is running in this process.
417    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    /// Whether an execution of `run_id` is running in this process.
432    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    /// Wire a [`DecisionProvider`] backend for `ctx.decision(...)` steps.
442    ///
443    /// Without this, a workflow that reaches a decision step fails with
444    /// [`EngineError::NoDecisionProvider`].
445    ///
446    /// # Examples
447    ///
448    /// ```no_run
449    /// use std::sync::Arc;
450    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
451    /// use ironflow_core::providers::record_replay_decision::RecordReplayDecisionProvider;
452    /// use ironflow_engine::engine::Engine;
453    /// use ironflow_store::memory::InMemoryStore;
454    ///
455    /// let engine = Engine::new(
456    ///     Arc::new(InMemoryStore::new()),
457    ///     Arc::new(ClaudeCodeProvider::new()),
458    /// )
459    /// .with_decision_provider(Arc::new(RecordReplayDecisionProvider::replay("tests/fixtures")));
460    /// ```
461    pub fn with_decision_provider(mut self, provider: Arc<dyn DecisionProvider>) -> Self {
462        self.decision_provider = Some(provider);
463        self
464    }
465
466    /// Wire a [`StepInterceptor`] that resolves steps without executing them.
467    ///
468    /// Used by [`crate::testing::TestEngine`] to mock shell, HTTP and approval
469    /// steps. `None` (the default) executes every step for real.
470    ///
471    /// # Examples
472    ///
473    /// ```no_run
474    /// use std::sync::Arc;
475    ///
476    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
477    /// use ironflow_engine::engine::Engine;
478    /// use ironflow_engine::executor::StepInterceptor;
479    /// use ironflow_store::memory::InMemoryStore;
480    ///
481    /// # fn example(interceptor: Arc<dyn StepInterceptor>) {
482    /// let engine = Engine::new(
483    ///     Arc::new(InMemoryStore::new()),
484    ///     Arc::new(ClaudeCodeProvider::new()),
485    /// )
486    /// .with_step_interceptor(interceptor);
487    /// # }
488    /// ```
489    pub fn with_step_interceptor(mut self, interceptor: Arc<dyn StepInterceptor>) -> Self {
490        self.step_interceptor = Some(interceptor);
491        self
492    }
493
494    /// The step interceptor wired into this engine, if any.
495    pub fn step_interceptor(&self) -> Option<&Arc<dyn StepInterceptor>> {
496        self.step_interceptor.as_ref()
497    }
498
499    /// Apply cost guardrails to this engine.
500    ///
501    /// Without this, both the per-run cap default and the monthly quota are
502    /// disabled and the engine behaves exactly as before.
503    ///
504    /// # Examples
505    ///
506    /// ```no_run
507    /// use std::sync::Arc;
508    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
509    /// use ironflow_engine::budget::BudgetConfig;
510    /// use ironflow_engine::engine::Engine;
511    /// use ironflow_store::memory::InMemoryStore;
512    ///
513    /// let engine = Engine::new(
514    ///     Arc::new(InMemoryStore::new()),
515    ///     Arc::new(ClaudeCodeProvider::new()),
516    /// )
517    /// .with_budget_config(BudgetConfig::from_env());
518    /// ```
519    pub fn with_budget_config(mut self, budget: BudgetConfig) -> Self {
520        self.budget = budget;
521        self
522    }
523
524    /// Returns the cost guardrails applied by this engine.
525    pub fn budget_config(&self) -> &BudgetConfig {
526        &self.budget
527    }
528
529    /// Apply workflow guard configuration to this engine.
530    ///
531    /// When set, every workflow run created by this engine is protected
532    /// by the guard. Handlers can override this via
533    /// [`WorkflowHandler::guard_config`].
534    ///
535    /// # Examples
536    ///
537    /// ```no_run
538    /// use std::sync::Arc;
539    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
540    /// use ironflow_engine::engine::Engine;
541    /// use ironflow_engine::guard::WorkflowGuardConfig;
542    /// use ironflow_store::memory::InMemoryStore;
543    ///
544    /// let engine = Engine::new(
545    ///     Arc::new(InMemoryStore::new()),
546    ///     Arc::new(ClaudeCodeProvider::new()),
547    /// )
548    /// .with_guard_config(WorkflowGuardConfig::default());
549    /// ```
550    pub fn with_guard_config(mut self, config: WorkflowGuardConfig) -> Self {
551        self.guard_config = Some(config);
552        self
553    }
554
555    /// Returns the workflow guard configuration, if any.
556    pub fn guard_config(&self) -> Option<&WorkflowGuardConfig> {
557        self.guard_config.as_ref()
558    }
559
560    /// Choose where a run resumes once its approval, human input or
561    /// escalation is resolved.
562    ///
563    /// Defaults to [`ExecutionMode::Local`]. Set [`ExecutionMode::Workers`]
564    /// on an API process that delegates execution to workers.
565    ///
566    /// # Examples
567    ///
568    /// ```no_run
569    /// use std::sync::Arc;
570    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
571    /// use ironflow_engine::engine::{Engine, ExecutionMode};
572    /// use ironflow_store::memory::InMemoryStore;
573    ///
574    /// let engine = Engine::new(
575    ///     Arc::new(InMemoryStore::new()),
576    ///     Arc::new(ClaudeCodeProvider::new()),
577    /// )
578    /// .with_execution_mode(ExecutionMode::Workers);
579    /// ```
580    pub fn with_execution_mode(mut self, mode: ExecutionMode) -> Self {
581        self.execution_mode = mode;
582        self
583    }
584
585    /// Returns where a run resumes once its gate is resolved.
586    pub fn execution_mode(&self) -> ExecutionMode {
587        self.execution_mode
588    }
589
590    /// Declare the worker tags carried by the process running this engine.
591    ///
592    /// Set by a worker at build time. A `ctx.workflow(..)` step then refuses
593    /// a child whose [`required_worker_tags`](WorkflowHandler::required_worker_tags)
594    /// are not all carried here, instead of running it on the wrong host.
595    /// Unset by default (API or local mode): no check is made.
596    ///
597    /// # Examples
598    ///
599    /// ```no_run
600    /// use std::sync::Arc;
601    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
602    /// use ironflow_engine::engine::Engine;
603    /// use ironflow_store::memory::InMemoryStore;
604    ///
605    /// let mut engine = Engine::new(
606    ///     Arc::new(InMemoryStore::new()),
607    ///     Arc::new(ClaudeCodeProvider::new()),
608    /// );
609    /// engine.set_worker_tags(vec!["gpu".to_string()]);
610    /// assert_eq!(engine.worker_tags(), Some(&["gpu".to_string()][..]));
611    /// ```
612    pub fn set_worker_tags(&mut self, tags: Vec<String>) {
613        self.worker_tags = Some(Arc::new(tags));
614    }
615
616    /// Returns the worker tags set by [`set_worker_tags`](Self::set_worker_tags),
617    /// or `None` when the engine does not run inside a tagged worker.
618    ///
619    /// # Examples
620    ///
621    /// ```no_run
622    /// use std::sync::Arc;
623    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
624    /// use ironflow_engine::engine::Engine;
625    /// use ironflow_store::memory::InMemoryStore;
626    ///
627    /// let engine = Engine::new(
628    ///     Arc::new(InMemoryStore::new()),
629    ///     Arc::new(ClaudeCodeProvider::new()),
630    /// );
631    /// assert!(engine.worker_tags().is_none());
632    /// ```
633    pub fn worker_tags(&self) -> Option<&[String]> {
634        self.worker_tags.as_deref().map(Vec::as_slice)
635    }
636
637    /// Attach a log sender for real-time step output streaming.
638    ///
639    /// When set, all workflow contexts created by this engine will forward
640    /// step output (shell stdout/stderr, agent system messages) to the
641    /// given sender.
642    pub fn set_log_sender(&mut self, sender: LogSender) {
643        self.log_sender = Some(sender);
644    }
645
646    /// Attach the backend that stores and serves artifact bytes.
647    ///
648    /// Every context this engine builds inherits it. Without one, steps that
649    /// declare artifacts fail explicitly and every other step is unaffected.
650    ///
651    /// # Examples
652    ///
653    /// ```no_run
654    /// use std::sync::Arc;
655    ///
656    /// use ironflow_engine::artifact::ArtifactSink;
657    /// use ironflow_engine::engine::Engine;
658    ///
659    /// # fn example(engine: &mut Engine, sink: Arc<dyn ArtifactSink>) {
660    /// engine.set_artifact_sink(sink);
661    /// # }
662    /// ```
663    pub fn set_artifact_sink(&mut self, sink: Arc<dyn ArtifactSink>) {
664        self.artifact_sink = Some(sink);
665    }
666
667    /// The artifact backend attached to this engine, if any.
668    pub fn artifact_sink(&self) -> Option<&Arc<dyn ArtifactSink>> {
669        self.artifact_sink.as_ref()
670    }
671
672    /// Attach a [`WorkflowEventBus`] for per-run real-time monitoring.
673    ///
674    /// When set, all workflow contexts created by this engine will publish
675    /// step-level events (`StepStarted`, `StepCompleted`, `StepFailed`)
676    /// to the bus.
677    ///
678    /// The bus channel of a run is released when the run reaches a terminal
679    /// status: subscribers drain the buffered events, then their receiver
680    /// closes. Call this once per engine; each call registers a releaser.
681    ///
682    /// # Examples
683    ///
684    /// ```no_run
685    /// use ironflow_engine::engine::Engine;
686    /// use ironflow_engine::notify::WorkflowEventBus;
687    ///
688    /// # fn example(engine: &mut Engine) {
689    /// engine.set_event_bus(WorkflowEventBus::new());
690    /// # }
691    /// ```
692    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    /// The workflow event bus attached to this engine, if any.
699    pub fn event_bus(&self) -> Option<&WorkflowEventBus> {
700        self.event_bus.as_ref()
701    }
702
703    /// Returns a reference to the backing store.
704    pub fn store(&self) -> &Arc<dyn Store> {
705        &self.store
706    }
707
708    /// Returns a reference to the agent provider.
709    pub fn provider(&self) -> &Arc<dyn AgentProvider> {
710        &self.provider
711    }
712
713    /// Build a [`WorkflowContext`] with access to the handler registry.
714    ///
715    /// The context is seeded with the run's attempt number and the cost and
716    /// duration already accumulated by previous attempts, so a retried run
717    /// reports the total it really consumed rather than only its last attempt.
718    ///
719    /// The run's persisted `max_cost_usd` becomes the context cost cap; `None`
720    /// disables the per-run budget check for that context.
721    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    /// Build a context with the guard attached.
757    ///
758    /// Uses the handler's `guard_config()` if present, otherwise falls back
759    /// to the engine's global configuration. Creates a fresh shared state
760    /// for each top-level run.
761    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    /// Reject the creation of a new run when the monthly quota is exhausted.
775    ///
776    /// The window is the current calendar month in UTC. Runs already in flight
777    /// are never interrupted -- only creation is refused.
778    ///
779    /// The enqueue helpers call it themselves. The API also calls it before
780    /// retrying or replaying a run, as those create runs without going through
781    /// the enqueue helpers.
782    ///
783    /// # Errors
784    ///
785    /// Returns [`EngineError::MonthlyBudgetExceeded`] when the accumulated cost
786    /// of the month has reached the configured quota. Returns
787    /// [`EngineError::Store`] when the aggregate query fails.
788    ///
789    /// # Examples
790    ///
791    /// ```no_run
792    /// use ironflow_engine::engine::Engine;
793    ///
794    /// # use ironflow_engine::error::EngineError;
795    /// # async fn example(engine: &Engine) -> Result<(), EngineError> {
796    /// engine.check_monthly_quota("deploy").await?;
797    /// # Ok(())
798    /// # }
799    /// ```
800    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    // -----------------------------------------------------------------------
839    // Handler registration
840    // -----------------------------------------------------------------------
841
842    /// Register a [`WorkflowHandler`] for dynamic workflow execution.
843    ///
844    /// The handler is looked up by [`WorkflowHandler::name`] when executing
845    /// or enqueuing.
846    ///
847    /// # Errors
848    ///
849    /// Returns [`EngineError::InvalidWorkflow`] if a handler with the same
850    /// name is already registered.
851    ///
852    /// # Examples
853    ///
854    /// ```no_run
855    /// use std::sync::Arc;
856    /// use ironflow_engine::engine::Engine;
857    /// use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
858    /// use ironflow_engine::context::WorkflowContext;
859    /// use ironflow_engine::config::ShellConfig;
860    /// use ironflow_store::memory::InMemoryStore;
861    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
862    ///
863    /// struct MyWorkflow;
864    /// impl WorkflowHandler for MyWorkflow {
865    ///     fn name(&self) -> &str { "my-workflow" }
866    ///     fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
867    ///         Box::pin(async move {
868    ///             ctx.shell("step1", ShellConfig::new("echo done")).await?;
869    ///             Ok(())
870    ///         })
871    ///     }
872    /// }
873    ///
874    /// let mut engine = Engine::new(
875    ///     Arc::new(InMemoryStore::new()),
876    ///     Arc::new(ClaudeCodeProvider::new()),
877    /// );
878    /// engine.register(MyWorkflow)?;
879    /// # Ok::<(), ironflow_engine::error::EngineError>(())
880    /// ```
881    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    /// Register a pre-boxed workflow handler.
897    ///
898    /// # Errors
899    ///
900    /// Returns [`EngineError::InvalidWorkflow`] if a handler with the same
901    /// name is already registered or if its category is invalid.
902    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    /// Get a registered handler by name.
918    pub fn get_handler(&self, name: &str) -> Option<&Arc<dyn WorkflowHandler>> {
919        self.handlers.get(name)
920    }
921
922    /// List registered handler names.
923    pub fn handler_names(&self) -> Vec<&str> {
924        self.handlers.keys().map(|s| s.as_str()).collect()
925    }
926
927    /// Get detailed info about a registered workflow handler.
928    pub fn handler_info(&self, name: &str) -> Option<WorkflowInfo> {
929        self.handlers.get(name).map(|h| h.describe())
930    }
931
932    /// List handlers that have a cron schedule configured.
933    ///
934    /// Returns pairs of `(workflow_name, cron_expression)` for all handlers
935    /// where [`WorkflowHandler::schedule`] returns `Some`.
936    ///
937    /// Use this to wire scheduled handlers into a scheduler
938    /// (e.g. the schedule ticker in `ironflow-api`).
939    ///
940    /// # Examples
941    ///
942    /// ```no_run
943    /// # use std::sync::Arc;
944    /// # use ironflow_engine::engine::Engine;
945    /// # use ironflow_store::memory::InMemoryStore;
946    /// # use ironflow_core::providers::claude::ClaudeCodeProvider;
947    /// let engine = Engine::new(
948    ///     Arc::new(InMemoryStore::new()),
949    ///     Arc::new(ClaudeCodeProvider::new()),
950    /// );
951    /// for (name, schedule) in engine.scheduled_handlers() {
952    ///     tracing::info!("{name} runs on schedule: {schedule}");
953    /// }
954    /// ```
955    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    /// Register an event subscriber for domain events.
963    ///
964    /// The subscriber is called only for events whose type is in
965    /// `event_types`. Pass [`Event::ALL`] to receive every event.
966    ///
967    /// # Examples
968    ///
969    /// ```no_run
970    /// use ironflow_engine::engine::Engine;
971    /// use ironflow_engine::notify::{Event, WebhookSubscriber};
972    /// use ironflow_store::memory::InMemoryStore;
973    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
974    /// use std::sync::Arc;
975    ///
976    /// let mut engine = Engine::new(
977    ///     Arc::new(InMemoryStore::new()),
978    ///     Arc::new(ClaudeCodeProvider::new()),
979    /// );
980    ///
981    /// engine.subscribe(
982    ///     WebhookSubscriber::new("https://hooks.example.com/events"),
983    ///     &[Event::RUN_STATUS_CHANGED, Event::STEP_FAILED],
984    /// );
985    /// ```
986    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    /// Returns a reference to the event publisher.
995    ///
996    /// Useful for publishing events from outside the engine (e.g. auth
997    /// routes in the API layer).
998    pub fn event_publisher(&self) -> &EventPublisher {
999        &self.event_publisher
1000    }
1001
1002    // -----------------------------------------------------------------------
1003    // Dynamic workflow execution (WorkflowHandler)
1004    // -----------------------------------------------------------------------
1005
1006    /// Execute a registered handler inline.
1007    ///
1008    /// Creates a run, builds a [`WorkflowContext`], calls the handler's
1009    /// [`execute`](WorkflowHandler::execute), and finalizes the run.
1010    ///
1011    /// # Errors
1012    ///
1013    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered
1014    /// with that name. Returns [`EngineError`] if execution fails.
1015    ///
1016    /// When the workflow is paused (see [`Engine::pause_workflow`]) the run is
1017    /// created and left `Pending`, with no steps: the returned
1018    /// `WorkflowResult.run.status.state` is `Pending`. The hold applies in
1019    /// every execution mode; [`Engine::resume_workflow`] runs the held run
1020    /// under [`ExecutionMode::Local`].
1021    ///
1022    /// # Examples
1023    ///
1024    /// ```no_run
1025    /// use std::sync::Arc;
1026    /// use ironflow_engine::engine::Engine;
1027    /// use ironflow_store::memory::InMemoryStore;
1028    /// use ironflow_store::models::TriggerKind;
1029    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
1030    /// use serde_json::json;
1031    ///
1032    /// # async fn example(engine: &Engine) -> Result<(), ironflow_engine::error::EngineError> {
1033    /// let run = engine.run_handler("deploy", TriggerKind::Manual, json!({})).await?;
1034    /// # Ok(())
1035    /// # }
1036    /// ```
1037    #[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    /// Build the execution plan for a registered handler without running it.
1106    ///
1107    /// Executes the handler with every step method in recording mode: no
1108    /// command is spawned, no HTTP request is sent, no agent is called,
1109    /// nothing is persisted. Conditions declared with
1110    /// [`WorkflowContext::when`](crate::context::WorkflowContext::when) are
1111    /// evaluated against `payload`; those declared with
1112    /// [`WorkflowContext::when_dynamic`](crate::context::WorkflowContext::when_dynamic)
1113    /// are reported as unevaluable.
1114    ///
1115    /// Step outputs are synthetic and success-shaped, so the plan follows the
1116    /// nominal branch. A handler that unwraps a decision answer, or that
1117    /// deserializes `ctx.input::<T>()` against a payload it does not match,
1118    /// aborts the plan: the partial plan is returned with
1119    /// [`ExecutionPlan::incomplete_reason`] set.
1120    ///
1121    /// The handler is bounded twice: by
1122    /// [`MAX_PLANNED_STEPS`](crate::plan::MAX_PLANNED_STEPS) on every step
1123    /// kind, and by [`PlanOptions::timeout`]. Either one
1124    /// returns the partial plan, with [`ExecutionPlan::truncated`] and
1125    /// [`ExecutionPlan::incomplete_reason`] set, not an error. `ctx.store()`
1126    /// and `ctx.secrets()` return an error while planning.
1127    ///
1128    /// # Errors
1129    ///
1130    /// Returns [`EngineError::InvalidWorkflow`] when no handler is registered
1131    /// under `handler_name` or when `options.max_depth` is zero. Returns
1132    /// [`EngineError::Store`] when the duration-history query fails. A handler
1133    /// that errors mid-plan does **not** fail this call.
1134    ///
1135    /// # Examples
1136    ///
1137    /// ```no_run
1138    /// use ironflow_engine::engine::Engine;
1139    /// use ironflow_engine::error::EngineError;
1140    /// use ironflow_engine::plan::PlanOptions;
1141    /// use serde_json::json;
1142    ///
1143    /// # async fn example(engine: &Engine) -> Result<(), EngineError> {
1144    /// let plan = engine
1145    ///     .plan_handler("deploy", json!({"env": "prod"}), PlanOptions::default())
1146    ///     .await?;
1147    /// for step in &plan.steps {
1148    ///     println!("{} ({:?})", step.name, step.kind);
1149    /// }
1150    /// # Ok(())
1151    /// # }
1152    /// ```
1153    #[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        // Deliberately bare: no guard, no event bus, no log sender, no artifact
1188        // sink and no budget. Planning produces no side effect to report.
1189        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    /// Enqueue a handler-based workflow for worker execution.
1230    ///
1231    /// The workflow name is stored in the run. The worker looks up the
1232    /// handler by name when executing.
1233    ///
1234    /// # Errors
1235    ///
1236    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered.
1237    /// Returns [`EngineError::MonthlyBudgetExceeded`] if the monthly cost quota
1238    /// is exhausted.
1239    #[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    /// Enqueue a handler-based workflow with labels, deferred scheduling, an
1261    /// optional cost cap, an optional author, and an optional idempotency key.
1262    ///
1263    /// See [`EnqueueOptions`] for the individual settings.
1264    ///
1265    /// When [`EnqueueOptions::idempotency_key`] is set and already bound to a run
1266    /// created within
1267    /// [`IDEMPOTENCY_WINDOW`](ironflow_store::entities::IDEMPOTENCY_WINDOW), nothing
1268    /// is enqueued and the original run is returned as [`RunCreation::Existing`].
1269    ///
1270    /// # Errors
1271    ///
1272    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered.
1273    /// Returns [`EngineError::MonthlyBudgetExceeded`] if the monthly cost quota
1274    /// is exhausted. Returns [`EngineError::ConcurrencyConflict`] if
1275    /// [`EnqueueOptions::concurrency_key`] is held by another non-terminal run.
1276    /// Returns [`EngineError::InvalidConcurrencyLimit`] if
1277    /// [`EnqueueOptions::concurrency_limits`] is invalid, before any other check.
1278    /// Returns [`EngineError::InvalidPriority`] if [`EnqueueOptions::priority`]
1279    /// is out of range, before any other check.
1280    /// Returns [`EngineError::InvalidWorkerTag`] if [`EnqueueOptions::worker_tags`]
1281    /// holds an invalid tag, checked right after the concurrency limits.
1282    /// Returns [`EngineError::Store`] if the run cannot be persisted.
1283    ///
1284    /// # Examples
1285    ///
1286    /// ```no_run
1287    /// use ironflow_engine::engine::{Engine, EnqueueOptions};
1288    /// use ironflow_store::models::TriggerKind;
1289    /// use serde_json::json;
1290    ///
1291    /// # async fn example(engine: &Engine) -> Result<(), ironflow_engine::error::EngineError> {
1292    /// let creation = engine
1293    ///     .enqueue_handler_with_options(
1294    ///         "deploy",
1295    ///         TriggerKind::Api,
1296    ///         json!({"env": "prod"}),
1297    ///         EnqueueOptions {
1298    ///             max_retries: 3,
1299    ///             idempotency_key: Some("github:abc-123".to_string()),
1300    ///             ..Default::default()
1301    ///         },
1302    ///     )
1303    ///     .await?;
1304    ///
1305    /// if creation.is_created() {
1306    ///     println!("enqueued {}", creation.run().id);
1307    /// }
1308    /// # Ok(())
1309    /// # }
1310    /// ```
1311    #[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        // Checked first: a malformed request is the caller's error, whatever
1333        // the handler or the quota.
1334        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    /// Execute a handler-based run (used by the worker after pick_next_pending).
1399    ///
1400    /// Looks up the handler by the run's `workflow_name` and executes it
1401    /// with a fresh [`WorkflowContext`], after
1402    /// [`AgentProvider::release_run`] has stopped whatever a previous
1403    /// execution of the run left running.
1404    ///
1405    /// # Errors
1406    ///
1407    /// Returns [`EngineError::InvalidWorkflow`] if no handler matches. A
1408    /// failed release fails the execution with [`EngineError::Operation`],
1409    /// replayed while the run has retries left. Returns
1410    /// [`EngineError::HandlerVersionMismatch`] when the handler's current
1411    /// version is incompatible with the run's `handler_version` -- checked
1412    /// before any step is replayed.
1413    ///
1414    /// A suspended child run of a sub-workflow is never executed on its own:
1415    /// its root run is resumed instead, and re-enters the child (see
1416    /// [`resume_run`](Self::resume_run)).
1417    #[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        // Replay the steps already persisted for this run: on a retry, an approval
1449        // a human already granted must not be asked again, and a run requeued to
1450        // `Pending` after its approval, human input or escalation resolved
1451        // (`ExecutionMode::Workers`, `retry_count` unchanged) must not re-run
1452        // completed steps. Neither must a run requeued by the reaper after its
1453        // worker lost the lease, which stays in the same attempt too. A
1454        // brand-new run has no steps, so this is a no-op.
1455        //
1456        // The handler version is checked first: replaying an incompatible
1457        // handler's steps risks serving one step's cached output to another
1458        // (`EngineError::ReplayDivergence`), so no step is replayed at all
1459        // when the handler changed incompatibly since the run was created.
1460        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    /// Execute a run by its ID (used by the worker after pick_next_pending).
1491    ///
1492    /// Delegates to [`execute_handler_run`](Self::execute_handler_run).
1493    ///
1494    /// # Errors
1495    ///
1496    /// Returns [`EngineError`] if the run is not found or execution fails.
1497    #[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    /// Resume a run after human approval.
1503    ///
1504    /// Re-executes the handler with step replay: completed steps return
1505    /// cached output, approved approval steps are skipped, and execution
1506    /// continues from the first unexecuted step.
1507    ///
1508    /// Supports multiple approval gates -- each resume replays all prior
1509    /// steps and stops at the next approval (or completes the run).
1510    ///
1511    /// When `run_id` is a child run of a sub-workflow, the root run of its
1512    /// chain is moved back to `Running` and resumed instead: it replays, and
1513    /// its open `Workflow` step re-enters the same child run. The returned
1514    /// result is the root run's.
1515    ///
1516    /// Like [`execute_handler_run`](Self::execute_handler_run), the handler
1517    /// only starts once [`AgentProvider::release_run`] has stopped whatever
1518    /// a previous execution of the run left running.
1519    ///
1520    /// # Errors
1521    ///
1522    /// Returns [`EngineError::InvalidWorkflow`] if no handler matches, or when
1523    /// the root run of a child cannot be resumed (it is running or finished:
1524    /// the child is then failed).
1525    /// Returns [`EngineError`] if execution fails or hits another approval.
1526    /// A failed release fails the execution with [`EngineError::Operation`].
1527    /// Returns [`EngineError::HandlerVersionMismatch`] when the handler's
1528    /// current version is incompatible with the run's `handler_version` --
1529    /// checked before any step is replayed.
1530    #[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    /// Resume the root run of a suspended child run.
1546    ///
1547    /// The root waits without `scheduled_at` while its child is suspended, so
1548    /// nothing but this path ever wakes it. It is moved to `Running` and
1549    /// resumed; its replay re-enters the child. A root already running, resumed
1550    /// by a sibling of the child in a wave, is waited for: if it re-entered the
1551    /// child meanwhile, the root is returned as it stands and nothing else
1552    /// runs. A finished root cannot take the child back: the child is failed
1553    /// (see [`claim_chain_root`](Self::claim_chain_root)).
1554    ///
1555    /// When the child holds a worker lease (it was picked by a worker), the
1556    /// lease is transferred to the root in the same update that moves it to
1557    /// `Running`, then released on the child: the worker keeps renewing the
1558    /// root it now executes, and the reaper recovers the root if that worker
1559    /// dies. A child without a lease (inline execution, API-side resume)
1560    /// leaves the root without one, as before.
1561    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    /// Move the suspended root run of a chain to `Running`.
1609    ///
1610    /// With `lease`, the root takes the worker lease of the child that
1611    /// resumes it, atomically with the transition, so it is never `Running`
1612    /// without an owner.
1613    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    /// Resume `run`, already loaded and already `Running`.
1644    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    /// Store a signal and resolve every step waiting for its `(name, key)`.
1694    ///
1695    /// Each waiting step validates the payload against the JSON schema it
1696    /// stored when it opened. A matching step is completed with the payload
1697    /// and its run, if `Sleeping`, goes back to `Pending`: under
1698    /// [`ExecutionMode::Local`] it resumes in a background task, under
1699    /// [`ExecutionMode::Workers`] a worker picks it up. A step whose schema
1700    /// the payload does not match keeps waiting and is listed in
1701    /// [`SignalDelivery::rejected`].
1702    ///
1703    /// The signal is stored even when nobody waits for it: a run opening its
1704    /// wait step later still finds it. A signal whose `idempotency_id` was
1705    /// already used is not stored nor delivered again, and comes back with
1706    /// [`SignalDelivery::duplicate`] set.
1707    ///
1708    /// Publishes [`Event::SignalReceived`] for every stored signal.
1709    ///
1710    /// # Errors
1711    ///
1712    /// Returns [`EngineError::InvalidSignal`] when `name` or `key` is empty,
1713    /// and [`EngineError::Store`] when the signal cannot be stored or its
1714    /// waiters cannot be listed.
1715    ///
1716    /// # Examples
1717    ///
1718    /// ```no_run
1719    /// use std::sync::Arc;
1720    /// use ironflow_engine::engine::Engine;
1721    /// use ironflow_engine::error::EngineError;
1722    /// use ironflow_store::entities::NewSignal;
1723    /// use serde_json::json;
1724    ///
1725    /// # async fn example(engine: Arc<Engine>) -> Result<(), EngineError> {
1726    /// let delivery = engine
1727    ///     .deliver_signal(NewSignal {
1728    ///         name: "ci.pipeline_finished".to_string(),
1729    ///         key: "4f2a9c1".to_string(),
1730    ///         payload: json!({"status": "success"}),
1731    ///         idempotency_id: Some("delivery-42".to_string()),
1732    ///     })
1733    ///     .await?;
1734    /// println!("{} runs resumed", delivery.resumed.len());
1735    /// # Ok(())
1736    /// # }
1737    /// ```
1738    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                // A concurrent delivery or the timeout resolved it first.
1806                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    /// Send a typed signal: shorthand for [`deliver_signal`](Self::deliver_signal)
1849    /// with `S::NAME` as the name and `signal` as the payload.
1850    ///
1851    /// `key` identifies the occurrence (a commit SHA, an order ID).
1852    /// `idempotency_id`, when set, makes a redelivery of the same event (a
1853    /// webhook retried by its sender) a no-op.
1854    ///
1855    /// # Errors
1856    ///
1857    /// Returns [`EngineError::Serialization`] when `signal` cannot be
1858    /// serialized, and every error of [`deliver_signal`](Self::deliver_signal).
1859    ///
1860    /// # Examples
1861    ///
1862    /// ```no_run
1863    /// use std::sync::Arc;
1864    /// use ironflow_engine::engine::Engine;
1865    /// use ironflow_engine::error::EngineError;
1866    /// use ironflow_engine::signal::Signal;
1867    /// use schemars::JsonSchema;
1868    /// use serde::{Deserialize, Serialize};
1869    ///
1870    /// #[derive(Serialize, Deserialize, JsonSchema)]
1871    /// struct PipelineFinished {
1872    ///     status: String,
1873    /// }
1874    ///
1875    /// impl Signal for PipelineFinished {
1876    ///     const NAME: &'static str = "ci.pipeline_finished";
1877    /// }
1878    ///
1879    /// # async fn example(engine: Arc<Engine>) -> Result<(), EngineError> {
1880    /// let finished = PipelineFinished { status: "success".to_string() };
1881    /// engine.send_signal(&finished, "4f2a9c1", Some("delivery-42")).await?;
1882    /// # Ok(())
1883    /// # }
1884    /// ```
1885    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    /// Resume a run requeued to `Pending` in a background task.
1902    ///
1903    /// Used under [`ExecutionMode::Local`], where no worker would pick the
1904    /// run up. The state change already happened, so a failed resume is
1905    /// logged, not rolled back.
1906    ///
1907    /// The local resumes of a run are serialized: a task reads the run only
1908    /// once the previous local resume of it has returned, so two tasks never
1909    /// both act on the same read. The task then waits for the execution of
1910    /// the run still in flight in this process, if any: a run paused then
1911    /// resumed while a step runs would otherwise be replayed next to the
1912    /// execution that has not noticed the pause yet. Once it is gone, the run
1913    /// is read again:
1914    ///
1915    /// - `Pending` is claimed with the `Pending -> Running` transition, a
1916    ///   compare-and-set in every store, the one a worker performs when it
1917    ///   picks a run. Only a successful claim resumes it here: a run another
1918    ///   executor claimed first is left to it.
1919    /// - `Running` with a worker lease is owned by that worker and left alone.
1920    /// - `Running` without a lease was left unfinished by the previous
1921    ///   execution in this process and is resumed as is, unless an execution
1922    ///   of it is still running here.
1923    /// - any other status means the previous execution finished the run or it
1924    ///   was taken elsewhere, and nothing is done.
1925    pub(crate) fn spawn_local_resume(self: &Arc<Self>, run_id: Uuid) {
1926        let engine = Arc::clone(self);
1927        spawn(async move {
1928            // One local resume of a run at a time, held until its execution
1929            // returns: a sibling task reads the run only once this one is done,
1930            // so it never sees the claim of a resume that has not started yet.
1931            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    /// Record a run failure, replaying the run later when retries remain.
1996    ///
1997    /// This is the single place where a failed run's fate is decided. When
1998    /// `retryable` is true and the run has not exhausted `max_retries`, the run
1999    /// moves to [`RunStatus::Retrying`] with `scheduled_at` set to
2000    /// `now + backoff` -- [`pick_next_pending`](ironflow_store::store::RunStore::pick_next_pending)
2001    /// picks it up again once that time has passed. Otherwise the run moves to
2002    /// [`RunStatus::Failed`].
2003    ///
2004    /// Either way, steps left non-terminal by the failed attempt are closed via
2005    /// [`fail_orphaned_steps`](Self::fail_orphaned_steps) so they are never
2006    /// confused with the next attempt's steps, and the sub-workflow runs it
2007    /// left non-terminal are cancelled by
2008    /// [`cancel_descendants`](Self::cancel_descendants) (a failure there is
2009    /// logged, not returned).
2010    ///
2011    /// Callers pass `retryable` explicitly rather than an error value, because
2012    /// the worker classifies failures it observes from the outside (a timeout, a
2013    /// panicked task) that never produce an [`EngineError`]. Use
2014    /// [`is_run_retryable`] to classify an
2015    /// [`EngineError`].
2016    ///
2017    /// `cost_usd` and `duration_ms` are `None` when the caller does not know the
2018    /// totals (a timeout or a panic observed from outside the handler); the
2019    /// values already stored on the run are then left untouched.
2020    ///
2021    /// A run an operator paused meanwhile is left untouched and
2022    /// [`RunStatus::Paused`] is returned: the resume replays the attempt.
2023    ///
2024    /// Returns the status the run was moved to.
2025    ///
2026    /// # Errors
2027    ///
2028    /// Returns [`EngineError::Store`] if the run does not exist or the update
2029    /// cannot be persisted.
2030    ///
2031    /// # Examples
2032    ///
2033    /// ```no_run
2034    /// use ironflow_engine::engine::Engine;
2035    /// use ironflow_engine::error::EngineError;
2036    /// use uuid::Uuid;
2037    ///
2038    /// # async fn example(engine: &Engine, run_id: Uuid) -> Result<(), EngineError> {
2039    /// let status = engine
2040    ///     .fail_or_schedule_retry(run_id, "run timed out after 300s", true, None, None)
2041    ///     .await?;
2042    /// # Ok(())
2043    /// # }
2044    /// ```
2045    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        // An operator paused the run: its fate is decided by the resume or
2060        // the cancellation, not by the attempt that stopped.
2061        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        // The attempt is over: a retry starts new children, and nothing drives
2105        // those this attempt left running.
2106        self.cancel_descendants_of_stopped_run(run_id, error).await;
2107
2108        Ok(status)
2109    }
2110
2111    /// Mark the steps of a run requeued after a lost worker lease as interrupted.
2112    ///
2113    /// Every `Running` step is marked `Failed` with
2114    /// [`STEP_INTERRUPTED_ERROR`](ironflow_store::store::STEP_INTERRUPTED_ERROR).
2115    /// The run keeps its attempt number, so when it is picked up again its
2116    /// finished steps are replayed and each interrupted step is executed again
2117    /// at the same position; an interrupted `Workflow` step re-enters the same
2118    /// child run. `Pending` and `AwaitingApproval` steps are left as they are,
2119    /// unlike [`fail_orphaned_steps`](Self::fail_orphaned_steps), which ends
2120    /// the run's steps for good.
2121    ///
2122    /// Errors from individual step updates are logged but do not abort the cleanup.
2123    ///
2124    /// # Errors
2125    ///
2126    /// Returns [`EngineError`] if listing steps fails.
2127    ///
2128    /// # Examples
2129    ///
2130    /// ```no_run
2131    /// use ironflow_engine::engine::Engine;
2132    /// use ironflow_engine::error::EngineError;
2133    /// use uuid::Uuid;
2134    ///
2135    /// # async fn example(engine: &Engine, run_id: Uuid) -> Result<(), EngineError> {
2136    /// engine.interrupt_running_steps(run_id).await?;
2137    /// # Ok(())
2138    /// # }
2139    /// ```
2140    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    /// Fail all non-terminal steps for a run.
2145    ///
2146    /// Called after a run is marked as failed (timeout, error, panic) to clean up
2147    /// orphaned steps that are still in `Running`, `Pending`, or `AwaitingApproval`.
2148    ///
2149    /// - `Running` / `AwaitingApproval` steps are marked `Failed`.
2150    /// - `Pending` steps are marked `Skipped` (FSM does not allow Pending -> Failed).
2151    ///
2152    /// Errors from individual step updates are logged but do not abort the cleanup.
2153    ///
2154    /// # Errors
2155    ///
2156    /// Returns [`EngineError`] if listing steps fails.
2157    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    /// Execute `handler` once [`AgentProvider::release_run`] has stopped
2219    /// whatever a previous execution of the run left running (an agent pod
2220    /// writing to a shared worktree). A failed release fails the execution
2221    /// before its first step, with [`EngineError::Operation`].
2222    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    /// Finalize a run with the given result and context.
2235    ///
2236    /// On success: updates run to Completed with cost, duration, and completed_at.
2237    /// On failure: updates run to Failed with error, cost, duration, and completed_at.
2238    /// Always: fetches and returns the final Run.
2239    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        // Covers the whole run: previous attempts plus this one, so a retried
2249        // run reports the time it really consumed.
2250        let total_duration = ctx.carried_duration_ms() + run_start.elapsed().as_millis() as u64;
2251        let completed_at = Utc::now();
2252
2253        // Paused while it ran: whatever the handler returned, the run stays
2254        // paused for the operator. Only what this execution spent is kept;
2255        // the resume replays from the first step that did not complete.
2256        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        // The pause stopped this execution but the run was resumed since: the
2282        // resume continues or restarts it, so nothing is recorded here.
2283        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                // No `scheduled_at`: the suspended descendant owns the wake-up
2368                // and resumes this run through `resume_chain`. A wake-up armed
2369                // here too would resume the chain twice.
2370                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                // A wave reports its first suspended child: the others wait too.
2394                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                // No `ApprovalRequested` event: a human input is not an approval.
2418                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                // Atomic with the step lock: a signal delivered since the step
2492                // opened leaves the run due right away instead of until the
2493                // deadline.
2494                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                // A guardrail stop (budget or workflow guard) is deliberate,
2535                // not a breakage: the run is cancelled, never failed and
2536                // never replayed.
2537                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                    // Written before the failure so a failed run keeps the
2570                    // verdict the handler set before returning its error.
2571                    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    /// Publish [`Event::ApprovalRequested`] for an approval gate that opened
2642    /// on `run_id`, with the requirement recorded when the gate opened.
2643    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    /// Fail every ancestor of a child run, closest first.
2666    ///
2667    /// Used when a gate inside a sub-workflow is rejected: the child run
2668    /// fails, and the runs suspended with it (its parent, up to the root)
2669    /// must not stay suspended on a child that will never resume. Each
2670    /// ancestor goes through [`fail_or_schedule_retry`](Self::fail_or_schedule_retry)
2671    /// without a retry, which also fails its open `Workflow` step. A run that
2672    /// is not a child of a sub-workflow has no ancestor: nothing happens.
2673    ///
2674    /// A paused ancestor is not failed: the root of the chain is set to resume
2675    /// to `Pending`, so its replay observes the failed child once an operator
2676    /// resumes it.
2677    ///
2678    /// # Errors
2679    ///
2680    /// Returns [`EngineError::Store`] if a run of the chain does not exist or
2681    /// its failure cannot be persisted.
2682    ///
2683    /// # Examples
2684    ///
2685    /// ```no_run
2686    /// use ironflow_engine::engine::Engine;
2687    /// use ironflow_engine::error::EngineError;
2688    /// use uuid::Uuid;
2689    ///
2690    /// # async fn example(engine: &Engine, child_run_id: Uuid) -> Result<(), EngineError> {
2691    /// engine
2692    ///     .fail_ancestors(child_run_id, "approval rejected in a sub-workflow")
2693    ///     .await?;
2694    /// # Ok(())
2695    /// # }
2696    /// ```
2697    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        // Labels are data: a chain that loops back on itself stops there.
2704        let mut visited = HashSet::from([run_id]);
2705
2706        while let Some(parent_id) = chain_parent(&current) {
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            // A paused chain waits for its operator: the root replays on
2714            // resume and observes the failed child, like a cancelled one.
2715            if status == RunStatus::Paused {
2716                self.requeue_paused_root(&current).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    /// Emit Prometheus metrics for a completed run.
2736    #[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    /// Record the metric and publish the audit event for a run that hit its
2760    /// cost cap.
2761    ///
2762    /// A non-[`RunBudgetExceeded`](EngineError::RunBudgetExceeded) error is
2763    /// ignored, so callers can pass the error unconditionally.
2764    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    /// Publish a run status changed event to all registered subscribers.
2795    ///
2796    /// `from` is always `Running` because `finalize_run` is only called
2797    /// from a running state.
2798    #[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    // Test handler that echoes a message via shell
2861    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    // Test handler that fails
2893    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    // -----------------------------------------------------------------------
3023    // Schedule tests
3024    // -----------------------------------------------------------------------
3025
3026    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        // Validated before the handler lookup.
3324        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        // Validated before the handler lookup.
3442        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        // Validated before the handler lookup.
3573        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        // Verify accessors return references
3664        let _ = engine.store();
3665        let _ = engine.provider();
3666    }
3667
3668    // -----------------------------------------------------------------------
3669    // Operation trait tests
3670    // -----------------------------------------------------------------------
3671
3672    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    // -----------------------------------------------------------------------
3842    // Approval + resume tests
3843    // -----------------------------------------------------------------------
3844
3845    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); // build + approval gate
3903        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    /// Record one vote on the open approval gate of `run_id`, then move the
3910    /// run to `Running`, the way the approve route does.
3911    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        // First execution: pauses at approval
3942        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        // Simulate approval: one vote, then transition to Running
3950        approve_open_gate(engine.store(), run.id).await;
3951
3952        // Resume: replays build, skips approval, executes deploy
3953        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); // build + approval + deploy
3958        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        // First execution: pauses at staging-gate
3973        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); // build + staging-gate
3982
3983        // First approval
3984        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); // build + staging-gate + deploy-staging + prod-gate
3991
3992        // Second approval
3993        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    // -----------------------------------------------------------------------
4012    // fail_orphaned_steps tests
4013    // -----------------------------------------------------------------------
4014
4015    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    // -----------------------------------------------------------------------
4463    // spawn_local_resume tests
4464    // -----------------------------------------------------------------------
4465
4466    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    /// Counts its executions, then completes.
4473    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    /// An engine running [`CountingWorkflow`], with one `Pending` run of it.
4491    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}