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::context::{PARENT_RUN_ID_LABEL, WorkflowContext, interrupt_running_steps};
43use crate::error::EngineError;
44use crate::executor::{StepInterceptor, StepResult};
45use crate::guard::{WorkflowGuardConfig, new_shared_guard_state};
46use crate::handler::{WorkflowHandler, WorkflowInfo, clamp_priority};
47use crate::log_sender::LogSender;
48use crate::notify::{
49    ApprovalRequestedEvent, Event, EventBusReleaser, EventPublisher, EventSubscriber,
50    RunBudgetExceededEvent, RunFailedEvent, RunStatusChangedEvent, SignalAwaitedEvent,
51    SignalReceivedEvent, WorkflowEventBus,
52};
53use crate::plan::{
54    ExecutionPlan, PlanOptions, PlanRecorder, SharedPlanRecorder, estimate_durations, lock_plan,
55};
56use crate::retry_policy::{backoff_for_retry, is_run_retryable};
57use crate::schedule::CronSchedule;
58use crate::signal::{
59    Signal, SignalDelivery, SignalRejected, SignalResumed, received_output, validate_step_payload,
60};
61use ironflow_core::decision::DecisionProvider;
62
63/// Result of a workflow execution, carrying the final [`Run`] and per-step
64/// metrics collected during execution.
65///
66/// Returned by [`Engine::run_handler`], [`Engine::execute_handler_run`],
67/// [`Engine::execute_run`], and [`Engine::resume_run`].
68///
69/// # Examples
70///
71/// ```no_run
72/// use ironflow_engine::engine::WorkflowResult;
73///
74/// # fn example(result: WorkflowResult) {
75/// println!("run {} finished with {} steps", result.run.id, result.steps.len());
76/// for step in &result.steps {
77///     println!("  {} ({:?}): {}ms", step.name, step.status, step.duration_ms);
78/// }
79/// # }
80/// ```
81#[derive(Debug, Clone)]
82pub struct WorkflowResult {
83    /// The finalized run record.
84    pub run: Run,
85    /// Per-step results in execution order.
86    pub steps: Vec<StepResult>,
87}
88
89/// Optional settings for [`Engine::enqueue_handler_with_options`].
90///
91/// All fields fall back to handler or server defaults when left at their
92/// [`Default`] value.
93///
94/// # Examples
95///
96/// ```
97/// use ironflow_engine::engine::EnqueueOptions;
98/// use rust_decimal::Decimal;
99///
100/// let options = EnqueueOptions {
101///     max_retries: 3,
102///     max_cost_usd: Some(Decimal::new(50, 2)),
103///     ..Default::default()
104/// };
105/// assert_eq!(options.max_retries, 3);
106/// ```
107#[derive(Debug, Clone, Default)]
108pub struct EnqueueOptions {
109    /// Number of automatic retries granted to the run.
110    pub max_retries: u32,
111    /// Labels merged on top of the handler's default labels.
112    pub labels: HashMap<String, String>,
113    /// Defer execution until this instant instead of running as soon as a
114    /// worker picks the run up.
115    pub scheduled_at: Option<DateTime<Utc>>,
116    /// Cost cap for the run. Overrides both the handler default and the server
117    /// default. `None` falls back to
118    /// [`BudgetConfig::resolve_run_cap`](crate::budget::BudgetConfig::resolve_run_cap).
119    pub max_cost_usd: Option<Decimal>,
120    /// Authenticated principal that triggered the run. `None` for cron,
121    /// webhook, and programmatic triggers.
122    pub created_by: Option<RunActor>,
123    /// Idempotency key binding this enqueue to a single run.
124    ///
125    /// When set and already bound to a run created within
126    /// [`IDEMPOTENCY_WINDOW`](ironflow_store::entities::IDEMPOTENCY_WINDOW),
127    /// nothing is enqueued and the original run is replayed.
128    pub idempotency_key: Option<String>,
129    /// Concurrency key making the run exclusive.
130    ///
131    /// When set, the run is refused with [`EngineError::ConcurrencyConflict`]
132    /// while another non-terminal run holds the same key. The key is released
133    /// once the holder reaches a terminal state.
134    pub concurrency_key: Option<String>,
135    /// Concurrency groups the run belongs to, each with the maximum number of
136    /// root runs of that group allowed to execute at once.
137    ///
138    /// Unlike [`concurrency_key`](Self::concurrency_key), the run is always
139    /// created: it stays pending until every group is under its limit. Empty
140    /// means no limit. Invalid limits are refused with
141    /// [`EngineError::InvalidConcurrencyLimit`].
142    pub concurrency_limits: Vec<ConcurrencyLimit>,
143    /// Queue priority of the run, overriding
144    /// [`WorkflowHandler::priority`](crate::handler::WorkflowHandler::priority).
145    ///
146    /// Workers pick the pending run with the highest priority first, then the
147    /// oldest among equal priorities. `None` falls back to the handler's
148    /// priority, clamped to the accepted range. An explicit value outside
149    /// [`MIN_PRIORITY`](ironflow_store::entities::MIN_PRIORITY)`..=`[`MAX_PRIORITY`](ironflow_store::entities::MAX_PRIORITY)
150    /// is refused with [`EngineError::InvalidPriority`].
151    pub priority: Option<i16>,
152    /// Worker tags the run requires, merged with the handler's
153    /// [`required_worker_tags`](WorkflowHandler::required_worker_tags).
154    ///
155    /// Only a worker carrying every tag picks the run. Invalid tags are
156    /// refused with [`EngineError::InvalidWorkerTag`].
157    pub worker_tags: Vec<String>,
158}
159
160/// Where a run resumes once an approval, a human input or an escalation
161/// resolves the gate it was suspended on.
162///
163/// # Examples
164///
165/// ```
166/// use ironflow_engine::engine::ExecutionMode;
167///
168/// assert_eq!(ExecutionMode::default(), ExecutionMode::Local);
169/// ```
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
171pub enum ExecutionMode {
172    /// Resume in-process via [`Engine::resume_run`]. Used by
173    /// [`crate::testing::TestEngine`] and single-process deployments where
174    /// the API also registers the handlers.
175    #[default]
176    Local,
177    /// Requeue the run to `Pending` instead. A worker's `pick_next_pending`
178    /// claims it and finishes it via [`Engine::execute_handler_run`].
179    Workers,
180}
181
182/// The workflow orchestration engine.
183///
184/// Holds references to the store, agent provider, and a registry of
185/// [`WorkflowHandler`]s.
186///
187/// # Examples
188///
189/// ```no_run
190/// use std::sync::Arc;
191/// use ironflow_engine::engine::Engine;
192/// use ironflow_engine::config::ShellConfig;
193/// use ironflow_engine::handler::{WorkflowHandler, HandlerFuture, WorkflowInfo};
194/// use ironflow_engine::context::WorkflowContext;
195/// use ironflow_store::memory::InMemoryStore;
196/// use ironflow_store::models::TriggerKind;
197/// use ironflow_core::providers::claude::ClaudeCodeProvider;
198/// use serde_json::json;
199///
200/// struct CiWorkflow;
201/// impl WorkflowHandler for CiWorkflow {
202///     fn name(&self) -> &str { "ci" }
203///     fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
204///         Box::pin(async move {
205///             ctx.shell("test", ShellConfig::new("cargo test")).await?;
206///             Ok(())
207///         })
208///     }
209/// }
210///
211/// # async fn example() -> Result<(), ironflow_engine::error::EngineError> {
212/// let store = Arc::new(InMemoryStore::new());
213/// let provider = Arc::new(ClaudeCodeProvider::new());
214/// let mut engine = Engine::new(store, provider);
215/// engine.register(CiWorkflow)?;
216///
217/// let result = engine.run_handler("ci", TriggerKind::Manual, json!({})).await?;
218/// tracing::info!(run_id = %result.run.id, status = ?result.run.status, steps = result.steps.len(), "run completed");
219/// # Ok(())
220/// # }
221/// ```
222pub struct Engine {
223    store: Arc<dyn Store>,
224    provider: Arc<dyn AgentProvider>,
225    handlers: HashMap<String, Arc<dyn WorkflowHandler>>,
226    event_publisher: EventPublisher,
227    log_sender: Option<LogSender>,
228    budget: BudgetConfig,
229    artifact_sink: Option<Arc<dyn ArtifactSink>>,
230    guard_config: Option<WorkflowGuardConfig>,
231    event_bus: Option<WorkflowEventBus>,
232    decision_provider: Option<Arc<dyn DecisionProvider>>,
233    step_interceptor: Option<Arc<dyn StepInterceptor>>,
234    execution_mode: ExecutionMode,
235    worker_tags: Option<Arc<Vec<String>>>,
236    active_runs: ActiveRuns,
237    local_resumes: ActiveRuns,
238}
239
240/// Per-run locks of this process, keyed by run id.
241type ActiveRuns = Arc<Mutex<HashMap<Uuid, Arc<AsyncMutex<()>>>>>;
242
243/// Marks a run as executing in this process, or as being resumed locally,
244/// until it is dropped.
245///
246/// Holds the run's lock, so a resume of the same run waits for the execution
247/// to end ([`Engine::wait_until_idle`]) instead of replaying it in parallel.
248struct ActiveRunGuard {
249    runs: ActiveRuns,
250    run_id: Uuid,
251    entry: Arc<AsyncMutex<()>>,
252    lock: Option<OwnedMutexGuard<()>>,
253}
254
255impl Drop for ActiveRunGuard {
256    fn drop(&mut self) {
257        self.lock.take();
258        prune_active_run(&self.runs, self.run_id, &self.entry);
259    }
260}
261
262/// Take the lock of `run_id` in `runs`, waiting for its current holder.
263async fn lock_run(runs: &ActiveRuns, run_id: Uuid) -> ActiveRunGuard {
264    let entry = {
265        let mut runs = runs.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
266        Arc::clone(runs.entry(run_id).or_default())
267    };
268    let lock = Arc::clone(&entry).lock_owned().await;
269    ActiveRunGuard {
270        runs: Arc::clone(runs),
271        run_id,
272        entry,
273        lock: Some(lock),
274    }
275}
276
277/// Remove the lock of `run_id` from `runs` once nobody holds or awaits it.
278///
279/// `entry` is the caller's own clone: the map plus that clone make two
280/// references, any more is a waiter that still needs the lock in the map.
281fn prune_active_run(runs: &ActiveRuns, run_id: Uuid, entry: &Arc<AsyncMutex<()>>) {
282    let mut runs = runs.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
283    let unused = runs
284        .get(&run_id)
285        .is_some_and(|current| Arc::ptr_eq(current, entry))
286        && Arc::strong_count(entry) == 2;
287    if unused {
288        runs.remove(&run_id);
289    }
290}
291
292/// Validate a workflow category path.
293///
294/// A category is a `/`-separated list of non-empty segments. This function
295/// rejects empty paths, leading or trailing `/`, consecutive `/`, and
296/// segments containing only whitespace.
297///
298/// # Errors
299///
300/// Returns [`EngineError::InvalidWorkflow`] when the category is malformed.
301fn validate_category(handler_name: &str, category: &str) -> Result<(), EngineError> {
302    let reject = |reason: &str| {
303        Err(EngineError::InvalidWorkflow(format!(
304            "handler '{handler_name}' has invalid category '{category}': {reason}"
305        )))
306    };
307
308    if category.is_empty() {
309        return reject("empty category");
310    }
311    if category.starts_with('/') {
312        return reject("leading '/'");
313    }
314    if category.ends_with('/') {
315        return reject("trailing '/'");
316    }
317    for segment in category.split('/') {
318        if segment.is_empty() {
319            return reject("empty segment (double '/')");
320        }
321        if segment.trim().is_empty() {
322            return reject("whitespace-only segment");
323        }
324    }
325    Ok(())
326}
327
328/// Read a run id from the label `key` of a sub-workflow child run.
329///
330/// `None` when the run was not started by a `Workflow` step, when the label
331/// is missing or invalid, or when it points back at the run itself.
332fn chain_label(run: &Run, key: &str) -> Option<Uuid> {
333    if !matches!(run.trigger, TriggerKind::Workflow) {
334        return None;
335    }
336    let id = Uuid::parse_str(run.labels.get(key)?).ok()?;
337    (id != run.id).then_some(id)
338}
339
340/// The root run of the chain a sub-workflow child run belongs to.
341///
342/// Resuming a child resumes this root instead: the root replays its steps
343/// and re-enters the same child run through its open `Workflow` step. The
344/// worker uses it to follow its lease from a child to the root it resumed.
345///
346/// `None` when `run` is not a sub-workflow child run.
347///
348/// # Examples
349///
350/// ```no_run
351/// use ironflow_engine::engine::chain_root;
352/// use ironflow_store::entities::Run;
353/// use uuid::Uuid;
354///
355/// fn lease_target(run: &Run) -> Uuid {
356///     chain_root(run).unwrap_or(run.id)
357/// }
358/// ```
359pub fn chain_root(run: &Run) -> Option<Uuid> {
360    chain_label(run, LABEL_ROOT_RUN_ID)
361}
362
363/// The run whose `Workflow` step started this sub-workflow child run.
364pub(crate) fn chain_parent(run: &Run) -> Option<Uuid> {
365    chain_label(run, PARENT_RUN_ID_LABEL)
366}
367
368impl Engine {
369    /// Create a new engine with the given store and agent provider.
370    ///
371    /// # Examples
372    ///
373    /// ```no_run
374    /// use std::sync::Arc;
375    /// use ironflow_engine::engine::Engine;
376    /// use ironflow_store::memory::InMemoryStore;
377    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
378    ///
379    /// let engine = Engine::new(
380    ///     Arc::new(InMemoryStore::new()),
381    ///     Arc::new(ClaudeCodeProvider::new()),
382    /// );
383    /// ```
384    pub fn new(store: Arc<dyn Store>, provider: Arc<dyn AgentProvider>) -> Self {
385        Self {
386            store,
387            provider,
388            handlers: HashMap::new(),
389            event_publisher: EventPublisher::new(),
390            log_sender: None,
391            budget: BudgetConfig::new(),
392            artifact_sink: None,
393            guard_config: None,
394            event_bus: None,
395            decision_provider: None,
396            step_interceptor: None,
397            execution_mode: ExecutionMode::default(),
398            worker_tags: None,
399            active_runs: Arc::new(Mutex::new(HashMap::new())),
400            local_resumes: Arc::new(Mutex::new(HashMap::new())),
401        }
402    }
403
404    /// Mark `run_id` as executing in this process, waiting for a previous
405    /// execution of the same run to end first.
406    async fn track_execution(&self, run_id: Uuid) -> ActiveRunGuard {
407        lock_run(&self.active_runs, run_id).await
408    }
409
410    /// Wait until no execution of `run_id` is running in this process.
411    async fn wait_until_idle(&self, run_id: Uuid) {
412        let entry = {
413            let runs = self
414                .active_runs
415                .lock()
416                .unwrap_or_else(|poisoned| poisoned.into_inner());
417            runs.get(&run_id).map(Arc::clone)
418        };
419        if let Some(entry) = entry {
420            drop(Arc::clone(&entry).lock_owned().await);
421            prune_active_run(&self.active_runs, run_id, &entry);
422        }
423    }
424
425    /// Whether an execution of `run_id` is running in this process.
426    pub(crate) fn is_executing(&self, run_id: Uuid) -> bool {
427        let runs = self
428            .active_runs
429            .lock()
430            .unwrap_or_else(|poisoned| poisoned.into_inner());
431        runs.get(&run_id)
432            .is_some_and(|entry| entry.try_lock().is_err())
433    }
434
435    /// Wire a [`DecisionProvider`] backend for `ctx.decision(...)` steps.
436    ///
437    /// Without this, a workflow that reaches a decision step fails with
438    /// [`EngineError::NoDecisionProvider`].
439    ///
440    /// # Examples
441    ///
442    /// ```no_run
443    /// use std::sync::Arc;
444    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
445    /// use ironflow_core::providers::record_replay_decision::RecordReplayDecisionProvider;
446    /// use ironflow_engine::engine::Engine;
447    /// use ironflow_store::memory::InMemoryStore;
448    ///
449    /// let engine = Engine::new(
450    ///     Arc::new(InMemoryStore::new()),
451    ///     Arc::new(ClaudeCodeProvider::new()),
452    /// )
453    /// .with_decision_provider(Arc::new(RecordReplayDecisionProvider::replay("tests/fixtures")));
454    /// ```
455    pub fn with_decision_provider(mut self, provider: Arc<dyn DecisionProvider>) -> Self {
456        self.decision_provider = Some(provider);
457        self
458    }
459
460    /// Wire a [`StepInterceptor`] that resolves steps without executing them.
461    ///
462    /// Used by [`crate::testing::TestEngine`] to mock shell, HTTP and approval
463    /// steps. `None` (the default) executes every step for real.
464    ///
465    /// # Examples
466    ///
467    /// ```no_run
468    /// use std::sync::Arc;
469    ///
470    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
471    /// use ironflow_engine::engine::Engine;
472    /// use ironflow_engine::executor::StepInterceptor;
473    /// use ironflow_store::memory::InMemoryStore;
474    ///
475    /// # fn example(interceptor: Arc<dyn StepInterceptor>) {
476    /// let engine = Engine::new(
477    ///     Arc::new(InMemoryStore::new()),
478    ///     Arc::new(ClaudeCodeProvider::new()),
479    /// )
480    /// .with_step_interceptor(interceptor);
481    /// # }
482    /// ```
483    pub fn with_step_interceptor(mut self, interceptor: Arc<dyn StepInterceptor>) -> Self {
484        self.step_interceptor = Some(interceptor);
485        self
486    }
487
488    /// The step interceptor wired into this engine, if any.
489    pub fn step_interceptor(&self) -> Option<&Arc<dyn StepInterceptor>> {
490        self.step_interceptor.as_ref()
491    }
492
493    /// Apply cost guardrails to this engine.
494    ///
495    /// Without this, both the per-run cap default and the monthly quota are
496    /// disabled and the engine behaves exactly as before.
497    ///
498    /// # Examples
499    ///
500    /// ```no_run
501    /// use std::sync::Arc;
502    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
503    /// use ironflow_engine::budget::BudgetConfig;
504    /// use ironflow_engine::engine::Engine;
505    /// use ironflow_store::memory::InMemoryStore;
506    ///
507    /// let engine = Engine::new(
508    ///     Arc::new(InMemoryStore::new()),
509    ///     Arc::new(ClaudeCodeProvider::new()),
510    /// )
511    /// .with_budget_config(BudgetConfig::from_env());
512    /// ```
513    pub fn with_budget_config(mut self, budget: BudgetConfig) -> Self {
514        self.budget = budget;
515        self
516    }
517
518    /// Returns the cost guardrails applied by this engine.
519    pub fn budget_config(&self) -> &BudgetConfig {
520        &self.budget
521    }
522
523    /// Apply workflow guard configuration to this engine.
524    ///
525    /// When set, every workflow run created by this engine is protected
526    /// by the guard. Handlers can override this via
527    /// [`WorkflowHandler::guard_config`].
528    ///
529    /// # Examples
530    ///
531    /// ```no_run
532    /// use std::sync::Arc;
533    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
534    /// use ironflow_engine::engine::Engine;
535    /// use ironflow_engine::guard::WorkflowGuardConfig;
536    /// use ironflow_store::memory::InMemoryStore;
537    ///
538    /// let engine = Engine::new(
539    ///     Arc::new(InMemoryStore::new()),
540    ///     Arc::new(ClaudeCodeProvider::new()),
541    /// )
542    /// .with_guard_config(WorkflowGuardConfig::default());
543    /// ```
544    pub fn with_guard_config(mut self, config: WorkflowGuardConfig) -> Self {
545        self.guard_config = Some(config);
546        self
547    }
548
549    /// Returns the workflow guard configuration, if any.
550    pub fn guard_config(&self) -> Option<&WorkflowGuardConfig> {
551        self.guard_config.as_ref()
552    }
553
554    /// Choose where a run resumes once its approval, human input or
555    /// escalation is resolved.
556    ///
557    /// Defaults to [`ExecutionMode::Local`]. Set [`ExecutionMode::Workers`]
558    /// on an API process that delegates execution to workers.
559    ///
560    /// # Examples
561    ///
562    /// ```no_run
563    /// use std::sync::Arc;
564    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
565    /// use ironflow_engine::engine::{Engine, ExecutionMode};
566    /// use ironflow_store::memory::InMemoryStore;
567    ///
568    /// let engine = Engine::new(
569    ///     Arc::new(InMemoryStore::new()),
570    ///     Arc::new(ClaudeCodeProvider::new()),
571    /// )
572    /// .with_execution_mode(ExecutionMode::Workers);
573    /// ```
574    pub fn with_execution_mode(mut self, mode: ExecutionMode) -> Self {
575        self.execution_mode = mode;
576        self
577    }
578
579    /// Returns where a run resumes once its gate is resolved.
580    pub fn execution_mode(&self) -> ExecutionMode {
581        self.execution_mode
582    }
583
584    /// Declare the worker tags carried by the process running this engine.
585    ///
586    /// Set by a worker at build time. A `ctx.workflow(..)` step then refuses
587    /// a child whose [`required_worker_tags`](WorkflowHandler::required_worker_tags)
588    /// are not all carried here, instead of running it on the wrong host.
589    /// Unset by default (API or local mode): no check is made.
590    ///
591    /// # Examples
592    ///
593    /// ```no_run
594    /// use std::sync::Arc;
595    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
596    /// use ironflow_engine::engine::Engine;
597    /// use ironflow_store::memory::InMemoryStore;
598    ///
599    /// let mut engine = Engine::new(
600    ///     Arc::new(InMemoryStore::new()),
601    ///     Arc::new(ClaudeCodeProvider::new()),
602    /// );
603    /// engine.set_worker_tags(vec!["gpu".to_string()]);
604    /// assert_eq!(engine.worker_tags(), Some(&["gpu".to_string()][..]));
605    /// ```
606    pub fn set_worker_tags(&mut self, tags: Vec<String>) {
607        self.worker_tags = Some(Arc::new(tags));
608    }
609
610    /// Returns the worker tags set by [`set_worker_tags`](Self::set_worker_tags),
611    /// or `None` when the engine does not run inside a tagged worker.
612    ///
613    /// # Examples
614    ///
615    /// ```no_run
616    /// use std::sync::Arc;
617    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
618    /// use ironflow_engine::engine::Engine;
619    /// use ironflow_store::memory::InMemoryStore;
620    ///
621    /// let engine = Engine::new(
622    ///     Arc::new(InMemoryStore::new()),
623    ///     Arc::new(ClaudeCodeProvider::new()),
624    /// );
625    /// assert!(engine.worker_tags().is_none());
626    /// ```
627    pub fn worker_tags(&self) -> Option<&[String]> {
628        self.worker_tags.as_deref().map(Vec::as_slice)
629    }
630
631    /// Attach a log sender for real-time step output streaming.
632    ///
633    /// When set, all workflow contexts created by this engine will forward
634    /// step output (shell stdout/stderr, agent system messages) to the
635    /// given sender.
636    pub fn set_log_sender(&mut self, sender: LogSender) {
637        self.log_sender = Some(sender);
638    }
639
640    /// Attach the backend that stores and serves artifact bytes.
641    ///
642    /// Every context this engine builds inherits it. Without one, steps that
643    /// declare artifacts fail explicitly and every other step is unaffected.
644    ///
645    /// # Examples
646    ///
647    /// ```no_run
648    /// use std::sync::Arc;
649    ///
650    /// use ironflow_engine::artifact::ArtifactSink;
651    /// use ironflow_engine::engine::Engine;
652    ///
653    /// # fn example(engine: &mut Engine, sink: Arc<dyn ArtifactSink>) {
654    /// engine.set_artifact_sink(sink);
655    /// # }
656    /// ```
657    pub fn set_artifact_sink(&mut self, sink: Arc<dyn ArtifactSink>) {
658        self.artifact_sink = Some(sink);
659    }
660
661    /// The artifact backend attached to this engine, if any.
662    pub fn artifact_sink(&self) -> Option<&Arc<dyn ArtifactSink>> {
663        self.artifact_sink.as_ref()
664    }
665
666    /// Attach a [`WorkflowEventBus`] for per-run real-time monitoring.
667    ///
668    /// When set, all workflow contexts created by this engine will publish
669    /// step-level events (`StepStarted`, `StepCompleted`, `StepFailed`)
670    /// to the bus.
671    ///
672    /// The bus channel of a run is released when the run reaches a terminal
673    /// status: subscribers drain the buffered events, then their receiver
674    /// closes. Call this once per engine; each call registers a releaser.
675    ///
676    /// # Examples
677    ///
678    /// ```no_run
679    /// use ironflow_engine::engine::Engine;
680    /// use ironflow_engine::notify::WorkflowEventBus;
681    ///
682    /// # fn example(engine: &mut Engine) {
683    /// engine.set_event_bus(WorkflowEventBus::new());
684    /// # }
685    /// ```
686    pub fn set_event_bus(&mut self, bus: WorkflowEventBus) {
687        self.event_bus = Some(bus.clone());
688        self.event_publisher
689            .subscribe(EventBusReleaser::new(bus), &[Event::RUN_STATUS_CHANGED]);
690    }
691
692    /// The workflow event bus attached to this engine, if any.
693    pub fn event_bus(&self) -> Option<&WorkflowEventBus> {
694        self.event_bus.as_ref()
695    }
696
697    /// Returns a reference to the backing store.
698    pub fn store(&self) -> &Arc<dyn Store> {
699        &self.store
700    }
701
702    /// Returns a reference to the agent provider.
703    pub fn provider(&self) -> &Arc<dyn AgentProvider> {
704        &self.provider
705    }
706
707    /// Build a [`WorkflowContext`] with access to the handler registry.
708    ///
709    /// The context is seeded with the run's attempt number and the cost and
710    /// duration already accumulated by previous attempts, so a retried run
711    /// reports the total it really consumed rather than only its last attempt.
712    ///
713    /// The run's persisted `max_cost_usd` becomes the context cost cap; `None`
714    /// disables the per-run budget check for that context.
715    fn build_context(&self, run: &Run) -> WorkflowContext {
716        let handlers = self.handlers.clone();
717        let resolver: crate::context::HandlerResolver =
718            Arc::new(move |name: &str| handlers.get(name).cloned());
719        let mut ctx = WorkflowContext::with_handler_resolver(
720            run.id,
721            run.workflow_name.clone(),
722            self.store.clone(),
723            self.provider.clone(),
724            resolver,
725        );
726        ctx.carry_over_run_totals(run.retry_count + 1, run.cost_usd, run.duration_ms);
727        ctx.set_max_cost_usd(run.max_cost_usd);
728        ctx.set_run_created_at(run.created_at);
729        if let Some(ref sender) = self.log_sender {
730            ctx.set_log_sender(sender.clone());
731        }
732        if let Some(ref sink) = self.artifact_sink {
733            ctx.set_artifact_sink(sink.clone());
734        }
735        if let Some(ref bus) = self.event_bus {
736            ctx.set_event_bus(bus.clone());
737        }
738        if let Some(ref provider) = self.decision_provider {
739            ctx.set_decision_provider(provider.clone());
740        }
741        if let Some(ref interceptor) = self.step_interceptor {
742            ctx.set_step_interceptor(interceptor.clone());
743        }
744        if let Some(ref tags) = self.worker_tags {
745            ctx.set_worker_tags(tags.clone());
746        }
747        ctx
748    }
749
750    /// Build a context with the guard attached.
751    ///
752    /// Uses the handler's `guard_config()` if present, otherwise falls back
753    /// to the engine's global configuration. Creates a fresh shared state
754    /// for each top-level run.
755    fn build_context_with_guard(
756        &self,
757        run: &Run,
758        handler: &dyn WorkflowHandler,
759    ) -> WorkflowContext {
760        let mut ctx = self.build_context(run);
761        let guard_config = handler.guard_config().or_else(|| self.guard_config.clone());
762        if let Some(config) = guard_config {
763            ctx.set_guard(config, new_shared_guard_state());
764        }
765        ctx
766    }
767
768    /// Reject the creation of a new run when the monthly quota is exhausted.
769    ///
770    /// The window is the current calendar month in UTC. Runs already in flight
771    /// are never interrupted -- only creation is refused.
772    ///
773    /// # Errors
774    ///
775    /// Returns [`EngineError::MonthlyBudgetExceeded`] when the accumulated cost
776    /// of the month has reached the configured quota. Returns
777    /// [`EngineError::Store`] when the aggregate query fails.
778    async fn check_monthly_quota(&self, workflow_name: &str) -> Result<(), EngineError> {
779        let Some(limit) = self.budget.monthly_cost_limit_usd else {
780            return Ok(());
781        };
782
783        let stats = self
784            .store
785            .get_stats(RunFilter {
786                created_after: Some(month_start(Utc::now())),
787                ..RunFilter::default()
788            })
789            .await?;
790
791        if stats.total_cost_usd < limit {
792            return Ok(());
793        }
794
795        warn!(
796            workflow = %workflow_name,
797            limit_usd = %limit,
798            spent_usd = %stats.total_cost_usd,
799            "monthly cost quota exhausted, refusing new run"
800        );
801
802        #[cfg(feature = "prometheus")]
803        counter!(
804            RUN_BUDGET_EXCEEDED_TOTAL,
805            "workflow" => workflow_name.to_string(),
806            "scope" => "monthly",
807        )
808        .increment(1);
809
810        Err(EngineError::MonthlyBudgetExceeded {
811            limit_usd: limit,
812            spent_usd: stats.total_cost_usd,
813        })
814    }
815
816    // -----------------------------------------------------------------------
817    // Handler registration
818    // -----------------------------------------------------------------------
819
820    /// Register a [`WorkflowHandler`] for dynamic workflow execution.
821    ///
822    /// The handler is looked up by [`WorkflowHandler::name`] when executing
823    /// or enqueuing.
824    ///
825    /// # Errors
826    ///
827    /// Returns [`EngineError::InvalidWorkflow`] if a handler with the same
828    /// name is already registered.
829    ///
830    /// # Examples
831    ///
832    /// ```no_run
833    /// use std::sync::Arc;
834    /// use ironflow_engine::engine::Engine;
835    /// use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
836    /// use ironflow_engine::context::WorkflowContext;
837    /// use ironflow_engine::config::ShellConfig;
838    /// use ironflow_store::memory::InMemoryStore;
839    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
840    ///
841    /// struct MyWorkflow;
842    /// impl WorkflowHandler for MyWorkflow {
843    ///     fn name(&self) -> &str { "my-workflow" }
844    ///     fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
845    ///         Box::pin(async move {
846    ///             ctx.shell("step1", ShellConfig::new("echo done")).await?;
847    ///             Ok(())
848    ///         })
849    ///     }
850    /// }
851    ///
852    /// let mut engine = Engine::new(
853    ///     Arc::new(InMemoryStore::new()),
854    ///     Arc::new(ClaudeCodeProvider::new()),
855    /// );
856    /// engine.register(MyWorkflow)?;
857    /// # Ok::<(), ironflow_engine::error::EngineError>(())
858    /// ```
859    pub fn register(&mut self, handler: impl WorkflowHandler + 'static) -> Result<(), EngineError> {
860        let name = handler.name().to_string();
861        if self.handlers.contains_key(&name) {
862            return Err(EngineError::InvalidWorkflow(format!(
863                "handler '{}' already registered",
864                name
865            )));
866        }
867        if let Some(category) = handler.category() {
868            validate_category(&name, category)?;
869        }
870        self.handlers.insert(name, Arc::new(handler));
871        Ok(())
872    }
873
874    /// Register a pre-boxed workflow handler.
875    ///
876    /// # Errors
877    ///
878    /// Returns [`EngineError::InvalidWorkflow`] if a handler with the same
879    /// name is already registered or if its category is invalid.
880    pub fn register_boxed(&mut self, handler: Box<dyn WorkflowHandler>) -> Result<(), EngineError> {
881        let name = handler.name().to_string();
882        if self.handlers.contains_key(&name) {
883            return Err(EngineError::InvalidWorkflow(format!(
884                "handler '{}' already registered",
885                name
886            )));
887        }
888        if let Some(category) = handler.category() {
889            validate_category(&name, category)?;
890        }
891        self.handlers.insert(name, Arc::from(handler));
892        Ok(())
893    }
894
895    /// Get a registered handler by name.
896    pub fn get_handler(&self, name: &str) -> Option<&Arc<dyn WorkflowHandler>> {
897        self.handlers.get(name)
898    }
899
900    /// List registered handler names.
901    pub fn handler_names(&self) -> Vec<&str> {
902        self.handlers.keys().map(|s| s.as_str()).collect()
903    }
904
905    /// Get detailed info about a registered workflow handler.
906    pub fn handler_info(&self, name: &str) -> Option<WorkflowInfo> {
907        self.handlers.get(name).map(|h| h.describe())
908    }
909
910    /// List handlers that have a cron schedule configured.
911    ///
912    /// Returns pairs of `(workflow_name, cron_expression)` for all handlers
913    /// where [`WorkflowHandler::schedule`] returns `Some`.
914    ///
915    /// Use this to wire scheduled handlers into a scheduler
916    /// (e.g. the schedule ticker in `ironflow-api`).
917    ///
918    /// # Examples
919    ///
920    /// ```no_run
921    /// # use std::sync::Arc;
922    /// # use ironflow_engine::engine::Engine;
923    /// # use ironflow_store::memory::InMemoryStore;
924    /// # use ironflow_core::providers::claude::ClaudeCodeProvider;
925    /// let engine = Engine::new(
926    ///     Arc::new(InMemoryStore::new()),
927    ///     Arc::new(ClaudeCodeProvider::new()),
928    /// );
929    /// for (name, schedule) in engine.scheduled_handlers() {
930    ///     tracing::info!("{name} runs on schedule: {schedule}");
931    /// }
932    /// ```
933    pub fn scheduled_handlers(&self) -> Vec<(&str, &CronSchedule)> {
934        self.handlers
935            .iter()
936            .filter_map(|(name, handler)| handler.schedule().map(|sched| (name.as_str(), sched)))
937            .collect()
938    }
939
940    /// Register an event subscriber for domain events.
941    ///
942    /// The subscriber is called only for events whose type is in
943    /// `event_types`. Pass [`Event::ALL`] to receive every event.
944    ///
945    /// # Examples
946    ///
947    /// ```no_run
948    /// use ironflow_engine::engine::Engine;
949    /// use ironflow_engine::notify::{Event, WebhookSubscriber};
950    /// use ironflow_store::memory::InMemoryStore;
951    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
952    /// use std::sync::Arc;
953    ///
954    /// let mut engine = Engine::new(
955    ///     Arc::new(InMemoryStore::new()),
956    ///     Arc::new(ClaudeCodeProvider::new()),
957    /// );
958    ///
959    /// engine.subscribe(
960    ///     WebhookSubscriber::new("https://hooks.example.com/events"),
961    ///     &[Event::RUN_STATUS_CHANGED, Event::STEP_FAILED],
962    /// );
963    /// ```
964    pub fn subscribe(
965        &mut self,
966        subscriber: impl EventSubscriber + 'static,
967        event_types: &[&'static str],
968    ) {
969        self.event_publisher.subscribe(subscriber, event_types);
970    }
971
972    /// Returns a reference to the event publisher.
973    ///
974    /// Useful for publishing events from outside the engine (e.g. auth
975    /// routes in the API layer).
976    pub fn event_publisher(&self) -> &EventPublisher {
977        &self.event_publisher
978    }
979
980    // -----------------------------------------------------------------------
981    // Dynamic workflow execution (WorkflowHandler)
982    // -----------------------------------------------------------------------
983
984    /// Execute a registered handler inline.
985    ///
986    /// Creates a run, builds a [`WorkflowContext`], calls the handler's
987    /// [`execute`](WorkflowHandler::execute), and finalizes the run.
988    ///
989    /// # Errors
990    ///
991    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered
992    /// with that name. Returns [`EngineError`] if execution fails.
993    ///
994    /// When the workflow is paused (see [`Engine::pause_workflow`]) the run is
995    /// created and left `Pending`, with no steps: the returned
996    /// `WorkflowResult.run.status.state` is `Pending`. The hold applies in
997    /// every execution mode; [`Engine::resume_workflow`] runs the held run
998    /// under [`ExecutionMode::Local`].
999    ///
1000    /// # Examples
1001    ///
1002    /// ```no_run
1003    /// use std::sync::Arc;
1004    /// use ironflow_engine::engine::Engine;
1005    /// use ironflow_store::memory::InMemoryStore;
1006    /// use ironflow_store::models::TriggerKind;
1007    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
1008    /// use serde_json::json;
1009    ///
1010    /// # async fn example(engine: &Engine) -> Result<(), ironflow_engine::error::EngineError> {
1011    /// let run = engine.run_handler("deploy", TriggerKind::Manual, json!({})).await?;
1012    /// # Ok(())
1013    /// # }
1014    /// ```
1015    #[tracing::instrument(name = "engine.run_handler", skip_all, fields(workflow = %handler_name))]
1016    pub async fn run_handler(
1017        &self,
1018        handler_name: &str,
1019        trigger: TriggerKind,
1020        payload: Value,
1021    ) -> Result<WorkflowResult, EngineError> {
1022        let handler = self
1023            .handlers
1024            .get(handler_name)
1025            .ok_or_else(|| {
1026                EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
1027            })?
1028            .clone();
1029
1030        self.check_monthly_quota(handler_name).await?;
1031
1032        let handler_version = handler.version().map(str::to_string);
1033        let max_cost_usd = self
1034            .budget
1035            .resolve_run_cap(None, handler.default_max_cost_usd());
1036        let run = self
1037            .store
1038            .create_run(NewRun {
1039                created_by: None,
1040                workflow_name: handler_name.to_string(),
1041                trigger,
1042                payload,
1043                max_retries: 0,
1044                handler_version,
1045                labels: handler.default_labels(),
1046                scheduled_at: None,
1047                idempotency_key: None,
1048                concurrency_key: None,
1049                priority: clamp_priority(handler.priority()),
1050                concurrency_limits: Vec::new(),
1051                max_cost_usd,
1052                worker_tags: normalize_worker_tags(handler.required_worker_tags()),
1053            })
1054            .await?
1055            .into_run();
1056
1057        let run_id = run.id;
1058        info!(run_id = %run_id, handler_version = run.handler_version.as_deref().unwrap_or(""), "run created");
1059
1060        if self.is_workflow_paused(handler_name).await? {
1061            info!(run_id = %run_id, workflow = %handler_name, "workflow paused, run left pending");
1062            return Ok(WorkflowResult {
1063                run,
1064                steps: Vec::new(),
1065            });
1066        }
1067
1068        self.store
1069            .update_run_status(run_id, RunStatus::Running)
1070            .await?;
1071
1072        #[cfg(feature = "prometheus")]
1073        gauge!(RUNS_ACTIVE, "workflow" => handler_name.to_string()).increment(1.0);
1074
1075        let run_start = Instant::now();
1076        let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
1077
1078        let result = handler.execute(&mut ctx).await;
1079        self.finalize_run(run_id, handler_name, result, &ctx, run_start, run.labels)
1080            .await
1081    }
1082
1083    /// Build the execution plan for a registered handler without running it.
1084    ///
1085    /// Executes the handler with every step method in recording mode: no
1086    /// command is spawned, no HTTP request is sent, no agent is called,
1087    /// nothing is persisted. Conditions declared with
1088    /// [`WorkflowContext::when`](crate::context::WorkflowContext::when) are
1089    /// evaluated against `payload`; those declared with
1090    /// [`WorkflowContext::when_dynamic`](crate::context::WorkflowContext::when_dynamic)
1091    /// are reported as unevaluable.
1092    ///
1093    /// Step outputs are synthetic and success-shaped, so the plan follows the
1094    /// nominal branch. A handler that unwraps a decision answer, or that
1095    /// deserializes `ctx.input::<T>()` against a payload it does not match,
1096    /// aborts the plan: the partial plan is returned with
1097    /// [`ExecutionPlan::incomplete_reason`] set.
1098    ///
1099    /// The handler is bounded twice: by
1100    /// [`MAX_PLANNED_STEPS`](crate::plan::MAX_PLANNED_STEPS) on every step
1101    /// kind, and by [`PlanOptions::timeout`]. Either one
1102    /// returns the partial plan, with [`ExecutionPlan::truncated`] and
1103    /// [`ExecutionPlan::incomplete_reason`] set, not an error. `ctx.store()`
1104    /// and `ctx.secrets()` return an error while planning.
1105    ///
1106    /// # Errors
1107    ///
1108    /// Returns [`EngineError::InvalidWorkflow`] when no handler is registered
1109    /// under `handler_name` or when `options.max_depth` is zero. Returns
1110    /// [`EngineError::Store`] when the duration-history query fails. A handler
1111    /// that errors mid-plan does **not** fail this call.
1112    ///
1113    /// # Examples
1114    ///
1115    /// ```no_run
1116    /// use ironflow_engine::engine::Engine;
1117    /// use ironflow_engine::error::EngineError;
1118    /// use ironflow_engine::plan::PlanOptions;
1119    /// use serde_json::json;
1120    ///
1121    /// # async fn example(engine: &Engine) -> Result<(), EngineError> {
1122    /// let plan = engine
1123    ///     .plan_handler("deploy", json!({"env": "prod"}), PlanOptions::default())
1124    ///     .await?;
1125    /// for step in &plan.steps {
1126    ///     println!("{} ({:?})", step.name, step.kind);
1127    /// }
1128    /// # Ok(())
1129    /// # }
1130    /// ```
1131    #[tracing::instrument(name = "engine.plan_handler", skip_all, fields(workflow = %handler_name))]
1132    pub async fn plan_handler(
1133        &self,
1134        handler_name: &str,
1135        payload: Value,
1136        options: PlanOptions,
1137    ) -> Result<ExecutionPlan, EngineError> {
1138        if options.max_depth == 0 {
1139            return Err(EngineError::InvalidWorkflow(
1140                "max_depth must be at least 1".to_string(),
1141            ));
1142        }
1143
1144        let handler = self
1145            .handlers
1146            .get(handler_name)
1147            .ok_or_else(|| {
1148                EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
1149            })?
1150            .clone();
1151
1152        let estimates = if options.estimate_durations {
1153            estimate_durations(&self.store, handler_name, options.sample_runs).await?
1154        } else {
1155            HashMap::new()
1156        };
1157
1158        let shared: SharedPlanRecorder = Arc::new(Mutex::new(PlanRecorder::new(
1159            handler_name.to_string(),
1160            payload,
1161            options.max_depth,
1162            estimates,
1163        )));
1164
1165        // Deliberately bare: no guard, no event bus, no log sender, no artifact
1166        // sink and no budget. Planning produces no side effect to report.
1167        let handlers = self.handlers.clone();
1168        let resolver: crate::context::HandlerResolver =
1169            Arc::new(move |name: &str| handlers.get(name).cloned());
1170        let mut ctx = WorkflowContext::with_handler_resolver(
1171            Uuid::now_v7(),
1172            handler_name.to_string(),
1173            self.store.clone(),
1174            self.provider.clone(),
1175            resolver,
1176        );
1177        ctx.set_plan(shared.clone());
1178
1179        match timeout(options.timeout, handler.execute(&mut ctx)).await {
1180            Ok(Ok(())) => {}
1181            Ok(Err(err)) => lock_plan(&shared).fail(err.to_string()),
1182            Err(_) => lock_plan(&shared).fail(format!(
1183                "plan timed out after {} s",
1184                options.timeout.as_secs_f64()
1185            )),
1186        }
1187        drop(ctx);
1188
1189        let plan = match Arc::try_unwrap(shared) {
1190            Ok(mutex) => mutex
1191                .into_inner()
1192                .unwrap_or_else(|poisoned| poisoned.into_inner())
1193                .into_plan(),
1194            Err(shared) => lock_plan(&shared).snapshot(),
1195        };
1196
1197        info!(
1198            workflow = %handler_name,
1199            steps = plan.steps.len(),
1200            truncated = plan.truncated,
1201            "execution plan built"
1202        );
1203
1204        Ok(plan)
1205    }
1206
1207    /// Enqueue a handler-based workflow for worker execution.
1208    ///
1209    /// The workflow name is stored in the run. The worker looks up the
1210    /// handler by name when executing.
1211    ///
1212    /// # Errors
1213    ///
1214    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered.
1215    /// Returns [`EngineError::MonthlyBudgetExceeded`] if the monthly cost quota
1216    /// is exhausted.
1217    #[tracing::instrument(name = "engine.enqueue_handler", skip_all, fields(workflow = %handler_name))]
1218    pub async fn enqueue_handler(
1219        &self,
1220        handler_name: &str,
1221        trigger: TriggerKind,
1222        payload: Value,
1223        max_retries: u32,
1224    ) -> Result<Run, EngineError> {
1225        self.enqueue_handler_with_options(
1226            handler_name,
1227            trigger,
1228            payload,
1229            EnqueueOptions {
1230                max_retries,
1231                ..Default::default()
1232            },
1233        )
1234        .await
1235        .map(RunCreation::into_run)
1236    }
1237
1238    /// Enqueue a handler-based workflow with labels, deferred scheduling, an
1239    /// optional cost cap, an optional author, and an optional idempotency key.
1240    ///
1241    /// See [`EnqueueOptions`] for the individual settings.
1242    ///
1243    /// When [`EnqueueOptions::idempotency_key`] is set and already bound to a run
1244    /// created within
1245    /// [`IDEMPOTENCY_WINDOW`](ironflow_store::entities::IDEMPOTENCY_WINDOW), nothing
1246    /// is enqueued and the original run is returned as [`RunCreation::Existing`].
1247    ///
1248    /// # Errors
1249    ///
1250    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered.
1251    /// Returns [`EngineError::MonthlyBudgetExceeded`] if the monthly cost quota
1252    /// is exhausted. Returns [`EngineError::ConcurrencyConflict`] if
1253    /// [`EnqueueOptions::concurrency_key`] is held by another non-terminal run.
1254    /// Returns [`EngineError::InvalidConcurrencyLimit`] if
1255    /// [`EnqueueOptions::concurrency_limits`] is invalid, before any other check.
1256    /// Returns [`EngineError::InvalidPriority`] if [`EnqueueOptions::priority`]
1257    /// is out of range, before any other check.
1258    /// Returns [`EngineError::InvalidWorkerTag`] if [`EnqueueOptions::worker_tags`]
1259    /// holds an invalid tag, checked right after the concurrency limits.
1260    /// Returns [`EngineError::Store`] if the run cannot be persisted.
1261    ///
1262    /// # Examples
1263    ///
1264    /// ```no_run
1265    /// use ironflow_engine::engine::{Engine, EnqueueOptions};
1266    /// use ironflow_store::models::TriggerKind;
1267    /// use serde_json::json;
1268    ///
1269    /// # async fn example(engine: &Engine) -> Result<(), ironflow_engine::error::EngineError> {
1270    /// let creation = engine
1271    ///     .enqueue_handler_with_options(
1272    ///         "deploy",
1273    ///         TriggerKind::Api,
1274    ///         json!({"env": "prod"}),
1275    ///         EnqueueOptions {
1276    ///             max_retries: 3,
1277    ///             idempotency_key: Some("github:abc-123".to_string()),
1278    ///             ..Default::default()
1279    ///         },
1280    ///     )
1281    ///     .await?;
1282    ///
1283    /// if creation.is_created() {
1284    ///     println!("enqueued {}", creation.run().id);
1285    /// }
1286    /// # Ok(())
1287    /// # }
1288    /// ```
1289    #[tracing::instrument(name = "engine.enqueue_handler_with_options", skip_all, fields(workflow = %handler_name))]
1290    pub async fn enqueue_handler_with_options(
1291        &self,
1292        handler_name: &str,
1293        trigger: TriggerKind,
1294        payload: Value,
1295        options: EnqueueOptions,
1296    ) -> Result<RunCreation, EngineError> {
1297        let EnqueueOptions {
1298            max_retries,
1299            labels,
1300            scheduled_at,
1301            max_cost_usd,
1302            created_by,
1303            idempotency_key,
1304            concurrency_key,
1305            concurrency_limits,
1306            priority,
1307            worker_tags,
1308        } = options;
1309
1310        // Checked first: a malformed request is the caller's error, whatever
1311        // the handler or the quota.
1312        validate_concurrency_limits(&concurrency_limits)
1313            .map_err(EngineError::InvalidConcurrencyLimit)?;
1314        if let Some(priority) = priority {
1315            validate_priority(priority).map_err(EngineError::InvalidPriority)?;
1316        }
1317        validate_worker_tags(&worker_tags).map_err(EngineError::InvalidWorkerTag)?;
1318
1319        let handler = self.handlers.get(handler_name).ok_or_else(|| {
1320            EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
1321        })?;
1322
1323        self.check_monthly_quota(handler_name).await?;
1324
1325        let handler_version = handler.version().map(str::to_string);
1326        let mut merged_labels = handler.default_labels();
1327        merged_labels.extend(labels);
1328        let resolved_cap = self
1329            .budget
1330            .resolve_run_cap(max_cost_usd, handler.default_max_cost_usd());
1331        let priority = priority.unwrap_or_else(|| clamp_priority(handler.priority()));
1332        let required_tags = normalize_worker_tags(
1333            handler
1334                .required_worker_tags()
1335                .into_iter()
1336                .chain(worker_tags),
1337        );
1338
1339        let creation = self
1340            .store
1341            .create_run(NewRun {
1342                workflow_name: handler_name.to_string(),
1343                trigger,
1344                payload,
1345                max_retries,
1346                handler_version,
1347                labels: merged_labels,
1348                scheduled_at,
1349                created_by,
1350                idempotency_key,
1351                concurrency_key,
1352                priority,
1353                concurrency_limits,
1354                max_cost_usd: resolved_cap,
1355                worker_tags: required_tags,
1356            })
1357            .await?;
1358
1359        match &creation {
1360            RunCreation::Created(run) => info!(
1361                run_id = %run.id,
1362                workflow = %handler_name,
1363                max_cost_usd = ?resolved_cap,
1364                "handler run enqueued"
1365            ),
1366            RunCreation::Existing(run) => info!(
1367                run_id = %run.id,
1368                workflow = %handler_name,
1369                "idempotent replay, nothing enqueued"
1370            ),
1371        }
1372
1373        Ok(creation)
1374    }
1375
1376    /// Execute a handler-based run (used by the worker after pick_next_pending).
1377    ///
1378    /// Looks up the handler by the run's `workflow_name` and executes it
1379    /// with a fresh [`WorkflowContext`], after
1380    /// [`AgentProvider::release_run`] has stopped whatever a previous
1381    /// execution of the run left running.
1382    ///
1383    /// # Errors
1384    ///
1385    /// Returns [`EngineError::InvalidWorkflow`] if no handler matches. A
1386    /// failed release fails the execution with [`EngineError::Operation`],
1387    /// replayed while the run has retries left. Returns
1388    /// [`EngineError::HandlerVersionMismatch`] when the handler's current
1389    /// version is incompatible with the run's `handler_version` -- checked
1390    /// before any step is replayed.
1391    ///
1392    /// A suspended child run of a sub-workflow is never executed on its own:
1393    /// its root run is resumed instead, and re-enters the child (see
1394    /// [`resume_run`](Self::resume_run)).
1395    #[tracing::instrument(name = "engine.execute_handler_run", skip_all, fields(run_id = %run_id))]
1396    pub async fn execute_handler_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
1397        let run = self
1398            .store
1399            .get_run(run_id)
1400            .await?
1401            .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
1402
1403        if let Some(root_run_id) = chain_root(&run) {
1404            return self.resume_chain(run, root_run_id).await;
1405        }
1406
1407        let _active = self.track_execution(run_id).await;
1408
1409        let handler = self
1410            .handlers
1411            .get(&run.workflow_name)
1412            .ok_or_else(|| {
1413                EngineError::InvalidWorkflow(format!(
1414                    "no handler registered: {}",
1415                    run.workflow_name
1416                ))
1417            })?
1418            .clone();
1419
1420        #[cfg(feature = "prometheus")]
1421        gauge!(RUNS_ACTIVE, "workflow" => run.workflow_name.clone()).increment(1.0);
1422
1423        let run_start = Instant::now();
1424        let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
1425
1426        // Replay the steps already persisted for this run: on a retry, an approval
1427        // a human already granted must not be asked again, and a run requeued to
1428        // `Pending` after its approval, human input or escalation resolved
1429        // (`ExecutionMode::Workers`, `retry_count` unchanged) must not re-run
1430        // completed steps. Neither must a run requeued by the reaper after its
1431        // worker lost the lease, which stays in the same attempt too. A
1432        // brand-new run has no steps, so this is a no-op.
1433        //
1434        // The handler version is checked first: replaying an incompatible
1435        // handler's steps risks serving one step's cached output to another
1436        // (`EngineError::ReplayDivergence`), so no step is replayed at all
1437        // when the handler changed incompatibly since the run was created.
1438        let result = if handler.is_version_compatible(run.handler_version.as_deref()) {
1439            ctx.load_replay_steps().await?;
1440            self.release_then_execute(run_id, handler.as_ref(), &mut ctx)
1441                .await
1442        } else {
1443            Err(EngineError::HandlerVersionMismatch {
1444                run_id,
1445                workflow_name: run.workflow_name.clone(),
1446                run_version: run
1447                    .handler_version
1448                    .clone()
1449                    .unwrap_or_else(|| "unknown".to_string()),
1450                current_version: handler
1451                    .version()
1452                    .map(str::to_string)
1453                    .unwrap_or_else(|| "unknown".to_string()),
1454            })
1455        };
1456
1457        self.finalize_run(
1458            run_id,
1459            &run.workflow_name,
1460            result,
1461            &ctx,
1462            run_start,
1463            run.labels,
1464        )
1465        .await
1466    }
1467
1468    /// Execute a run by its ID (used by the worker after pick_next_pending).
1469    ///
1470    /// Delegates to [`execute_handler_run`](Self::execute_handler_run).
1471    ///
1472    /// # Errors
1473    ///
1474    /// Returns [`EngineError`] if the run is not found or execution fails.
1475    #[tracing::instrument(name = "engine.execute_run", skip_all, fields(run_id = %run_id))]
1476    pub async fn execute_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
1477        self.execute_handler_run(run_id).await
1478    }
1479
1480    /// Resume a run after human approval.
1481    ///
1482    /// Re-executes the handler with step replay: completed steps return
1483    /// cached output, approved approval steps are skipped, and execution
1484    /// continues from the first unexecuted step.
1485    ///
1486    /// Supports multiple approval gates -- each resume replays all prior
1487    /// steps and stops at the next approval (or completes the run).
1488    ///
1489    /// When `run_id` is a child run of a sub-workflow, the root run of its
1490    /// chain is moved back to `Running` and resumed instead: it replays, and
1491    /// its open `Workflow` step re-enters the same child run. The returned
1492    /// result is the root run's.
1493    ///
1494    /// Like [`execute_handler_run`](Self::execute_handler_run), the handler
1495    /// only starts once [`AgentProvider::release_run`] has stopped whatever
1496    /// a previous execution of the run left running.
1497    ///
1498    /// # Errors
1499    ///
1500    /// Returns [`EngineError::InvalidWorkflow`] if no handler matches, or when
1501    /// the root run of a child cannot be resumed (it is running or finished:
1502    /// the child is then failed).
1503    /// Returns [`EngineError`] if execution fails or hits another approval.
1504    /// A failed release fails the execution with [`EngineError::Operation`].
1505    /// Returns [`EngineError::HandlerVersionMismatch`] when the handler's
1506    /// current version is incompatible with the run's `handler_version` --
1507    /// checked before any step is replayed.
1508    #[tracing::instrument(name = "engine.resume_run", skip_all, fields(run_id = %run_id))]
1509    pub async fn resume_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
1510        let run = self
1511            .store
1512            .get_run(run_id)
1513            .await?
1514            .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
1515
1516        if let Some(root_run_id) = chain_root(&run) {
1517            return self.resume_chain(run, root_run_id).await;
1518        }
1519
1520        self.resume_loaded_run(run).await
1521    }
1522
1523    /// Resume the root run of a suspended child run.
1524    ///
1525    /// The root waits without `scheduled_at` while its child is suspended, so
1526    /// nothing but this path ever wakes it. It is moved to `Running` and
1527    /// resumed; its replay re-enters the child. A root that is not suspended
1528    /// (already running, or finished) cannot take the child back: the child
1529    /// is failed.
1530    ///
1531    /// When the child holds a worker lease (it was picked by a worker), the
1532    /// lease is transferred to the root in the same update that moves it to
1533    /// `Running`, then released on the child: the worker keeps renewing the
1534    /// root it now executes, and the reaper recovers the root if that worker
1535    /// dies. A child without a lease (inline execution, API-side resume)
1536    /// leaves the root without one, as before.
1537    async fn resume_chain(
1538        &self,
1539        child: Run,
1540        root_run_id: Uuid,
1541    ) -> Result<WorkflowResult, EngineError> {
1542        let child_run_id = child.id;
1543        let lease = child.worker_id.zip(child.lease_expires_at);
1544        let root = self
1545            .store
1546            .get_run(root_run_id)
1547            .await?
1548            .ok_or(EngineError::Store(StoreError::RunNotFound(root_run_id)))?;
1549
1550        match root.status.state {
1551            RunStatus::AwaitingApproval | RunStatus::Pending => {
1552                self.move_root_to_running(root_run_id, lease.as_ref())
1553                    .await?;
1554            }
1555            RunStatus::Sleeping => {
1556                self.store
1557                    .update_run_status(root_run_id, RunStatus::Pending)
1558                    .await?;
1559                self.move_root_to_running(root_run_id, lease.as_ref())
1560                    .await?;
1561            }
1562            other => {
1563                let reason = format!(
1564                    "cannot resume child run {child_run_id}: root run {root_run_id} is {other}"
1565                );
1566                if let Err(err) = self
1567                    .fail_or_schedule_retry(child_run_id, &reason, false, None, None)
1568                    .await
1569                {
1570                    error!(
1571                        run_id = %child_run_id,
1572                        error = %err,
1573                        "failed to fail a child run whose root cannot resume"
1574                    );
1575                }
1576                return Err(EngineError::InvalidWorkflow(reason));
1577            }
1578        }
1579
1580        if lease.is_some() {
1581            self.store
1582                .update_run(
1583                    child_run_id,
1584                    RunUpdate {
1585                        lease: Some(LeaseUpdate::Release),
1586                        ..RunUpdate::default()
1587                    },
1588                )
1589                .await?;
1590        }
1591
1592        info!(
1593            run_id = %child_run_id,
1594            root_run_id = %root_run_id,
1595            lease_transferred = lease.is_some(),
1596            "child run resumed through its root run"
1597        );
1598
1599        let root = self
1600            .store
1601            .get_run(root_run_id)
1602            .await?
1603            .ok_or(EngineError::Store(StoreError::RunNotFound(root_run_id)))?;
1604        self.resume_loaded_run(root).await
1605    }
1606
1607    /// Move the suspended root run of a chain to `Running`.
1608    ///
1609    /// With `lease`, the root takes the worker lease of the child that
1610    /// resumes it, atomically with the transition, so it is never `Running`
1611    /// without an owner.
1612    async fn move_root_to_running(
1613        &self,
1614        root_run_id: Uuid,
1615        lease: Option<&(String, DateTime<Utc>)>,
1616    ) -> Result<(), EngineError> {
1617        match lease {
1618            Some((worker_id, expires_at)) => {
1619                self.store
1620                    .update_run(
1621                        root_run_id,
1622                        RunUpdate {
1623                            status: Some(RunStatus::Running),
1624                            lease: Some(LeaseUpdate::Set {
1625                                worker_id: worker_id.clone(),
1626                                expires_at: *expires_at,
1627                            }),
1628                            ..RunUpdate::default()
1629                        },
1630                    )
1631                    .await?;
1632            }
1633            None => {
1634                self.store
1635                    .update_run_status(root_run_id, RunStatus::Running)
1636                    .await?;
1637            }
1638        }
1639        Ok(())
1640    }
1641
1642    /// Resume `run`, already loaded and already `Running`.
1643    async fn resume_loaded_run(&self, run: Run) -> Result<WorkflowResult, EngineError> {
1644        let run_id = run.id;
1645        let _active = self.track_execution(run_id).await;
1646        let handler = self
1647            .handlers
1648            .get(&run.workflow_name)
1649            .ok_or_else(|| {
1650                EngineError::InvalidWorkflow(format!(
1651                    "no handler registered: {}",
1652                    run.workflow_name
1653                ))
1654            })?
1655            .clone();
1656
1657        info!(run_id = %run_id, workflow = %run.workflow_name, "resuming run after approval");
1658
1659        let run_start = Instant::now();
1660        let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
1661
1662        let result = if handler.is_version_compatible(run.handler_version.as_deref()) {
1663            ctx.load_replay_steps().await?;
1664            self.release_then_execute(run_id, handler.as_ref(), &mut ctx)
1665                .await
1666        } else {
1667            Err(EngineError::HandlerVersionMismatch {
1668                run_id,
1669                workflow_name: run.workflow_name.clone(),
1670                run_version: run
1671                    .handler_version
1672                    .clone()
1673                    .unwrap_or_else(|| "unknown".to_string()),
1674                current_version: handler
1675                    .version()
1676                    .map(str::to_string)
1677                    .unwrap_or_else(|| "unknown".to_string()),
1678            })
1679        };
1680
1681        self.finalize_run(
1682            run_id,
1683            &run.workflow_name,
1684            result,
1685            &ctx,
1686            run_start,
1687            run.labels,
1688        )
1689        .await
1690    }
1691
1692    /// Store a signal and resolve every step waiting for its `(name, key)`.
1693    ///
1694    /// Each waiting step validates the payload against the JSON schema it
1695    /// stored when it opened. A matching step is completed with the payload
1696    /// and its run, if `Sleeping`, goes back to `Pending`: under
1697    /// [`ExecutionMode::Local`] it resumes in a background task, under
1698    /// [`ExecutionMode::Workers`] a worker picks it up. A step whose schema
1699    /// the payload does not match keeps waiting and is listed in
1700    /// [`SignalDelivery::rejected`].
1701    ///
1702    /// The signal is stored even when nobody waits for it: a run opening its
1703    /// wait step later still finds it. A signal whose `idempotency_id` was
1704    /// already used is not stored nor delivered again, and comes back with
1705    /// [`SignalDelivery::duplicate`] set.
1706    ///
1707    /// Publishes [`Event::SignalReceived`] for every stored signal.
1708    ///
1709    /// # Errors
1710    ///
1711    /// Returns [`EngineError::InvalidSignal`] when `name` or `key` is empty,
1712    /// and [`EngineError::Store`] when the signal cannot be stored or its
1713    /// waiters cannot be listed.
1714    ///
1715    /// # Examples
1716    ///
1717    /// ```no_run
1718    /// use std::sync::Arc;
1719    /// use ironflow_engine::engine::Engine;
1720    /// use ironflow_engine::error::EngineError;
1721    /// use ironflow_store::entities::NewSignal;
1722    /// use serde_json::json;
1723    ///
1724    /// # async fn example(engine: Arc<Engine>) -> Result<(), EngineError> {
1725    /// let delivery = engine
1726    ///     .deliver_signal(NewSignal {
1727    ///         name: "ci.pipeline_finished".to_string(),
1728    ///         key: "4f2a9c1".to_string(),
1729    ///         payload: json!({"status": "success"}),
1730    ///         idempotency_id: Some("delivery-42".to_string()),
1731    ///     })
1732    ///     .await?;
1733    /// println!("{} runs resumed", delivery.resumed.len());
1734    /// # Ok(())
1735    /// # }
1736    /// ```
1737    pub async fn deliver_signal(
1738        self: &Arc<Self>,
1739        signal: NewSignal,
1740    ) -> Result<SignalDelivery, EngineError> {
1741        if signal.name.trim().is_empty() {
1742            return Err(EngineError::InvalidSignal(
1743                "signal name must not be empty".to_string(),
1744            ));
1745        }
1746        if signal.key.trim().is_empty() {
1747            return Err(EngineError::InvalidSignal(
1748                "signal key must not be empty".to_string(),
1749            ));
1750        }
1751
1752        let stored = match self.store.insert_signal(signal).await? {
1753            SignalInsert::Created(stored) => stored,
1754            SignalInsert::Duplicate(existing) => {
1755                info!(
1756                    signal_id = %existing.id,
1757                    signal = %existing.name,
1758                    key = %existing.key,
1759                    "duplicate signal ignored"
1760                );
1761                return Ok(SignalDelivery {
1762                    signal_id: existing.id,
1763                    duplicate: true,
1764                    resumed: Vec::new(),
1765                    rejected: Vec::new(),
1766                });
1767            }
1768        };
1769
1770        let waiters = self
1771            .store
1772            .list_signal_waiters(&stored.name, &stored.key)
1773            .await?;
1774        let mut resumed = Vec::new();
1775        let mut rejected = Vec::new();
1776
1777        for step in waiters {
1778            if let Err(error) = validate_step_payload(step.input.as_ref(), &stored.payload) {
1779                rejected.push(SignalRejected {
1780                    run_id: step.run_id,
1781                    step_id: step.id,
1782                    error,
1783                });
1784                continue;
1785            }
1786
1787            match self
1788                .store
1789                .resolve_signal_step(step.id, received_output(&stored))
1790                .await
1791            {
1792                Ok(SignalStepResolution::Resolved {
1793                    run_id,
1794                    run_resumed,
1795                }) => {
1796                    resumed.push(SignalResumed {
1797                        run_id,
1798                        step_id: step.id,
1799                    });
1800                    if run_resumed && self.execution_mode == ExecutionMode::Local {
1801                        self.spawn_local_resume(run_id);
1802                    }
1803                }
1804                // A concurrent delivery or the timeout resolved it first.
1805                Ok(SignalStepResolution::NotWaiting { .. }) => {}
1806                Err(err) => {
1807                    error!(
1808                        run_id = %step.run_id,
1809                        step_id = %step.id,
1810                        error = %err,
1811                        "failed to resolve a waiting signal step"
1812                    );
1813                    rejected.push(SignalRejected {
1814                        run_id: step.run_id,
1815                        step_id: step.id,
1816                        error: err.to_string(),
1817                    });
1818                }
1819            }
1820        }
1821
1822        info!(
1823            signal_id = %stored.id,
1824            signal = %stored.name,
1825            key = %stored.key,
1826            resumed = resumed.len(),
1827            rejected = rejected.len(),
1828            "signal received"
1829        );
1830        self.event_publisher
1831            .publish(Event::SignalReceived(SignalReceivedEvent {
1832                signal_id: stored.id,
1833                name: stored.name.clone(),
1834                key: stored.key.clone(),
1835                resumed_runs: resumed.iter().map(|r| r.run_id).collect(),
1836                at: stored.received_at,
1837            }));
1838
1839        Ok(SignalDelivery {
1840            signal_id: stored.id,
1841            duplicate: false,
1842            resumed,
1843            rejected,
1844        })
1845    }
1846
1847    /// Send a typed signal: shorthand for [`deliver_signal`](Self::deliver_signal)
1848    /// with `S::NAME` as the name and `signal` as the payload.
1849    ///
1850    /// `key` identifies the occurrence (a commit SHA, an order ID).
1851    /// `idempotency_id`, when set, makes a redelivery of the same event (a
1852    /// webhook retried by its sender) a no-op.
1853    ///
1854    /// # Errors
1855    ///
1856    /// Returns [`EngineError::Serialization`] when `signal` cannot be
1857    /// serialized, and every error of [`deliver_signal`](Self::deliver_signal).
1858    ///
1859    /// # Examples
1860    ///
1861    /// ```no_run
1862    /// use std::sync::Arc;
1863    /// use ironflow_engine::engine::Engine;
1864    /// use ironflow_engine::error::EngineError;
1865    /// use ironflow_engine::signal::Signal;
1866    /// use schemars::JsonSchema;
1867    /// use serde::{Deserialize, Serialize};
1868    ///
1869    /// #[derive(Serialize, Deserialize, JsonSchema)]
1870    /// struct PipelineFinished {
1871    ///     status: String,
1872    /// }
1873    ///
1874    /// impl Signal for PipelineFinished {
1875    ///     const NAME: &'static str = "ci.pipeline_finished";
1876    /// }
1877    ///
1878    /// # async fn example(engine: Arc<Engine>) -> Result<(), EngineError> {
1879    /// let finished = PipelineFinished { status: "success".to_string() };
1880    /// engine.send_signal(&finished, "4f2a9c1", Some("delivery-42")).await?;
1881    /// # Ok(())
1882    /// # }
1883    /// ```
1884    pub async fn send_signal<S: Signal>(
1885        self: &Arc<Self>,
1886        signal: &S,
1887        key: &str,
1888        idempotency_id: Option<&str>,
1889    ) -> Result<SignalDelivery, EngineError> {
1890        let payload = to_value(signal)?;
1891        self.deliver_signal(NewSignal {
1892            name: S::NAME.to_string(),
1893            key: key.to_string(),
1894            payload,
1895            idempotency_id: idempotency_id.map(str::to_string),
1896        })
1897        .await
1898    }
1899
1900    /// Resume a run requeued to `Pending` in a background task.
1901    ///
1902    /// Used under [`ExecutionMode::Local`], where no worker would pick the
1903    /// run up. The state change already happened, so a failed resume is
1904    /// logged, not rolled back.
1905    ///
1906    /// The local resumes of a run are serialized: a task reads the run only
1907    /// once the previous local resume of it has returned, so two tasks never
1908    /// both act on the same read. The task then waits for the execution of
1909    /// the run still in flight in this process, if any: a run paused then
1910    /// resumed while a step runs would otherwise be replayed next to the
1911    /// execution that has not noticed the pause yet. Once it is gone, the run
1912    /// is read again:
1913    ///
1914    /// - `Pending` is claimed with the `Pending -> Running` transition, a
1915    ///   compare-and-set in every store, the one a worker performs when it
1916    ///   picks a run. Only a successful claim resumes it here: a run another
1917    ///   executor claimed first is left to it.
1918    /// - `Running` with a worker lease is owned by that worker and left alone.
1919    /// - `Running` without a lease was left unfinished by the previous
1920    ///   execution in this process and is resumed as is, unless an execution
1921    ///   of it is still running here.
1922    /// - any other status means the previous execution finished the run or it
1923    ///   was taken elsewhere, and nothing is done.
1924    pub(crate) fn spawn_local_resume(self: &Arc<Self>, run_id: Uuid) {
1925        let engine = Arc::clone(self);
1926        spawn(async move {
1927            // One local resume of a run at a time, held until its execution
1928            // returns: a sibling task reads the run only once this one is done,
1929            // so it never sees the claim of a resume that has not started yet.
1930            let _resuming = lock_run(&engine.local_resumes, run_id).await;
1931            engine.wait_until_idle(run_id).await;
1932            let run = match engine.store.get_run(run_id).await {
1933                Ok(Some(run)) => run,
1934                Ok(None) => {
1935                    error!(run_id = %run_id, "run to restart not found");
1936                    return;
1937                }
1938                Err(err) => {
1939                    error!(run_id = %run_id, error = %err, "failed to load a run to restart");
1940                    return;
1941                }
1942            };
1943            match run.status.state {
1944                RunStatus::Pending => {
1945                    match engine
1946                        .store
1947                        .update_run_status(run_id, RunStatus::Running)
1948                        .await
1949                    {
1950                        Ok(()) => {}
1951                        Err(StoreError::InvalidTransition { from, .. }) => {
1952                            info!(
1953                                run_id = %run_id,
1954                                status = %from,
1955                                "run claimed elsewhere, left to its worker"
1956                            );
1957                            return;
1958                        }
1959                        Err(err) => {
1960                            error!(run_id = %run_id, error = %err, "failed to restart a woken run");
1961                            return;
1962                        }
1963                    }
1964                }
1965                RunStatus::Running if run.worker_id.is_some() => {
1966                    info!(
1967                        run_id = %run_id,
1968                        worker_id = ?run.worker_id,
1969                        "run owned by a worker lease, local resume skipped"
1970                    );
1971                    return;
1972                }
1973                RunStatus::Running => {
1974                    if engine.is_executing(run_id) {
1975                        info!(run_id = %run_id, "run already executing in this process, resume skipped");
1976                        return;
1977                    }
1978                }
1979                status => {
1980                    info!(
1981                        run_id = %run_id,
1982                        status = %status,
1983                        "run no longer waiting to restart, resume skipped"
1984                    );
1985                    return;
1986                }
1987            }
1988            if let Err(err) = engine.resume_run(run_id).await {
1989                error!(run_id = %run_id, error = %err, "failed to resume a woken run");
1990            }
1991        });
1992    }
1993
1994    /// Record a run failure, replaying the run later when retries remain.
1995    ///
1996    /// This is the single place where a failed run's fate is decided. When
1997    /// `retryable` is true and the run has not exhausted `max_retries`, the run
1998    /// moves to [`RunStatus::Retrying`] with `scheduled_at` set to
1999    /// `now + backoff` -- [`pick_next_pending`](ironflow_store::store::RunStore::pick_next_pending)
2000    /// picks it up again once that time has passed. Otherwise the run moves to
2001    /// [`RunStatus::Failed`].
2002    ///
2003    /// Either way, steps left non-terminal by the failed attempt are closed via
2004    /// [`fail_orphaned_steps`](Self::fail_orphaned_steps) so they are never
2005    /// confused with the next attempt's steps, and the sub-workflow runs it
2006    /// left non-terminal are cancelled by
2007    /// [`cancel_descendants`](Self::cancel_descendants) (a failure there is
2008    /// logged, not returned).
2009    ///
2010    /// Callers pass `retryable` explicitly rather than an error value, because
2011    /// the worker classifies failures it observes from the outside (a timeout, a
2012    /// panicked task) that never produce an [`EngineError`]. Use
2013    /// [`is_run_retryable`] to classify an
2014    /// [`EngineError`].
2015    ///
2016    /// `cost_usd` and `duration_ms` are `None` when the caller does not know the
2017    /// totals (a timeout or a panic observed from outside the handler); the
2018    /// values already stored on the run are then left untouched.
2019    ///
2020    /// A run an operator paused meanwhile is left untouched and
2021    /// [`RunStatus::Paused`] is returned: the resume replays the attempt.
2022    ///
2023    /// Returns the status the run was moved to.
2024    ///
2025    /// # Errors
2026    ///
2027    /// Returns [`EngineError::Store`] if the run does not exist or the update
2028    /// cannot be persisted.
2029    ///
2030    /// # Examples
2031    ///
2032    /// ```no_run
2033    /// use ironflow_engine::engine::Engine;
2034    /// use ironflow_engine::error::EngineError;
2035    /// use uuid::Uuid;
2036    ///
2037    /// # async fn example(engine: &Engine, run_id: Uuid) -> Result<(), EngineError> {
2038    /// let status = engine
2039    ///     .fail_or_schedule_retry(run_id, "run timed out after 300s", true, None, None)
2040    ///     .await?;
2041    /// # Ok(())
2042    /// # }
2043    /// ```
2044    pub async fn fail_or_schedule_retry(
2045        &self,
2046        run_id: Uuid,
2047        error: &str,
2048        retryable: bool,
2049        cost_usd: Option<Decimal>,
2050        duration_ms: Option<u64>,
2051    ) -> Result<RunStatus, EngineError> {
2052        let run = self
2053            .store
2054            .get_run(run_id)
2055            .await?
2056            .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
2057
2058        // An operator paused the run: its fate is decided by the resume or
2059        // the cancellation, not by the attempt that stopped.
2060        if run.status.state == RunStatus::Paused {
2061            info!(run_id = %run_id, error = %error, "run paused, failure not recorded");
2062            return Ok(RunStatus::Paused);
2063        }
2064
2065        let has_attempts_left = run.retry_count < run.max_retries;
2066        let update = if retryable && has_attempts_left {
2067            let backoff = backoff_for_retry(run.retry_count);
2068            let scheduled_at = Utc::now() + TimeDelta::milliseconds(backoff.as_millis() as i64);
2069
2070            info!(
2071                run_id = %run_id,
2072                workflow = %run.workflow_name,
2073                attempt = run.retry_count + 1,
2074                max_retries = run.max_retries,
2075                backoff_secs = backoff.as_secs(),
2076                scheduled_at = %scheduled_at,
2077                "run failed, scheduling retry"
2078            );
2079
2080            RunUpdate {
2081                status: Some(RunStatus::Retrying),
2082                error: Some(error.to_string()),
2083                increment_retry: true,
2084                cost_usd,
2085                duration_ms,
2086                scheduled_at: Some(scheduled_at),
2087                ..RunUpdate::default()
2088            }
2089        } else {
2090            RunUpdate {
2091                status: Some(RunStatus::Failed),
2092                error: Some(error.to_string()),
2093                cost_usd,
2094                duration_ms,
2095                completed_at: Some(Utc::now()),
2096                ..RunUpdate::default()
2097            }
2098        };
2099
2100        let status = update.status.unwrap_or(RunStatus::Failed);
2101        self.store.update_run(run_id, update).await?;
2102        self.fail_orphaned_steps(run_id, error).await?;
2103        // The attempt is over: a retry starts new children, and nothing drives
2104        // those this attempt left running.
2105        self.cancel_descendants_of_stopped_run(run_id, error).await;
2106
2107        Ok(status)
2108    }
2109
2110    /// Mark the steps of a run requeued after a lost worker lease as interrupted.
2111    ///
2112    /// Every `Running` step is marked `Failed` with
2113    /// [`STEP_INTERRUPTED_ERROR`](ironflow_store::store::STEP_INTERRUPTED_ERROR).
2114    /// The run keeps its attempt number, so when it is picked up again its
2115    /// finished steps are replayed and each interrupted step is executed again
2116    /// at the same position; an interrupted `Workflow` step re-enters the same
2117    /// child run. `Pending` and `AwaitingApproval` steps are left as they are,
2118    /// unlike [`fail_orphaned_steps`](Self::fail_orphaned_steps), which ends
2119    /// the run's steps for good.
2120    ///
2121    /// Errors from individual step updates are logged but do not abort the cleanup.
2122    ///
2123    /// # Errors
2124    ///
2125    /// Returns [`EngineError`] if listing steps fails.
2126    ///
2127    /// # Examples
2128    ///
2129    /// ```no_run
2130    /// use ironflow_engine::engine::Engine;
2131    /// use ironflow_engine::error::EngineError;
2132    /// use uuid::Uuid;
2133    ///
2134    /// # async fn example(engine: &Engine, run_id: Uuid) -> Result<(), EngineError> {
2135    /// engine.interrupt_running_steps(run_id).await?;
2136    /// # Ok(())
2137    /// # }
2138    /// ```
2139    pub async fn interrupt_running_steps(&self, run_id: Uuid) -> Result<(), EngineError> {
2140        interrupt_running_steps(self.store.as_ref(), run_id).await
2141    }
2142
2143    /// Fail all non-terminal steps for a run.
2144    ///
2145    /// Called after a run is marked as failed (timeout, error, panic) to clean up
2146    /// orphaned steps that are still in `Running`, `Pending`, or `AwaitingApproval`.
2147    ///
2148    /// - `Running` / `AwaitingApproval` steps are marked `Failed`.
2149    /// - `Pending` steps are marked `Skipped` (FSM does not allow Pending -> Failed).
2150    ///
2151    /// Errors from individual step updates are logged but do not abort the cleanup.
2152    ///
2153    /// # Errors
2154    ///
2155    /// Returns [`EngineError`] if listing steps fails.
2156    pub async fn fail_orphaned_steps(
2157        &self,
2158        run_id: Uuid,
2159        error_message: &str,
2160    ) -> Result<(), EngineError> {
2161        let steps = self.store.list_steps(run_id).await?;
2162        let now = Utc::now();
2163
2164        for step in steps {
2165            if step.status.state.is_terminal() {
2166                continue;
2167            }
2168
2169            let (target_status, error) = match step.status.state {
2170                StepStatus::Running | StepStatus::AwaitingApproval => {
2171                    let err = if step.error.is_some() {
2172                        None
2173                    } else {
2174                        Some(error_message.to_string())
2175                    };
2176                    (StepStatus::Failed, err)
2177                }
2178                StepStatus::Pending => (StepStatus::Skipped, None),
2179                _ => continue,
2180            };
2181
2182            if let Err(e) = self
2183                .store
2184                .update_step(
2185                    step.id,
2186                    StepUpdate {
2187                        status: Some(target_status),
2188                        error,
2189                        completed_at: Some(now),
2190                        ..StepUpdate::default()
2191                    },
2192                )
2193                .await
2194            {
2195                warn!(
2196                    run_id = %run_id,
2197                    step_id = %step.id,
2198                    step_name = %step.name,
2199                    error = %e,
2200                    "failed to cleanup orphaned step"
2201                );
2202            } else {
2203                info!(
2204                    run_id = %run_id,
2205                    step_id = %step.id,
2206                    step_name = %step.name,
2207                    from = %step.status.state,
2208                    to = %target_status,
2209                    "cleaned up orphaned step"
2210                );
2211            }
2212        }
2213
2214        Ok(())
2215    }
2216
2217    /// Execute `handler` once [`AgentProvider::release_run`] has stopped
2218    /// whatever a previous execution of the run left running (an agent pod
2219    /// writing to a shared worktree). A failed release fails the execution
2220    /// before its first step, with [`EngineError::Operation`].
2221    async fn release_then_execute(
2222        &self,
2223        run_id: Uuid,
2224        handler: &dyn WorkflowHandler,
2225        ctx: &mut WorkflowContext,
2226    ) -> Result<(), EngineError> {
2227        match self.provider.release_run(&run_id.to_string()).await {
2228            Ok(()) => handler.execute(ctx).await,
2229            Err(e) => Err(EngineError::Operation(OperationError::Agent(e))),
2230        }
2231    }
2232
2233    /// Finalize a run with the given result and context.
2234    ///
2235    /// On success: updates run to Completed with cost, duration, and completed_at.
2236    /// On failure: updates run to Failed with error, cost, duration, and completed_at.
2237    /// Always: fetches and returns the final Run.
2238    async fn finalize_run(
2239        &self,
2240        run_id: Uuid,
2241        workflow_name: &str,
2242        result: Result<(), EngineError>,
2243        ctx: &WorkflowContext,
2244        run_start: Instant,
2245        run_labels: HashMap<String, String>,
2246    ) -> Result<WorkflowResult, EngineError> {
2247        // Covers the whole run: previous attempts plus this one, so a retried
2248        // run reports the time it really consumed.
2249        let total_duration = ctx.carried_duration_ms() + run_start.elapsed().as_millis() as u64;
2250        let completed_at = Utc::now();
2251
2252        // Paused while it ran: whatever the handler returned, the run stays
2253        // paused for the operator. Only what this execution spent is kept;
2254        // the resume replays from the first step that did not complete.
2255        if let Some(run) = self.store.get_run(run_id).await?
2256            && run.status.state == RunStatus::Paused
2257        {
2258            let run = self
2259                .store
2260                .update_run_returning(
2261                    run_id,
2262                    RunUpdate {
2263                        cost_usd: Some(ctx.total_cost_usd()),
2264                        duration_ms: Some(total_duration),
2265                        ..RunUpdate::default()
2266                    },
2267                )
2268                .await?;
2269            info!(
2270                run_id = %run_id,
2271                outcome = ?result.err().map(|err| err.to_string()),
2272                "run paused, execution stopped"
2273            );
2274            return Ok(WorkflowResult {
2275                run,
2276                steps: ctx.step_results().to_vec(),
2277            });
2278        }
2279
2280        // The pause stopped this execution but the run was resumed since: the
2281        // resume continues or restarts it, so nothing is recorded here.
2282        if matches!(result, Err(EngineError::RunPaused { .. })) {
2283            let run = self
2284                .store
2285                .get_run(run_id)
2286                .await?
2287                .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
2288            info!(
2289                run_id = %run_id,
2290                status = %run.status.state,
2291                "run resumed after the pause, execution stopped"
2292            );
2293            return Ok(WorkflowResult {
2294                run,
2295                steps: ctx.step_results().to_vec(),
2296            });
2297        }
2298
2299        let final_status;
2300        let final_run;
2301
2302        match result {
2303            Ok(()) => {
2304                final_status = if ctx.has_allowed_failure() {
2305                    RunStatus::Warning
2306                } else {
2307                    RunStatus::Completed
2308                };
2309                final_run = self
2310                    .store
2311                    .update_run_returning(
2312                        run_id,
2313                        RunUpdate {
2314                            status: Some(final_status),
2315                            cost_usd: Some(ctx.total_cost_usd()),
2316                            duration_ms: Some(total_duration),
2317                            completed_at: Some(completed_at),
2318                            output: ctx.output().cloned(),
2319                            ..RunUpdate::default()
2320                        },
2321                    )
2322                    .await?;
2323
2324                info!(
2325                    run_id = %run_id,
2326                    status = %final_status,
2327                    cost_usd = %ctx.total_cost_usd(),
2328                    duration_ms = total_duration,
2329                    "run completed"
2330                );
2331            }
2332            Err(EngineError::ApprovalRequired {
2333                run_id: approval_run_id,
2334                step_id,
2335                ref message,
2336            }) => {
2337                final_status = RunStatus::AwaitingApproval;
2338                final_run = self
2339                    .store
2340                    .update_run_returning(
2341                        run_id,
2342                        RunUpdate {
2343                            status: Some(RunStatus::AwaitingApproval),
2344                            cost_usd: Some(ctx.total_cost_usd()),
2345                            duration_ms: Some(total_duration),
2346                            ..RunUpdate::default()
2347                        },
2348                    )
2349                    .await?;
2350
2351                info!(
2352                    run_id = %approval_run_id,
2353                    step_id = %step_id,
2354                    message = %message,
2355                    "run awaiting approval"
2356                );
2357
2358                self.publish_approval_requested(approval_run_id, step_id, message)
2359                    .await?;
2360            }
2361            Err(EngineError::ChildSuspended {
2362                run_id: child_run_id,
2363                ref cause,
2364            }) => {
2365                final_status = cause.suspension_status();
2366                // No `scheduled_at`: the suspended descendant owns the wake-up
2367                // and resumes this run through `resume_chain`. A wake-up armed
2368                // here too would resume the chain twice.
2369                final_run = self
2370                    .store
2371                    .update_run_returning(
2372                        run_id,
2373                        RunUpdate {
2374                            status: Some(final_status),
2375                            cost_usd: Some(ctx.total_cost_usd()),
2376                            duration_ms: Some(total_duration),
2377                            ..RunUpdate::default()
2378                        },
2379                    )
2380                    .await?;
2381
2382                let leaf = cause.suspension_leaf();
2383                info!(
2384                    run_id = %run_id,
2385                    child_run_id = %child_run_id,
2386                    status = %final_status,
2387                    cause = %leaf,
2388                    "run suspended with its child run"
2389                );
2390
2391                match leaf {
2392                    EngineError::ApprovalRequired {
2393                        run_id: approval_run_id,
2394                        step_id,
2395                        message,
2396                    } => {
2397                        self.publish_approval_requested(*approval_run_id, *step_id, message)
2398                            .await?;
2399                    }
2400                    EngineError::SignalWaiting {
2401                        run_id: wait_run_id,
2402                        step_id,
2403                        step_name,
2404                        name,
2405                        key,
2406                        deadline_at,
2407                    } => {
2408                        self.event_publisher
2409                            .publish(Event::SignalAwaited(SignalAwaitedEvent {
2410                                run_id: *wait_run_id,
2411                                step_id: *step_id,
2412                                step_name: step_name.clone(),
2413                                name: name.clone(),
2414                                key: key.clone(),
2415                                deadline_at: *deadline_at,
2416                                at: Utc::now(),
2417                            }));
2418                    }
2419                    // A human input or a delay publishes no suspension event,
2420                    // like on a top-level run.
2421                    _ => {}
2422                }
2423            }
2424            Err(EngineError::HumanInputRequired {
2425                run_id: input_run_id,
2426                step_id,
2427                ref message,
2428            }) => {
2429                final_status = RunStatus::AwaitingApproval;
2430                final_run = self
2431                    .store
2432                    .update_run_returning(
2433                        run_id,
2434                        RunUpdate {
2435                            status: Some(RunStatus::AwaitingApproval),
2436                            cost_usd: Some(ctx.total_cost_usd()),
2437                            duration_ms: Some(total_duration),
2438                            ..RunUpdate::default()
2439                        },
2440                    )
2441                    .await?;
2442
2443                // No `ApprovalRequested` event: a human input is not an approval.
2444                info!(
2445                    run_id = %input_run_id,
2446                    step_id = %step_id,
2447                    message = %message,
2448                    "run awaiting human input"
2449                );
2450            }
2451            Err(EngineError::DelaySleeping {
2452                run_id: delay_run_id,
2453                step_id,
2454                wake_at,
2455            }) => {
2456                final_status = RunStatus::Sleeping;
2457                final_run = self
2458                    .store
2459                    .update_run_returning(
2460                        run_id,
2461                        RunUpdate {
2462                            status: Some(RunStatus::Sleeping),
2463                            cost_usd: Some(ctx.total_cost_usd()),
2464                            duration_ms: Some(total_duration),
2465                            scheduled_at: Some(wake_at),
2466                            ..RunUpdate::default()
2467                        },
2468                    )
2469                    .await?;
2470
2471                info!(
2472                    run_id = %delay_run_id,
2473                    step_id = %step_id,
2474                    wake_at = %wake_at,
2475                    "run sleeping until delay elapses"
2476                );
2477            }
2478            Err(EngineError::CapacitySleeping {
2479                run_id: capacity_run_id,
2480                step_id,
2481                ref kind,
2482                wake_at,
2483            }) => {
2484                final_status = RunStatus::Sleeping;
2485                final_run = self
2486                    .store
2487                    .update_run_returning(
2488                        run_id,
2489                        RunUpdate {
2490                            status: Some(RunStatus::Sleeping),
2491                            cost_usd: Some(ctx.total_cost_usd()),
2492                            duration_ms: Some(total_duration),
2493                            scheduled_at: Some(wake_at),
2494                            capacity_wait_kind: Some(ProviderKind::new(kind.as_str())),
2495                            ..RunUpdate::default()
2496                        },
2497                    )
2498                    .await?;
2499
2500                info!(
2501                    run_id = %capacity_run_id,
2502                    step_id = %step_id,
2503                    kind = %kind,
2504                    wake_at = %wake_at,
2505                    "run sleeping until provider capacity returns"
2506                );
2507            }
2508            Err(EngineError::SignalWaiting {
2509                run_id: wait_run_id,
2510                step_id,
2511                ref step_name,
2512                ref name,
2513                ref key,
2514                deadline_at,
2515            }) => {
2516                final_status = RunStatus::Sleeping;
2517                // Atomic with the step lock: a signal delivered since the step
2518                // opened leaves the run due right away instead of until the
2519                // deadline.
2520                let waiting = self
2521                    .store
2522                    .suspend_run_on_signal(run_id, step_id, deadline_at)
2523                    .await?;
2524                final_run = self
2525                    .store
2526                    .update_run_returning(
2527                        run_id,
2528                        RunUpdate {
2529                            cost_usd: Some(ctx.total_cost_usd()),
2530                            duration_ms: Some(total_duration),
2531                            ..RunUpdate::default()
2532                        },
2533                    )
2534                    .await?;
2535
2536                if waiting {
2537                    self.event_publisher
2538                        .publish(Event::SignalAwaited(SignalAwaitedEvent {
2539                            run_id: wait_run_id,
2540                            step_id,
2541                            step_name: step_name.clone(),
2542                            name: name.clone(),
2543                            key: key.clone(),
2544                            deadline_at,
2545                            at: Utc::now(),
2546                        }));
2547                }
2548
2549                info!(
2550                    run_id = %wait_run_id,
2551                    step_id = %step_id,
2552                    signal = %name,
2553                    key = %key,
2554                    deadline_at = %deadline_at,
2555                    waiting,
2556                    "run sleeping until a signal arrives"
2557                );
2558            }
2559            Err(err) => {
2560                // A guardrail stop (budget or workflow guard) is deliberate,
2561                // not a breakage: the run is cancelled, never failed and
2562                // never replayed.
2563                let guardrail_stop = matches!(
2564                    err,
2565                    EngineError::RunBudgetExceeded { .. } | EngineError::WorkflowGuardRejected(_)
2566                );
2567
2568                final_status = if guardrail_stop {
2569                    if let Err(store_err) = self
2570                        .store
2571                        .update_run(
2572                            run_id,
2573                            RunUpdate {
2574                                status: Some(RunStatus::Cancelled),
2575                                error: Some(err.to_string()),
2576                                cost_usd: Some(ctx.total_cost_usd()),
2577                                duration_ms: Some(total_duration),
2578                                completed_at: Some(completed_at),
2579                                output: ctx.output().cloned(),
2580                                ..RunUpdate::default()
2581                            },
2582                        )
2583                        .await
2584                    {
2585                        error!(run_id = %run_id, store_error = %store_err, "failed to persist run cancellation");
2586                    }
2587                    if let Err(cleanup_err) = self
2588                        .fail_orphaned_steps(run_id, "run stopped: guardrail limit reached")
2589                        .await
2590                    {
2591                        error!(run_id = %run_id, store_error = %cleanup_err, "failed to cleanup orphaned steps");
2592                    }
2593                    RunStatus::Cancelled
2594                } else {
2595                    // Written before the failure so a failed run keeps the
2596                    // verdict the handler set before returning its error.
2597                    if let Some(output) = ctx.output()
2598                        && let Err(store_err) = self
2599                            .store
2600                            .update_run(
2601                                run_id,
2602                                RunUpdate {
2603                                    output: Some(output.clone()),
2604                                    ..RunUpdate::default()
2605                                },
2606                            )
2607                            .await
2608                    {
2609                        error!(run_id = %run_id, store_error = %store_err, "failed to persist run output");
2610                    }
2611                    self.fail_or_schedule_retry(
2612                        run_id,
2613                        &err.to_string(),
2614                        is_run_retryable(&err),
2615                        Some(ctx.total_cost_usd()),
2616                        Some(total_duration),
2617                    )
2618                    .await
2619                    .unwrap_or_else(|store_err| {
2620                        error!(run_id = %run_id, store_error = %store_err, "failed to persist run failure");
2621                        RunStatus::Failed
2622                    })
2623                };
2624
2625                if matches!(err, EngineError::RunBudgetExceeded { .. }) {
2626                    self.on_run_budget_exceeded(workflow_name, run_id, &err);
2627                }
2628
2629                error!(run_id = %run_id, status = %final_status, error = %err, "run stopped");
2630
2631                self.publish_run_status_changed(
2632                    workflow_name,
2633                    run_id,
2634                    final_status,
2635                    Some(err.to_string()),
2636                    ctx,
2637                    total_duration,
2638                    run_labels,
2639                );
2640
2641                #[cfg(feature = "prometheus")]
2642                self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
2643
2644                return Err(err);
2645            }
2646        }
2647
2648        self.publish_run_status_changed(
2649            workflow_name,
2650            run_id,
2651            final_status,
2652            None,
2653            ctx,
2654            total_duration,
2655            run_labels,
2656        );
2657
2658        #[cfg(feature = "prometheus")]
2659        self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
2660
2661        Ok(WorkflowResult {
2662            run: final_run,
2663            steps: ctx.step_results().to_vec(),
2664        })
2665    }
2666
2667    /// Publish [`Event::ApprovalRequested`] for an approval gate that opened
2668    /// on `run_id`, with the requirement recorded when the gate opened.
2669    async fn publish_approval_requested(
2670        &self,
2671        run_id: Uuid,
2672        step_id: Uuid,
2673        message: &str,
2674    ) -> Result<(), EngineError> {
2675        let requirement = self
2676            .store
2677            .get_step(step_id)
2678            .await?
2679            .and_then(|s| s.approval_requirement);
2680        self.event_publisher
2681            .publish(Event::ApprovalRequested(ApprovalRequestedEvent {
2682                run_id,
2683                step_id,
2684                message: message.to_string(),
2685                requirement,
2686                at: Utc::now(),
2687            }));
2688        Ok(())
2689    }
2690
2691    /// Fail every ancestor of a child run, closest first.
2692    ///
2693    /// Used when a gate inside a sub-workflow is rejected: the child run
2694    /// fails, and the runs suspended with it (its parent, up to the root)
2695    /// must not stay suspended on a child that will never resume. Each
2696    /// ancestor goes through [`fail_or_schedule_retry`](Self::fail_or_schedule_retry)
2697    /// without a retry, which also fails its open `Workflow` step. A run that
2698    /// is not a child of a sub-workflow has no ancestor: nothing happens.
2699    ///
2700    /// A paused ancestor is not failed: the root of the chain is set to resume
2701    /// to `Pending`, so its replay observes the failed child once an operator
2702    /// resumes it.
2703    ///
2704    /// # Errors
2705    ///
2706    /// Returns [`EngineError::Store`] if a run of the chain does not exist or
2707    /// its failure cannot be persisted.
2708    ///
2709    /// # Examples
2710    ///
2711    /// ```no_run
2712    /// use ironflow_engine::engine::Engine;
2713    /// use ironflow_engine::error::EngineError;
2714    /// use uuid::Uuid;
2715    ///
2716    /// # async fn example(engine: &Engine, child_run_id: Uuid) -> Result<(), EngineError> {
2717    /// engine
2718    ///     .fail_ancestors(child_run_id, "approval rejected in a sub-workflow")
2719    ///     .await?;
2720    /// # Ok(())
2721    /// # }
2722    /// ```
2723    pub async fn fail_ancestors(&self, run_id: Uuid, reason: &str) -> Result<(), EngineError> {
2724        let mut current = self
2725            .store
2726            .get_run(run_id)
2727            .await?
2728            .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
2729        // Labels are data: a chain that loops back on itself stops there.
2730        let mut visited = HashSet::from([run_id]);
2731
2732        while let Some(parent_id) = chain_parent(&current) {
2733            if !visited.insert(parent_id) {
2734                break;
2735            }
2736            let status = self
2737                .fail_or_schedule_retry(parent_id, reason, false, None, None)
2738                .await?;
2739            // A paused chain waits for its operator: the root replays on
2740            // resume and observes the failed child, like a cancelled one.
2741            if status == RunStatus::Paused {
2742                self.requeue_paused_root(&current).await?;
2743                break;
2744            }
2745            info!(
2746                run_id = %run_id,
2747                ancestor_run_id = %parent_id,
2748                status = %status,
2749                "ancestor run failed with its child"
2750            );
2751            current = self
2752                .store
2753                .get_run(parent_id)
2754                .await?
2755                .ok_or(EngineError::Store(StoreError::RunNotFound(parent_id)))?;
2756        }
2757
2758        Ok(())
2759    }
2760
2761    /// Emit Prometheus metrics for a completed run.
2762    #[cfg(feature = "prometheus")]
2763    fn emit_run_metrics(
2764        &self,
2765        workflow_name: &str,
2766        status: RunStatus,
2767        duration_ms: u64,
2768        ctx: &WorkflowContext,
2769    ) {
2770        let status_str = status.to_string();
2771        let wf = workflow_name.to_string();
2772
2773        counter!(RUNS_TOTAL, "workflow" => wf.clone(), "status" => status_str.clone()).increment(1);
2774        histogram!(RUN_DURATION_SECONDS, "workflow" => wf.clone(), "status" => status_str)
2775            .record(duration_ms as f64 / 1000.0);
2776        histogram!(RUN_COST_USD, "workflow" => wf.clone()).record(
2777            ctx.total_cost_usd()
2778                .to_string()
2779                .parse::<f64>()
2780                .unwrap_or(0.0),
2781        );
2782        gauge!(RUNS_ACTIVE, "workflow" => wf).decrement(1.0);
2783    }
2784
2785    /// Record the metric and publish the audit event for a run that hit its
2786    /// cost cap.
2787    ///
2788    /// A non-[`RunBudgetExceeded`](EngineError::RunBudgetExceeded) error is
2789    /// ignored, so callers can pass the error unconditionally.
2790    fn on_run_budget_exceeded(&self, workflow_name: &str, run_id: Uuid, err: &EngineError) {
2791        let EngineError::RunBudgetExceeded {
2792            limit_usd,
2793            spent_usd,
2794            step_budget_usd,
2795            ..
2796        } = err
2797        else {
2798            return;
2799        };
2800
2801        #[cfg(feature = "prometheus")]
2802        counter!(
2803            RUN_BUDGET_EXCEEDED_TOTAL,
2804            "workflow" => workflow_name.to_string(),
2805            "scope" => "run",
2806        )
2807        .increment(1);
2808
2809        self.event_publisher
2810            .publish(Event::RunBudgetExceeded(RunBudgetExceededEvent {
2811                run_id,
2812                workflow_name: workflow_name.to_string(),
2813                limit_usd: *limit_usd,
2814                spent_usd: *spent_usd,
2815                step_budget_usd: *step_budget_usd,
2816                at: Utc::now(),
2817            }));
2818    }
2819
2820    /// Publish a run status changed event to all registered subscribers.
2821    ///
2822    /// `from` is always `Running` because `finalize_run` is only called
2823    /// from a running state.
2824    #[allow(clippy::too_many_arguments)]
2825    fn publish_run_status_changed(
2826        &self,
2827        workflow_name: &str,
2828        run_id: Uuid,
2829        to: RunStatus,
2830        error: Option<String>,
2831        ctx: &WorkflowContext,
2832        duration_ms: u64,
2833        labels: HashMap<String, String>,
2834    ) {
2835        let now = Utc::now();
2836        let cost_usd = ctx.total_cost_usd();
2837        let wf = workflow_name.to_string();
2838
2839        self.event_publisher
2840            .publish(Event::RunStatusChanged(RunStatusChangedEvent {
2841                run_id,
2842                workflow_name: wf.clone(),
2843                from: RunStatus::Running,
2844                to,
2845                error: error.clone(),
2846                cost_usd,
2847                duration_ms,
2848                labels: labels.clone(),
2849                at: now,
2850            }));
2851
2852        if to == RunStatus::Failed {
2853            self.event_publisher
2854                .publish(Event::RunFailed(RunFailedEvent {
2855                    run_id,
2856                    workflow_name: wf,
2857                    error,
2858                    cost_usd,
2859                    duration_ms,
2860                    labels,
2861                    at: now,
2862                }));
2863        }
2864    }
2865}
2866
2867impl fmt::Debug for Engine {
2868    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2869        f.debug_struct("Engine")
2870            .field("handlers", &self.handlers.keys().collect::<Vec<_>>())
2871            .finish_non_exhaustive()
2872    }
2873}
2874
2875#[cfg(test)]
2876mod tests {
2877    use super::*;
2878    use crate::config::ShellConfig;
2879    use crate::handler::{HandlerFuture, WorkflowHandler};
2880    use ironflow_core::providers::claude::ClaudeCodeProvider;
2881    use ironflow_core::providers::record_replay::RecordReplayProvider;
2882    use ironflow_store::memory::InMemoryStore;
2883    use ironflow_store::models::{MAX_PRIORITY, MIN_PRIORITY, StepApproval, StepStatus};
2884    use serde_json::json;
2885
2886    // Test handler that echoes a message via shell
2887    struct EchoWorkflow;
2888
2889    impl WorkflowHandler for EchoWorkflow {
2890        fn name(&self) -> &str {
2891            "echo-workflow"
2892        }
2893
2894        fn describe(&self) -> WorkflowInfo {
2895            WorkflowInfo {
2896                description: "A simple workflow that echoes hello".to_string(),
2897                source_code: None,
2898                sub_workflows: Vec::new(),
2899                category: None,
2900                version: self.version().map(str::to_string),
2901                compatible_versions: Vec::new(),
2902                input_schema: None,
2903                default_labels: HashMap::new(),
2904                schedule: self.schedule().cloned(),
2905                default_max_cost_usd: self.default_max_cost_usd(),
2906                priority: self.priority(),
2907            }
2908        }
2909
2910        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2911            Box::pin(async move {
2912                ctx.shell("greet", ShellConfig::new("echo hello")).await?;
2913                Ok(())
2914            })
2915        }
2916    }
2917
2918    // Test handler that fails
2919    struct FailingWorkflow;
2920
2921    impl WorkflowHandler for FailingWorkflow {
2922        fn name(&self) -> &str {
2923            "failing-workflow"
2924        }
2925
2926        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2927            Box::pin(async move {
2928                ctx.shell("fail", ShellConfig::new("exit 1")).await?;
2929                Ok(())
2930            })
2931        }
2932    }
2933
2934    fn create_test_engine() -> Engine {
2935        let store = Arc::new(InMemoryStore::new());
2936        let inner = ClaudeCodeProvider::new();
2937        let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
2938            inner,
2939            "/tmp/ironflow-fixtures",
2940        ));
2941        Engine::new(store, provider)
2942    }
2943
2944    #[test]
2945    fn engine_new_creates_instance() {
2946        let engine = create_test_engine();
2947        assert_eq!(engine.handler_names().len(), 0);
2948    }
2949
2950    #[test]
2951    fn execution_mode_defaults_to_local() {
2952        let engine = create_test_engine();
2953        assert_eq!(engine.execution_mode(), ExecutionMode::Local);
2954    }
2955
2956    #[test]
2957    fn with_execution_mode_overrides_the_default() {
2958        let engine = create_test_engine().with_execution_mode(ExecutionMode::Workers);
2959        assert_eq!(engine.execution_mode(), ExecutionMode::Workers);
2960    }
2961
2962    #[test]
2963    fn engine_register_handler() {
2964        let mut engine = create_test_engine();
2965        let result = engine.register(EchoWorkflow);
2966        assert!(result.is_ok());
2967        assert_eq!(engine.handler_names().len(), 1);
2968        assert!(engine.handler_names().contains(&"echo-workflow"));
2969    }
2970
2971    #[test]
2972    fn engine_register_duplicate_returns_error() {
2973        let mut engine = create_test_engine();
2974        engine.register(EchoWorkflow).unwrap();
2975        let result = engine.register(EchoWorkflow);
2976        assert!(result.is_err());
2977    }
2978
2979    #[test]
2980    fn engine_get_handler_found() {
2981        let mut engine = create_test_engine();
2982        engine.register(EchoWorkflow).unwrap();
2983        let handler = engine.get_handler("echo-workflow");
2984        assert!(handler.is_some());
2985    }
2986
2987    #[test]
2988    fn engine_get_handler_not_found() {
2989        let engine = create_test_engine();
2990        let handler = engine.get_handler("nonexistent");
2991        assert!(handler.is_none());
2992    }
2993
2994    #[test]
2995    fn engine_handler_names_lists_all() {
2996        let mut engine = create_test_engine();
2997        engine.register(EchoWorkflow).unwrap();
2998        engine.register(FailingWorkflow).unwrap();
2999        let names = engine.handler_names();
3000        assert_eq!(names.len(), 2);
3001        assert!(names.contains(&"echo-workflow"));
3002        assert!(names.contains(&"failing-workflow"));
3003    }
3004
3005    #[test]
3006    fn engine_handler_info_returns_description() {
3007        let mut engine = create_test_engine();
3008        engine.register(EchoWorkflow).unwrap();
3009        let info = engine.handler_info("echo-workflow");
3010        assert!(info.is_some());
3011        let info = info.unwrap();
3012        assert_eq!(info.description, "A simple workflow that echoes hello");
3013    }
3014
3015    struct CategorizedWorkflow;
3016
3017    impl WorkflowHandler for CategorizedWorkflow {
3018        fn name(&self) -> &str {
3019            "categorized"
3020        }
3021        fn category(&self) -> Option<&str> {
3022            Some("data/etl")
3023        }
3024        fn execute<'a>(
3025            &'a self,
3026            _ctx: &'a mut WorkflowContext,
3027        ) -> crate::handler::HandlerFuture<'a> {
3028            Box::pin(async move { Ok(()) })
3029        }
3030    }
3031
3032    #[test]
3033    fn engine_default_describe_propagates_category() {
3034        let mut engine = create_test_engine();
3035        engine.register(CategorizedWorkflow).unwrap();
3036        let info = engine.handler_info("categorized").unwrap();
3037        assert_eq!(info.category.as_deref(), Some("data/etl"));
3038    }
3039
3040    #[test]
3041    fn engine_default_describe_without_category() {
3042        let mut engine = create_test_engine();
3043        engine.register(EchoWorkflow).unwrap();
3044        let info = engine.handler_info("echo-workflow").unwrap();
3045        assert!(info.category.is_none());
3046    }
3047
3048    // -----------------------------------------------------------------------
3049    // Schedule tests
3050    // -----------------------------------------------------------------------
3051
3052    struct ScheduledWorkflow {
3053        schedule: CronSchedule,
3054    }
3055
3056    impl ScheduledWorkflow {
3057        fn new() -> Self {
3058            Self {
3059                schedule: CronSchedule::new("0 0 * * * *").unwrap(),
3060            }
3061        }
3062    }
3063
3064    impl WorkflowHandler for ScheduledWorkflow {
3065        fn name(&self) -> &str {
3066            "scheduled"
3067        }
3068        fn schedule(&self) -> Option<&CronSchedule> {
3069            Some(&self.schedule)
3070        }
3071        fn execute<'a>(
3072            &'a self,
3073            _ctx: &'a mut WorkflowContext,
3074        ) -> crate::handler::HandlerFuture<'a> {
3075            Box::pin(async move { Ok(()) })
3076        }
3077    }
3078
3079    #[test]
3080    fn engine_default_describe_propagates_schedule() {
3081        let mut engine = create_test_engine();
3082        engine.register(ScheduledWorkflow::new()).unwrap();
3083        let info = engine.handler_info("scheduled").unwrap();
3084        assert_eq!(
3085            info.schedule.as_ref().map(|s| s.as_str()),
3086            Some("0 0 * * * *")
3087        );
3088    }
3089
3090    #[test]
3091    fn engine_default_describe_without_schedule() {
3092        let mut engine = create_test_engine();
3093        engine.register(EchoWorkflow).unwrap();
3094        let info = engine.handler_info("echo-workflow").unwrap();
3095        assert!(info.schedule.is_none());
3096    }
3097
3098    #[test]
3099    fn scheduled_handlers_returns_only_scheduled() {
3100        let mut engine = create_test_engine();
3101        engine.register(EchoWorkflow).unwrap();
3102        engine.register(ScheduledWorkflow::new()).unwrap();
3103        engine.register(FailingWorkflow).unwrap();
3104
3105        let scheduled = engine.scheduled_handlers();
3106        assert_eq!(scheduled.len(), 1);
3107        assert_eq!(scheduled[0].0, "scheduled");
3108        assert_eq!(scheduled[0].1.as_str(), "0 0 * * * *");
3109    }
3110
3111    #[test]
3112    fn scheduled_handlers_empty_when_none_scheduled() {
3113        let mut engine = create_test_engine();
3114        engine.register(EchoWorkflow).unwrap();
3115        engine.register(FailingWorkflow).unwrap();
3116
3117        let scheduled = engine.scheduled_handlers();
3118        assert!(scheduled.is_empty());
3119    }
3120
3121    struct BadCategoryWorkflow(&'static str);
3122
3123    impl WorkflowHandler for BadCategoryWorkflow {
3124        fn name(&self) -> &str {
3125            "bad-category"
3126        }
3127        fn category(&self) -> Option<&str> {
3128            Some(self.0)
3129        }
3130        fn execute<'a>(
3131            &'a self,
3132            _ctx: &'a mut WorkflowContext,
3133        ) -> crate::handler::HandlerFuture<'a> {
3134            Box::pin(async move { Ok(()) })
3135        }
3136    }
3137
3138    #[test]
3139    fn engine_register_rejects_empty_category() {
3140        let mut engine = create_test_engine();
3141        let err = engine.register(BadCategoryWorkflow("")).unwrap_err();
3142        match err {
3143            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty category")),
3144            other => panic!("expected InvalidWorkflow, got {other:?}"),
3145        }
3146    }
3147
3148    #[test]
3149    fn engine_register_rejects_leading_slash_category() {
3150        let mut engine = create_test_engine();
3151        let err = engine
3152            .register(BadCategoryWorkflow("/data/etl"))
3153            .unwrap_err();
3154        match err {
3155            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("leading '/'")),
3156            other => panic!("expected InvalidWorkflow, got {other:?}"),
3157        }
3158    }
3159
3160    #[test]
3161    fn engine_register_rejects_trailing_slash_category() {
3162        let mut engine = create_test_engine();
3163        let err = engine
3164            .register(BadCategoryWorkflow("data/etl/"))
3165            .unwrap_err();
3166        match err {
3167            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("trailing '/'")),
3168            other => panic!("expected InvalidWorkflow, got {other:?}"),
3169        }
3170    }
3171
3172    #[test]
3173    fn engine_register_rejects_double_slash_category() {
3174        let mut engine = create_test_engine();
3175        let err = engine
3176            .register(BadCategoryWorkflow("data//etl"))
3177            .unwrap_err();
3178        match err {
3179            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty segment")),
3180            other => panic!("expected InvalidWorkflow, got {other:?}"),
3181        }
3182    }
3183
3184    #[test]
3185    fn engine_register_rejects_whitespace_only_segment_category() {
3186        let mut engine = create_test_engine();
3187        let err = engine
3188            .register(BadCategoryWorkflow("data/ /etl"))
3189            .unwrap_err();
3190        match err {
3191            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("whitespace-only segment")),
3192            other => panic!("expected InvalidWorkflow, got {other:?}"),
3193        }
3194    }
3195
3196    #[test]
3197    fn engine_register_accepts_valid_nested_category() {
3198        let mut engine = create_test_engine();
3199        assert!(engine.register(CategorizedWorkflow).is_ok());
3200    }
3201
3202    #[tokio::test]
3203    async fn engine_unknown_workflow_returns_error() {
3204        let engine = create_test_engine();
3205        let result = engine
3206            .run_handler("unknown", TriggerKind::Manual, json!({}))
3207            .await;
3208        assert!(result.is_err());
3209        match result {
3210            Err(EngineError::InvalidWorkflow(msg)) => {
3211                assert!(msg.contains("no handler registered"));
3212            }
3213            _ => panic!("expected InvalidWorkflow error"),
3214        }
3215    }
3216
3217    #[tokio::test]
3218    async fn engine_enqueue_handler_creates_pending_run() {
3219        let mut engine = create_test_engine();
3220        engine.register(EchoWorkflow).unwrap();
3221
3222        let run = engine
3223            .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
3224            .await
3225            .unwrap();
3226        assert_eq!(run.status.state, RunStatus::Pending);
3227        assert_eq!(run.workflow_name, "echo-workflow");
3228    }
3229
3230    #[tokio::test]
3231    async fn enqueue_handler_leaves_the_run_unattributed() {
3232        let mut engine = create_test_engine();
3233        engine.register(EchoWorkflow).unwrap();
3234
3235        let run = engine
3236            .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
3237            .await
3238            .unwrap();
3239
3240        assert!(run.created_by.is_none());
3241    }
3242
3243    #[tokio::test]
3244    async fn enqueue_handler_with_options_records_the_author() {
3245        let mut engine = create_test_engine();
3246        engine.register(EchoWorkflow).unwrap();
3247        let actor = RunActor::User {
3248            user_id: Uuid::now_v7(),
3249        };
3250
3251        let run = engine
3252            .enqueue_handler_with_options(
3253                "echo-workflow",
3254                TriggerKind::Api,
3255                json!({}),
3256                EnqueueOptions {
3257                    created_by: Some(actor.clone()),
3258                    ..Default::default()
3259                },
3260            )
3261            .await
3262            .unwrap()
3263            .into_run();
3264
3265        assert_eq!(run.created_by, Some(actor));
3266    }
3267
3268    #[tokio::test]
3269    async fn enqueue_handler_with_options_accepts_no_author() {
3270        let mut engine = create_test_engine();
3271        engine.register(EchoWorkflow).unwrap();
3272
3273        let run = engine
3274            .enqueue_handler_with_options(
3275                "echo-workflow",
3276                TriggerKind::Cron {
3277                    schedule: "0 * * * * *".to_string(),
3278                    schedule_id: None,
3279                    scheduled_for: None,
3280                },
3281                json!({}),
3282                EnqueueOptions::default(),
3283            )
3284            .await
3285            .unwrap()
3286            .into_run();
3287
3288        assert!(run.created_by.is_none());
3289    }
3290
3291    #[tokio::test]
3292    async fn enqueue_handler_with_options_stores_concurrency_limits() {
3293        let mut engine = create_test_engine();
3294        engine.register(EchoWorkflow).unwrap();
3295        let limits = vec![
3296            ConcurrencyLimit::new("repo:acme", 2),
3297            ConcurrencyLimit::new("tenant:42", 5),
3298        ];
3299
3300        let run = engine
3301            .enqueue_handler_with_options(
3302                "echo-workflow",
3303                TriggerKind::Api,
3304                json!({}),
3305                EnqueueOptions {
3306                    concurrency_limits: limits.clone(),
3307                    ..Default::default()
3308                },
3309            )
3310            .await
3311            .unwrap()
3312            .into_run();
3313
3314        assert_eq!(run.concurrency_limits, limits);
3315    }
3316
3317    #[tokio::test]
3318    async fn enqueue_rejects_invalid_concurrency_limits() {
3319        let mut engine = create_test_engine();
3320        engine.register(EchoWorkflow).unwrap();
3321
3322        let invalid = [
3323            vec![ConcurrencyLimit::new("repo:acme", 0)],
3324            vec![ConcurrencyLimit::new("", 1)],
3325            vec![
3326                ConcurrencyLimit::new("repo:acme", 1),
3327                ConcurrencyLimit::new("repo:acme", 2),
3328            ],
3329        ];
3330        for concurrency_limits in invalid {
3331            let err = engine
3332                .enqueue_handler_with_options(
3333                    "echo-workflow",
3334                    TriggerKind::Api,
3335                    json!({}),
3336                    EnqueueOptions {
3337                        concurrency_limits,
3338                        ..Default::default()
3339                    },
3340                )
3341                .await
3342                .unwrap_err();
3343            assert!(
3344                matches!(err, EngineError::InvalidConcurrencyLimit(_)),
3345                "{err:?}"
3346            );
3347        }
3348
3349        // Validated before the handler lookup.
3350        let err = engine
3351            .enqueue_handler_with_options(
3352                "not-registered",
3353                TriggerKind::Api,
3354                json!({}),
3355                EnqueueOptions {
3356                    concurrency_limits: vec![ConcurrencyLimit::new("repo:acme", 0)],
3357                    ..Default::default()
3358                },
3359            )
3360            .await
3361            .unwrap_err();
3362        assert!(
3363            matches!(err, EngineError::InvalidConcurrencyLimit(_)),
3364            "{err:?}"
3365        );
3366
3367        let page = engine
3368            .store()
3369            .list_runs(RunFilter::default(), 1, 10)
3370            .await
3371            .unwrap();
3372        assert_eq!(page.total, 0, "no run may be created");
3373    }
3374
3375    struct UrgentWorkflow;
3376
3377    impl WorkflowHandler for UrgentWorkflow {
3378        fn name(&self) -> &str {
3379            "urgent-workflow"
3380        }
3381
3382        fn priority(&self) -> i16 {
3383            60
3384        }
3385
3386        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3387            Box::pin(async { Ok(()) })
3388        }
3389    }
3390
3391    #[tokio::test]
3392    async fn enqueue_priority_defaults_to_the_handler_priority() {
3393        let mut engine = create_test_engine();
3394        engine.register(EchoWorkflow).unwrap();
3395        engine.register(UrgentWorkflow).unwrap();
3396
3397        let echo = engine
3398            .enqueue_handler_with_options(
3399                "echo-workflow",
3400                TriggerKind::Api,
3401                json!({}),
3402                EnqueueOptions::default(),
3403            )
3404            .await
3405            .unwrap()
3406            .into_run();
3407        assert_eq!(echo.priority, 0);
3408
3409        let urgent = engine
3410            .enqueue_handler_with_options(
3411                "urgent-workflow",
3412                TriggerKind::Api,
3413                json!({}),
3414                EnqueueOptions::default(),
3415            )
3416            .await
3417            .unwrap()
3418            .into_run();
3419        assert_eq!(urgent.priority, 60);
3420    }
3421
3422    #[tokio::test]
3423    async fn enqueue_priority_explicit_value_overrides_the_handler() {
3424        let mut engine = create_test_engine();
3425        engine.register(UrgentWorkflow).unwrap();
3426
3427        let run = engine
3428            .enqueue_handler_with_options(
3429                "urgent-workflow",
3430                TriggerKind::Api,
3431                json!({}),
3432                EnqueueOptions {
3433                    priority: Some(-20),
3434                    ..Default::default()
3435                },
3436            )
3437            .await
3438            .unwrap()
3439            .into_run();
3440        assert_eq!(run.priority, -20);
3441
3442        let stored = engine.store().get_run(run.id).await.unwrap().unwrap();
3443        assert_eq!(stored.priority, -20);
3444    }
3445
3446    #[tokio::test]
3447    async fn enqueue_priority_out_of_range_is_rejected() {
3448        let mut engine = create_test_engine();
3449        engine.register(EchoWorkflow).unwrap();
3450
3451        for priority in [MAX_PRIORITY + 1, MIN_PRIORITY - 1] {
3452            let err = engine
3453                .enqueue_handler_with_options(
3454                    "echo-workflow",
3455                    TriggerKind::Api,
3456                    json!({}),
3457                    EnqueueOptions {
3458                        priority: Some(priority),
3459                        ..Default::default()
3460                    },
3461                )
3462                .await
3463                .unwrap_err();
3464            assert!(matches!(err, EngineError::InvalidPriority(_)), "{err:?}");
3465        }
3466
3467        // Validated before the handler lookup.
3468        let err = engine
3469            .enqueue_handler_with_options(
3470                "not-registered",
3471                TriggerKind::Api,
3472                json!({}),
3473                EnqueueOptions {
3474                    priority: Some(MAX_PRIORITY + 1),
3475                    ..Default::default()
3476                },
3477            )
3478            .await
3479            .unwrap_err();
3480        assert!(matches!(err, EngineError::InvalidPriority(_)), "{err:?}");
3481
3482        let page = engine
3483            .store()
3484            .list_runs(RunFilter::default(), 1, 10)
3485            .await
3486            .unwrap();
3487        assert_eq!(page.total, 0, "no run may be created");
3488    }
3489
3490    #[tokio::test]
3491    async fn enqueue_priority_bounds_are_accepted() {
3492        let mut engine = create_test_engine();
3493        engine.register(EchoWorkflow).unwrap();
3494
3495        for priority in [MIN_PRIORITY, MAX_PRIORITY] {
3496            let run = engine
3497                .enqueue_handler_with_options(
3498                    "echo-workflow",
3499                    TriggerKind::Api,
3500                    json!({}),
3501                    EnqueueOptions {
3502                        priority: Some(priority),
3503                        ..Default::default()
3504                    },
3505                )
3506                .await
3507                .unwrap()
3508                .into_run();
3509            assert_eq!(run.priority, priority);
3510        }
3511    }
3512
3513    struct GpuWorkflow;
3514
3515    impl WorkflowHandler for GpuWorkflow {
3516        fn name(&self) -> &str {
3517            "gpu-workflow"
3518        }
3519
3520        fn required_worker_tags(&self) -> Vec<String> {
3521            vec!["gpu".to_string()]
3522        }
3523
3524        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3525            Box::pin(async move { Ok(()) })
3526        }
3527    }
3528
3529    #[tokio::test]
3530    async fn enqueue_merges_handler_and_request_worker_tags() {
3531        let mut engine = create_test_engine();
3532        engine.register(GpuWorkflow).unwrap();
3533
3534        let run = engine
3535            .enqueue_handler_with_options(
3536                "gpu-workflow",
3537                TriggerKind::Api,
3538                json!({}),
3539                EnqueueOptions {
3540                    worker_tags: vec!["region:eu".to_string(), "gpu".to_string()],
3541                    ..Default::default()
3542                },
3543            )
3544            .await
3545            .unwrap()
3546            .into_run();
3547
3548        assert_eq!(
3549            run.worker_tags,
3550            vec!["gpu".to_string(), "region:eu".to_string()]
3551        );
3552    }
3553
3554    #[tokio::test]
3555    async fn enqueue_without_worker_tags_keeps_handler_tags() {
3556        let mut engine = create_test_engine();
3557        engine.register(GpuWorkflow).unwrap();
3558        engine.register(EchoWorkflow).unwrap();
3559
3560        let gpu = engine
3561            .enqueue_handler("gpu-workflow", TriggerKind::Api, json!({}), 0)
3562            .await
3563            .unwrap();
3564        assert_eq!(gpu.worker_tags, vec!["gpu".to_string()]);
3565
3566        let echo = engine
3567            .enqueue_handler("echo-workflow", TriggerKind::Api, json!({}), 0)
3568            .await
3569            .unwrap();
3570        assert!(echo.worker_tags.is_empty());
3571    }
3572
3573    #[tokio::test]
3574    async fn enqueue_rejects_invalid_worker_tags() {
3575        let mut engine = create_test_engine();
3576        engine.register(EchoWorkflow).unwrap();
3577
3578        for worker_tags in [
3579            vec!["bad,tag".to_string()],
3580            vec![" ".to_string()],
3581            vec!["x".repeat(65)],
3582        ] {
3583            let err = engine
3584                .enqueue_handler_with_options(
3585                    "echo-workflow",
3586                    TriggerKind::Api,
3587                    json!({}),
3588                    EnqueueOptions {
3589                        worker_tags,
3590                        ..Default::default()
3591                    },
3592                )
3593                .await
3594                .unwrap_err();
3595            assert!(matches!(err, EngineError::InvalidWorkerTag(_)), "{err:?}");
3596        }
3597
3598        // Validated before the handler lookup.
3599        let err = engine
3600            .enqueue_handler_with_options(
3601                "not-registered",
3602                TriggerKind::Api,
3603                json!({}),
3604                EnqueueOptions {
3605                    worker_tags: vec!["bad,tag".to_string()],
3606                    ..Default::default()
3607                },
3608            )
3609            .await
3610            .unwrap_err();
3611        assert!(matches!(err, EngineError::InvalidWorkerTag(_)), "{err:?}");
3612    }
3613
3614    #[test]
3615    fn worker_tags_are_unset_by_default() {
3616        let engine = create_test_engine();
3617        assert!(engine.worker_tags().is_none());
3618    }
3619
3620    #[test]
3621    fn set_worker_tags_stores_the_tags() {
3622        let mut engine = create_test_engine();
3623        engine.set_worker_tags(vec!["gpu".to_string()]);
3624        assert_eq!(engine.worker_tags(), Some(&["gpu".to_string()][..]));
3625
3626        engine.set_worker_tags(Vec::new());
3627        assert_eq!(engine.worker_tags(), Some(&[][..]));
3628    }
3629
3630    #[tokio::test]
3631    async fn run_handler_records_handler_worker_tags() {
3632        let mut engine = create_test_engine();
3633        engine.register(GpuWorkflow).unwrap();
3634
3635        let result = engine
3636            .run_handler("gpu-workflow", TriggerKind::Manual, json!({}))
3637            .await
3638            .unwrap();
3639        assert_eq!(result.run.worker_tags, vec!["gpu".to_string()]);
3640    }
3641
3642    #[tokio::test]
3643    async fn run_handler_leaves_the_run_unattributed() {
3644        let mut engine = create_test_engine();
3645        engine.register(EchoWorkflow).unwrap();
3646
3647        let run = engine
3648            .run_handler("echo-workflow", TriggerKind::Manual, json!({}))
3649            .await
3650            .unwrap()
3651            .run;
3652
3653        assert!(run.created_by.is_none());
3654    }
3655
3656    #[tokio::test]
3657    async fn run_handler_priority_comes_from_the_handler() {
3658        let mut engine = create_test_engine();
3659        engine.register(UrgentWorkflow).unwrap();
3660
3661        let run = engine
3662            .run_handler("urgent-workflow", TriggerKind::Manual, json!({}))
3663            .await
3664            .unwrap()
3665            .run;
3666
3667        assert_eq!(run.priority, 60);
3668    }
3669
3670    #[tokio::test]
3671    async fn engine_register_boxed() {
3672        let mut engine = create_test_engine();
3673        let handler: Box<dyn WorkflowHandler> = Box::new(EchoWorkflow);
3674        let result = engine.register_boxed(handler);
3675        assert!(result.is_ok());
3676        assert_eq!(engine.handler_names().len(), 1);
3677    }
3678
3679    #[tokio::test]
3680    async fn engine_store_and_provider_accessors() {
3681        let store = Arc::new(InMemoryStore::new());
3682        let inner = ClaudeCodeProvider::new();
3683        let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
3684            inner,
3685            "/tmp/ironflow-fixtures",
3686        ));
3687        let engine = Engine::new(store.clone(), provider.clone());
3688
3689        // Verify accessors return references
3690        let _ = engine.store();
3691        let _ = engine.provider();
3692    }
3693
3694    // -----------------------------------------------------------------------
3695    // Operation trait tests
3696    // -----------------------------------------------------------------------
3697
3698    use crate::operation::{Operation, OperationContext};
3699    use async_trait::async_trait;
3700    use ironflow_core::error::OperationError;
3701    use ironflow_store::models::StepKind;
3702
3703    struct FakeGitlabOp {
3704        project_id: u64,
3705        title: String,
3706    }
3707
3708    #[async_trait]
3709    impl Operation for FakeGitlabOp {
3710        fn kind(&self) -> &str {
3711            "gitlab"
3712        }
3713
3714        async fn execute(&self, _ctx: &OperationContext) -> Result<Value, OperationError> {
3715            Ok(json!({
3716                "issue_id": 42,
3717                "project_id": self.project_id,
3718                "title": self.title,
3719            }))
3720        }
3721
3722        fn input(&self) -> Option<Value> {
3723            Some(json!({
3724                "project_id": self.project_id,
3725                "title": self.title,
3726            }))
3727        }
3728    }
3729
3730    struct FailingOp;
3731
3732    #[async_trait]
3733    impl Operation for FailingOp {
3734        fn kind(&self) -> &str {
3735            "broken-service"
3736        }
3737
3738        async fn execute(&self, _ctx: &OperationContext) -> Result<Value, OperationError> {
3739            Err(OperationError::Http {
3740                status: None,
3741                message: "service unavailable".to_string(),
3742            })
3743        }
3744    }
3745
3746    struct OperationWorkflow;
3747
3748    impl WorkflowHandler for OperationWorkflow {
3749        fn name(&self) -> &str {
3750            "operation-workflow"
3751        }
3752
3753        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3754            Box::pin(async move {
3755                let op = FakeGitlabOp {
3756                    project_id: 123,
3757                    title: "Bug report".to_string(),
3758                };
3759                ctx.operation("create-issue", &op).await?;
3760                Ok(())
3761            })
3762        }
3763    }
3764
3765    struct FailingOperationWorkflow;
3766
3767    impl WorkflowHandler for FailingOperationWorkflow {
3768        fn name(&self) -> &str {
3769            "failing-operation-workflow"
3770        }
3771
3772        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3773            Box::pin(async move {
3774                ctx.operation("broken-call", &FailingOp).await?;
3775                Ok(())
3776            })
3777        }
3778    }
3779
3780    struct MixedWorkflow;
3781
3782    impl WorkflowHandler for MixedWorkflow {
3783        fn name(&self) -> &str {
3784            "mixed-workflow"
3785        }
3786
3787        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3788            Box::pin(async move {
3789                ctx.shell("build", ShellConfig::new("echo built")).await?;
3790                let op = FakeGitlabOp {
3791                    project_id: 456,
3792                    title: "Deploy done".to_string(),
3793                };
3794                let result = ctx.operation("notify-gitlab", &op).await?;
3795                assert_eq!(result.output["issue_id"], 42);
3796                Ok(())
3797            })
3798        }
3799    }
3800
3801    #[tokio::test]
3802    async fn operation_step_happy_path() {
3803        let mut engine = create_test_engine();
3804        engine.register(OperationWorkflow).unwrap();
3805
3806        let run = engine
3807            .run_handler("operation-workflow", TriggerKind::Manual, json!({}))
3808            .await
3809            .unwrap()
3810            .run;
3811
3812        assert_eq!(run.status.state, RunStatus::Completed);
3813
3814        let steps = engine.store().list_steps(run.id).await.unwrap();
3815
3816        assert_eq!(steps.len(), 1);
3817        assert_eq!(steps[0].name, "create-issue");
3818        assert_eq!(steps[0].kind, StepKind::Custom("gitlab".to_string()));
3819        assert_eq!(
3820            steps[0].status.state,
3821            ironflow_store::models::StepStatus::Completed
3822        );
3823
3824        let output = steps[0].output.as_ref().unwrap();
3825        assert_eq!(output["issue_id"], 42);
3826        assert_eq!(output["project_id"], 123);
3827
3828        let input = steps[0].input.as_ref().unwrap();
3829        assert_eq!(input["project_id"], 123);
3830        assert_eq!(input["title"], "Bug report");
3831    }
3832
3833    #[tokio::test]
3834    async fn operation_step_failure_marks_run_failed() {
3835        let mut engine = create_test_engine();
3836        engine.register(FailingOperationWorkflow).unwrap();
3837
3838        let result = engine
3839            .run_handler("failing-operation-workflow", TriggerKind::Manual, json!({}))
3840            .await;
3841
3842        assert!(result.is_err());
3843    }
3844
3845    #[tokio::test]
3846    async fn operation_mixed_with_shell_steps() {
3847        let mut engine = create_test_engine();
3848        engine.register(MixedWorkflow).unwrap();
3849
3850        let run = engine
3851            .run_handler("mixed-workflow", TriggerKind::Manual, json!({}))
3852            .await
3853            .unwrap()
3854            .run;
3855
3856        assert_eq!(run.status.state, RunStatus::Completed);
3857
3858        let steps = engine.store().list_steps(run.id).await.unwrap();
3859
3860        assert_eq!(steps.len(), 2);
3861        assert_eq!(steps[0].kind, StepKind::Shell);
3862        assert_eq!(steps[1].kind, StepKind::Custom("gitlab".to_string()));
3863        assert_eq!(steps[0].position, 0);
3864        assert_eq!(steps[1].position, 1);
3865    }
3866
3867    // -----------------------------------------------------------------------
3868    // Approval + resume tests
3869    // -----------------------------------------------------------------------
3870
3871    use crate::config::ApprovalConfig;
3872
3873    struct SingleApprovalWorkflow;
3874
3875    impl WorkflowHandler for SingleApprovalWorkflow {
3876        fn name(&self) -> &str {
3877            "single-approval"
3878        }
3879
3880        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3881            Box::pin(async move {
3882                ctx.shell("build", ShellConfig::new("echo built")).await?;
3883                ctx.approval("gate", ApprovalConfig::new("OK?")).await?;
3884                ctx.shell("deploy", ShellConfig::new("echo deployed"))
3885                    .await?;
3886                Ok(())
3887            })
3888        }
3889    }
3890
3891    struct DoubleApprovalWorkflow;
3892
3893    impl WorkflowHandler for DoubleApprovalWorkflow {
3894        fn name(&self) -> &str {
3895            "double-approval"
3896        }
3897
3898        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
3899            Box::pin(async move {
3900                ctx.shell("build", ShellConfig::new("echo built")).await?;
3901                ctx.approval("staging-gate", ApprovalConfig::new("Deploy staging?"))
3902                    .await?;
3903                ctx.shell("deploy-staging", ShellConfig::new("echo staging"))
3904                    .await?;
3905                ctx.approval("prod-gate", ApprovalConfig::new("Deploy prod?"))
3906                    .await?;
3907                ctx.shell("deploy-prod", ShellConfig::new("echo prod"))
3908                    .await?;
3909                Ok(())
3910            })
3911        }
3912    }
3913
3914    #[tokio::test]
3915    async fn approval_pauses_run() {
3916        let mut engine = create_test_engine();
3917        engine.register(SingleApprovalWorkflow).unwrap();
3918
3919        let run = engine
3920            .run_handler("single-approval", TriggerKind::Manual, json!({}))
3921            .await
3922            .unwrap()
3923            .run;
3924
3925        assert_eq!(run.status.state, RunStatus::AwaitingApproval);
3926
3927        let steps = engine.store().list_steps(run.id).await.unwrap();
3928        assert_eq!(steps.len(), 2); // build + approval gate
3929        assert_eq!(steps[0].kind, StepKind::Shell);
3930        assert_eq!(steps[0].status.state, StepStatus::Completed);
3931        assert_eq!(steps[1].kind, StepKind::Approval);
3932        assert_eq!(steps[1].status.state, StepStatus::AwaitingApproval);
3933    }
3934
3935    /// Record one vote on the open approval gate of `run_id`, then move the
3936    /// run to `Running`, the way the approve route does.
3937    async fn approve_open_gate(store: &Arc<dyn Store>, run_id: Uuid) {
3938        let gate = store
3939            .list_steps(run_id)
3940            .await
3941            .unwrap()
3942            .into_iter()
3943            .find(|step| step.status.state == StepStatus::AwaitingApproval)
3944            .expect("an open approval gate");
3945        store
3946            .record_step_approval(
3947                gate.id,
3948                StepApproval {
3949                    user_id: Uuid::now_v7(),
3950                    approved_by: "tester".to_string(),
3951                    at: Utc::now(),
3952                },
3953            )
3954            .await
3955            .unwrap();
3956        store
3957            .update_run_status(run_id, RunStatus::Running)
3958            .await
3959            .unwrap();
3960    }
3961
3962    #[tokio::test]
3963    async fn approval_resume_completes_run() {
3964        let mut engine = create_test_engine();
3965        engine.register(SingleApprovalWorkflow).unwrap();
3966
3967        // First execution: pauses at approval
3968        let run = engine
3969            .run_handler("single-approval", TriggerKind::Manual, json!({}))
3970            .await
3971            .unwrap()
3972            .run;
3973        assert_eq!(run.status.state, RunStatus::AwaitingApproval);
3974
3975        // Simulate approval: one vote, then transition to Running
3976        approve_open_gate(engine.store(), run.id).await;
3977
3978        // Resume: replays build, skips approval, executes deploy
3979        let resumed = engine.resume_run(run.id).await.unwrap().run;
3980        assert_eq!(resumed.status.state, RunStatus::Completed);
3981
3982        let steps = engine.store().list_steps(run.id).await.unwrap();
3983        assert_eq!(steps.len(), 3); // build + approval + deploy
3984        assert_eq!(steps[0].name, "build");
3985        assert_eq!(steps[0].status.state, StepStatus::Completed);
3986        assert_eq!(steps[1].name, "gate");
3987        assert_eq!(steps[1].kind, StepKind::Approval);
3988        assert_eq!(steps[1].status.state, StepStatus::Completed);
3989        assert_eq!(steps[2].name, "deploy");
3990        assert_eq!(steps[2].status.state, StepStatus::Completed);
3991    }
3992
3993    #[tokio::test]
3994    async fn double_approval_two_resumes() {
3995        let mut engine = create_test_engine();
3996        engine.register(DoubleApprovalWorkflow).unwrap();
3997
3998        // First execution: pauses at staging-gate
3999        let run = engine
4000            .run_handler("double-approval", TriggerKind::Manual, json!({}))
4001            .await
4002            .unwrap()
4003            .run;
4004        assert_eq!(run.status.state, RunStatus::AwaitingApproval);
4005
4006        let steps = engine.store().list_steps(run.id).await.unwrap();
4007        assert_eq!(steps.len(), 2); // build + staging-gate
4008
4009        // First approval
4010        approve_open_gate(engine.store(), run.id).await;
4011
4012        let resumed = engine.resume_run(run.id).await.unwrap().run;
4013        assert_eq!(resumed.status.state, RunStatus::AwaitingApproval);
4014
4015        let steps = engine.store().list_steps(run.id).await.unwrap();
4016        assert_eq!(steps.len(), 4); // build + staging-gate + deploy-staging + prod-gate
4017
4018        // Second approval
4019        approve_open_gate(engine.store(), run.id).await;
4020
4021        let final_run = engine.resume_run(run.id).await.unwrap().run;
4022        assert_eq!(final_run.status.state, RunStatus::Completed);
4023
4024        let steps = engine.store().list_steps(run.id).await.unwrap();
4025        assert_eq!(steps.len(), 5);
4026        assert_eq!(steps[0].name, "build");
4027        assert_eq!(steps[1].name, "staging-gate");
4028        assert_eq!(steps[2].name, "deploy-staging");
4029        assert_eq!(steps[3].name, "prod-gate");
4030        assert_eq!(steps[4].name, "deploy-prod");
4031
4032        for step in &steps {
4033            assert_eq!(step.status.state, StepStatus::Completed);
4034        }
4035    }
4036
4037    // -----------------------------------------------------------------------
4038    // fail_orphaned_steps tests
4039    // -----------------------------------------------------------------------
4040
4041    use ironflow_store::models::{NewStep, StepUpdate, step_trace_id};
4042
4043    async fn create_step_with_status(
4044        store: &Arc<dyn Store>,
4045        run_id: Uuid,
4046        name: &str,
4047        position: u32,
4048        status: StepStatus,
4049    ) -> ironflow_store::models::Step {
4050        let step = store
4051            .create_step(NewStep {
4052                run_id,
4053                trace_id: step_trace_id(run_id, name, position),
4054                name: name.to_string(),
4055                kind: StepKind::Shell,
4056                position,
4057                input: None,
4058                is_error_handler: false,
4059            })
4060            .await
4061            .unwrap();
4062
4063        match status {
4064            StepStatus::Pending => {}
4065            StepStatus::Running => {
4066                store
4067                    .update_step(
4068                        step.id,
4069                        StepUpdate {
4070                            status: Some(StepStatus::Running),
4071                            ..StepUpdate::default()
4072                        },
4073                    )
4074                    .await
4075                    .unwrap();
4076            }
4077            StepStatus::Completed => {
4078                store
4079                    .update_step(
4080                        step.id,
4081                        StepUpdate {
4082                            status: Some(StepStatus::Running),
4083                            ..StepUpdate::default()
4084                        },
4085                    )
4086                    .await
4087                    .unwrap();
4088                store
4089                    .update_step(
4090                        step.id,
4091                        StepUpdate {
4092                            status: Some(StepStatus::Completed),
4093                            ..StepUpdate::default()
4094                        },
4095                    )
4096                    .await
4097                    .unwrap();
4098            }
4099            StepStatus::AwaitingApproval => {
4100                store
4101                    .update_step(
4102                        step.id,
4103                        StepUpdate {
4104                            status: Some(StepStatus::Running),
4105                            ..StepUpdate::default()
4106                        },
4107                    )
4108                    .await
4109                    .unwrap();
4110                store
4111                    .update_step(
4112                        step.id,
4113                        StepUpdate {
4114                            status: Some(StepStatus::AwaitingApproval),
4115                            ..StepUpdate::default()
4116                        },
4117                    )
4118                    .await
4119                    .unwrap();
4120            }
4121            _ => panic!("unsupported status for test helper: {status}"),
4122        }
4123
4124        store.get_step(step.id).await.unwrap().unwrap()
4125    }
4126
4127    #[tokio::test]
4128    async fn fail_orphaned_steps_marks_running_as_failed() {
4129        let engine = create_test_engine();
4130        let run = engine
4131            .store()
4132            .create_run(NewRun {
4133                created_by: None,
4134                workflow_name: "test".to_string(),
4135                trigger: TriggerKind::Manual,
4136                payload: json!({}),
4137                max_retries: 0,
4138                handler_version: None,
4139                labels: HashMap::new(),
4140                scheduled_at: None,
4141                idempotency_key: None,
4142                concurrency_key: None,
4143                priority: 0,
4144                concurrency_limits: Vec::new(),
4145                max_cost_usd: None,
4146                worker_tags: Vec::new(),
4147            })
4148            .await
4149            .unwrap()
4150            .into_run();
4151
4152        let step = create_step_with_status(
4153            engine.store(),
4154            run.id,
4155            "running-step",
4156            0,
4157            StepStatus::Running,
4158        )
4159        .await;
4160
4161        engine
4162            .fail_orphaned_steps(run.id, "parent run timed out")
4163            .await
4164            .unwrap();
4165
4166        let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
4167        assert_eq!(updated.status.state, StepStatus::Failed);
4168        assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
4169        assert!(updated.completed_at.is_some());
4170    }
4171
4172    #[tokio::test]
4173    async fn fail_orphaned_steps_marks_pending_as_skipped() {
4174        let engine = create_test_engine();
4175        let run = engine
4176            .store()
4177            .create_run(NewRun {
4178                created_by: None,
4179                workflow_name: "test".to_string(),
4180                trigger: TriggerKind::Manual,
4181                payload: json!({}),
4182                max_retries: 0,
4183                handler_version: None,
4184                labels: HashMap::new(),
4185                scheduled_at: None,
4186                idempotency_key: None,
4187                concurrency_key: None,
4188                priority: 0,
4189                concurrency_limits: Vec::new(),
4190                max_cost_usd: None,
4191                worker_tags: Vec::new(),
4192            })
4193            .await
4194            .unwrap()
4195            .into_run();
4196
4197        let step = create_step_with_status(
4198            engine.store(),
4199            run.id,
4200            "pending-step",
4201            0,
4202            StepStatus::Pending,
4203        )
4204        .await;
4205
4206        engine
4207            .fail_orphaned_steps(run.id, "parent run timed out")
4208            .await
4209            .unwrap();
4210
4211        let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
4212        assert_eq!(updated.status.state, StepStatus::Skipped);
4213        assert!(updated.error.is_none());
4214        assert!(updated.completed_at.is_some());
4215    }
4216
4217    #[tokio::test]
4218    async fn fail_orphaned_steps_marks_awaiting_approval_as_failed() {
4219        let engine = create_test_engine();
4220        let run = engine
4221            .store()
4222            .create_run(NewRun {
4223                created_by: None,
4224                workflow_name: "test".to_string(),
4225                trigger: TriggerKind::Manual,
4226                payload: json!({}),
4227                max_retries: 0,
4228                handler_version: None,
4229                labels: HashMap::new(),
4230                scheduled_at: None,
4231                idempotency_key: None,
4232                concurrency_key: None,
4233                priority: 0,
4234                concurrency_limits: Vec::new(),
4235                max_cost_usd: None,
4236                worker_tags: Vec::new(),
4237            })
4238            .await
4239            .unwrap()
4240            .into_run();
4241
4242        let step = create_step_with_status(
4243            engine.store(),
4244            run.id,
4245            "approval-step",
4246            0,
4247            StepStatus::AwaitingApproval,
4248        )
4249        .await;
4250
4251        engine
4252            .fail_orphaned_steps(run.id, "parent run timed out")
4253            .await
4254            .unwrap();
4255
4256        let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
4257        assert_eq!(updated.status.state, StepStatus::Failed);
4258        assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
4259        assert!(updated.completed_at.is_some());
4260    }
4261
4262    #[tokio::test]
4263    async fn fail_orphaned_steps_skips_terminal_steps() {
4264        let engine = create_test_engine();
4265        let run = engine
4266            .store()
4267            .create_run(NewRun {
4268                created_by: None,
4269                workflow_name: "test".to_string(),
4270                trigger: TriggerKind::Manual,
4271                payload: json!({}),
4272                max_retries: 0,
4273                handler_version: None,
4274                labels: HashMap::new(),
4275                scheduled_at: None,
4276                idempotency_key: None,
4277                concurrency_key: None,
4278                priority: 0,
4279                concurrency_limits: Vec::new(),
4280                max_cost_usd: None,
4281                worker_tags: Vec::new(),
4282            })
4283            .await
4284            .unwrap()
4285            .into_run();
4286
4287        let completed_step =
4288            create_step_with_status(engine.store(), run.id, "done", 0, StepStatus::Completed).await;
4289        let running_step =
4290            create_step_with_status(engine.store(), run.id, "in-flight", 1, StepStatus::Running)
4291                .await;
4292
4293        engine
4294            .fail_orphaned_steps(run.id, "parent run timed out")
4295            .await
4296            .unwrap();
4297
4298        let completed = engine
4299            .store()
4300            .get_step(completed_step.id)
4301            .await
4302            .unwrap()
4303            .unwrap();
4304        assert_eq!(completed.status.state, StepStatus::Completed);
4305
4306        let failed = engine
4307            .store()
4308            .get_step(running_step.id)
4309            .await
4310            .unwrap()
4311            .unwrap();
4312        assert_eq!(failed.status.state, StepStatus::Failed);
4313    }
4314
4315    #[tokio::test]
4316    async fn fail_orphaned_steps_mixed_states() {
4317        let engine = create_test_engine();
4318        let run = engine
4319            .store()
4320            .create_run(NewRun {
4321                created_by: None,
4322                workflow_name: "test".to_string(),
4323                trigger: TriggerKind::Manual,
4324                payload: json!({}),
4325                max_retries: 0,
4326                handler_version: None,
4327                labels: HashMap::new(),
4328                scheduled_at: None,
4329                idempotency_key: None,
4330                concurrency_key: None,
4331                priority: 0,
4332                concurrency_limits: Vec::new(),
4333                max_cost_usd: None,
4334                worker_tags: Vec::new(),
4335            })
4336            .await
4337            .unwrap()
4338            .into_run();
4339
4340        let s_completed =
4341            create_step_with_status(engine.store(), run.id, "step-1", 0, StepStatus::Completed)
4342                .await;
4343        let s_running =
4344            create_step_with_status(engine.store(), run.id, "step-2", 1, StepStatus::Running).await;
4345        let s_pending =
4346            create_step_with_status(engine.store(), run.id, "step-3", 2, StepStatus::Pending).await;
4347
4348        engine.fail_orphaned_steps(run.id, "timeout").await.unwrap();
4349
4350        let r_completed = engine
4351            .store()
4352            .get_step(s_completed.id)
4353            .await
4354            .unwrap()
4355            .unwrap();
4356        assert_eq!(r_completed.status.state, StepStatus::Completed);
4357
4358        let r_running = engine
4359            .store()
4360            .get_step(s_running.id)
4361            .await
4362            .unwrap()
4363            .unwrap();
4364        assert_eq!(r_running.status.state, StepStatus::Failed);
4365        assert_eq!(r_running.error.as_deref(), Some("timeout"));
4366
4367        let r_pending = engine
4368            .store()
4369            .get_step(s_pending.id)
4370            .await
4371            .unwrap()
4372            .unwrap();
4373        assert_eq!(r_pending.status.state, StepStatus::Skipped);
4374        assert!(r_pending.error.is_none());
4375    }
4376
4377    #[tokio::test]
4378    async fn fail_orphaned_steps_no_steps_is_noop() {
4379        let engine = create_test_engine();
4380        let run = engine
4381            .store()
4382            .create_run(NewRun {
4383                created_by: None,
4384                workflow_name: "test".to_string(),
4385                trigger: TriggerKind::Manual,
4386                payload: json!({}),
4387                max_retries: 0,
4388                handler_version: None,
4389                labels: HashMap::new(),
4390                scheduled_at: None,
4391                idempotency_key: None,
4392                concurrency_key: None,
4393                priority: 0,
4394                concurrency_limits: Vec::new(),
4395                max_cost_usd: None,
4396                worker_tags: Vec::new(),
4397            })
4398            .await
4399            .unwrap()
4400            .into_run();
4401
4402        let result = engine.fail_orphaned_steps(run.id, "timeout").await;
4403        assert!(result.is_ok());
4404    }
4405
4406    #[tokio::test]
4407    async fn fail_orphaned_steps_preserves_existing_error() {
4408        let engine = create_test_engine();
4409        let run = engine
4410            .store()
4411            .create_run(NewRun {
4412                created_by: None,
4413                workflow_name: "test".to_string(),
4414                trigger: TriggerKind::Manual,
4415                payload: json!({}),
4416                max_retries: 0,
4417                handler_version: None,
4418                labels: HashMap::new(),
4419                scheduled_at: None,
4420                idempotency_key: None,
4421                concurrency_key: None,
4422                priority: 0,
4423                concurrency_limits: Vec::new(),
4424                max_cost_usd: None,
4425                worker_tags: Vec::new(),
4426            })
4427            .await
4428            .unwrap()
4429            .into_run();
4430
4431        let step_with_error = create_step_with_status(
4432            engine.store(),
4433            run.id,
4434            "already-errored",
4435            0,
4436            StepStatus::Running,
4437        )
4438        .await;
4439
4440        engine
4441            .store()
4442            .update_step(
4443                step_with_error.id,
4444                StepUpdate {
4445                    error: Some("real error from provider".to_string()),
4446                    ..StepUpdate::default()
4447                },
4448            )
4449            .await
4450            .unwrap();
4451
4452        let step_no_error = create_step_with_status(
4453            engine.store(),
4454            run.id,
4455            "no-error-yet",
4456            1,
4457            StepStatus::Running,
4458        )
4459        .await;
4460
4461        engine
4462            .fail_orphaned_steps(run.id, "parent run failed")
4463            .await
4464            .unwrap();
4465
4466        let updated_with = engine
4467            .store()
4468            .get_step(step_with_error.id)
4469            .await
4470            .unwrap()
4471            .unwrap();
4472        assert_eq!(updated_with.status.state, StepStatus::Failed);
4473        assert_eq!(
4474            updated_with.error.as_deref(),
4475            Some("real error from provider"),
4476        );
4477
4478        let updated_without = engine
4479            .store()
4480            .get_step(step_no_error.id)
4481            .await
4482            .unwrap()
4483            .unwrap();
4484        assert_eq!(updated_without.status.state, StepStatus::Failed);
4485        assert_eq!(updated_without.error.as_deref(), Some("parent run failed"),);
4486    }
4487
4488    // -----------------------------------------------------------------------
4489    // spawn_local_resume tests
4490    // -----------------------------------------------------------------------
4491
4492    use std::sync::atomic::{AtomicUsize, Ordering};
4493    use std::time::Duration;
4494
4495    use ironflow_store::models::LeaseRequest;
4496    use tokio::time::sleep;
4497
4498    /// Counts its executions, then completes.
4499    struct CountingWorkflow {
4500        executions: Arc<AtomicUsize>,
4501    }
4502
4503    impl WorkflowHandler for CountingWorkflow {
4504        fn name(&self) -> &str {
4505            "local-resume-counter"
4506        }
4507
4508        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
4509            Box::pin(async move {
4510                self.executions.fetch_add(1, Ordering::SeqCst);
4511                Ok(())
4512            })
4513        }
4514    }
4515
4516    /// An engine running [`CountingWorkflow`], with one `Pending` run of it.
4517    async fn engine_with_pending_counter() -> (Arc<Engine>, Arc<AtomicUsize>, Uuid) {
4518        let executions = Arc::new(AtomicUsize::new(0));
4519        let mut engine = create_test_engine();
4520        engine
4521            .register(CountingWorkflow {
4522                executions: executions.clone(),
4523            })
4524            .unwrap();
4525        let run = engine
4526            .enqueue_handler("local-resume-counter", TriggerKind::Manual, json!({}), 0)
4527            .await
4528            .unwrap();
4529        (Arc::new(engine), executions, run.id)
4530    }
4531
4532    #[tokio::test]
4533    async fn local_resume_leaves_a_run_claimed_by_a_worker_alone() {
4534        let (engine, executions, run_id) = engine_with_pending_counter().await;
4535        let lease = LeaseRequest {
4536            worker_id: "worker-1".to_string(),
4537            ttl: Duration::from_secs(30),
4538        };
4539        let picked = engine
4540            .store()
4541            .pick_next_pending_for(Some(lease), None)
4542            .await
4543            .unwrap()
4544            .expect("the run is due");
4545        assert_eq!(picked.id, run_id);
4546
4547        engine.spawn_local_resume(run_id);
4548        sleep(Duration::from_millis(200)).await;
4549
4550        assert_eq!(executions.load(Ordering::SeqCst), 0);
4551        let run = engine.store().get_run(run_id).await.unwrap().unwrap();
4552        assert_eq!(run.status.state, RunStatus::Running);
4553        assert_eq!(run.worker_id.as_deref(), Some("worker-1"));
4554    }
4555
4556    #[tokio::test]
4557    async fn local_resume_claims_a_pending_run_once() {
4558        let (engine, executions, run_id) = engine_with_pending_counter().await;
4559
4560        engine.spawn_local_resume(run_id);
4561        engine.spawn_local_resume(run_id);
4562        sleep(Duration::from_millis(200)).await;
4563
4564        assert_eq!(executions.load(Ordering::SeqCst), 1);
4565        let run = engine.store().get_run(run_id).await.unwrap().unwrap();
4566        assert_eq!(run.status.state, RunStatus::Completed);
4567    }
4568
4569    #[tokio::test]
4570    async fn local_resume_of_a_running_run_executes_it_once() {
4571        let (engine, executions, run_id) = engine_with_pending_counter().await;
4572        engine
4573            .store()
4574            .update_run_status(run_id, RunStatus::Running)
4575            .await
4576            .unwrap();
4577
4578        engine.spawn_local_resume(run_id);
4579        engine.spawn_local_resume(run_id);
4580        sleep(Duration::from_millis(200)).await;
4581
4582        assert_eq!(executions.load(Ordering::SeqCst), 1);
4583        let run = engine.store().get_run(run_id).await.unwrap().unwrap();
4584        assert_eq!(run.status.state, RunStatus::Completed);
4585    }
4586}