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 cron scheduler
562    /// (e.g. `ironflow_runtime::Runtime::cron`).
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(err) => {
1214                // A guardrail stop (budget or workflow guard) is deliberate,
1215                // not a breakage: the run is cancelled, never failed and
1216                // never replayed.
1217                let guardrail_stop = matches!(
1218                    err,
1219                    EngineError::RunBudgetExceeded { .. } | EngineError::WorkflowGuardRejected(_)
1220                );
1221
1222                final_status = if guardrail_stop {
1223                    if let Err(store_err) = self
1224                        .store
1225                        .update_run(
1226                            run_id,
1227                            RunUpdate {
1228                                status: Some(RunStatus::Cancelled),
1229                                error: Some(err.to_string()),
1230                                cost_usd: Some(ctx.total_cost_usd()),
1231                                duration_ms: Some(total_duration),
1232                                completed_at: Some(completed_at),
1233                                ..RunUpdate::default()
1234                            },
1235                        )
1236                        .await
1237                    {
1238                        error!(run_id = %run_id, store_error = %store_err, "failed to persist run cancellation");
1239                    }
1240                    if let Err(cleanup_err) = self
1241                        .fail_orphaned_steps(run_id, "run stopped: guardrail limit reached")
1242                        .await
1243                    {
1244                        error!(run_id = %run_id, store_error = %cleanup_err, "failed to cleanup orphaned steps");
1245                    }
1246                    RunStatus::Cancelled
1247                } else {
1248                    self.fail_or_schedule_retry(
1249                        run_id,
1250                        &err.to_string(),
1251                        is_run_retryable(&err),
1252                        Some(ctx.total_cost_usd()),
1253                        Some(total_duration),
1254                    )
1255                    .await
1256                    .unwrap_or_else(|store_err| {
1257                        error!(run_id = %run_id, store_error = %store_err, "failed to persist run failure");
1258                        RunStatus::Failed
1259                    })
1260                };
1261
1262                if matches!(err, EngineError::RunBudgetExceeded { .. }) {
1263                    self.on_run_budget_exceeded(workflow_name, run_id, &err);
1264                }
1265
1266                error!(run_id = %run_id, status = %final_status, error = %err, "run stopped");
1267
1268                self.publish_run_status_changed(
1269                    workflow_name,
1270                    run_id,
1271                    final_status,
1272                    Some(err.to_string()),
1273                    ctx,
1274                    total_duration,
1275                    run_labels,
1276                );
1277
1278                #[cfg(feature = "prometheus")]
1279                self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
1280
1281                return Err(err);
1282            }
1283        }
1284
1285        self.publish_run_status_changed(
1286            workflow_name,
1287            run_id,
1288            final_status,
1289            None,
1290            ctx,
1291            total_duration,
1292            run_labels,
1293        );
1294
1295        #[cfg(feature = "prometheus")]
1296        self.emit_run_metrics(workflow_name, final_status, total_duration, ctx);
1297
1298        Ok(WorkflowResult {
1299            run: final_run,
1300            steps: ctx.step_results().to_vec(),
1301        })
1302    }
1303
1304    /// Emit Prometheus metrics for a completed run.
1305    #[cfg(feature = "prometheus")]
1306    fn emit_run_metrics(
1307        &self,
1308        workflow_name: &str,
1309        status: RunStatus,
1310        duration_ms: u64,
1311        ctx: &WorkflowContext,
1312    ) {
1313        let status_str = status.to_string();
1314        let wf = workflow_name.to_string();
1315
1316        counter!(RUNS_TOTAL, "workflow" => wf.clone(), "status" => status_str.clone()).increment(1);
1317        histogram!(RUN_DURATION_SECONDS, "workflow" => wf.clone(), "status" => status_str)
1318            .record(duration_ms as f64 / 1000.0);
1319        histogram!(RUN_COST_USD, "workflow" => wf.clone()).record(
1320            ctx.total_cost_usd()
1321                .to_string()
1322                .parse::<f64>()
1323                .unwrap_or(0.0),
1324        );
1325        gauge!(RUNS_ACTIVE, "workflow" => wf).decrement(1.0);
1326    }
1327
1328    /// Record the metric and publish the audit event for a run that hit its
1329    /// cost cap.
1330    ///
1331    /// A non-[`RunBudgetExceeded`](EngineError::RunBudgetExceeded) error is
1332    /// ignored, so callers can pass the error unconditionally.
1333    fn on_run_budget_exceeded(&self, workflow_name: &str, run_id: Uuid, err: &EngineError) {
1334        let EngineError::RunBudgetExceeded {
1335            limit_usd,
1336            spent_usd,
1337            step_budget_usd,
1338            ..
1339        } = err
1340        else {
1341            return;
1342        };
1343
1344        #[cfg(feature = "prometheus")]
1345        counter!(
1346            RUN_BUDGET_EXCEEDED_TOTAL,
1347            "workflow" => workflow_name.to_string(),
1348            "scope" => "run",
1349        )
1350        .increment(1);
1351
1352        self.event_publisher.publish(Event::RunBudgetExceeded {
1353            run_id,
1354            workflow_name: workflow_name.to_string(),
1355            limit_usd: *limit_usd,
1356            spent_usd: *spent_usd,
1357            step_budget_usd: *step_budget_usd,
1358            at: Utc::now(),
1359        });
1360    }
1361
1362    /// Publish a run status changed event to all registered subscribers.
1363    ///
1364    /// `from` is always `Running` because `finalize_run` is only called
1365    /// from a running state.
1366    #[allow(clippy::too_many_arguments)]
1367    fn publish_run_status_changed(
1368        &self,
1369        workflow_name: &str,
1370        run_id: Uuid,
1371        to: RunStatus,
1372        error: Option<String>,
1373        ctx: &WorkflowContext,
1374        duration_ms: u64,
1375        labels: HashMap<String, String>,
1376    ) {
1377        let now = Utc::now();
1378        let cost_usd = ctx.total_cost_usd();
1379        let wf = workflow_name.to_string();
1380
1381        self.event_publisher.publish(Event::RunStatusChanged {
1382            run_id,
1383            workflow_name: wf.clone(),
1384            from: RunStatus::Running,
1385            to,
1386            error: error.clone(),
1387            cost_usd,
1388            duration_ms,
1389            labels: labels.clone(),
1390            at: now,
1391        });
1392
1393        if to == RunStatus::Failed {
1394            self.event_publisher.publish(Event::RunFailed {
1395                run_id,
1396                workflow_name: wf,
1397                error,
1398                cost_usd,
1399                duration_ms,
1400                labels,
1401                at: now,
1402            });
1403        }
1404    }
1405}
1406
1407impl fmt::Debug for Engine {
1408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1409        f.debug_struct("Engine")
1410            .field("handlers", &self.handlers.keys().collect::<Vec<_>>())
1411            .finish_non_exhaustive()
1412    }
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417    use super::*;
1418    use crate::config::ShellConfig;
1419    use crate::handler::{HandlerFuture, WorkflowHandler};
1420    use ironflow_core::providers::claude::ClaudeCodeProvider;
1421    use ironflow_core::providers::record_replay::RecordReplayProvider;
1422    use ironflow_store::memory::InMemoryStore;
1423    use ironflow_store::models::StepStatus;
1424    use serde_json::json;
1425
1426    // Test handler that echoes a message via shell
1427    struct EchoWorkflow;
1428
1429    impl WorkflowHandler for EchoWorkflow {
1430        fn name(&self) -> &str {
1431            "echo-workflow"
1432        }
1433
1434        fn describe(&self) -> WorkflowInfo {
1435            WorkflowInfo {
1436                description: "A simple workflow that echoes hello".to_string(),
1437                source_code: None,
1438                sub_workflows: Vec::new(),
1439                category: None,
1440                version: self.version().map(str::to_string),
1441                compatible_versions: Vec::new(),
1442                input_schema: None,
1443                default_labels: HashMap::new(),
1444                schedule: self.schedule().cloned(),
1445                default_max_cost_usd: self.default_max_cost_usd(),
1446            }
1447        }
1448
1449        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1450            Box::pin(async move {
1451                ctx.shell("greet", ShellConfig::new("echo hello")).await?;
1452                Ok(())
1453            })
1454        }
1455    }
1456
1457    // Test handler that fails
1458    struct FailingWorkflow;
1459
1460    impl WorkflowHandler for FailingWorkflow {
1461        fn name(&self) -> &str {
1462            "failing-workflow"
1463        }
1464
1465        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1466            Box::pin(async move {
1467                ctx.shell("fail", ShellConfig::new("exit 1")).await?;
1468                Ok(())
1469            })
1470        }
1471    }
1472
1473    fn create_test_engine() -> Engine {
1474        let store = Arc::new(InMemoryStore::new());
1475        let inner = ClaudeCodeProvider::new();
1476        let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
1477            inner,
1478            "/tmp/ironflow-fixtures",
1479        ));
1480        Engine::new(store, provider)
1481    }
1482
1483    #[test]
1484    fn engine_new_creates_instance() {
1485        let engine = create_test_engine();
1486        assert_eq!(engine.handler_names().len(), 0);
1487    }
1488
1489    #[test]
1490    fn engine_register_handler() {
1491        let mut engine = create_test_engine();
1492        let result = engine.register(EchoWorkflow);
1493        assert!(result.is_ok());
1494        assert_eq!(engine.handler_names().len(), 1);
1495        assert!(engine.handler_names().contains(&"echo-workflow"));
1496    }
1497
1498    #[test]
1499    fn engine_register_duplicate_returns_error() {
1500        let mut engine = create_test_engine();
1501        engine.register(EchoWorkflow).unwrap();
1502        let result = engine.register(EchoWorkflow);
1503        assert!(result.is_err());
1504    }
1505
1506    #[test]
1507    fn engine_get_handler_found() {
1508        let mut engine = create_test_engine();
1509        engine.register(EchoWorkflow).unwrap();
1510        let handler = engine.get_handler("echo-workflow");
1511        assert!(handler.is_some());
1512    }
1513
1514    #[test]
1515    fn engine_get_handler_not_found() {
1516        let engine = create_test_engine();
1517        let handler = engine.get_handler("nonexistent");
1518        assert!(handler.is_none());
1519    }
1520
1521    #[test]
1522    fn engine_handler_names_lists_all() {
1523        let mut engine = create_test_engine();
1524        engine.register(EchoWorkflow).unwrap();
1525        engine.register(FailingWorkflow).unwrap();
1526        let names = engine.handler_names();
1527        assert_eq!(names.len(), 2);
1528        assert!(names.contains(&"echo-workflow"));
1529        assert!(names.contains(&"failing-workflow"));
1530    }
1531
1532    #[test]
1533    fn engine_handler_info_returns_description() {
1534        let mut engine = create_test_engine();
1535        engine.register(EchoWorkflow).unwrap();
1536        let info = engine.handler_info("echo-workflow");
1537        assert!(info.is_some());
1538        let info = info.unwrap();
1539        assert_eq!(info.description, "A simple workflow that echoes hello");
1540    }
1541
1542    struct CategorizedWorkflow;
1543
1544    impl WorkflowHandler for CategorizedWorkflow {
1545        fn name(&self) -> &str {
1546            "categorized"
1547        }
1548        fn category(&self) -> Option<&str> {
1549            Some("data/etl")
1550        }
1551        fn execute<'a>(
1552            &'a self,
1553            _ctx: &'a mut WorkflowContext,
1554        ) -> crate::handler::HandlerFuture<'a> {
1555            Box::pin(async move { Ok(()) })
1556        }
1557    }
1558
1559    #[test]
1560    fn engine_default_describe_propagates_category() {
1561        let mut engine = create_test_engine();
1562        engine.register(CategorizedWorkflow).unwrap();
1563        let info = engine.handler_info("categorized").unwrap();
1564        assert_eq!(info.category.as_deref(), Some("data/etl"));
1565    }
1566
1567    #[test]
1568    fn engine_default_describe_without_category() {
1569        let mut engine = create_test_engine();
1570        engine.register(EchoWorkflow).unwrap();
1571        let info = engine.handler_info("echo-workflow").unwrap();
1572        assert!(info.category.is_none());
1573    }
1574
1575    // -----------------------------------------------------------------------
1576    // Schedule tests
1577    // -----------------------------------------------------------------------
1578
1579    struct ScheduledWorkflow {
1580        schedule: CronSchedule,
1581    }
1582
1583    impl ScheduledWorkflow {
1584        fn new() -> Self {
1585            Self {
1586                schedule: CronSchedule::new("0 0 * * * *").unwrap(),
1587            }
1588        }
1589    }
1590
1591    impl WorkflowHandler for ScheduledWorkflow {
1592        fn name(&self) -> &str {
1593            "scheduled"
1594        }
1595        fn schedule(&self) -> Option<&CronSchedule> {
1596            Some(&self.schedule)
1597        }
1598        fn execute<'a>(
1599            &'a self,
1600            _ctx: &'a mut WorkflowContext,
1601        ) -> crate::handler::HandlerFuture<'a> {
1602            Box::pin(async move { Ok(()) })
1603        }
1604    }
1605
1606    #[test]
1607    fn engine_default_describe_propagates_schedule() {
1608        let mut engine = create_test_engine();
1609        engine.register(ScheduledWorkflow::new()).unwrap();
1610        let info = engine.handler_info("scheduled").unwrap();
1611        assert_eq!(
1612            info.schedule.as_ref().map(|s| s.as_str()),
1613            Some("0 0 * * * *")
1614        );
1615    }
1616
1617    #[test]
1618    fn engine_default_describe_without_schedule() {
1619        let mut engine = create_test_engine();
1620        engine.register(EchoWorkflow).unwrap();
1621        let info = engine.handler_info("echo-workflow").unwrap();
1622        assert!(info.schedule.is_none());
1623    }
1624
1625    #[test]
1626    fn scheduled_handlers_returns_only_scheduled() {
1627        let mut engine = create_test_engine();
1628        engine.register(EchoWorkflow).unwrap();
1629        engine.register(ScheduledWorkflow::new()).unwrap();
1630        engine.register(FailingWorkflow).unwrap();
1631
1632        let scheduled = engine.scheduled_handlers();
1633        assert_eq!(scheduled.len(), 1);
1634        assert_eq!(scheduled[0].0, "scheduled");
1635        assert_eq!(scheduled[0].1.as_str(), "0 0 * * * *");
1636    }
1637
1638    #[test]
1639    fn scheduled_handlers_empty_when_none_scheduled() {
1640        let mut engine = create_test_engine();
1641        engine.register(EchoWorkflow).unwrap();
1642        engine.register(FailingWorkflow).unwrap();
1643
1644        let scheduled = engine.scheduled_handlers();
1645        assert!(scheduled.is_empty());
1646    }
1647
1648    struct BadCategoryWorkflow(&'static str);
1649
1650    impl WorkflowHandler for BadCategoryWorkflow {
1651        fn name(&self) -> &str {
1652            "bad-category"
1653        }
1654        fn category(&self) -> Option<&str> {
1655            Some(self.0)
1656        }
1657        fn execute<'a>(
1658            &'a self,
1659            _ctx: &'a mut WorkflowContext,
1660        ) -> crate::handler::HandlerFuture<'a> {
1661            Box::pin(async move { Ok(()) })
1662        }
1663    }
1664
1665    #[test]
1666    fn engine_register_rejects_empty_category() {
1667        let mut engine = create_test_engine();
1668        let err = engine.register(BadCategoryWorkflow("")).unwrap_err();
1669        match err {
1670            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty category")),
1671            other => panic!("expected InvalidWorkflow, got {other:?}"),
1672        }
1673    }
1674
1675    #[test]
1676    fn engine_register_rejects_leading_slash_category() {
1677        let mut engine = create_test_engine();
1678        let err = engine
1679            .register(BadCategoryWorkflow("/data/etl"))
1680            .unwrap_err();
1681        match err {
1682            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("leading '/'")),
1683            other => panic!("expected InvalidWorkflow, got {other:?}"),
1684        }
1685    }
1686
1687    #[test]
1688    fn engine_register_rejects_trailing_slash_category() {
1689        let mut engine = create_test_engine();
1690        let err = engine
1691            .register(BadCategoryWorkflow("data/etl/"))
1692            .unwrap_err();
1693        match err {
1694            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("trailing '/'")),
1695            other => panic!("expected InvalidWorkflow, got {other:?}"),
1696        }
1697    }
1698
1699    #[test]
1700    fn engine_register_rejects_double_slash_category() {
1701        let mut engine = create_test_engine();
1702        let err = engine
1703            .register(BadCategoryWorkflow("data//etl"))
1704            .unwrap_err();
1705        match err {
1706            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("empty segment")),
1707            other => panic!("expected InvalidWorkflow, got {other:?}"),
1708        }
1709    }
1710
1711    #[test]
1712    fn engine_register_rejects_whitespace_only_segment_category() {
1713        let mut engine = create_test_engine();
1714        let err = engine
1715            .register(BadCategoryWorkflow("data/ /etl"))
1716            .unwrap_err();
1717        match err {
1718            EngineError::InvalidWorkflow(msg) => assert!(msg.contains("whitespace-only segment")),
1719            other => panic!("expected InvalidWorkflow, got {other:?}"),
1720        }
1721    }
1722
1723    #[test]
1724    fn engine_register_accepts_valid_nested_category() {
1725        let mut engine = create_test_engine();
1726        assert!(engine.register(CategorizedWorkflow).is_ok());
1727    }
1728
1729    #[tokio::test]
1730    async fn engine_unknown_workflow_returns_error() {
1731        let engine = create_test_engine();
1732        let result = engine
1733            .run_handler("unknown", TriggerKind::Manual, json!({}))
1734            .await;
1735        assert!(result.is_err());
1736        match result {
1737            Err(EngineError::InvalidWorkflow(msg)) => {
1738                assert!(msg.contains("no handler registered"));
1739            }
1740            _ => panic!("expected InvalidWorkflow error"),
1741        }
1742    }
1743
1744    #[tokio::test]
1745    async fn engine_enqueue_handler_creates_pending_run() {
1746        let mut engine = create_test_engine();
1747        engine.register(EchoWorkflow).unwrap();
1748
1749        let run = engine
1750            .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
1751            .await
1752            .unwrap();
1753        assert_eq!(run.status.state, RunStatus::Pending);
1754        assert_eq!(run.workflow_name, "echo-workflow");
1755    }
1756
1757    #[tokio::test]
1758    async fn enqueue_handler_leaves_the_run_unattributed() {
1759        let mut engine = create_test_engine();
1760        engine.register(EchoWorkflow).unwrap();
1761
1762        let run = engine
1763            .enqueue_handler("echo-workflow", TriggerKind::Manual, json!({}), 0)
1764            .await
1765            .unwrap();
1766
1767        assert!(run.created_by.is_none());
1768    }
1769
1770    #[tokio::test]
1771    async fn enqueue_handler_with_options_records_the_author() {
1772        let mut engine = create_test_engine();
1773        engine.register(EchoWorkflow).unwrap();
1774        let actor = RunActor::User {
1775            user_id: Uuid::now_v7(),
1776        };
1777
1778        let run = engine
1779            .enqueue_handler_with_options(
1780                "echo-workflow",
1781                TriggerKind::Api,
1782                json!({}),
1783                EnqueueOptions {
1784                    created_by: Some(actor.clone()),
1785                    ..Default::default()
1786                },
1787            )
1788            .await
1789            .unwrap()
1790            .into_run();
1791
1792        assert_eq!(run.created_by, Some(actor));
1793    }
1794
1795    #[tokio::test]
1796    async fn enqueue_handler_with_options_accepts_no_author() {
1797        let mut engine = create_test_engine();
1798        engine.register(EchoWorkflow).unwrap();
1799
1800        let run = engine
1801            .enqueue_handler_with_options(
1802                "echo-workflow",
1803                TriggerKind::Cron {
1804                    schedule: "0 * * * * *".to_string(),
1805                },
1806                json!({}),
1807                EnqueueOptions::default(),
1808            )
1809            .await
1810            .unwrap()
1811            .into_run();
1812
1813        assert!(run.created_by.is_none());
1814    }
1815
1816    #[tokio::test]
1817    async fn run_handler_leaves_the_run_unattributed() {
1818        let mut engine = create_test_engine();
1819        engine.register(EchoWorkflow).unwrap();
1820
1821        let run = engine
1822            .run_handler("echo-workflow", TriggerKind::Manual, json!({}))
1823            .await
1824            .unwrap()
1825            .run;
1826
1827        assert!(run.created_by.is_none());
1828    }
1829
1830    #[tokio::test]
1831    async fn engine_register_boxed() {
1832        let mut engine = create_test_engine();
1833        let handler: Box<dyn WorkflowHandler> = Box::new(EchoWorkflow);
1834        let result = engine.register_boxed(handler);
1835        assert!(result.is_ok());
1836        assert_eq!(engine.handler_names().len(), 1);
1837    }
1838
1839    #[tokio::test]
1840    async fn engine_store_and_provider_accessors() {
1841        let store = Arc::new(InMemoryStore::new());
1842        let inner = ClaudeCodeProvider::new();
1843        let provider: Arc<dyn AgentProvider> = Arc::new(RecordReplayProvider::replay(
1844            inner,
1845            "/tmp/ironflow-fixtures",
1846        ));
1847        let engine = Engine::new(store.clone(), provider.clone());
1848
1849        // Verify accessors return references
1850        let _ = engine.store();
1851        let _ = engine.provider();
1852    }
1853
1854    // -----------------------------------------------------------------------
1855    // Operation trait tests
1856    // -----------------------------------------------------------------------
1857
1858    use crate::operation::Operation;
1859    use ironflow_store::models::StepKind;
1860    use std::future::Future;
1861    use std::pin::Pin;
1862
1863    struct FakeGitlabOp {
1864        project_id: u64,
1865        title: String,
1866    }
1867
1868    impl Operation for FakeGitlabOp {
1869        fn kind(&self) -> &str {
1870            "gitlab"
1871        }
1872
1873        fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
1874            Box::pin(async move {
1875                Ok(json!({
1876                    "issue_id": 42,
1877                    "project_id": self.project_id,
1878                    "title": self.title,
1879                }))
1880            })
1881        }
1882
1883        fn input(&self) -> Option<Value> {
1884            Some(json!({
1885                "project_id": self.project_id,
1886                "title": self.title,
1887            }))
1888        }
1889    }
1890
1891    struct FailingOp;
1892
1893    impl Operation for FailingOp {
1894        fn kind(&self) -> &str {
1895            "broken-service"
1896        }
1897
1898        fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
1899            Box::pin(async move { Err(EngineError::StepConfig("service unavailable".to_string())) })
1900        }
1901    }
1902
1903    struct OperationWorkflow;
1904
1905    impl WorkflowHandler for OperationWorkflow {
1906        fn name(&self) -> &str {
1907            "operation-workflow"
1908        }
1909
1910        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1911            Box::pin(async move {
1912                let op = FakeGitlabOp {
1913                    project_id: 123,
1914                    title: "Bug report".to_string(),
1915                };
1916                ctx.operation("create-issue", &op).await?;
1917                Ok(())
1918            })
1919        }
1920    }
1921
1922    struct FailingOperationWorkflow;
1923
1924    impl WorkflowHandler for FailingOperationWorkflow {
1925        fn name(&self) -> &str {
1926            "failing-operation-workflow"
1927        }
1928
1929        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1930            Box::pin(async move {
1931                ctx.operation("broken-call", &FailingOp).await?;
1932                Ok(())
1933            })
1934        }
1935    }
1936
1937    struct MixedWorkflow;
1938
1939    impl WorkflowHandler for MixedWorkflow {
1940        fn name(&self) -> &str {
1941            "mixed-workflow"
1942        }
1943
1944        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1945            Box::pin(async move {
1946                ctx.shell("build", ShellConfig::new("echo built")).await?;
1947                let op = FakeGitlabOp {
1948                    project_id: 456,
1949                    title: "Deploy done".to_string(),
1950                };
1951                let result = ctx.operation("notify-gitlab", &op).await?;
1952                assert_eq!(result.output["issue_id"], 42);
1953                Ok(())
1954            })
1955        }
1956    }
1957
1958    #[tokio::test]
1959    async fn operation_step_happy_path() {
1960        let mut engine = create_test_engine();
1961        engine.register(OperationWorkflow).unwrap();
1962
1963        let run = engine
1964            .run_handler("operation-workflow", TriggerKind::Manual, json!({}))
1965            .await
1966            .unwrap()
1967            .run;
1968
1969        assert_eq!(run.status.state, RunStatus::Completed);
1970
1971        let steps = engine.store().list_steps(run.id).await.unwrap();
1972
1973        assert_eq!(steps.len(), 1);
1974        assert_eq!(steps[0].name, "create-issue");
1975        assert_eq!(steps[0].kind, StepKind::Custom("gitlab".to_string()));
1976        assert_eq!(
1977            steps[0].status.state,
1978            ironflow_store::models::StepStatus::Completed
1979        );
1980
1981        let output = steps[0].output.as_ref().unwrap();
1982        assert_eq!(output["issue_id"], 42);
1983        assert_eq!(output["project_id"], 123);
1984
1985        let input = steps[0].input.as_ref().unwrap();
1986        assert_eq!(input["project_id"], 123);
1987        assert_eq!(input["title"], "Bug report");
1988    }
1989
1990    #[tokio::test]
1991    async fn operation_step_failure_marks_run_failed() {
1992        let mut engine = create_test_engine();
1993        engine.register(FailingOperationWorkflow).unwrap();
1994
1995        let result = engine
1996            .run_handler("failing-operation-workflow", TriggerKind::Manual, json!({}))
1997            .await;
1998
1999        assert!(result.is_err());
2000    }
2001
2002    #[tokio::test]
2003    async fn operation_mixed_with_shell_steps() {
2004        let mut engine = create_test_engine();
2005        engine.register(MixedWorkflow).unwrap();
2006
2007        let run = engine
2008            .run_handler("mixed-workflow", TriggerKind::Manual, json!({}))
2009            .await
2010            .unwrap()
2011            .run;
2012
2013        assert_eq!(run.status.state, RunStatus::Completed);
2014
2015        let steps = engine.store().list_steps(run.id).await.unwrap();
2016
2017        assert_eq!(steps.len(), 2);
2018        assert_eq!(steps[0].kind, StepKind::Shell);
2019        assert_eq!(steps[1].kind, StepKind::Custom("gitlab".to_string()));
2020        assert_eq!(steps[0].position, 0);
2021        assert_eq!(steps[1].position, 1);
2022    }
2023
2024    // -----------------------------------------------------------------------
2025    // Approval + resume tests
2026    // -----------------------------------------------------------------------
2027
2028    use crate::config::ApprovalConfig;
2029
2030    struct SingleApprovalWorkflow;
2031
2032    impl WorkflowHandler for SingleApprovalWorkflow {
2033        fn name(&self) -> &str {
2034            "single-approval"
2035        }
2036
2037        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2038            Box::pin(async move {
2039                ctx.shell("build", ShellConfig::new("echo built")).await?;
2040                ctx.approval("gate", ApprovalConfig::new("OK?")).await?;
2041                ctx.shell("deploy", ShellConfig::new("echo deployed"))
2042                    .await?;
2043                Ok(())
2044            })
2045        }
2046    }
2047
2048    struct DoubleApprovalWorkflow;
2049
2050    impl WorkflowHandler for DoubleApprovalWorkflow {
2051        fn name(&self) -> &str {
2052            "double-approval"
2053        }
2054
2055        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
2056            Box::pin(async move {
2057                ctx.shell("build", ShellConfig::new("echo built")).await?;
2058                ctx.approval("staging-gate", ApprovalConfig::new("Deploy staging?"))
2059                    .await?;
2060                ctx.shell("deploy-staging", ShellConfig::new("echo staging"))
2061                    .await?;
2062                ctx.approval("prod-gate", ApprovalConfig::new("Deploy prod?"))
2063                    .await?;
2064                ctx.shell("deploy-prod", ShellConfig::new("echo prod"))
2065                    .await?;
2066                Ok(())
2067            })
2068        }
2069    }
2070
2071    #[tokio::test]
2072    async fn approval_pauses_run() {
2073        let mut engine = create_test_engine();
2074        engine.register(SingleApprovalWorkflow).unwrap();
2075
2076        let run = engine
2077            .run_handler("single-approval", TriggerKind::Manual, json!({}))
2078            .await
2079            .unwrap()
2080            .run;
2081
2082        assert_eq!(run.status.state, RunStatus::AwaitingApproval);
2083
2084        let steps = engine.store().list_steps(run.id).await.unwrap();
2085        assert_eq!(steps.len(), 2); // build + approval gate
2086        assert_eq!(steps[0].kind, StepKind::Shell);
2087        assert_eq!(steps[0].status.state, StepStatus::Completed);
2088        assert_eq!(steps[1].kind, StepKind::Approval);
2089        assert_eq!(steps[1].status.state, StepStatus::AwaitingApproval);
2090    }
2091
2092    #[tokio::test]
2093    async fn approval_resume_completes_run() {
2094        let mut engine = create_test_engine();
2095        engine.register(SingleApprovalWorkflow).unwrap();
2096
2097        // First execution: pauses at approval
2098        let run = engine
2099            .run_handler("single-approval", TriggerKind::Manual, json!({}))
2100            .await
2101            .unwrap()
2102            .run;
2103        assert_eq!(run.status.state, RunStatus::AwaitingApproval);
2104
2105        // Simulate approval: transition to Running
2106        engine
2107            .store()
2108            .update_run_status(run.id, RunStatus::Running)
2109            .await
2110            .unwrap();
2111
2112        // Resume: replays build, skips approval, executes deploy
2113        let resumed = engine.resume_run(run.id).await.unwrap().run;
2114        assert_eq!(resumed.status.state, RunStatus::Completed);
2115
2116        let steps = engine.store().list_steps(run.id).await.unwrap();
2117        assert_eq!(steps.len(), 3); // build + approval + deploy
2118        assert_eq!(steps[0].name, "build");
2119        assert_eq!(steps[0].status.state, StepStatus::Completed);
2120        assert_eq!(steps[1].name, "gate");
2121        assert_eq!(steps[1].kind, StepKind::Approval);
2122        assert_eq!(steps[1].status.state, StepStatus::Completed);
2123        assert_eq!(steps[2].name, "deploy");
2124        assert_eq!(steps[2].status.state, StepStatus::Completed);
2125    }
2126
2127    #[tokio::test]
2128    async fn double_approval_two_resumes() {
2129        let mut engine = create_test_engine();
2130        engine.register(DoubleApprovalWorkflow).unwrap();
2131
2132        // First execution: pauses at staging-gate
2133        let run = engine
2134            .run_handler("double-approval", TriggerKind::Manual, json!({}))
2135            .await
2136            .unwrap()
2137            .run;
2138        assert_eq!(run.status.state, RunStatus::AwaitingApproval);
2139
2140        let steps = engine.store().list_steps(run.id).await.unwrap();
2141        assert_eq!(steps.len(), 2); // build + staging-gate
2142
2143        // First approval
2144        engine
2145            .store()
2146            .update_run_status(run.id, RunStatus::Running)
2147            .await
2148            .unwrap();
2149
2150        let resumed = engine.resume_run(run.id).await.unwrap().run;
2151        assert_eq!(resumed.status.state, RunStatus::AwaitingApproval);
2152
2153        let steps = engine.store().list_steps(run.id).await.unwrap();
2154        assert_eq!(steps.len(), 4); // build + staging-gate + deploy-staging + prod-gate
2155
2156        // Second approval
2157        engine
2158            .store()
2159            .update_run_status(run.id, RunStatus::Running)
2160            .await
2161            .unwrap();
2162
2163        let final_run = engine.resume_run(run.id).await.unwrap().run;
2164        assert_eq!(final_run.status.state, RunStatus::Completed);
2165
2166        let steps = engine.store().list_steps(run.id).await.unwrap();
2167        assert_eq!(steps.len(), 5);
2168        assert_eq!(steps[0].name, "build");
2169        assert_eq!(steps[1].name, "staging-gate");
2170        assert_eq!(steps[2].name, "deploy-staging");
2171        assert_eq!(steps[3].name, "prod-gate");
2172        assert_eq!(steps[4].name, "deploy-prod");
2173
2174        for step in &steps {
2175            assert_eq!(step.status.state, StepStatus::Completed);
2176        }
2177    }
2178
2179    // -----------------------------------------------------------------------
2180    // fail_orphaned_steps tests
2181    // -----------------------------------------------------------------------
2182
2183    use ironflow_store::models::{NewStep, StepUpdate, step_trace_id};
2184
2185    async fn create_step_with_status(
2186        store: &Arc<dyn Store>,
2187        run_id: Uuid,
2188        name: &str,
2189        position: u32,
2190        status: StepStatus,
2191    ) -> ironflow_store::models::Step {
2192        let step = store
2193            .create_step(NewStep {
2194                run_id,
2195                trace_id: step_trace_id(run_id, name, position),
2196                name: name.to_string(),
2197                kind: StepKind::Shell,
2198                position,
2199                input: None,
2200                is_error_handler: false,
2201            })
2202            .await
2203            .unwrap();
2204
2205        match status {
2206            StepStatus::Pending => {}
2207            StepStatus::Running => {
2208                store
2209                    .update_step(
2210                        step.id,
2211                        StepUpdate {
2212                            status: Some(StepStatus::Running),
2213                            ..StepUpdate::default()
2214                        },
2215                    )
2216                    .await
2217                    .unwrap();
2218            }
2219            StepStatus::Completed => {
2220                store
2221                    .update_step(
2222                        step.id,
2223                        StepUpdate {
2224                            status: Some(StepStatus::Running),
2225                            ..StepUpdate::default()
2226                        },
2227                    )
2228                    .await
2229                    .unwrap();
2230                store
2231                    .update_step(
2232                        step.id,
2233                        StepUpdate {
2234                            status: Some(StepStatus::Completed),
2235                            ..StepUpdate::default()
2236                        },
2237                    )
2238                    .await
2239                    .unwrap();
2240            }
2241            StepStatus::AwaitingApproval => {
2242                store
2243                    .update_step(
2244                        step.id,
2245                        StepUpdate {
2246                            status: Some(StepStatus::Running),
2247                            ..StepUpdate::default()
2248                        },
2249                    )
2250                    .await
2251                    .unwrap();
2252                store
2253                    .update_step(
2254                        step.id,
2255                        StepUpdate {
2256                            status: Some(StepStatus::AwaitingApproval),
2257                            ..StepUpdate::default()
2258                        },
2259                    )
2260                    .await
2261                    .unwrap();
2262            }
2263            _ => panic!("unsupported status for test helper: {status}"),
2264        }
2265
2266        store.get_step(step.id).await.unwrap().unwrap()
2267    }
2268
2269    #[tokio::test]
2270    async fn fail_orphaned_steps_marks_running_as_failed() {
2271        let engine = create_test_engine();
2272        let run = engine
2273            .store()
2274            .create_run(NewRun {
2275                created_by: None,
2276                workflow_name: "test".to_string(),
2277                trigger: TriggerKind::Manual,
2278                payload: json!({}),
2279                max_retries: 0,
2280                handler_version: None,
2281                labels: HashMap::new(),
2282                scheduled_at: None,
2283                idempotency_key: None,
2284                max_cost_usd: None,
2285            })
2286            .await
2287            .unwrap()
2288            .into_run();
2289
2290        let step = create_step_with_status(
2291            engine.store(),
2292            run.id,
2293            "running-step",
2294            0,
2295            StepStatus::Running,
2296        )
2297        .await;
2298
2299        engine
2300            .fail_orphaned_steps(run.id, "parent run timed out")
2301            .await
2302            .unwrap();
2303
2304        let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2305        assert_eq!(updated.status.state, StepStatus::Failed);
2306        assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
2307        assert!(updated.completed_at.is_some());
2308    }
2309
2310    #[tokio::test]
2311    async fn fail_orphaned_steps_marks_pending_as_skipped() {
2312        let engine = create_test_engine();
2313        let run = engine
2314            .store()
2315            .create_run(NewRun {
2316                created_by: None,
2317                workflow_name: "test".to_string(),
2318                trigger: TriggerKind::Manual,
2319                payload: json!({}),
2320                max_retries: 0,
2321                handler_version: None,
2322                labels: HashMap::new(),
2323                scheduled_at: None,
2324                idempotency_key: None,
2325                max_cost_usd: None,
2326            })
2327            .await
2328            .unwrap()
2329            .into_run();
2330
2331        let step = create_step_with_status(
2332            engine.store(),
2333            run.id,
2334            "pending-step",
2335            0,
2336            StepStatus::Pending,
2337        )
2338        .await;
2339
2340        engine
2341            .fail_orphaned_steps(run.id, "parent run timed out")
2342            .await
2343            .unwrap();
2344
2345        let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2346        assert_eq!(updated.status.state, StepStatus::Skipped);
2347        assert!(updated.error.is_none());
2348        assert!(updated.completed_at.is_some());
2349    }
2350
2351    #[tokio::test]
2352    async fn fail_orphaned_steps_marks_awaiting_approval_as_failed() {
2353        let engine = create_test_engine();
2354        let run = engine
2355            .store()
2356            .create_run(NewRun {
2357                created_by: None,
2358                workflow_name: "test".to_string(),
2359                trigger: TriggerKind::Manual,
2360                payload: json!({}),
2361                max_retries: 0,
2362                handler_version: None,
2363                labels: HashMap::new(),
2364                scheduled_at: None,
2365                idempotency_key: None,
2366                max_cost_usd: None,
2367            })
2368            .await
2369            .unwrap()
2370            .into_run();
2371
2372        let step = create_step_with_status(
2373            engine.store(),
2374            run.id,
2375            "approval-step",
2376            0,
2377            StepStatus::AwaitingApproval,
2378        )
2379        .await;
2380
2381        engine
2382            .fail_orphaned_steps(run.id, "parent run timed out")
2383            .await
2384            .unwrap();
2385
2386        let updated = engine.store().get_step(step.id).await.unwrap().unwrap();
2387        assert_eq!(updated.status.state, StepStatus::Failed);
2388        assert_eq!(updated.error.as_deref(), Some("parent run timed out"));
2389        assert!(updated.completed_at.is_some());
2390    }
2391
2392    #[tokio::test]
2393    async fn fail_orphaned_steps_skips_terminal_steps() {
2394        let engine = create_test_engine();
2395        let run = engine
2396            .store()
2397            .create_run(NewRun {
2398                created_by: None,
2399                workflow_name: "test".to_string(),
2400                trigger: TriggerKind::Manual,
2401                payload: json!({}),
2402                max_retries: 0,
2403                handler_version: None,
2404                labels: HashMap::new(),
2405                scheduled_at: None,
2406                idempotency_key: None,
2407                max_cost_usd: None,
2408            })
2409            .await
2410            .unwrap()
2411            .into_run();
2412
2413        let completed_step =
2414            create_step_with_status(engine.store(), run.id, "done", 0, StepStatus::Completed).await;
2415        let running_step =
2416            create_step_with_status(engine.store(), run.id, "in-flight", 1, StepStatus::Running)
2417                .await;
2418
2419        engine
2420            .fail_orphaned_steps(run.id, "parent run timed out")
2421            .await
2422            .unwrap();
2423
2424        let completed = engine
2425            .store()
2426            .get_step(completed_step.id)
2427            .await
2428            .unwrap()
2429            .unwrap();
2430        assert_eq!(completed.status.state, StepStatus::Completed);
2431
2432        let failed = engine
2433            .store()
2434            .get_step(running_step.id)
2435            .await
2436            .unwrap()
2437            .unwrap();
2438        assert_eq!(failed.status.state, StepStatus::Failed);
2439    }
2440
2441    #[tokio::test]
2442    async fn fail_orphaned_steps_mixed_states() {
2443        let engine = create_test_engine();
2444        let run = engine
2445            .store()
2446            .create_run(NewRun {
2447                created_by: None,
2448                workflow_name: "test".to_string(),
2449                trigger: TriggerKind::Manual,
2450                payload: json!({}),
2451                max_retries: 0,
2452                handler_version: None,
2453                labels: HashMap::new(),
2454                scheduled_at: None,
2455                idempotency_key: None,
2456                max_cost_usd: None,
2457            })
2458            .await
2459            .unwrap()
2460            .into_run();
2461
2462        let s_completed =
2463            create_step_with_status(engine.store(), run.id, "step-1", 0, StepStatus::Completed)
2464                .await;
2465        let s_running =
2466            create_step_with_status(engine.store(), run.id, "step-2", 1, StepStatus::Running).await;
2467        let s_pending =
2468            create_step_with_status(engine.store(), run.id, "step-3", 2, StepStatus::Pending).await;
2469
2470        engine.fail_orphaned_steps(run.id, "timeout").await.unwrap();
2471
2472        let r_completed = engine
2473            .store()
2474            .get_step(s_completed.id)
2475            .await
2476            .unwrap()
2477            .unwrap();
2478        assert_eq!(r_completed.status.state, StepStatus::Completed);
2479
2480        let r_running = engine
2481            .store()
2482            .get_step(s_running.id)
2483            .await
2484            .unwrap()
2485            .unwrap();
2486        assert_eq!(r_running.status.state, StepStatus::Failed);
2487        assert_eq!(r_running.error.as_deref(), Some("timeout"));
2488
2489        let r_pending = engine
2490            .store()
2491            .get_step(s_pending.id)
2492            .await
2493            .unwrap()
2494            .unwrap();
2495        assert_eq!(r_pending.status.state, StepStatus::Skipped);
2496        assert!(r_pending.error.is_none());
2497    }
2498
2499    #[tokio::test]
2500    async fn fail_orphaned_steps_no_steps_is_noop() {
2501        let engine = create_test_engine();
2502        let run = engine
2503            .store()
2504            .create_run(NewRun {
2505                created_by: None,
2506                workflow_name: "test".to_string(),
2507                trigger: TriggerKind::Manual,
2508                payload: json!({}),
2509                max_retries: 0,
2510                handler_version: None,
2511                labels: HashMap::new(),
2512                scheduled_at: None,
2513                idempotency_key: None,
2514                max_cost_usd: None,
2515            })
2516            .await
2517            .unwrap()
2518            .into_run();
2519
2520        let result = engine.fail_orphaned_steps(run.id, "timeout").await;
2521        assert!(result.is_ok());
2522    }
2523
2524    #[tokio::test]
2525    async fn fail_orphaned_steps_preserves_existing_error() {
2526        let engine = create_test_engine();
2527        let run = engine
2528            .store()
2529            .create_run(NewRun {
2530                created_by: None,
2531                workflow_name: "test".to_string(),
2532                trigger: TriggerKind::Manual,
2533                payload: json!({}),
2534                max_retries: 0,
2535                handler_version: None,
2536                labels: HashMap::new(),
2537                scheduled_at: None,
2538                idempotency_key: None,
2539                max_cost_usd: None,
2540            })
2541            .await
2542            .unwrap()
2543            .into_run();
2544
2545        let step_with_error = create_step_with_status(
2546            engine.store(),
2547            run.id,
2548            "already-errored",
2549            0,
2550            StepStatus::Running,
2551        )
2552        .await;
2553
2554        engine
2555            .store()
2556            .update_step(
2557                step_with_error.id,
2558                StepUpdate {
2559                    error: Some("real error from provider".to_string()),
2560                    ..StepUpdate::default()
2561                },
2562            )
2563            .await
2564            .unwrap();
2565
2566        let step_no_error = create_step_with_status(
2567            engine.store(),
2568            run.id,
2569            "no-error-yet",
2570            1,
2571            StepStatus::Running,
2572        )
2573        .await;
2574
2575        engine
2576            .fail_orphaned_steps(run.id, "parent run failed")
2577            .await
2578            .unwrap();
2579
2580        let updated_with = engine
2581            .store()
2582            .get_step(step_with_error.id)
2583            .await
2584            .unwrap()
2585            .unwrap();
2586        assert_eq!(updated_with.status.state, StepStatus::Failed);
2587        assert_eq!(
2588            updated_with.error.as_deref(),
2589            Some("real error from provider"),
2590        );
2591
2592        let updated_without = engine
2593            .store()
2594            .get_step(step_no_error.id)
2595            .await
2596            .unwrap()
2597            .unwrap();
2598        assert_eq!(updated_without.status.state, StepStatus::Failed);
2599        assert_eq!(updated_without.error.as_deref(), Some("parent run failed"),);
2600    }
2601}