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