Skip to main content

ironflow_engine/
engine.rs

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