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