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