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;
10use std::fmt;
11use std::sync::Arc;
12use std::time::Instant;
13
14use chrono::{DateTime, TimeDelta, Utc};
15use rust_decimal::Decimal;
16use serde_json::Value;
17use tracing::{error, info, warn};
18use uuid::Uuid;
19
20#[cfg(feature = "prometheus")]
21use ironflow_core::metric_names::{
22    RUN_BUDGET_EXCEEDED_TOTAL, RUN_COST_USD, RUN_DURATION_SECONDS, RUNS_ACTIVE, RUNS_TOTAL,
23};
24use ironflow_core::provider::AgentProvider;
25use ironflow_store::error::StoreError;
26use ironflow_store::models::{
27    NewRun, Run, RunActor, RunCreation, RunFilter, RunStatus, RunUpdate, StepStatus, StepUpdate,
28    TriggerKind,
29};
30use ironflow_store::store::Store;
31#[cfg(feature = "prometheus")]
32use metrics::{counter, gauge, histogram};
33
34use crate::artifact::ArtifactSink;
35use crate::budget::{BudgetConfig, month_start};
36use crate::context::WorkflowContext;
37use crate::error::EngineError;
38use crate::executor::StepResult;
39use crate::guard::{WorkflowGuardConfig, new_shared_guard_state};
40use crate::handler::{WorkflowHandler, WorkflowInfo};
41use crate::log_sender::LogSender;
42use crate::notify::{Event, EventPublisher, EventSubscriber, WorkflowEventBus};
43use crate::retry_policy::{backoff_for_retry, is_run_retryable};
44use crate::schedule::CronSchedule;
45
46/// Result of a workflow execution, carrying the final [`Run`] and per-step
47/// metrics collected during execution.
48///
49/// Returned by [`Engine::run_handler`], [`Engine::execute_handler_run`],
50/// [`Engine::execute_run`], and [`Engine::resume_run`].
51///
52/// # Examples
53///
54/// ```no_run
55/// use ironflow_engine::engine::WorkflowResult;
56///
57/// # fn example(result: WorkflowResult) {
58/// println!("run {} finished with {} steps", result.run.id, result.steps.len());
59/// for step in &result.steps {
60///     println!("  {} ({:?}): {}ms", step.name, step.status, step.duration_ms);
61/// }
62/// # }
63/// ```
64#[derive(Debug, Clone)]
65pub struct WorkflowResult {
66    /// The finalized run record.
67    pub run: Run,
68    /// Per-step results in execution order.
69    pub steps: Vec<StepResult>,
70}
71
72/// Optional settings for [`Engine::enqueue_handler_with_options`].
73///
74/// All fields fall back to handler or server defaults when left at their
75/// [`Default`] value.
76///
77/// # Examples
78///
79/// ```
80/// use ironflow_engine::engine::EnqueueOptions;
81/// use rust_decimal::Decimal;
82///
83/// let options = EnqueueOptions {
84///     max_retries: 3,
85///     max_cost_usd: Some(Decimal::new(50, 2)),
86///     ..Default::default()
87/// };
88/// assert_eq!(options.max_retries, 3);
89/// ```
90#[derive(Debug, Clone, Default)]
91pub struct EnqueueOptions {
92    /// Number of automatic retries granted to the run.
93    pub max_retries: u32,
94    /// Labels merged on top of the handler's default labels.
95    pub labels: HashMap<String, String>,
96    /// Defer execution until this instant instead of running as soon as a
97    /// worker picks the run up.
98    pub scheduled_at: Option<DateTime<Utc>>,
99    /// Cost cap for the run. Overrides both the handler default and the server
100    /// default. `None` falls back to
101    /// [`BudgetConfig::resolve_run_cap`](crate::budget::BudgetConfig::resolve_run_cap).
102    pub max_cost_usd: Option<Decimal>,
103    /// Authenticated principal that triggered the run. `None` for cron,
104    /// webhook, and programmatic triggers.
105    pub created_by: Option<RunActor>,
106    /// Idempotency key binding this enqueue to a single run.
107    ///
108    /// When set and already bound to a run created within
109    /// [`IDEMPOTENCY_WINDOW`](ironflow_store::entities::IDEMPOTENCY_WINDOW),
110    /// nothing is enqueued and the original run is replayed.
111    pub idempotency_key: Option<String>,
112}
113
114/// The workflow orchestration engine.
115///
116/// Holds references to the store, agent provider, and a registry of
117/// [`WorkflowHandler`]s.
118///
119/// # Examples
120///
121/// ```no_run
122/// use std::sync::Arc;
123/// use ironflow_engine::engine::Engine;
124/// use ironflow_engine::config::ShellConfig;
125/// use ironflow_engine::handler::{WorkflowHandler, HandlerFuture, WorkflowInfo};
126/// use ironflow_engine::context::WorkflowContext;
127/// use ironflow_store::memory::InMemoryStore;
128/// use ironflow_store::models::TriggerKind;
129/// use ironflow_core::providers::claude::ClaudeCodeProvider;
130/// use serde_json::json;
131///
132/// struct CiWorkflow;
133/// impl WorkflowHandler for CiWorkflow {
134///     fn name(&self) -> &str { "ci" }
135///     fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
136///         Box::pin(async move {
137///             ctx.shell("test", ShellConfig::new("cargo test")).await?;
138///             Ok(())
139///         })
140///     }
141/// }
142///
143/// # async fn example() -> Result<(), ironflow_engine::error::EngineError> {
144/// let store = Arc::new(InMemoryStore::new());
145/// let provider = Arc::new(ClaudeCodeProvider::new());
146/// let mut engine = Engine::new(store, provider);
147/// engine.register(CiWorkflow)?;
148///
149/// let result = engine.run_handler("ci", TriggerKind::Manual, json!({})).await?;
150/// tracing::info!(run_id = %result.run.id, status = ?result.run.status, steps = result.steps.len(), "run completed");
151/// # Ok(())
152/// # }
153/// ```
154pub struct Engine {
155    store: Arc<dyn Store>,
156    provider: Arc<dyn AgentProvider>,
157    handlers: HashMap<String, Arc<dyn WorkflowHandler>>,
158    event_publisher: EventPublisher,
159    log_sender: Option<LogSender>,
160    budget: BudgetConfig,
161    artifact_sink: Option<Arc<dyn ArtifactSink>>,
162    guard_config: Option<WorkflowGuardConfig>,
163    event_bus: Option<WorkflowEventBus>,
164}
165
166/// Validate a workflow category path.
167///
168/// A category is a `/`-separated list of non-empty segments. This function
169/// rejects empty paths, leading or trailing `/`, consecutive `/`, and
170/// segments containing only whitespace.
171///
172/// # Errors
173///
174/// Returns [`EngineError::InvalidWorkflow`] when the category is malformed.
175fn validate_category(handler_name: &str, category: &str) -> Result<(), EngineError> {
176    let reject = |reason: &str| {
177        Err(EngineError::InvalidWorkflow(format!(
178            "handler '{handler_name}' has invalid category '{category}': {reason}"
179        )))
180    };
181
182    if category.is_empty() {
183        return reject("empty category");
184    }
185    if category.starts_with('/') {
186        return reject("leading '/'");
187    }
188    if category.ends_with('/') {
189        return reject("trailing '/'");
190    }
191    for segment in category.split('/') {
192        if segment.is_empty() {
193            return reject("empty segment (double '/')");
194        }
195        if segment.trim().is_empty() {
196            return reject("whitespace-only segment");
197        }
198    }
199    Ok(())
200}
201
202impl Engine {
203    /// Create a new engine with the given store and agent provider.
204    ///
205    /// # Examples
206    ///
207    /// ```no_run
208    /// use std::sync::Arc;
209    /// use ironflow_engine::engine::Engine;
210    /// use ironflow_store::memory::InMemoryStore;
211    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
212    ///
213    /// let engine = Engine::new(
214    ///     Arc::new(InMemoryStore::new()),
215    ///     Arc::new(ClaudeCodeProvider::new()),
216    /// );
217    /// ```
218    pub fn new(store: Arc<dyn Store>, provider: Arc<dyn AgentProvider>) -> Self {
219        Self {
220            store,
221            provider,
222            handlers: HashMap::new(),
223            event_publisher: EventPublisher::new(),
224            log_sender: None,
225            budget: BudgetConfig::new(),
226            artifact_sink: None,
227            guard_config: None,
228            event_bus: None,
229        }
230    }
231
232    /// Apply cost guardrails to this engine.
233    ///
234    /// Without this, both the per-run cap default and the monthly quota are
235    /// disabled and the engine behaves exactly as before.
236    ///
237    /// # Examples
238    ///
239    /// ```no_run
240    /// use std::sync::Arc;
241    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
242    /// use ironflow_engine::budget::BudgetConfig;
243    /// use ironflow_engine::engine::Engine;
244    /// use ironflow_store::memory::InMemoryStore;
245    ///
246    /// let engine = Engine::new(
247    ///     Arc::new(InMemoryStore::new()),
248    ///     Arc::new(ClaudeCodeProvider::new()),
249    /// )
250    /// .with_budget_config(BudgetConfig::from_env());
251    /// ```
252    pub fn with_budget_config(mut self, budget: BudgetConfig) -> Self {
253        self.budget = budget;
254        self
255    }
256
257    /// Returns the cost guardrails applied by this engine.
258    pub fn budget_config(&self) -> &BudgetConfig {
259        &self.budget
260    }
261
262    /// Apply workflow guard configuration to this engine.
263    ///
264    /// When set, every workflow run created by this engine is protected
265    /// by the guard. Handlers can override this via
266    /// [`WorkflowHandler::guard_config`].
267    ///
268    /// # Examples
269    ///
270    /// ```no_run
271    /// use std::sync::Arc;
272    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
273    /// use ironflow_engine::engine::Engine;
274    /// use ironflow_engine::guard::WorkflowGuardConfig;
275    /// use ironflow_store::memory::InMemoryStore;
276    ///
277    /// let engine = Engine::new(
278    ///     Arc::new(InMemoryStore::new()),
279    ///     Arc::new(ClaudeCodeProvider::new()),
280    /// )
281    /// .with_guard_config(WorkflowGuardConfig::default());
282    /// ```
283    pub fn with_guard_config(mut self, config: WorkflowGuardConfig) -> Self {
284        self.guard_config = Some(config);
285        self
286    }
287
288    /// Returns the workflow guard configuration, if any.
289    pub fn guard_config(&self) -> Option<&WorkflowGuardConfig> {
290        self.guard_config.as_ref()
291    }
292
293    /// Attach a log sender for real-time step output streaming.
294    ///
295    /// When set, all workflow contexts created by this engine will forward
296    /// step output (shell stdout/stderr, agent system messages) to the
297    /// given sender.
298    pub fn set_log_sender(&mut self, sender: LogSender) {
299        self.log_sender = Some(sender);
300    }
301
302    /// Attach the backend that stores and serves artifact bytes.
303    ///
304    /// Every context this engine builds inherits it. Without one, steps that
305    /// declare artifacts fail explicitly and every other step is unaffected.
306    ///
307    /// # Examples
308    ///
309    /// ```no_run
310    /// use std::sync::Arc;
311    ///
312    /// use ironflow_engine::artifact::ArtifactSink;
313    /// use ironflow_engine::engine::Engine;
314    ///
315    /// # fn example(engine: &mut Engine, sink: Arc<dyn ArtifactSink>) {
316    /// engine.set_artifact_sink(sink);
317    /// # }
318    /// ```
319    pub fn set_artifact_sink(&mut self, sink: Arc<dyn ArtifactSink>) {
320        self.artifact_sink = Some(sink);
321    }
322
323    /// The artifact backend attached to this engine, if any.
324    pub fn artifact_sink(&self) -> Option<&Arc<dyn ArtifactSink>> {
325        self.artifact_sink.as_ref()
326    }
327
328    /// Attach a [`WorkflowEventBus`] for per-run real-time monitoring.
329    ///
330    /// When set, all workflow contexts created by this engine will publish
331    /// step-level events (`StepStarted`, `StepCompleted`, `StepFailed`)
332    /// to the bus.
333    ///
334    /// # Examples
335    ///
336    /// ```no_run
337    /// use ironflow_engine::engine::Engine;
338    /// use ironflow_engine::notify::WorkflowEventBus;
339    ///
340    /// # fn example(engine: &mut Engine) {
341    /// engine.set_event_bus(WorkflowEventBus::new());
342    /// # }
343    /// ```
344    pub fn set_event_bus(&mut self, bus: WorkflowEventBus) {
345        self.event_bus = Some(bus);
346    }
347
348    /// The workflow event bus attached to this engine, if any.
349    pub fn event_bus(&self) -> Option<&WorkflowEventBus> {
350        self.event_bus.as_ref()
351    }
352
353    /// Returns a reference to the backing store.
354    pub fn store(&self) -> &Arc<dyn Store> {
355        &self.store
356    }
357
358    /// Returns a reference to the agent provider.
359    pub fn provider(&self) -> &Arc<dyn AgentProvider> {
360        &self.provider
361    }
362
363    /// Build a [`WorkflowContext`] with access to the handler registry.
364    ///
365    /// The context is seeded with the run's attempt number and the cost and
366    /// duration already accumulated by previous attempts, so a retried run
367    /// reports the total it really consumed rather than only its last attempt.
368    ///
369    /// The run's persisted `max_cost_usd` becomes the context cost cap; `None`
370    /// disables the per-run budget check for that context.
371    fn build_context(&self, run: &Run) -> WorkflowContext {
372        let handlers = self.handlers.clone();
373        let resolver: crate::context::HandlerResolver =
374            Arc::new(move |name: &str| handlers.get(name).cloned());
375        let mut ctx = WorkflowContext::with_handler_resolver(
376            run.id,
377            self.store.clone(),
378            self.provider.clone(),
379            resolver,
380        );
381        ctx.carry_over_run_totals(run.retry_count + 1, run.cost_usd, run.duration_ms);
382        ctx.set_max_cost_usd(run.max_cost_usd);
383        if let Some(ref sender) = self.log_sender {
384            ctx.set_log_sender(sender.clone());
385        }
386        if let Some(ref sink) = self.artifact_sink {
387            ctx.set_artifact_sink(sink.clone());
388        }
389        if let Some(ref bus) = self.event_bus {
390            ctx.set_event_bus(bus.clone());
391        }
392        ctx
393    }
394
395    /// Build a context with the guard attached.
396    ///
397    /// Uses the handler's `guard_config()` if present, otherwise falls back
398    /// to the engine's global configuration. Creates a fresh shared state
399    /// for each top-level run.
400    fn build_context_with_guard(
401        &self,
402        run: &Run,
403        handler: &dyn WorkflowHandler,
404    ) -> WorkflowContext {
405        let mut ctx = self.build_context(run);
406        let guard_config = handler.guard_config().or_else(|| self.guard_config.clone());
407        if let Some(config) = guard_config {
408            ctx.set_guard(config, new_shared_guard_state());
409        }
410        ctx
411    }
412
413    /// Reject the creation of a new run when the monthly quota is exhausted.
414    ///
415    /// The window is the current calendar month in UTC. Runs already in flight
416    /// are never interrupted -- only creation is refused.
417    ///
418    /// # Errors
419    ///
420    /// Returns [`EngineError::MonthlyBudgetExceeded`] when the accumulated cost
421    /// of the month has reached the configured quota. Returns
422    /// [`EngineError::Store`] when the aggregate query fails.
423    async fn check_monthly_quota(&self, workflow_name: &str) -> Result<(), EngineError> {
424        let Some(limit) = self.budget.monthly_cost_limit_usd else {
425            return Ok(());
426        };
427
428        let stats = self
429            .store
430            .get_stats(RunFilter {
431                created_after: Some(month_start(Utc::now())),
432                ..RunFilter::default()
433            })
434            .await?;
435
436        if stats.total_cost_usd < limit {
437            return Ok(());
438        }
439
440        warn!(
441            workflow = %workflow_name,
442            limit_usd = %limit,
443            spent_usd = %stats.total_cost_usd,
444            "monthly cost quota exhausted, refusing new run"
445        );
446
447        #[cfg(feature = "prometheus")]
448        counter!(
449            RUN_BUDGET_EXCEEDED_TOTAL,
450            "workflow" => workflow_name.to_string(),
451            "scope" => "monthly",
452        )
453        .increment(1);
454
455        Err(EngineError::MonthlyBudgetExceeded {
456            limit_usd: limit,
457            spent_usd: stats.total_cost_usd,
458        })
459    }
460
461    // -----------------------------------------------------------------------
462    // Handler registration
463    // -----------------------------------------------------------------------
464
465    /// Register a [`WorkflowHandler`] for dynamic workflow execution.
466    ///
467    /// The handler is looked up by [`WorkflowHandler::name`] when executing
468    /// or enqueuing.
469    ///
470    /// # Errors
471    ///
472    /// Returns [`EngineError::InvalidWorkflow`] if a handler with the same
473    /// name is already registered.
474    ///
475    /// # Examples
476    ///
477    /// ```no_run
478    /// use std::sync::Arc;
479    /// use ironflow_engine::engine::Engine;
480    /// use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
481    /// use ironflow_engine::context::WorkflowContext;
482    /// use ironflow_engine::config::ShellConfig;
483    /// use ironflow_store::memory::InMemoryStore;
484    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
485    ///
486    /// struct MyWorkflow;
487    /// impl WorkflowHandler for MyWorkflow {
488    ///     fn name(&self) -> &str { "my-workflow" }
489    ///     fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
490    ///         Box::pin(async move {
491    ///             ctx.shell("step1", ShellConfig::new("echo done")).await?;
492    ///             Ok(())
493    ///         })
494    ///     }
495    /// }
496    ///
497    /// let mut engine = Engine::new(
498    ///     Arc::new(InMemoryStore::new()),
499    ///     Arc::new(ClaudeCodeProvider::new()),
500    /// );
501    /// engine.register(MyWorkflow)?;
502    /// # Ok::<(), ironflow_engine::error::EngineError>(())
503    /// ```
504    pub fn register(&mut self, handler: impl WorkflowHandler + 'static) -> Result<(), EngineError> {
505        let name = handler.name().to_string();
506        if self.handlers.contains_key(&name) {
507            return Err(EngineError::InvalidWorkflow(format!(
508                "handler '{}' already registered",
509                name
510            )));
511        }
512        if let Some(category) = handler.category() {
513            validate_category(&name, category)?;
514        }
515        self.handlers.insert(name, Arc::new(handler));
516        Ok(())
517    }
518
519    /// Register a pre-boxed workflow handler.
520    ///
521    /// # Errors
522    ///
523    /// Returns [`EngineError::InvalidWorkflow`] if a handler with the same
524    /// name is already registered or if its category is invalid.
525    pub fn register_boxed(&mut self, handler: Box<dyn WorkflowHandler>) -> Result<(), EngineError> {
526        let name = handler.name().to_string();
527        if self.handlers.contains_key(&name) {
528            return Err(EngineError::InvalidWorkflow(format!(
529                "handler '{}' already registered",
530                name
531            )));
532        }
533        if let Some(category) = handler.category() {
534            validate_category(&name, category)?;
535        }
536        self.handlers.insert(name, Arc::from(handler));
537        Ok(())
538    }
539
540    /// Get a registered handler by name.
541    pub fn get_handler(&self, name: &str) -> Option<&Arc<dyn WorkflowHandler>> {
542        self.handlers.get(name)
543    }
544
545    /// List registered handler names.
546    pub fn handler_names(&self) -> Vec<&str> {
547        self.handlers.keys().map(|s| s.as_str()).collect()
548    }
549
550    /// Get detailed info about a registered workflow handler.
551    pub fn handler_info(&self, name: &str) -> Option<WorkflowInfo> {
552        self.handlers.get(name).map(|h| h.describe())
553    }
554
555    /// List handlers that have a cron schedule configured.
556    ///
557    /// Returns pairs of `(workflow_name, cron_expression)` for all handlers
558    /// where [`WorkflowHandler::schedule`] returns `Some`.
559    ///
560    /// Use this to wire scheduled handlers into a cron scheduler
561    /// (e.g. `ironflow_runtime::Runtime::cron`).
562    ///
563    /// # Examples
564    ///
565    /// ```no_run
566    /// # use std::sync::Arc;
567    /// # use ironflow_engine::engine::Engine;
568    /// # use ironflow_store::memory::InMemoryStore;
569    /// # use ironflow_core::providers::claude::ClaudeCodeProvider;
570    /// let engine = Engine::new(
571    ///     Arc::new(InMemoryStore::new()),
572    ///     Arc::new(ClaudeCodeProvider::new()),
573    /// );
574    /// for (name, schedule) in engine.scheduled_handlers() {
575    ///     tracing::info!("{name} runs on schedule: {schedule}");
576    /// }
577    /// ```
578    pub fn scheduled_handlers(&self) -> Vec<(&str, &CronSchedule)> {
579        self.handlers
580            .iter()
581            .filter_map(|(name, handler)| handler.schedule().map(|sched| (name.as_str(), sched)))
582            .collect()
583    }
584
585    /// Register an event subscriber for domain events.
586    ///
587    /// The subscriber is called only for events whose type is in
588    /// `event_types`. Pass [`Event::ALL`] to receive every event.
589    ///
590    /// # Examples
591    ///
592    /// ```no_run
593    /// use ironflow_engine::engine::Engine;
594    /// use ironflow_engine::notify::{Event, WebhookSubscriber};
595    /// use ironflow_store::memory::InMemoryStore;
596    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
597    /// use std::sync::Arc;
598    ///
599    /// let mut engine = Engine::new(
600    ///     Arc::new(InMemoryStore::new()),
601    ///     Arc::new(ClaudeCodeProvider::new()),
602    /// );
603    ///
604    /// engine.subscribe(
605    ///     WebhookSubscriber::new("https://hooks.example.com/events"),
606    ///     &[Event::RUN_STATUS_CHANGED, Event::STEP_FAILED],
607    /// );
608    /// ```
609    pub fn subscribe(
610        &mut self,
611        subscriber: impl EventSubscriber + 'static,
612        event_types: &[&'static str],
613    ) {
614        self.event_publisher.subscribe(subscriber, event_types);
615    }
616
617    /// Returns a reference to the event publisher.
618    ///
619    /// Useful for publishing events from outside the engine (e.g. auth
620    /// routes in the API layer).
621    pub fn event_publisher(&self) -> &EventPublisher {
622        &self.event_publisher
623    }
624
625    // -----------------------------------------------------------------------
626    // Dynamic workflow execution (WorkflowHandler)
627    // -----------------------------------------------------------------------
628
629    /// Execute a registered handler inline.
630    ///
631    /// Creates a run, builds a [`WorkflowContext`], calls the handler's
632    /// [`execute`](WorkflowHandler::execute), and finalizes the run.
633    ///
634    /// # Errors
635    ///
636    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered
637    /// with that name. Returns [`EngineError`] if execution fails.
638    ///
639    /// # Examples
640    ///
641    /// ```no_run
642    /// use std::sync::Arc;
643    /// use ironflow_engine::engine::Engine;
644    /// use ironflow_store::memory::InMemoryStore;
645    /// use ironflow_store::models::TriggerKind;
646    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
647    /// use serde_json::json;
648    ///
649    /// # async fn example(engine: &Engine) -> Result<(), ironflow_engine::error::EngineError> {
650    /// let run = engine.run_handler("deploy", TriggerKind::Manual, json!({})).await?;
651    /// # Ok(())
652    /// # }
653    /// ```
654    #[tracing::instrument(name = "engine.run_handler", skip_all, fields(workflow = %handler_name))]
655    pub async fn run_handler(
656        &self,
657        handler_name: &str,
658        trigger: TriggerKind,
659        payload: Value,
660    ) -> Result<WorkflowResult, EngineError> {
661        let handler = self
662            .handlers
663            .get(handler_name)
664            .ok_or_else(|| {
665                EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
666            })?
667            .clone();
668
669        self.check_monthly_quota(handler_name).await?;
670
671        let handler_version = handler.version().map(str::to_string);
672        let max_cost_usd = self
673            .budget
674            .resolve_run_cap(None, handler.default_max_cost_usd());
675        let run = self
676            .store
677            .create_run(NewRun {
678                created_by: None,
679                workflow_name: handler_name.to_string(),
680                trigger,
681                payload,
682                max_retries: 0,
683                handler_version,
684                labels: handler.default_labels(),
685                scheduled_at: None,
686                idempotency_key: None,
687                max_cost_usd,
688            })
689            .await?
690            .into_run();
691
692        let run_id = run.id;
693        info!(run_id = %run_id, handler_version = run.handler_version.as_deref().unwrap_or(""), "run created");
694
695        self.store
696            .update_run_status(run_id, RunStatus::Running)
697            .await?;
698
699        #[cfg(feature = "prometheus")]
700        gauge!(RUNS_ACTIVE, "workflow" => handler_name.to_string()).increment(1.0);
701
702        let run_start = Instant::now();
703        let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
704
705        let result = handler.execute(&mut ctx).await;
706        self.finalize_run(run_id, handler_name, result, &ctx, run_start, run.labels)
707            .await
708    }
709
710    /// Enqueue a handler-based workflow for worker execution.
711    ///
712    /// The workflow name is stored in the run. The worker looks up the
713    /// handler by name when executing.
714    ///
715    /// # Errors
716    ///
717    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered.
718    /// Returns [`EngineError::MonthlyBudgetExceeded`] if the monthly cost quota
719    /// is exhausted.
720    #[tracing::instrument(name = "engine.enqueue_handler", skip_all, fields(workflow = %handler_name))]
721    pub async fn enqueue_handler(
722        &self,
723        handler_name: &str,
724        trigger: TriggerKind,
725        payload: Value,
726        max_retries: u32,
727    ) -> Result<Run, EngineError> {
728        self.enqueue_handler_with_options(
729            handler_name,
730            trigger,
731            payload,
732            EnqueueOptions {
733                max_retries,
734                ..Default::default()
735            },
736        )
737        .await
738        .map(RunCreation::into_run)
739    }
740
741    /// Enqueue a handler-based workflow with labels, deferred scheduling, an
742    /// optional cost cap, an optional author, and an optional idempotency key.
743    ///
744    /// See [`EnqueueOptions`] for the individual settings.
745    ///
746    /// When [`EnqueueOptions::idempotency_key`] is set and already bound to a run
747    /// created within
748    /// [`IDEMPOTENCY_WINDOW`](ironflow_store::entities::IDEMPOTENCY_WINDOW), nothing
749    /// is enqueued and the original run is returned as [`RunCreation::Existing`].
750    ///
751    /// # Errors
752    ///
753    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered.
754    /// Returns [`EngineError::MonthlyBudgetExceeded`] if the monthly cost quota
755    /// is exhausted. Returns [`EngineError::Store`] if the run cannot be
756    /// persisted.
757    ///
758    /// # Examples
759    ///
760    /// ```no_run
761    /// use ironflow_engine::engine::{Engine, EnqueueOptions};
762    /// use ironflow_store::models::TriggerKind;
763    /// use serde_json::json;
764    ///
765    /// # async fn example(engine: &Engine) -> Result<(), ironflow_engine::error::EngineError> {
766    /// let creation = engine
767    ///     .enqueue_handler_with_options(
768    ///         "deploy",
769    ///         TriggerKind::Api,
770    ///         json!({"env": "prod"}),
771    ///         EnqueueOptions {
772    ///             max_retries: 3,
773    ///             idempotency_key: Some("github:abc-123".to_string()),
774    ///             ..Default::default()
775    ///         },
776    ///     )
777    ///     .await?;
778    ///
779    /// if creation.is_created() {
780    ///     println!("enqueued {}", creation.run().id);
781    /// }
782    /// # Ok(())
783    /// # }
784    /// ```
785    #[tracing::instrument(name = "engine.enqueue_handler_with_options", skip_all, fields(workflow = %handler_name))]
786    pub async fn enqueue_handler_with_options(
787        &self,
788        handler_name: &str,
789        trigger: TriggerKind,
790        payload: Value,
791        options: EnqueueOptions,
792    ) -> Result<RunCreation, EngineError> {
793        let EnqueueOptions {
794            max_retries,
795            labels,
796            scheduled_at,
797            max_cost_usd,
798            created_by,
799            idempotency_key,
800        } = options;
801
802        let handler = self.handlers.get(handler_name).ok_or_else(|| {
803            EngineError::InvalidWorkflow(format!("no handler registered: {handler_name}"))
804        })?;
805
806        self.check_monthly_quota(handler_name).await?;
807
808        let handler_version = handler.version().map(str::to_string);
809        let mut merged_labels = handler.default_labels();
810        merged_labels.extend(labels);
811        let resolved_cap = self
812            .budget
813            .resolve_run_cap(max_cost_usd, handler.default_max_cost_usd());
814
815        let creation = self
816            .store
817            .create_run(NewRun {
818                workflow_name: handler_name.to_string(),
819                trigger,
820                payload,
821                max_retries,
822                handler_version,
823                labels: merged_labels,
824                scheduled_at,
825                created_by,
826                idempotency_key,
827                max_cost_usd: resolved_cap,
828            })
829            .await?;
830
831        match &creation {
832            RunCreation::Created(run) => info!(
833                run_id = %run.id,
834                workflow = %handler_name,
835                max_cost_usd = ?resolved_cap,
836                "handler run enqueued"
837            ),
838            RunCreation::Existing(run) => info!(
839                run_id = %run.id,
840                workflow = %handler_name,
841                "idempotent replay, nothing enqueued"
842            ),
843        }
844
845        Ok(creation)
846    }
847
848    /// Execute a handler-based run (used by the worker after pick_next_pending).
849    ///
850    /// Looks up the handler by the run's `workflow_name` and executes it
851    /// with a fresh [`WorkflowContext`].
852    ///
853    /// # Errors
854    ///
855    /// Returns [`EngineError::InvalidWorkflow`] if no handler matches.
856    #[tracing::instrument(name = "engine.execute_handler_run", skip_all, fields(run_id = %run_id))]
857    pub async fn execute_handler_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
858        let run = self
859            .store
860            .get_run(run_id)
861            .await?
862            .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
863
864        let handler = self
865            .handlers
866            .get(&run.workflow_name)
867            .ok_or_else(|| {
868                EngineError::InvalidWorkflow(format!(
869                    "no handler registered: {}",
870                    run.workflow_name
871                ))
872            })?
873            .clone();
874
875        #[cfg(feature = "prometheus")]
876        gauge!(RUNS_ACTIVE, "workflow" => run.workflow_name.clone()).increment(1.0);
877
878        let run_start = Instant::now();
879        let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
880
881        // On a retry the whole run is replayed from the start, but an approval a
882        // human already granted must not be asked again.
883        if run.retry_count > 0 {
884            ctx.load_replay_steps().await?;
885        }
886
887        let result = handler.execute(&mut ctx).await;
888        self.finalize_run(
889            run_id,
890            &run.workflow_name,
891            result,
892            &ctx,
893            run_start,
894            run.labels,
895        )
896        .await
897    }
898
899    /// Execute a run by its ID (used by the worker after pick_next_pending).
900    ///
901    /// Delegates to [`execute_handler_run`](Self::execute_handler_run).
902    ///
903    /// # Errors
904    ///
905    /// Returns [`EngineError`] if the run is not found or execution fails.
906    #[tracing::instrument(name = "engine.execute_run", skip_all, fields(run_id = %run_id))]
907    pub async fn execute_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
908        self.execute_handler_run(run_id).await
909    }
910
911    /// Resume a run after human approval.
912    ///
913    /// Re-executes the handler with step replay: completed steps return
914    /// cached output, approved approval steps are skipped, and execution
915    /// continues from the first unexecuted step.
916    ///
917    /// Supports multiple approval gates -- each resume replays all prior
918    /// steps and stops at the next approval (or completes the run).
919    ///
920    /// # Errors
921    ///
922    /// Returns [`EngineError::InvalidWorkflow`] if no handler matches.
923    /// Returns [`EngineError`] if execution fails or hits another approval.
924    #[tracing::instrument(name = "engine.resume_run", skip_all, fields(run_id = %run_id))]
925    pub async fn resume_run(&self, run_id: Uuid) -> Result<WorkflowResult, EngineError> {
926        let run = self
927            .store
928            .get_run(run_id)
929            .await?
930            .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
931
932        let handler = self
933            .handlers
934            .get(&run.workflow_name)
935            .ok_or_else(|| {
936                EngineError::InvalidWorkflow(format!(
937                    "no handler registered: {}",
938                    run.workflow_name
939                ))
940            })?
941            .clone();
942
943        info!(run_id = %run_id, workflow = %run.workflow_name, "resuming run after approval");
944
945        let run_start = Instant::now();
946        let mut ctx = self.build_context_with_guard(&run, handler.as_ref());
947        ctx.load_replay_steps().await?;
948
949        let result = handler.execute(&mut ctx).await;
950        self.finalize_run(
951            run_id,
952            &run.workflow_name,
953            result,
954            &ctx,
955            run_start,
956            run.labels,
957        )
958        .await
959    }
960
961    /// Record a run failure, replaying the run later when retries remain.
962    ///
963    /// This is the single place where a failed run's fate is decided. When
964    /// `retryable` is true and the run has not exhausted `max_retries`, the run
965    /// moves to [`RunStatus::Retrying`] with `scheduled_at` set to
966    /// `now + backoff` -- [`pick_next_pending`](ironflow_store::store::RunStore::pick_next_pending)
967    /// picks it up again once that time has passed. Otherwise the run moves to
968    /// [`RunStatus::Failed`].
969    ///
970    /// Either way, steps left non-terminal by the failed attempt are closed via
971    /// [`fail_orphaned_steps`](Self::fail_orphaned_steps) so they are never
972    /// confused with the next attempt's steps.
973    ///
974    /// Callers pass `retryable` explicitly rather than an error value, because
975    /// the worker classifies failures it observes from the outside (a timeout, a
976    /// panicked task) that never produce an [`EngineError`]. Use
977    /// [`is_run_retryable`] to classify an
978    /// [`EngineError`].
979    ///
980    /// `cost_usd` and `duration_ms` are `None` when the caller does not know the
981    /// totals (a timeout or a panic observed from outside the handler); the
982    /// values already stored on the run are then left untouched.
983    ///
984    /// Returns the status the run was moved to.
985    ///
986    /// # Errors
987    ///
988    /// Returns [`EngineError::Store`] if the run does not exist or the update
989    /// cannot be persisted.
990    ///
991    /// # Examples
992    ///
993    /// ```no_run
994    /// use ironflow_engine::engine::Engine;
995    /// use ironflow_engine::error::EngineError;
996    /// use uuid::Uuid;
997    ///
998    /// # async fn example(engine: &Engine, run_id: Uuid) -> Result<(), EngineError> {
999    /// let status = engine
1000    ///     .fail_or_schedule_retry(run_id, "run timed out after 300s", true, None, None)
1001    ///     .await?;
1002    /// # Ok(())
1003    /// # }
1004    /// ```
1005    pub async fn fail_or_schedule_retry(
1006        &self,
1007        run_id: Uuid,
1008        error: &str,
1009        retryable: bool,
1010        cost_usd: Option<Decimal>,
1011        duration_ms: Option<u64>,
1012    ) -> Result<RunStatus, EngineError> {
1013        let run = self
1014            .store
1015            .get_run(run_id)
1016            .await?
1017            .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))?;
1018
1019        let has_attempts_left = run.retry_count < run.max_retries;
1020        let update = if retryable && has_attempts_left {
1021            let backoff = backoff_for_retry(run.retry_count);
1022            let scheduled_at = Utc::now() + TimeDelta::milliseconds(backoff.as_millis() as i64);
1023
1024            info!(
1025                run_id = %run_id,
1026                workflow = %run.workflow_name,
1027                attempt = run.retry_count + 1,
1028                max_retries = run.max_retries,
1029                backoff_secs = backoff.as_secs(),
1030                scheduled_at = %scheduled_at,
1031                "run failed, scheduling retry"
1032            );
1033
1034            RunUpdate {
1035                status: Some(RunStatus::Retrying),
1036                error: Some(error.to_string()),
1037                increment_retry: true,
1038                cost_usd,
1039                duration_ms,
1040                scheduled_at: Some(scheduled_at),
1041                ..RunUpdate::default()
1042            }
1043        } else {
1044            RunUpdate {
1045                status: Some(RunStatus::Failed),
1046                error: Some(error.to_string()),
1047                cost_usd,
1048                duration_ms,
1049                completed_at: Some(Utc::now()),
1050                ..RunUpdate::default()
1051            }
1052        };
1053
1054        let status = update.status.unwrap_or(RunStatus::Failed);
1055        self.store.update_run(run_id, update).await?;
1056        self.fail_orphaned_steps(run_id, error).await?;
1057
1058        Ok(status)
1059    }
1060
1061    /// Fail all non-terminal steps for a run.
1062    ///
1063    /// Called after a run is marked as failed (timeout, error, panic) to clean up
1064    /// orphaned steps that are still in `Running`, `Pending`, or `AwaitingApproval`.
1065    ///
1066    /// - `Running` / `AwaitingApproval` steps are marked `Failed`.
1067    /// - `Pending` steps are marked `Skipped` (FSM does not allow Pending -> Failed).
1068    ///
1069    /// Errors from individual step updates are logged but do not abort the cleanup.
1070    ///
1071    /// # Errors
1072    ///
1073    /// Returns [`EngineError`] if listing steps fails.
1074    pub async fn fail_orphaned_steps(
1075        &self,
1076        run_id: Uuid,
1077        error_message: &str,
1078    ) -> Result<(), EngineError> {
1079        let steps = self.store.list_steps(run_id).await?;
1080        let now = Utc::now();
1081
1082        for step in steps {
1083            if step.status.state.is_terminal() {
1084                continue;
1085            }
1086
1087            let (target_status, error) = match step.status.state {
1088                StepStatus::Running | StepStatus::AwaitingApproval => {
1089                    let err = if step.error.is_some() {
1090                        None
1091                    } else {
1092                        Some(error_message.to_string())
1093                    };
1094                    (StepStatus::Failed, err)
1095                }
1096                StepStatus::Pending => (StepStatus::Skipped, None),
1097                _ => continue,
1098            };
1099
1100            if let Err(e) = self
1101                .store
1102                .update_step(
1103                    step.id,
1104                    StepUpdate {
1105                        status: Some(target_status),
1106                        error,
1107                        completed_at: Some(now),
1108                        ..StepUpdate::default()
1109                    },
1110                )
1111                .await
1112            {
1113                warn!(
1114                    run_id = %run_id,
1115                    step_id = %step.id,
1116                    step_name = %step.name,
1117                    error = %e,
1118                    "failed to cleanup orphaned step"
1119                );
1120            } else {
1121                info!(
1122                    run_id = %run_id,
1123                    step_id = %step.id,
1124                    step_name = %step.name,
1125                    from = %step.status.state,
1126                    to = %target_status,
1127                    "cleaned up orphaned step"
1128                );
1129            }
1130        }
1131
1132        Ok(())
1133    }
1134
1135    /// Finalize a run with the given result and context.
1136    ///
1137    /// On success: updates run to Completed with cost, duration, and completed_at.
1138    /// On failure: updates run to Failed with error, cost, duration, and completed_at.
1139    /// Always: fetches and returns the final Run.
1140    async fn finalize_run(
1141        &self,
1142        run_id: Uuid,
1143        workflow_name: &str,
1144        result: Result<(), EngineError>,
1145        ctx: &WorkflowContext,
1146        run_start: Instant,
1147        run_labels: HashMap<String, String>,
1148    ) -> Result<WorkflowResult, EngineError> {
1149        // Covers the whole run: previous attempts plus this one, so a retried
1150        // run reports the time it really consumed.
1151        let total_duration = ctx.carried_duration_ms() + run_start.elapsed().as_millis() as u64;
1152        let completed_at = Utc::now();
1153
1154        let final_status;
1155        let final_run;
1156
1157        match result {
1158            Ok(()) => {
1159                final_status = if ctx.has_allowed_failure() {
1160                    RunStatus::Warning
1161                } else {
1162                    RunStatus::Completed
1163                };
1164                final_run = self
1165                    .store
1166                    .update_run_returning(
1167                        run_id,
1168                        RunUpdate {
1169                            status: Some(final_status),
1170                            cost_usd: Some(ctx.total_cost_usd()),
1171                            duration_ms: Some(total_duration),
1172                            completed_at: Some(completed_at),
1173                            ..RunUpdate::default()
1174                        },
1175                    )
1176                    .await?;
1177
1178                info!(
1179                    run_id = %run_id,
1180                    status = %final_status,
1181                    cost_usd = %ctx.total_cost_usd(),
1182                    duration_ms = total_duration,
1183                    "run completed"
1184                );
1185            }
1186            Err(EngineError::ApprovalRequired {
1187                run_id: approval_run_id,
1188                step_id,
1189                ref message,
1190            }) => {
1191                final_status = RunStatus::AwaitingApproval;
1192                final_run = self
1193                    .store
1194                    .update_run_returning(
1195                        run_id,
1196                        RunUpdate {
1197                            status: Some(RunStatus::AwaitingApproval),
1198                            cost_usd: Some(ctx.total_cost_usd()),
1199                            duration_ms: Some(total_duration),
1200                            ..RunUpdate::default()
1201                        },
1202                    )
1203                    .await?;
1204
1205                info!(
1206                    run_id = %approval_run_id,
1207                    step_id = %step_id,
1208                    message = %message,
1209                    "run awaiting approval"
1210                );
1211            }
1212            Err(err) => {
1213                // A guardrail stop (budget or workflow guard) is deliberate,
1214                // not a breakage: the run is cancelled, never failed and
1215                // never replayed.
1216                let guardrail_stop = matches!(
1217                    err,
1218                    EngineError::RunBudgetExceeded { .. } | EngineError::WorkflowGuardRejected(_)
1219                );
1220
1221                final_status = if guardrail_stop {
1222                    if let Err(store_err) = self
1223                        .store
1224                        .update_run(
1225                            run_id,
1226                            RunUpdate {
1227                                status: Some(RunStatus::Cancelled),
1228                                error: Some(err.to_string()),
1229                                cost_usd: Some(ctx.total_cost_usd()),
1230                                duration_ms: Some(total_duration),
1231                                completed_at: Some(completed_at),
1232                                ..RunUpdate::default()
1233                            },
1234                        )
1235                        .await
1236                    {
1237                        error!(run_id = %run_id, store_error = %store_err, "failed to persist run cancellation");
1238                    }
1239                    if let Err(cleanup_err) = self
1240                        .fail_orphaned_steps(run_id, "run stopped: guardrail limit reached")
1241                        .await
1242                    {
1243                        error!(run_id = %run_id, store_error = %cleanup_err, "failed to cleanup orphaned steps");
1244                    }
1245                    RunStatus::Cancelled
1246                } else {
1247                    self.fail_or_schedule_retry(
1248                        run_id,
1249                        &err.to_string(),
1250                        is_run_retryable(&err),
1251                        Some(ctx.total_cost_usd()),
1252                        Some(total_duration),
1253                    )
1254                    .await
1255                    .unwrap_or_else(|store_err| {
1256                        error!(run_id = %run_id, store_error = %store_err, "failed to persist run failure");
1257                        RunStatus::Failed
1258                    })
1259                };
1260
1261                if matches!(err, EngineError::RunBudgetExceeded { .. }) {
1262                    self.on_run_budget_exceeded(workflow_name, run_id, &err);
1263                }
1264
1265                error!(run_id = %run_id, status = %final_status, error = %err, "run stopped");
1266
1267                self.publish_run_status_changed(
1268                    workflow_name,
1269                    run_id,
1270                    final_status,
1271                    Some(err.to_string()),
1272                    ctx,
1273                    total_duration,
1274                    run_labels,
1275                );
1276
1277                #[cfg(feature = "prometheus")]
1278                self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
1279
1280                return Err(err);
1281            }
1282        }
1283
1284        self.publish_run_status_changed(
1285            workflow_name,
1286            run_id,
1287            final_status,
1288            None,
1289            ctx,
1290            total_duration,
1291            run_labels,
1292        );
1293
1294        #[cfg(feature = "prometheus")]
1295        self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
1296
1297        Ok(WorkflowResult {
1298            run: final_run,
1299            steps: ctx.step_results().to_vec(),
1300        })
1301    }
1302
1303    /// Emit Prometheus metrics for a completed run.
1304    #[cfg(feature = "prometheus")]
1305    fn emit_run_metrics(
1306        &self,
1307        workflow_name: &str,
1308        status: RunStatus,
1309        duration_ms: u64,
1310        ctx: &WorkflowContext,
1311    ) {
1312        let status_str = status.to_string();
1313        let wf = workflow_name.to_string();
1314
1315        counter!(RUNS_TOTAL, "workflow" => wf.clone(), "status" => status_str.clone()).increment(1);
1316        histogram!(RUN_DURATION_SECONDS, "workflow" => wf.clone(), "status" => status_str)
1317            .record(duration_ms as f64 / 1000.0);
1318        histogram!(RUN_COST_USD, "workflow" => wf.clone()).record(
1319            ctx.total_cost_usd()
1320                .to_string()
1321                .parse::<f64>()
1322                .unwrap_or(0.0),
1323        );
1324        gauge!(RUNS_ACTIVE, "workflow" => wf).decrement(1.0);
1325    }
1326
1327    /// Record the metric and publish the audit event for a run that hit its
1328    /// cost cap.
1329    ///
1330    /// A non-[`RunBudgetExceeded`](EngineError::RunBudgetExceeded) error is
1331    /// ignored, so callers can pass the error unconditionally.
1332    fn on_run_budget_exceeded(&self, workflow_name: &str, run_id: Uuid, err: &EngineError) {
1333        let EngineError::RunBudgetExceeded {
1334            limit_usd,
1335            spent_usd,
1336            step_budget_usd,
1337            ..
1338        } = err
1339        else {
1340            return;
1341        };
1342
1343        #[cfg(feature = "prometheus")]
1344        counter!(
1345            RUN_BUDGET_EXCEEDED_TOTAL,
1346            "workflow" => workflow_name.to_string(),
1347            "scope" => "run",
1348        )
1349        .increment(1);
1350
1351        self.event_publisher.publish(Event::RunBudgetExceeded {
1352            run_id,
1353            workflow_name: workflow_name.to_string(),
1354            limit_usd: *limit_usd,
1355            spent_usd: *spent_usd,
1356            step_budget_usd: *step_budget_usd,
1357            at: Utc::now(),
1358        });
1359    }
1360
1361    /// Publish a run status changed event to all registered subscribers.
1362    ///
1363    /// `from` is always `Running` because `finalize_run` is only called
1364    /// from a running state.
1365    #[allow(clippy::too_many_arguments)]
1366    fn publish_run_status_changed(
1367        &self,
1368        workflow_name: &str,
1369        run_id: Uuid,
1370        to: RunStatus,
1371        error: Option<String>,
1372        ctx: &WorkflowContext,
1373        duration_ms: u64,
1374        labels: HashMap<String, String>,
1375    ) {
1376        let now = Utc::now();
1377        let cost_usd = ctx.total_cost_usd();
1378        let wf = workflow_name.to_string();
1379
1380        self.event_publisher.publish(Event::RunStatusChanged {
1381            run_id,
1382            workflow_name: wf.clone(),
1383            from: RunStatus::Running,
1384            to,
1385            error: error.clone(),
1386            cost_usd,
1387            duration_ms,
1388            labels: labels.clone(),
1389            at: now,
1390        });
1391
1392        if to == RunStatus::Failed {
1393            self.event_publisher.publish(Event::RunFailed {
1394                run_id,
1395                workflow_name: wf,
1396                error,
1397                cost_usd,
1398                duration_ms,
1399                labels,
1400                at: now,
1401            });
1402        }
1403    }
1404}
1405
1406impl fmt::Debug for Engine {
1407    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1408        f.debug_struct("Engine")
1409            .field("handlers", &self.handlers.keys().collect::<Vec<_>>())
1410            .finish_non_exhaustive()
1411    }
1412}
1413
1414#[cfg(test)]
1415mod tests {
1416    use super::*;
1417    use crate::config::ShellConfig;
1418    use crate::handler::{HandlerFuture, WorkflowHandler};
1419    use ironflow_core::providers::claude::ClaudeCodeProvider;
1420    use ironflow_core::providers::record_replay::RecordReplayProvider;
1421    use ironflow_store::memory::InMemoryStore;
1422    use ironflow_store::models::StepStatus;
1423    use serde_json::json;
1424
1425    // Test handler that echoes a message via shell
1426    struct EchoWorkflow;
1427
1428    impl WorkflowHandler for EchoWorkflow {
1429        fn name(&self) -> &str {
1430            "echo-workflow"
1431        }
1432
1433        fn describe(&self) -> WorkflowInfo {
1434            WorkflowInfo {
1435                description: "A simple workflow that echoes hello".to_string(),
1436                source_code: None,
1437                sub_workflows: Vec::new(),
1438                category: None,
1439                version: self.version().map(str::to_string),
1440                compatible_versions: Vec::new(),
1441                input_schema: None,
1442                default_labels: HashMap::new(),
1443                schedule: self.schedule().cloned(),
1444                default_max_cost_usd: self.default_max_cost_usd(),
1445            }
1446        }
1447
1448        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1449            Box::pin(async move {
1450                ctx.shell("greet", ShellConfig::new("echo hello")).await?;
1451                Ok(())
1452            })
1453        }
1454    }
1455
1456    // Test handler that fails
1457    struct FailingWorkflow;
1458
1459    impl WorkflowHandler for FailingWorkflow {
1460        fn name(&self) -> &str {
1461            "failing-workflow"
1462        }
1463
1464        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1465            Box::pin(async move {
1466                ctx.shell("fail", ShellConfig::new("exit 1")).await?;
1467                Ok(())
1468            })
1469        }
1470    }
1471
1472    fn create_test_engine() -> Engine {
1473        let store = Arc::new(InMemoryStore::new());
1474        let inner = ClaudeCodeProvider::new();
1475        let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
1476            inner,
1477            "/tmp/ironflow-fixtures",
1478        ));
1479        Engine::new(store, provider)
1480    }
1481
1482    #[test]
1483    fn engine_new_creates_instance() {
1484        let engine = create_test_engine();
1485        assert_eq!(engine.handler_names().len(), 0);
1486    }
1487
1488    #[test]
1489    fn engine_register_handler() {
1490        let mut engine = create_test_engine();
1491        let result = engine.register(EchoWorkflow);
1492        assert!(result.is_ok());
1493        assert_eq!(engine.handler_names().len(), 1);
1494        assert!(engine.handler_names().contains(&"echo-workflow"));
1495    }
1496
1497    #[test]
1498    fn engine_register_duplicate_returns_error() {
1499        let mut engine = create_test_engine();
1500        engine.register(EchoWorkflow).unwrap();
1501        let result = engine.register(EchoWorkflow);
1502        assert!(result.is_err());
1503    }
1504
1505    #[test]
1506    fn engine_get_handler_found() {
1507        let mut engine = create_test_engine();
1508        engine.register(EchoWorkflow).unwrap();
1509        let handler = engine.get_handler("echo-workflow");
1510        assert!(handler.is_some());
1511    }
1512
1513    #[test]
1514    fn engine_get_handler_not_found() {
1515        let engine = create_test_engine();
1516        let handler = engine.get_handler("nonexistent");
1517        assert!(handler.is_none());
1518    }
1519
1520    #[test]
1521    fn engine_handler_names_lists_all() {
1522        let mut engine = create_test_engine();
1523        engine.register(EchoWorkflow).unwrap();
1524        engine.register(FailingWorkflow).unwrap();
1525        let names = engine.handler_names();
1526        assert_eq!(names.len(), 2);
1527        assert!(names.contains(&"echo-workflow"));
1528        assert!(names.contains(&"failing-workflow"));
1529    }
1530
1531    #[test]
1532    fn engine_handler_info_returns_description() {
1533        let mut engine = create_test_engine();
1534        engine.register(EchoWorkflow).unwrap();
1535        let info = engine.handler_info("echo-workflow");
1536        assert!(info.is_some());
1537        let info = info.unwrap();
1538        assert_eq!(info.description, "A simple workflow that echoes hello");
1539    }
1540
1541    struct CategorizedWorkflow;
1542
1543    impl WorkflowHandler for CategorizedWorkflow {
1544        fn name(&self) -> &str {
1545            "categorized"
1546        }
1547        fn category(&self) -> Option<&str> {
1548            Some("data/etl")
1549        }
1550        fn execute<'a>(
1551            &'a self,
1552            _ctx: &'a mut WorkflowContext,
1553        ) -> crate::handler::HandlerFuture<'a> {
1554            Box::pin(async move { Ok(()) })
1555        }
1556    }
1557
1558    #[test]
1559    fn engine_default_describe_propagates_category() {
1560        let mut engine = create_test_engine();
1561        engine.register(CategorizedWorkflow).unwrap();
1562        let info = engine.handler_info("categorized").unwrap();
1563        assert_eq!(info.category.as_deref(), Some("data/etl"));
1564    }
1565
1566    #[test]
1567    fn engine_default_describe_without_category() {
1568        let mut engine = create_test_engine();
1569        engine.register(EchoWorkflow).unwrap();
1570        let info = engine.handler_info("echo-workflow").unwrap();
1571        assert!(info.category.is_none());
1572    }
1573
1574    // -----------------------------------------------------------------------
1575    // Schedule tests
1576    // -----------------------------------------------------------------------
1577
1578    struct ScheduledWorkflow {
1579        schedule: CronSchedule,
1580    }
1581
1582    impl ScheduledWorkflow {
1583        fn new() -> Self {
1584            Self {
1585                schedule: CronSchedule::new("0 0 * * * *").unwrap(),
1586            }
1587        }
1588    }
1589
1590    impl WorkflowHandler for ScheduledWorkflow {
1591        fn name(&self) -> &str {
1592            "scheduled"
1593        }
1594        fn schedule(&self) -> Option<&CronSchedule> {
1595            Some(&self.schedule)
1596        }
1597        fn execute<'a>(
1598            &'a self,
1599            _ctx: &'a mut WorkflowContext,
1600        ) -> crate::handler::HandlerFuture<'a> {
1601            Box::pin(async move { Ok(()) })
1602        }
1603    }
1604
1605    #[test]
1606    fn engine_default_describe_propagates_schedule() {
1607        let mut engine = create_test_engine();
1608        engine.register(ScheduledWorkflow::new()).unwrap();
1609        let info = engine.handler_info("scheduled").unwrap();
1610        assert_eq!(
1611            info.schedule.as_ref().map(|s| s.as_str()),
1612            Some("0 0 * * * *")
1613        );
1614    }
1615
1616    #[test]
1617    fn engine_default_describe_without_schedule() {
1618        let mut engine = create_test_engine();
1619        engine.register(EchoWorkflow).unwrap();
1620        let info = engine.handler_info("echo-workflow").unwrap();
1621        assert!(info.schedule.is_none());
1622    }
1623
1624    #[test]
1625    fn scheduled_handlers_returns_only_scheduled() {
1626        let mut engine = create_test_engine();
1627        engine.register(EchoWorkflow).unwrap();
1628        engine.register(ScheduledWorkflow::new()).unwrap();
1629        engine.register(FailingWorkflow).unwrap();
1630
1631        let scheduled = engine.scheduled_handlers();
1632        assert_eq!(scheduled.len(), 1);
1633        assert_eq!(scheduled[0].0, "scheduled");
1634        assert_eq!(scheduled[0].1.as_str(), "0 0 * * * *");
1635    }
1636
1637    #[test]
1638    fn scheduled_handlers_empty_when_none_scheduled() {
1639        let mut engine = create_test_engine();
1640        engine.register(EchoWorkflow).unwrap();
1641        engine.register(FailingWorkflow).unwrap();
1642
1643        let scheduled = engine.scheduled_handlers();
1644        assert!(scheduled.is_empty());
1645    }
1646
1647    struct BadCategoryWorkflow(&'static str);
1648
1649    impl WorkflowHandler for BadCategoryWorkflow {
1650        fn name(&self) -> &str {
1651            "bad-category"
1652        }
1653        fn category(&self) -> Option<&str> {
1654            Some(self.0)
1655        }
1656        fn execute<'a>(
1657            &'a self,
1658            _ctx: &'a mut WorkflowContext,
1659        ) -> crate::handler::HandlerFuture<'a> {
1660            Box::pin(async move { Ok(()) })
1661        }
1662    }
1663
1664    #[test]
1665    fn engine_register_rejects_empty_category() {
1666        let mut engine = create_test_engine();
1667        let err = engine.register(BadCategoryWorkflow("")).unwrap_err();
1668        match err {
1669            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty category")),
1670            other => panic!("expected InvalidWorkflow, got {other:?}"),
1671        }
1672    }
1673
1674    #[test]
1675    fn engine_register_rejects_leading_slash_category() {
1676        let mut engine = create_test_engine();
1677        let err = engine
1678            .register(BadCategoryWorkflow("/data/etl"))
1679            .unwrap_err();
1680        match err {
1681            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("leading '/'")),
1682            other => panic!("expected InvalidWorkflow, got {other:?}"),
1683        }
1684    }
1685
1686    #[test]
1687    fn engine_register_rejects_trailing_slash_category() {
1688        let mut engine = create_test_engine();
1689        let err = engine
1690            .register(BadCategoryWorkflow("data/etl/"))
1691            .unwrap_err();
1692        match err {
1693            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("trailing '/'")),
1694            other => panic!("expected InvalidWorkflow, got {other:?}"),
1695        }
1696    }
1697
1698    #[test]
1699    fn engine_register_rejects_double_slash_category() {
1700        let mut engine = create_test_engine();
1701        let err = engine
1702            .register(BadCategoryWorkflow("data//etl"))
1703            .unwrap_err();
1704        match err {
1705            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty segment")),
1706            other => panic!("expected InvalidWorkflow, got {other:?}"),
1707        }
1708    }
1709
1710    #[test]
1711    fn engine_register_rejects_whitespace_only_segment_category() {
1712        let mut engine = create_test_engine();
1713        let err = engine
1714            .register(BadCategoryWorkflow("data/ /etl"))
1715            .unwrap_err();
1716        match err {
1717            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("whitespace-only segment")),
1718            other => panic!("expected InvalidWorkflow, got {other:?}"),
1719        }
1720    }
1721
1722    #[test]
1723    fn engine_register_accepts_valid_nested_category() {
1724        let mut engine = create_test_engine();
1725        assert!(engine.register(CategorizedWorkflow).is_ok());
1726    }
1727
1728    #[tokio::test]
1729    async fn engine_unknown_workflow_returns_error() {
1730        let engine = create_test_engine();
1731        let result = engine
1732            .run_handler("unknown", TriggerKind::Manual, json!({}))
1733            .await;
1734        assert!(result.is_err());
1735        match result {
1736            Err(EngineError::InvalidWorkflow(msg)) => {
1737                assert!(msg.contains("no handler registered"));
1738            }
1739            _ => panic!("expected InvalidWorkflow error"),
1740        }
1741    }
1742
1743    #[tokio::test]
1744    async fn engine_enqueue_handler_creates_pending_run() {
1745        let mut engine = create_test_engine();
1746        engine.register(EchoWorkflow).unwrap();
1747
1748        let run = engine
1749            .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
1750            .await
1751            .unwrap();
1752        assert_eq!(run.status.state, RunStatus::Pending);
1753        assert_eq!(run.workflow_name, "echo-workflow");
1754    }
1755
1756    #[tokio::test]
1757    async fn enqueue_handler_leaves_the_run_unattributed() {
1758        let mut engine = create_test_engine();
1759        engine.register(EchoWorkflow).unwrap();
1760
1761        let run = engine
1762            .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
1763            .await
1764            .unwrap();
1765
1766        assert!(run.created_by.is_none());
1767    }
1768
1769    #[tokio::test]
1770    async fn enqueue_handler_with_options_records_the_author() {
1771        let mut engine = create_test_engine();
1772        engine.register(EchoWorkflow).unwrap();
1773        let actor = RunActor::User {
1774            user_id: Uuid::now_v7(),
1775        };
1776
1777        let run = engine
1778            .enqueue_handler_with_options(
1779                "echo-workflow",
1780                TriggerKind::Api,
1781                json!({}),
1782                EnqueueOptions {
1783                    created_by: Some(actor.clone()),
1784                    ..Default::default()
1785                },
1786            )
1787            .await
1788            .unwrap()
1789            .into_run();
1790
1791        assert_eq!(run.created_by, Some(actor));
1792    }
1793
1794    #[tokio::test]
1795    async fn enqueue_handler_with_options_accepts_no_author() {
1796        let mut engine = create_test_engine();
1797        engine.register(EchoWorkflow).unwrap();
1798
1799        let run = engine
1800            .enqueue_handler_with_options(
1801                "echo-workflow",
1802                TriggerKind::Cron {
1803                    schedule: "0 * * * * *".to_string(),
1804                },
1805                json!({}),
1806                EnqueueOptions::default(),
1807            )
1808            .await
1809            .unwrap()
1810            .into_run();
1811
1812        assert!(run.created_by.is_none());
1813    }
1814
1815    #[tokio::test]
1816    async fn run_handler_leaves_the_run_unattributed() {
1817        let mut engine = create_test_engine();
1818        engine.register(EchoWorkflow).unwrap();
1819
1820        let run = engine
1821            .run_handler("echo-workflow", TriggerKind::Manual, json!({}))
1822            .await
1823            .unwrap()
1824            .run;
1825
1826        assert!(run.created_by.is_none());
1827    }
1828
1829    #[tokio::test]
1830    async fn engine_register_boxed() {
1831        let mut engine = create_test_engine();
1832        let handler: Box<dyn WorkflowHandler> = Box::new(EchoWorkflow);
1833        let result = engine.register_boxed(handler);
1834        assert!(result.is_ok());
1835        assert_eq!(engine.handler_names().len(), 1);
1836    }
1837
1838    #[tokio::test]
1839    async fn engine_store_and_provider_accessors() {
1840        let store = Arc::new(InMemoryStore::new());
1841        let inner = ClaudeCodeProvider::new();
1842        let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
1843            inner,
1844            "/tmp/ironflow-fixtures",
1845        ));
1846        let engine = Engine::new(store.clone(), provider.clone());
1847
1848        // Verify accessors return references
1849        let _ = engine.store();
1850        let _ = engine.provider();
1851    }
1852
1853    // -----------------------------------------------------------------------
1854    // Operation trait tests
1855    // -----------------------------------------------------------------------
1856
1857    use crate::operation::Operation;
1858    use ironflow_store::models::StepKind;
1859    use std::future::Future;
1860    use std::pin::Pin;
1861
1862    struct FakeGitlabOp {
1863        project_id: u64,
1864        title: String,
1865    }
1866
1867    impl Operation for FakeGitlabOp {
1868        fn kind(&self) -> &str {
1869            "gitlab"
1870        }
1871
1872        fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
1873            Box::pin(async move {
1874                Ok(json!({
1875                    "issue_id": 42,
1876                    "project_id": self.project_id,
1877                    "title": self.title,
1878                }))
1879            })
1880        }
1881
1882        fn input(&self) -> Option<Value> {
1883            Some(json!({
1884                "project_id": self.project_id,
1885                "title": self.title,
1886            }))
1887        }
1888    }
1889
1890    struct FailingOp;
1891
1892    impl Operation for FailingOp {
1893        fn kind(&self) -> &str {
1894            "broken-service"
1895        }
1896
1897        fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
1898            Box::pin(async move { Err(EngineError::StepConfig("service unavailable".to_string())) })
1899        }
1900    }
1901
1902    struct OperationWorkflow;
1903
1904    impl WorkflowHandler for OperationWorkflow {
1905        fn name(&self) -> &str {
1906            "operation-workflow"
1907        }
1908
1909        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1910            Box::pin(async move {
1911                let op = FakeGitlabOp {
1912                    project_id: 123,
1913                    title: "Bug report".to_string(),
1914                };
1915                ctx.operation("create-issue", &op).await?;
1916                Ok(())
1917            })
1918        }
1919    }
1920
1921    struct FailingOperationWorkflow;
1922
1923    impl WorkflowHandler for FailingOperationWorkflow {
1924        fn name(&self) -> &str {
1925            "failing-operation-workflow"
1926        }
1927
1928        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1929            Box::pin(async move {
1930                ctx.operation("broken-call", &FailingOp).await?;
1931                Ok(())
1932            })
1933        }
1934    }
1935
1936    struct MixedWorkflow;
1937
1938    impl WorkflowHandler for MixedWorkflow {
1939        fn name(&self) -> &str {
1940            "mixed-workflow"
1941        }
1942
1943        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1944            Box::pin(async move {
1945                ctx.shell("build", ShellConfig::new("echo built")).await?;
1946                let op = FakeGitlabOp {
1947                    project_id: 456,
1948                    title: "Deploy done".to_string(),
1949                };
1950                let result = ctx.operation("notify-gitlab", &op).await?;
1951                assert_eq!(result.output["issue_id"], 42);
1952                Ok(())
1953            })
1954        }
1955    }
1956
1957    #[tokio::test]
1958    async fn operation_step_happy_path() {
1959        let mut engine = create_test_engine();
1960        engine.register(OperationWorkflow).unwrap();
1961
1962        let run = engine
1963            .run_handler("operation-workflow", TriggerKind::Manual, json!({}))
1964            .await
1965            .unwrap()
1966            .run;
1967
1968        assert_eq!(run.status.state, RunStatus::Completed);
1969
1970        let steps = engine.store().list_steps(run.id).await.unwrap();
1971
1972        assert_eq!(steps.len(), 1);
1973        assert_eq!(steps[0].name, "create-issue");
1974        assert_eq!(steps[0].kind, StepKind::Custom("gitlab".to_string()));
1975        assert_eq!(
1976            steps[0].status.state,
1977            ironflow_store::models::StepStatus::Completed
1978        );
1979
1980        let output = steps[0].output.as_ref().unwrap();
1981        assert_eq!(output["issue_id"], 42);
1982        assert_eq!(output["project_id"], 123);
1983
1984        let input = steps[0].input.as_ref().unwrap();
1985        assert_eq!(input["project_id"], 123);
1986        assert_eq!(input["title"], "Bug report");
1987    }
1988
1989    #[tokio::test]
1990    async fn operation_step_failure_marks_run_failed() {
1991        let mut engine = create_test_engine();
1992        engine.register(FailingOperationWorkflow).unwrap();
1993
1994        let result = engine
1995            .run_handler("failing-operation-workflow", TriggerKind::Manual, json!({}))
1996            .await;
1997
1998        assert!(result.is_err());
1999    }
2000
2001    #[tokio::test]
2002    async fn operation_mixed_with_shell_steps() {
2003        let mut engine = create_test_engine();
2004        engine.register(MixedWorkflow).unwrap();
2005
2006        let run = engine
2007            .run_handler("mixed-workflow", TriggerKind::Manual, json!({}))
2008            .await
2009            .unwrap()
2010            .run;
2011
2012        assert_eq!(run.status.state, RunStatus::Completed);
2013
2014        let steps = engine.store().list_steps(run.id).await.unwrap();
2015
2016        assert_eq!(steps.len(), 2);
2017        assert_eq!(steps[0].kind, StepKind::Shell);
2018        assert_eq!(steps[1].kind, StepKind::Custom("gitlab".to_string()));
2019        assert_eq!(steps[0].position, 0);
2020        assert_eq!(steps[1].position, 1);
2021    }
2022
2023    // -----------------------------------------------------------------------
2024    // Approval + resume tests
2025    // -----------------------------------------------------------------------
2026
2027    use crate::config::ApprovalConfig;
2028
2029    struct SingleApprovalWorkflow;
2030
2031    impl WorkflowHandler for SingleApprovalWorkflow {
2032        fn name(&self) -> &str {
2033            "single-approval"
2034        }
2035
2036        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2037            Box::pin(async move {
2038                ctx.shell("build", ShellConfig::new("echo built")).await?;
2039                ctx.approval("gate", ApprovalConfig::new("OK?")).await?;
2040                ctx.shell("deploy", ShellConfig::new("echo deployed"))
2041                    .await?;
2042                Ok(())
2043            })
2044        }
2045    }
2046
2047    struct DoubleApprovalWorkflow;
2048
2049    impl WorkflowHandler for DoubleApprovalWorkflow {
2050        fn name(&self) -> &str {
2051            "double-approval"
2052        }
2053
2054        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2055            Box::pin(async move {
2056                ctx.shell("build", ShellConfig::new("echo built")).await?;
2057                ctx.approval("staging-gate", ApprovalConfig::new("Deploy staging?"))
2058                    .await?;
2059                ctx.shell("deploy-staging", ShellConfig::new("echo staging"))
2060                    .await?;
2061                ctx.approval("prod-gate", ApprovalConfig::new("Deploy prod?"))
2062                    .await?;
2063                ctx.shell("deploy-prod", ShellConfig::new("echo prod"))
2064                    .await?;
2065                Ok(())
2066            })
2067        }
2068    }
2069
2070    #[tokio::test]
2071    async fn approval_pauses_run() {
2072        let mut engine = create_test_engine();
2073        engine.register(SingleApprovalWorkflow).unwrap();
2074
2075        let run = engine
2076            .run_handler("single-approval", TriggerKind::Manual, json!({}))
2077            .await
2078            .unwrap()
2079            .run;
2080
2081        assert_eq!(run.status.state, RunStatus::AwaitingApproval);
2082
2083        let steps = engine.store().list_steps(run.id).await.unwrap();
2084        assert_eq!(steps.len(), 2); // build + approval gate
2085        assert_eq!(steps[0].kind, StepKind::Shell);
2086        assert_eq!(steps[0].status.state, StepStatus::Completed);
2087        assert_eq!(steps[1].kind, StepKind::Approval);
2088        assert_eq!(steps[1].status.state, StepStatus::AwaitingApproval);
2089    }
2090
2091    #[tokio::test]
2092    async fn approval_resume_completes_run() {
2093        let mut engine = create_test_engine();
2094        engine.register(SingleApprovalWorkflow).unwrap();
2095
2096        // First execution: pauses at approval
2097        let run = engine
2098            .run_handler("single-approval", TriggerKind::Manual, json!({}))
2099            .await
2100            .unwrap()
2101            .run;
2102        assert_eq!(run.status.state, RunStatus::AwaitingApproval);
2103
2104        // Simulate approval: transition to Running
2105        engine
2106            .store()
2107            .update_run_status(run.id, RunStatus::Running)
2108            .await
2109            .unwrap();
2110
2111        // Resume: replays build, skips approval, executes deploy
2112        let resumed = engine.resume_run(run.id).await.unwrap().run;
2113        assert_eq!(resumed.status.state, RunStatus::Completed);
2114
2115        let steps = engine.store().list_steps(run.id).await.unwrap();
2116        assert_eq!(steps.len(), 3); // build + approval + deploy
2117        assert_eq!(steps[0].name, "build");
2118        assert_eq!(steps[0].status.state, StepStatus::Completed);
2119        assert_eq!(steps[1].name, "gate");
2120        assert_eq!(steps[1].kind, StepKind::Approval);
2121        assert_eq!(steps[1].status.state, StepStatus::Completed);
2122        assert_eq!(steps[2].name, "deploy");
2123        assert_eq!(steps[2].status.state, StepStatus::Completed);
2124    }
2125
2126    #[tokio::test]
2127    async fn double_approval_two_resumes() {
2128        let mut engine = create_test_engine();
2129        engine.register(DoubleApprovalWorkflow).unwrap();
2130
2131        // First execution: pauses at staging-gate
2132        let run = engine
2133            .run_handler("double-approval", TriggerKind::Manual, json!({}))
2134            .await
2135            .unwrap()
2136            .run;
2137        assert_eq!(run.status.state, RunStatus::AwaitingApproval);
2138
2139        let steps = engine.store().list_steps(run.id).await.unwrap();
2140        assert_eq!(steps.len(), 2); // build + staging-gate
2141
2142        // First approval
2143        engine
2144            .store()
2145            .update_run_status(run.id, RunStatus::Running)
2146            .await
2147            .unwrap();
2148
2149        let resumed = engine.resume_run(run.id).await.unwrap().run;
2150        assert_eq!(resumed.status.state, RunStatus::AwaitingApproval);
2151
2152        let steps = engine.store().list_steps(run.id).await.unwrap();
2153        assert_eq!(steps.len(), 4); // build + staging-gate + deploy-staging + prod-gate
2154
2155        // Second approval
2156        engine
2157            .store()
2158            .update_run_status(run.id, RunStatus::Running)
2159            .await
2160            .unwrap();
2161
2162        let final_run = engine.resume_run(run.id).await.unwrap().run;
2163        assert_eq!(final_run.status.state, RunStatus::Completed);
2164
2165        let steps = engine.store().list_steps(run.id).await.unwrap();
2166        assert_eq!(steps.len(), 5);
2167        assert_eq!(steps[0].name, "build");
2168        assert_eq!(steps[1].name, "staging-gate");
2169        assert_eq!(steps[2].name, "deploy-staging");
2170        assert_eq!(steps[3].name, "prod-gate");
2171        assert_eq!(steps[4].name, "deploy-prod");
2172
2173        for step in &steps {
2174            assert_eq!(step.status.state, StepStatus::Completed);
2175        }
2176    }
2177
2178    // -----------------------------------------------------------------------
2179    // fail_orphaned_steps tests
2180    // -----------------------------------------------------------------------
2181
2182    use ironflow_store::models::{NewStep, StepUpdate, step_trace_id};
2183
2184    async fn create_step_with_status(
2185        store: &Arc<dyn Store>,
2186        run_id: Uuid,
2187        name: &str,
2188        position: u32,
2189        status: StepStatus,
2190    ) -> ironflow_store::models::Step {
2191        let step = store
2192            .create_step(NewStep {
2193                run_id,
2194                trace_id: step_trace_id(run_id, name, position),
2195                name: name.to_string(),
2196                kind: StepKind::Shell,
2197                position,
2198                input: None,
2199                is_error_handler: false,
2200            })
2201            .await
2202            .unwrap();
2203
2204        match status {
2205            StepStatus::Pending => {}
2206            StepStatus::Running => {
2207                store
2208                    .update_step(
2209                        step.id,
2210                        StepUpdate {
2211                            status: Some(StepStatus::Running),
2212                            ..StepUpdate::default()
2213                        },
2214                    )
2215                    .await
2216                    .unwrap();
2217            }
2218            StepStatus::Completed => {
2219                store
2220                    .update_step(
2221                        step.id,
2222                        StepUpdate {
2223                            status: Some(StepStatus::Running),
2224                            ..StepUpdate::default()
2225                        },
2226                    )
2227                    .await
2228                    .unwrap();
2229                store
2230                    .update_step(
2231                        step.id,
2232                        StepUpdate {
2233                            status: Some(StepStatus::Completed),
2234                            ..StepUpdate::default()
2235                        },
2236                    )
2237                    .await
2238                    .unwrap();
2239            }
2240            StepStatus::AwaitingApproval => {
2241                store
2242                    .update_step(
2243                        step.id,
2244                        StepUpdate {
2245                            status: Some(StepStatus::Running),
2246                            ..StepUpdate::default()
2247                        },
2248                    )
2249                    .await
2250                    .unwrap();
2251                store
2252                    .update_step(
2253                        step.id,
2254                        StepUpdate {
2255                            status: Some(StepStatus::AwaitingApproval),
2256                            ..StepUpdate::default()
2257                        },
2258                    )
2259                    .await
2260                    .unwrap();
2261            }
2262            _ => panic!("unsupported status for test helper: {status}"),
2263        }
2264
2265        store.get_step(step.id).await.unwrap().unwrap()
2266    }
2267
2268    #[tokio::test]
2269    async fn fail_orphaned_steps_marks_running_as_failed() {
2270        let engine = create_test_engine();
2271        let run = engine
2272            .store()
2273            .create_run(NewRun {
2274                created_by: None,
2275                workflow_name: "test".to_string(),
2276                trigger: TriggerKind::Manual,
2277                payload: json!({}),
2278                max_retries: 0,
2279                handler_version: None,
2280                labels: HashMap::new(),
2281                scheduled_at: None,
2282                idempotency_key: None,
2283                max_cost_usd: None,
2284            })
2285            .await
2286            .unwrap()
2287            .into_run();
2288
2289        let step = create_step_with_status(
2290            engine.store(),
2291            run.id,
2292            "running-step",
2293            0,
2294            StepStatus::Running,
2295        )
2296        .await;
2297
2298        engine
2299            .fail_orphaned_steps(run.id, "parent run timed out")
2300            .await
2301            .unwrap();
2302
2303        let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2304        assert_eq!(updated.status.state, StepStatus::Failed);
2305        assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
2306        assert!(updated.completed_at.is_some());
2307    }
2308
2309    #[tokio::test]
2310    async fn fail_orphaned_steps_marks_pending_as_skipped() {
2311        let engine = create_test_engine();
2312        let run = engine
2313            .store()
2314            .create_run(NewRun {
2315                created_by: None,
2316                workflow_name: "test".to_string(),
2317                trigger: TriggerKind::Manual,
2318                payload: json!({}),
2319                max_retries: 0,
2320                handler_version: None,
2321                labels: HashMap::new(),
2322                scheduled_at: None,
2323                idempotency_key: None,
2324                max_cost_usd: None,
2325            })
2326            .await
2327            .unwrap()
2328            .into_run();
2329
2330        let step = create_step_with_status(
2331            engine.store(),
2332            run.id,
2333            "pending-step",
2334            0,
2335            StepStatus::Pending,
2336        )
2337        .await;
2338
2339        engine
2340            .fail_orphaned_steps(run.id, "parent run timed out")
2341            .await
2342            .unwrap();
2343
2344        let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2345        assert_eq!(updated.status.state, StepStatus::Skipped);
2346        assert!(updated.error.is_none());
2347        assert!(updated.completed_at.is_some());
2348    }
2349
2350    #[tokio::test]
2351    async fn fail_orphaned_steps_marks_awaiting_approval_as_failed() {
2352        let engine = create_test_engine();
2353        let run = engine
2354            .store()
2355            .create_run(NewRun {
2356                created_by: None,
2357                workflow_name: "test".to_string(),
2358                trigger: TriggerKind::Manual,
2359                payload: json!({}),
2360                max_retries: 0,
2361                handler_version: None,
2362                labels: HashMap::new(),
2363                scheduled_at: None,
2364                idempotency_key: None,
2365                max_cost_usd: None,
2366            })
2367            .await
2368            .unwrap()
2369            .into_run();
2370
2371        let step = create_step_with_status(
2372            engine.store(),
2373            run.id,
2374            "approval-step",
2375            0,
2376            StepStatus::AwaitingApproval,
2377        )
2378        .await;
2379
2380        engine
2381            .fail_orphaned_steps(run.id, "parent run timed out")
2382            .await
2383            .unwrap();
2384
2385        let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2386        assert_eq!(updated.status.state, StepStatus::Failed);
2387        assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
2388        assert!(updated.completed_at.is_some());
2389    }
2390
2391    #[tokio::test]
2392    async fn fail_orphaned_steps_skips_terminal_steps() {
2393        let engine = create_test_engine();
2394        let run = engine
2395            .store()
2396            .create_run(NewRun {
2397                created_by: None,
2398                workflow_name: "test".to_string(),
2399                trigger: TriggerKind::Manual,
2400                payload: json!({}),
2401                max_retries: 0,
2402                handler_version: None,
2403                labels: HashMap::new(),
2404                scheduled_at: None,
2405                idempotency_key: None,
2406                max_cost_usd: None,
2407            })
2408            .await
2409            .unwrap()
2410            .into_run();
2411
2412        let completed_step =
2413            create_step_with_status(engine.store(), run.id, "done", 0, StepStatus::Completed).await;
2414        let running_step =
2415            create_step_with_status(engine.store(), run.id, "in-flight", 1, StepStatus::Running)
2416                .await;
2417
2418        engine
2419            .fail_orphaned_steps(run.id, "parent run timed out")
2420            .await
2421            .unwrap();
2422
2423        let completed = engine
2424            .store()
2425            .get_step(completed_step.id)
2426            .await
2427            .unwrap()
2428            .unwrap();
2429        assert_eq!(completed.status.state, StepStatus::Completed);
2430
2431        let failed = engine
2432            .store()
2433            .get_step(running_step.id)
2434            .await
2435            .unwrap()
2436            .unwrap();
2437        assert_eq!(failed.status.state, StepStatus::Failed);
2438    }
2439
2440    #[tokio::test]
2441    async fn fail_orphaned_steps_mixed_states() {
2442        let engine = create_test_engine();
2443        let run = engine
2444            .store()
2445            .create_run(NewRun {
2446                created_by: None,
2447                workflow_name: "test".to_string(),
2448                trigger: TriggerKind::Manual,
2449                payload: json!({}),
2450                max_retries: 0,
2451                handler_version: None,
2452                labels: HashMap::new(),
2453                scheduled_at: None,
2454                idempotency_key: None,
2455                max_cost_usd: None,
2456            })
2457            .await
2458            .unwrap()
2459            .into_run();
2460
2461        let s_completed =
2462            create_step_with_status(engine.store(), run.id, "step-1", 0, StepStatus::Completed)
2463                .await;
2464        let s_running =
2465            create_step_with_status(engine.store(), run.id, "step-2", 1, StepStatus::Running).await;
2466        let s_pending =
2467            create_step_with_status(engine.store(), run.id, "step-3", 2, StepStatus::Pending).await;
2468
2469        engine.fail_orphaned_steps(run.id, "timeout").await.unwrap();
2470
2471        let r_completed = engine
2472            .store()
2473            .get_step(s_completed.id)
2474            .await
2475            .unwrap()
2476            .unwrap();
2477        assert_eq!(r_completed.status.state, StepStatus::Completed);
2478
2479        let r_running = engine
2480            .store()
2481            .get_step(s_running.id)
2482            .await
2483            .unwrap()
2484            .unwrap();
2485        assert_eq!(r_running.status.state, StepStatus::Failed);
2486        assert_eq!(r_running.error.as_deref(), Some("timeout"));
2487
2488        let r_pending = engine
2489            .store()
2490            .get_step(s_pending.id)
2491            .await
2492            .unwrap()
2493            .unwrap();
2494        assert_eq!(r_pending.status.state, StepStatus::Skipped);
2495        assert!(r_pending.error.is_none());
2496    }
2497
2498    #[tokio::test]
2499    async fn fail_orphaned_steps_no_steps_is_noop() {
2500        let engine = create_test_engine();
2501        let run = engine
2502            .store()
2503            .create_run(NewRun {
2504                created_by: None,
2505                workflow_name: "test".to_string(),
2506                trigger: TriggerKind::Manual,
2507                payload: json!({}),
2508                max_retries: 0,
2509                handler_version: None,
2510                labels: HashMap::new(),
2511                scheduled_at: None,
2512                idempotency_key: None,
2513                max_cost_usd: None,
2514            })
2515            .await
2516            .unwrap()
2517            .into_run();
2518
2519        let result = engine.fail_orphaned_steps(run.id, "timeout").await;
2520        assert!(result.is_ok());
2521    }
2522
2523    #[tokio::test]
2524    async fn fail_orphaned_steps_preserves_existing_error() {
2525        let engine = create_test_engine();
2526        let run = engine
2527            .store()
2528            .create_run(NewRun {
2529                created_by: None,
2530                workflow_name: "test".to_string(),
2531                trigger: TriggerKind::Manual,
2532                payload: json!({}),
2533                max_retries: 0,
2534                handler_version: None,
2535                labels: HashMap::new(),
2536                scheduled_at: None,
2537                idempotency_key: None,
2538                max_cost_usd: None,
2539            })
2540            .await
2541            .unwrap()
2542            .into_run();
2543
2544        let step_with_error = create_step_with_status(
2545            engine.store(),
2546            run.id,
2547            "already-errored",
2548            0,
2549            StepStatus::Running,
2550        )
2551        .await;
2552
2553        engine
2554            .store()
2555            .update_step(
2556                step_with_error.id,
2557                StepUpdate {
2558                    error: Some("real error from provider".to_string()),
2559                    ..StepUpdate::default()
2560                },
2561            )
2562            .await
2563            .unwrap();
2564
2565        let step_no_error = create_step_with_status(
2566            engine.store(),
2567            run.id,
2568            "no-error-yet",
2569            1,
2570            StepStatus::Running,
2571        )
2572        .await;
2573
2574        engine
2575            .fail_orphaned_steps(run.id, "parent run failed")
2576            .await
2577            .unwrap();
2578
2579        let updated_with = engine
2580            .store()
2581            .get_step(step_with_error.id)
2582            .await
2583            .unwrap()
2584            .unwrap();
2585        assert_eq!(updated_with.status.state, StepStatus::Failed);
2586        assert_eq!(
2587            updated_with.error.as_deref(),
2588            Some("real error from provider"),
2589        );
2590
2591        let updated_without = engine
2592            .store()
2593            .get_step(step_no_error.id)
2594            .await
2595            .unwrap()
2596            .unwrap();
2597        assert_eq!(updated_without.status.state, StepStatus::Failed);
2598        assert_eq!(updated_without.error.as_deref(), Some("parent run failed"),);
2599    }
2600}