Skip to main content

ironflow_engine/
engine.rs

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