Skip to main content

ironflow_engine/
context.rs

1//! [`WorkflowContext`] — execution context for dynamic workflows.
2//!
3//! Provides step execution methods that automatically persist results to the
4//! store. Each call to [`shell`](WorkflowContext::shell),
5//! [`http`](WorkflowContext::http), [`agent`](WorkflowContext::agent), or
6//! [`workflow`](WorkflowContext::workflow) creates a step record, executes the
7//! operation, captures the output, and returns a [`StepOutput`] that the next
8//! step can reference.
9//!
10//! # Examples
11//!
12//! ```no_run
13//! use ironflow_engine::context::WorkflowContext;
14//! use ironflow_engine::config::{ShellConfig, AgentStepConfig};
15//! use ironflow_engine::error::EngineError;
16//!
17//! # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
18//! let build = ctx.shell("build", ShellConfig::new("cargo build")).await?;
19//! let review = ctx.agent("review", AgentStepConfig::new(
20//!     &format!("Build output:\n{}", build.output["stdout"])
21//! )).await?;
22//! # Ok(())
23//! # }
24//! ```
25
26use std::collections::HashMap;
27use std::fmt;
28use std::sync::Arc;
29use std::time::Instant;
30
31use chrono::{DateTime, Utc};
32use futures_util::StreamExt;
33use rust_decimal::Decimal;
34use serde_json::{Value, json};
35use tokio::task::{Id, JoinSet};
36use tracing::{Span, error, info, warn};
37use uuid::Uuid;
38
39use ironflow_core::error::{AgentError, OperationError};
40use ironflow_core::provider::AgentProvider;
41use ironflow_store::models::{
42    ArtifactLookup, NewRun, NewStep, NewStepDependency, RunStatus, RunUpdate, Step, StepKind,
43    StepStatus, StepUpdate, TriggerKind,
44};
45use ironflow_store::store::Store;
46
47use ironflow_artifacts::name::guess_content_type;
48use ironflow_artifacts::stream_from_bytes;
49use ironflow_store::entities::Artifact;
50
51use crate::artifact::{
52    ArtifactSink, ArtifactUpload, StepLocation, collect_outputs, materialize_inputs,
53};
54use crate::budget::step_budget_usd;
55use crate::config::{
56    AgentStepConfig, ApprovalConfig, HttpConfig, ShellConfig, StepConfig, WorkflowStepConfig,
57};
58use crate::error::EngineError;
59use crate::executor::{ParallelStepResult, StepOutput, execute_step_config};
60use crate::handler::WorkflowHandler;
61use crate::log_sender::{LogSender, StepLogSender};
62use crate::operation::Operation;
63
64/// Callback type for resolving workflow handlers by name.
65pub(crate) type HandlerResolver =
66    Arc<dyn Fn(&str) -> Option<Arc<dyn WorkflowHandler>> + Send + Sync>;
67
68/// Execution context for a single workflow run.
69///
70/// Tracks the current step position and provides convenience methods
71/// for executing operations with automatic persistence.
72///
73/// # Examples
74///
75/// ```no_run
76/// use ironflow_engine::context::WorkflowContext;
77/// use ironflow_engine::config::ShellConfig;
78/// use ironflow_engine::error::EngineError;
79///
80/// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
81/// let result = ctx.shell("greet", ShellConfig::new("echo hello")).await?;
82/// assert!(result.output["stdout"].as_str().unwrap().contains("hello"));
83/// # Ok(())
84/// # }
85/// ```
86pub struct WorkflowContext {
87    run_id: Uuid,
88    store: Arc<dyn Store>,
89    provider: Arc<dyn AgentProvider>,
90    handler_resolver: Option<HandlerResolver>,
91    position: u32,
92    /// IDs of the last executed step(s) -- used to record DAG dependencies.
93    last_step_ids: Vec<Uuid>,
94    /// Accumulated cost across all steps in this run.
95    total_cost_usd: Decimal,
96    /// Accumulated duration across all steps.
97    total_duration_ms: u64,
98    /// Cumulative cost cap for this run, resolved at creation. `None` = no cap.
99    max_cost_usd: Option<Decimal>,
100    /// Cost already spent by ancestor runs when this context belongs to a
101    /// sub-workflow. Zero for a top-level run.
102    inherited_cost_usd: Decimal,
103    /// Steps from a previous execution of the *same* attempt, keyed by position.
104    /// Used when resuming after approval to replay completed steps.
105    replay_steps: HashMap<u32, Step>,
106    /// Approvals granted in an *earlier* attempt, keyed by position, holding the
107    /// attempt that granted them. An approval is carried by the run, not by the
108    /// attempt, so a retry never asks a human to approve the same gate twice.
109    granted_approvals: HashMap<u32, u32>,
110    /// Which run attempt this context is executing (1-based).
111    attempt: u32,
112    /// Wall-clock duration already recorded on the run by previous attempts.
113    /// Added to this attempt's duration when the run is finalized.
114    carried_duration_ms: u64,
115    /// Optional sender for real-time log streaming.
116    log_sender: Option<LogSender>,
117    /// Where artifact bytes are read and written. `None` when no artifact
118    /// storage is configured: steps that declare artifacts then fail explicitly
119    /// instead of silently dropping their files.
120    artifact_sink: Option<Arc<dyn ArtifactSink>>,
121    /// Set to `true` when at least one `allow_failure` step failed.
122    has_allowed_failure: bool,
123    /// Error handlers registered via [`on_error`](Self::on_error).
124    error_handlers: Vec<OnErrorHandler>,
125}
126
127/// A registered error handler that fires when a subsequent step fails.
128struct OnErrorHandler {
129    name: String,
130    config: StepConfig,
131}
132
133impl WorkflowContext {
134    /// Create a new context for a run.
135    ///
136    /// Not typically called directly — the [`Engine`](crate::engine::Engine)
137    /// creates this when executing a [`WorkflowHandler`].
138    pub fn new(run_id: Uuid, store: Arc<dyn Store>, provider: Arc<dyn AgentProvider>) -> Self {
139        Self {
140            run_id,
141            store,
142            provider,
143            handler_resolver: None,
144            position: 0,
145            last_step_ids: Vec::new(),
146            total_cost_usd: Decimal::ZERO,
147            total_duration_ms: 0,
148            max_cost_usd: None,
149            inherited_cost_usd: Decimal::ZERO,
150            replay_steps: HashMap::new(),
151            granted_approvals: HashMap::new(),
152            attempt: 1,
153            carried_duration_ms: 0,
154            log_sender: None,
155            artifact_sink: None,
156            has_allowed_failure: false,
157            error_handlers: Vec::new(),
158        }
159    }
160
161    /// Create a new context with a handler resolver for sub-workflow support.
162    ///
163    /// The resolver is called when [`workflow`](Self::workflow) is invoked to
164    /// look up registered handlers by name.
165    pub(crate) fn with_handler_resolver(
166        run_id: Uuid,
167        store: Arc<dyn Store>,
168        provider: Arc<dyn AgentProvider>,
169        resolver: HandlerResolver,
170    ) -> Self {
171        Self {
172            run_id,
173            store,
174            provider,
175            handler_resolver: Some(resolver),
176            position: 0,
177            last_step_ids: Vec::new(),
178            total_cost_usd: Decimal::ZERO,
179            total_duration_ms: 0,
180            max_cost_usd: None,
181            inherited_cost_usd: Decimal::ZERO,
182            replay_steps: HashMap::new(),
183            granted_approvals: HashMap::new(),
184            attempt: 1,
185            carried_duration_ms: 0,
186            log_sender: None,
187            artifact_sink: None,
188            has_allowed_failure: false,
189            error_handlers: Vec::new(),
190        }
191    }
192
193    /// Attach a log sender for real-time step output streaming.
194    pub fn set_log_sender(&mut self, sender: LogSender) {
195        self.log_sender = Some(sender);
196    }
197
198    /// Attach the backend that stores and serves artifact bytes.
199    ///
200    /// Without one, any step that declares an output or calls
201    /// [`put_artifact`](Self::put_artifact) fails with
202    /// [`EngineError::ArtifactsUnavailable`]. Every other step is unaffected,
203    /// so an existing deployment keeps working until artifacts are configured.
204    ///
205    /// # Examples
206    ///
207    /// ```no_run
208    /// use std::sync::Arc;
209    ///
210    /// use ironflow_engine::artifact::ArtifactSink;
211    /// use ironflow_engine::context::WorkflowContext;
212    ///
213    /// # fn example(ctx: &mut WorkflowContext, sink: Arc<dyn ArtifactSink>) {
214    /// ctx.set_artifact_sink(sink);
215    /// # }
216    /// ```
217    pub fn set_artifact_sink(&mut self, sink: Arc<dyn ArtifactSink>) {
218        self.artifact_sink = Some(sink);
219    }
220
221    /// The artifact backend, or an explicit error when none is configured.
222    fn artifact_sink(&self) -> Result<&Arc<dyn ArtifactSink>, EngineError> {
223        self.artifact_sink.as_ref().ok_or_else(|| {
224            EngineError::ArtifactsUnavailable(
225                "no artifact storage is attached to this run".to_string(),
226            )
227        })
228    }
229
230    /// Store an in-memory payload as an artifact of the given step.
231    ///
232    /// The declarative [`ShellConfig::output`](crate::config::ShellConfig::output)
233    /// covers shell steps; this covers custom operations and agent steps, which
234    /// have no working directory to collect from.
235    ///
236    /// The MIME type is guessed from `name` unless `content_type` is set.
237    ///
238    /// # Errors
239    ///
240    /// Returns [`EngineError::ArtifactsUnavailable`] when no backend is
241    /// attached, [`EngineError::Artifact`] when the name is invalid or storage
242    /// fails, and [`EngineError::Store`] when the step already owns that name.
243    ///
244    /// # Examples
245    ///
246    /// ```no_run
247    /// use ironflow_engine::context::WorkflowContext;
248    /// use ironflow_engine::error::EngineError;
249    /// use uuid::Uuid;
250    ///
251    /// # async fn example(ctx: &WorkflowContext, step_id: Uuid) -> Result<(), EngineError> {
252    /// let artifact = ctx
253    ///     .put_artifact(step_id, "summary.json", None, br#"{"ok":true}"#.to_vec())
254    ///     .await?;
255    /// assert_eq!(artifact.content_type, "application/json");
256    /// # Ok(())
257    /// # }
258    /// ```
259    pub async fn put_artifact(
260        &self,
261        step_id: Uuid,
262        name: &str,
263        content_type: Option<&str>,
264        content: Vec<u8>,
265    ) -> Result<Artifact, EngineError> {
266        let sink = self.artifact_sink()?;
267        sink.put(
268            ArtifactUpload {
269                run_id: self.run_id,
270                step_id,
271                name: name.to_string(),
272                content_type: content_type
273                    .map(str::to_string)
274                    .unwrap_or_else(|| guess_content_type(name)),
275            },
276            stream_from_bytes(content),
277        )
278        .await
279    }
280
281    /// Read back an artifact produced earlier in this run.
282    ///
283    /// Resolution follows the same rule as a declared input: same run and
284    /// attempt, steps positioned strictly before the current one, closest
285    /// producer wins.
286    ///
287    /// # Errors
288    ///
289    /// Returns [`EngineError::ArtifactNotFound`] when nothing matches,
290    /// [`EngineError::ArtifactsUnavailable`] when no backend is attached, and
291    /// [`EngineError::Artifact`] when the bytes cannot be read.
292    ///
293    /// # Examples
294    ///
295    /// ```no_run
296    /// use ironflow_engine::context::WorkflowContext;
297    /// use ironflow_engine::error::EngineError;
298    ///
299    /// # async fn example(ctx: &WorkflowContext) -> Result<(), EngineError> {
300    /// let bytes = ctx.get_artifact("build", "report.html").await?;
301    /// println!("{} bytes", bytes.len());
302    /// # Ok(())
303    /// # }
304    /// ```
305    pub async fn get_artifact(&self, step: &str, name: &str) -> Result<Vec<u8>, EngineError> {
306        let sink = self.artifact_sink()?;
307
308        let artifact = self
309            .store
310            .find_artifact_for_input(ArtifactLookup {
311                run_id: self.run_id,
312                attempt: self.attempt,
313                before_position: self.position,
314                step_name: step.to_string(),
315                name: name.to_string(),
316            })
317            .await?
318            .ok_or_else(|| EngineError::ArtifactNotFound {
319                step: step.to_string(),
320                name: name.to_string(),
321            })?;
322
323        let mut content = sink.get(&artifact).await?;
324        let mut buffer = Vec::with_capacity(artifact.size_bytes as usize);
325        while let Some(chunk) = content.next().await {
326            let chunk = chunk?;
327            buffer.extend_from_slice(chunk.as_ref());
328        }
329
330        Ok(buffer)
331    }
332
333    /// Place a shell step's declared inputs in its working directory.
334    ///
335    /// A step that declares none needs no backend, so the check for one only
336    /// happens when there is something to materialize.
337    async fn prepare_step_inputs(
338        &self,
339        config: &StepConfig,
340        position: u32,
341    ) -> Result<(), EngineError> {
342        let StepConfig::Shell(shell) = config else {
343            return Ok(());
344        };
345        if shell.inputs.is_empty() {
346            return Ok(());
347        }
348
349        materialize_inputs(
350            self.artifact_sink()?,
351            &self.store,
352            shell,
353            StepLocation {
354                run_id: self.run_id,
355                attempt: self.attempt,
356                position,
357            },
358        )
359        .await
360    }
361
362    /// Store a shell step's declared outputs.
363    ///
364    /// On a failed step this is best-effort: the collection error is logged and
365    /// swallowed so it never masks the failure that actually stopped the step.
366    async fn store_step_outputs(
367        &self,
368        config: &StepConfig,
369        step_id: Uuid,
370        step_name: &str,
371        step_succeeded: bool,
372    ) -> Result<(), EngineError> {
373        let StepConfig::Shell(shell) = config else {
374            return Ok(());
375        };
376        if shell.outputs.is_empty() {
377            return Ok(());
378        }
379
380        let sink = match self.artifact_sink() {
381            Ok(sink) => sink,
382            Err(err) if step_succeeded => return Err(err),
383            Err(err) => {
384                warn!(
385                    run_id = %self.run_id,
386                    step = %step_name,
387                    error = %err,
388                    "cannot collect outputs of a failed step"
389                );
390                return Ok(());
391            }
392        };
393
394        let collected =
395            collect_outputs(sink, shell, self.run_id, step_id, step_name, step_succeeded).await;
396
397        match collected {
398            Ok(()) => Ok(()),
399            Err(err) if step_succeeded => Err(err),
400            Err(err) => {
401                warn!(
402                    run_id = %self.run_id,
403                    step = %step_name,
404                    error = %err,
405                    "failed to collect outputs of a failed step"
406                );
407                Ok(())
408            }
409        }
410    }
411
412    /// Seed the context with the run's attempt number and the totals already
413    /// accumulated by previous attempts.
414    ///
415    /// Called by the engine before executing a handler. Steps created by this
416    /// context belong to `attempt`, and the cost and duration it reports at the
417    /// end cover the whole run, not just this attempt.
418    pub(crate) fn carry_over_run_totals(
419        &mut self,
420        attempt: u32,
421        cost_usd: Decimal,
422        duration_ms: u64,
423    ) {
424        self.attempt = attempt;
425        self.total_cost_usd = cost_usd;
426        self.carried_duration_ms = duration_ms;
427    }
428
429    /// Wall-clock duration already recorded on the run by previous attempts.
430    pub(crate) fn carried_duration_ms(&self) -> u64 {
431        self.carried_duration_ms
432    }
433
434    /// The run attempt this context is executing (1-based).
435    pub fn attempt(&self) -> u32 {
436        self.attempt
437    }
438
439    /// Set the cumulative cost cap enforced before every agent step.
440    ///
441    /// Called by the [`Engine`](crate::engine::Engine) with the run's persisted
442    /// `max_cost_usd`. `None` disables the check.
443    ///
444    /// # Examples
445    ///
446    /// ```no_run
447    /// use ironflow_engine::context::WorkflowContext;
448    /// use rust_decimal::Decimal;
449    ///
450    /// # fn example(ctx: &mut WorkflowContext) {
451    /// ctx.set_max_cost_usd(Some(Decimal::new(200, 2))); // $2.00
452    /// # }
453    /// ```
454    pub fn set_max_cost_usd(&mut self, cap: Option<Decimal>) {
455        self.max_cost_usd = cap;
456    }
457
458    /// The cumulative cost cap of this run, if any.
459    pub fn max_cost_usd(&self) -> Option<Decimal> {
460        self.max_cost_usd
461    }
462
463    /// Total cost charged against the cap: this run plus every ancestor run.
464    ///
465    /// For a top-level run this equals [`total_cost_usd`](Self::total_cost_usd).
466    /// For a sub-workflow it also includes what the parent chain already spent.
467    pub fn charged_cost_usd(&self) -> Decimal {
468        self.inherited_cost_usd + self.total_cost_usd
469    }
470
471    /// Reject the upcoming agent work when it would cross the run's cost cap.
472    ///
473    /// `step_budget` is the declared budget of the step (or the sum of budgets
474    /// for a parallel wave). Called *before* any step record is created so a
475    /// refused run never launches the work it could not afford.
476    ///
477    /// # Errors
478    ///
479    /// Returns [`EngineError::RunBudgetExceeded`] when
480    /// `charged_cost + step_budget` exceeds the cap.
481    fn check_run_budget(&self, step_budget: Decimal) -> Result<(), EngineError> {
482        let Some(limit) = self.max_cost_usd else {
483            return Ok(());
484        };
485
486        let spent = self.charged_cost_usd();
487        if spent + step_budget <= limit {
488            return Ok(());
489        }
490
491        error!(
492            run_id = %self.run_id,
493            limit_usd = %limit,
494            spent_usd = %spent,
495            step_budget_usd = %step_budget,
496            "run cost cap reached, refusing agent step"
497        );
498
499        Err(EngineError::RunBudgetExceeded {
500            run_id: self.run_id,
501            limit_usd: limit,
502            spent_usd: spent,
503            step_budget_usd: step_budget,
504        })
505    }
506
507    /// Load existing steps from the store for replay after approval.
508    ///
509    /// Called by the engine when resuming a run. All completed steps
510    /// and the approved approval step are indexed by position so that
511    /// `execute_step` and `approval` can skip them.
512    ///
513    /// Only steps of the current attempt are replayed: positions repeat across
514    /// attempts, so replaying an earlier attempt's steps would skip the whole
515    /// workflow. The one exception is an approval already granted in an earlier
516    /// attempt -- approval is carried by the run, not by the attempt, so a human
517    /// is never asked to approve the same gate twice.
518    pub(crate) async fn load_replay_steps(&mut self) -> Result<(), EngineError> {
519        let steps = self.store.list_steps(self.run_id).await?;
520        for step in steps {
521            let dominated = matches!(
522                step.status.state,
523                StepStatus::Completed | StepStatus::Running | StepStatus::AwaitingApproval
524            );
525            if !dominated {
526                continue;
527            }
528
529            if step.attempt == self.attempt {
530                self.replay_steps.insert(step.position, step);
531            } else if step.kind == StepKind::Approval && step.status.state == StepStatus::Completed
532            {
533                self.granted_approvals.insert(step.position, step.attempt);
534            }
535        }
536        Ok(())
537    }
538
539    /// The run ID this context is executing for.
540    pub fn run_id(&self) -> Uuid {
541        self.run_id
542    }
543
544    /// Accumulated cost across all executed steps so far.
545    pub fn total_cost_usd(&self) -> Decimal {
546        self.total_cost_usd
547    }
548
549    /// Whether at least one `allow_failure` step failed during this run.
550    pub fn has_allowed_failure(&self) -> bool {
551        self.has_allowed_failure
552    }
553
554    /// Accumulated duration across all executed steps so far.
555    pub fn total_duration_ms(&self) -> u64 {
556        self.total_duration_ms
557    }
558
559    /// Execute multiple steps concurrently (wait-all model).
560    ///
561    /// All steps in the batch execute in parallel via `tokio::JoinSet`.
562    /// Each step is recorded with the same `position` (execution wave).
563    /// Dependencies on previous steps are recorded automatically.
564    ///
565    /// When `fail_fast` is true, remaining steps are aborted on the first
566    /// failure. When false, all steps run to completion and the first
567    /// error is returned.
568    ///
569    /// # Errors
570    ///
571    /// Returns [`EngineError`] if any step fails.
572    ///
573    /// # Examples
574    ///
575    /// ```no_run
576    /// use ironflow_engine::context::WorkflowContext;
577    /// use ironflow_engine::config::{StepConfig, ShellConfig};
578    /// use ironflow_engine::error::EngineError;
579    ///
580    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
581    /// let results = ctx.parallel(
582    ///     vec![
583    ///         ("test-unit", StepConfig::Shell(ShellConfig::new("cargo test --lib"))),
584    ///         ("lint", StepConfig::Shell(ShellConfig::new("cargo clippy"))),
585    ///     ],
586    ///     true,
587    /// ).await?;
588    ///
589    /// for r in &results {
590    ///     println!("{}: {:?}", r.name, r.output.output);
591    /// }
592    /// # Ok(())
593    /// # }
594    /// ```
595    pub async fn parallel(
596        &mut self,
597        steps: Vec<(&str, StepConfig)>,
598        fail_fast: bool,
599    ) -> Result<Vec<ParallelStepResult>, EngineError> {
600        if steps.is_empty() {
601            return Ok(Vec::new());
602        }
603
604        // Cost cap: the whole wave is charged at once. Refused before any step
605        // record is created, so nothing in the wave starts.
606        let wave_budget: Decimal = steps
607            .iter()
608            .filter_map(|(_, config)| match config {
609                StepConfig::Agent(agent_config) => Some(agent_config.max_budget_usd),
610                _ => None,
611            })
612            .map(step_budget_usd)
613            .sum();
614        self.check_run_budget(wave_budget)?;
615
616        let wave_position = self.position;
617        self.position += 1;
618
619        let now = Utc::now();
620        let mut step_records: Vec<(Uuid, String, StepConfig)> = Vec::with_capacity(steps.len());
621
622        for (name, config) in &steps {
623            let kind = config.kind();
624            let step = self
625                .store
626                .create_step(NewStep {
627                    run_id: self.run_id,
628                    name: name.to_string(),
629                    kind,
630                    position: wave_position,
631                    input: Some(serde_json::to_value(config)?),
632                    is_error_handler: false,
633                })
634                .await?;
635
636            self.start_step(step.id, now).await?;
637
638            // Inputs are materialized before any step in the wave starts, so a
639            // missing one fails the wave rather than a half-run command.
640            if let Err(err) = self.prepare_step_inputs(config, wave_position).await {
641                self.fail_step(step.id, &err).await;
642                if !config.allow_failure() {
643                    return Err(err);
644                }
645                self.has_allowed_failure = true;
646                info!(
647                    run_id = %self.run_id,
648                    step = %name,
649                    error = %err,
650                    "parallel step input preparation failed but allow_failure is set, skipping"
651                );
652                continue;
653            }
654
655            step_records.push((step.id, name.to_string(), config.clone()));
656        }
657
658        let mut join_set = JoinSet::new();
659        let mut task_index: HashMap<Id, usize> = HashMap::new();
660        for (idx, (step_id, step_name, config)) in step_records.iter().enumerate() {
661            let provider = self.provider.clone();
662            let config = config.clone();
663            let step_log_sender = self
664                .log_sender
665                .as_ref()
666                .map(|s| StepLogSender::new(s.clone(), self.run_id, *step_id, step_name.clone()));
667            let handle = join_set.spawn(async move {
668                (
669                    idx,
670                    execute_step_config(&config, &provider, step_log_sender).await,
671                )
672            });
673            task_index.insert(handle.id(), idx);
674        }
675
676        // JoinSet returns in completion order; indexed_results restores input order.
677        let mut indexed_results: Vec<Option<Result<StepOutput, String>>> =
678            vec![None; step_records.len()];
679        let mut first_error: Option<EngineError> = None;
680
681        while let Some(join_result) = join_set.join_next().await {
682            let (idx, step_result) = match join_result {
683                Ok(r) => r,
684                Err(e) => {
685                    let error_msg = format!("join error: {e}");
686                    if let Some(&idx) = task_index.get(&e.id()) {
687                        let (step_id, step_name, _) = &step_records[idx];
688                        let completed_at = Utc::now();
689                        error!(
690                            run_id = %self.run_id,
691                            step = %step_name,
692                            error = %error_msg,
693                            "parallel step panicked or was cancelled"
694                        );
695                        if let Err(store_err) = self
696                            .store
697                            .update_step(
698                                *step_id,
699                                StepUpdate {
700                                    status: Some(StepStatus::Failed),
701                                    error: Some(error_msg.clone()),
702                                    completed_at: Some(completed_at),
703                                    ..StepUpdate::default()
704                                },
705                            )
706                            .await
707                        {
708                            error!(
709                                run_id = %self.run_id,
710                                step_id = %step_id,
711                                error = %store_err,
712                                "failed to persist JoinError for step"
713                            );
714                        }
715                        indexed_results[idx] = Some(Err(error_msg.clone()));
716                    }
717                    if first_error.is_none() {
718                        first_error = Some(EngineError::StepConfig(error_msg));
719                    }
720                    if fail_fast {
721                        join_set.abort_all();
722                    }
723                    continue;
724                }
725            };
726
727            let (step_id, step_name, step_config) = &step_records[idx];
728            let completed_at = Utc::now();
729
730            if let Err(err) = self
731                .store_step_outputs(step_config, *step_id, step_name, step_result.is_ok())
732                .await
733            {
734                self.fail_step(*step_id, &err).await;
735                indexed_results[idx] = Some(Err(err.to_string()));
736                if first_error.is_none() {
737                    first_error = Some(err);
738                }
739                if fail_fast {
740                    join_set.abort_all();
741                }
742                continue;
743            }
744
745            match step_result {
746                Ok(output) => {
747                    self.total_cost_usd += output.cost_usd;
748                    self.total_duration_ms += output.duration_ms;
749
750                    let debug_messages_json = output.debug_messages_json();
751
752                    self.store
753                        .update_step(
754                            *step_id,
755                            StepUpdate {
756                                status: Some(StepStatus::Completed),
757                                output: Some(output.output.clone()),
758                                duration_ms: Some(output.duration_ms),
759                                cost_usd: Some(output.cost_usd),
760                                input_tokens: output.input_tokens,
761                                output_tokens: output.output_tokens,
762                                completed_at: Some(completed_at),
763                                debug_messages: debug_messages_json,
764                                ..StepUpdate::default()
765                            },
766                        )
767                        .await?;
768
769                    info!(
770                        run_id = %self.run_id,
771                        step = %step_name,
772                        duration_ms = output.duration_ms,
773                        "parallel step completed"
774                    );
775
776                    indexed_results[idx] = Some(Ok(output));
777                }
778                Err(err) => {
779                    let err_msg = err.to_string();
780                    let debug_messages_json = extract_debug_messages_from_error(&err);
781                    let partial = extract_partial_usage_from_error(&err);
782                    let raw_response_output = extract_raw_response_from_error(&err);
783
784                    if let Some(ref usage) = partial {
785                        if let Some(cost) = usage.cost_usd {
786                            self.total_cost_usd += cost;
787                        }
788                        if let Some(dur) = usage.duration_ms {
789                            self.total_duration_ms += dur;
790                        }
791                    }
792
793                    if let Err(store_err) = self
794                        .store
795                        .update_step(
796                            *step_id,
797                            StepUpdate {
798                                status: Some(StepStatus::Failed),
799                                error: Some(err_msg.clone()),
800                                output: raw_response_output.clone(),
801                                completed_at: Some(completed_at),
802                                debug_messages: debug_messages_json,
803                                duration_ms: partial.as_ref().and_then(|p| p.duration_ms),
804                                cost_usd: partial.as_ref().and_then(|p| p.cost_usd),
805                                input_tokens: partial.as_ref().and_then(|p| p.input_tokens),
806                                output_tokens: partial.as_ref().and_then(|p| p.output_tokens),
807                                ..StepUpdate::default()
808                            },
809                        )
810                        .await
811                    {
812                        tracing::error!(
813                            step_id = %step_id,
814                            error = %store_err,
815                            "failed to persist parallel step failure"
816                        );
817                    }
818
819                    if step_config.allow_failure() {
820                        self.has_allowed_failure = true;
821                        info!(
822                            run_id = %self.run_id,
823                            step = %step_name,
824                            error = %err_msg,
825                            "parallel step failed but allow_failure is set, continuing"
826                        );
827                        indexed_results[idx] = Some(Ok(allowed_failure_output(
828                            &err_msg,
829                            raw_response_output,
830                            partial.as_ref(),
831                        )));
832                    } else {
833                        indexed_results[idx] = Some(Err(err_msg.clone()));
834
835                        if first_error.is_none() {
836                            first_error = Some(err);
837                        }
838
839                        if fail_fast {
840                            join_set.abort_all();
841                        }
842                    }
843                }
844            }
845        }
846
847        if let Some(err) = first_error {
848            return Err(err);
849        }
850
851        self.last_step_ids = step_records.iter().map(|(id, _, _)| *id).collect();
852
853        // Build results in original order.
854        let results: Vec<ParallelStepResult> = step_records
855            .iter()
856            .enumerate()
857            .map(|(idx, (step_id, name, _))| {
858                let output = match indexed_results[idx].take() {
859                    Some(Ok(o)) => o,
860                    _ => unreachable!("all steps succeeded if no error returned"),
861                };
862                ParallelStepResult {
863                    name: name.clone(),
864                    output,
865                    step_id: *step_id,
866                }
867            })
868            .collect();
869
870        Ok(results)
871    }
872
873    /// Execute a shell step.
874    ///
875    /// Creates the step record, runs the command, persists the result,
876    /// and returns the output for use in subsequent steps.
877    ///
878    /// # Errors
879    ///
880    /// Returns [`EngineError`] if the command fails or the store errors.
881    ///
882    /// # Examples
883    ///
884    /// ```no_run
885    /// use ironflow_engine::context::WorkflowContext;
886    /// use ironflow_engine::config::ShellConfig;
887    /// use ironflow_engine::error::EngineError;
888    ///
889    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
890    /// let files = ctx.shell("list", ShellConfig::new("ls -la")).await?;
891    /// println!("stdout: {}", files.output["stdout"]);
892    /// # Ok(())
893    /// # }
894    /// ```
895    pub async fn shell(
896        &mut self,
897        name: &str,
898        config: ShellConfig,
899    ) -> Result<StepOutput, EngineError> {
900        self.execute_step(name, StepKind::Shell, StepConfig::Shell(config))
901            .await
902    }
903
904    /// Execute an HTTP step.
905    ///
906    /// # Errors
907    ///
908    /// Returns [`EngineError`] if the request fails or the store errors.
909    ///
910    /// # Examples
911    ///
912    /// ```no_run
913    /// use ironflow_engine::context::WorkflowContext;
914    /// use ironflow_engine::config::HttpConfig;
915    /// use ironflow_engine::error::EngineError;
916    ///
917    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
918    /// let resp = ctx.http("health", HttpConfig::get("https://api.example.com/health")).await?;
919    /// println!("status: {}", resp.output["status"]);
920    /// # Ok(())
921    /// # }
922    /// ```
923    pub async fn http(
924        &mut self,
925        name: &str,
926        config: HttpConfig,
927    ) -> Result<StepOutput, EngineError> {
928        self.execute_step(name, StepKind::Http, StepConfig::Http(config))
929            .await
930    }
931
932    /// Execute an agent step.
933    ///
934    /// # Errors
935    ///
936    /// Returns [`EngineError`] if the agent invocation fails or the store errors.
937    ///
938    /// # Examples
939    ///
940    /// ```no_run
941    /// use ironflow_engine::context::WorkflowContext;
942    /// use ironflow_engine::config::AgentStepConfig;
943    /// use ironflow_engine::error::EngineError;
944    ///
945    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
946    /// let review = ctx.agent("review", AgentStepConfig::new("Review the code")).await?;
947    /// println!("review: {}", review.output);
948    /// # Ok(())
949    /// # }
950    /// ```
951    pub async fn agent(
952        &mut self,
953        name: &str,
954        config: impl Into<AgentStepConfig>,
955    ) -> Result<StepOutput, EngineError> {
956        self.execute_step(name, StepKind::Agent, StepConfig::Agent(config.into()))
957            .await
958    }
959
960    /// Create a human approval gate.
961    ///
962    /// On first execution, records an approval step and returns
963    /// [`EngineError::ApprovalRequired`] to suspend the run. The engine
964    /// transitions the run to `AwaitingApproval`.
965    ///
966    /// On resume (after a human approved via the API), the approval step
967    /// is replayed: it is marked as `Completed` and execution continues
968    /// past it. Multiple approval gates in the same handler work -- each
969    /// one pauses and resumes independently.
970    ///
971    /// # Errors
972    ///
973    /// Returns [`EngineError::ApprovalRequired`] to pause the run on
974    /// first execution. Returns other [`EngineError`] variants on store
975    /// failures.
976    ///
977    /// # Examples
978    ///
979    /// ```no_run
980    /// use ironflow_engine::context::WorkflowContext;
981    /// use ironflow_engine::config::ApprovalConfig;
982    /// use ironflow_engine::error::EngineError;
983    ///
984    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
985    /// ctx.approval("deploy-gate", ApprovalConfig::new("Approve deployment?")).await?;
986    /// // Execution continues here after approval
987    /// # Ok(())
988    /// # }
989    /// ```
990    pub async fn approval(
991        &mut self,
992        name: &str,
993        config: ApprovalConfig,
994    ) -> Result<(), EngineError> {
995        let position = self.position;
996        self.position += 1;
997
998        // Replay: if this approval step exists from a prior execution,
999        // the run was approved -- mark it completed (if not already) and continue.
1000        if let Some(existing) = self.replay_steps.get(&position)
1001            && existing.kind == StepKind::Approval
1002        {
1003            if existing.status.state == StepStatus::AwaitingApproval {
1004                self.store
1005                    .update_step(
1006                        existing.id,
1007                        StepUpdate {
1008                            status: Some(StepStatus::Completed),
1009                            completed_at: Some(Utc::now()),
1010                            ..StepUpdate::default()
1011                        },
1012                    )
1013                    .await?;
1014            }
1015
1016            self.last_step_ids = vec![existing.id];
1017            info!(
1018                run_id = %self.run_id,
1019                step = %name,
1020                position,
1021                "approval step replayed (approved)"
1022            );
1023            return Ok(());
1024        }
1025
1026        // Carried over: a human already approved this gate in an earlier
1027        // attempt. Record a fresh step in the current attempt so that each
1028        // attempt keeps a complete, self-contained DAG, and continue.
1029        if let Some(&granted_in) = self.granted_approvals.get(&position) {
1030            let step = self
1031                .store
1032                .create_step(NewStep {
1033                    run_id: self.run_id,
1034                    name: name.to_string(),
1035                    kind: StepKind::Approval,
1036                    position,
1037                    input: Some(serde_json::to_value(&config)?),
1038                    is_error_handler: false,
1039                })
1040                .await?;
1041
1042            let now = Utc::now();
1043            self.start_step(step.id, now).await?;
1044            self.store
1045                .update_step(
1046                    step.id,
1047                    StepUpdate {
1048                        status: Some(StepStatus::Completed),
1049                        output: Some(json!({"approved_in_attempt": granted_in})),
1050                        completed_at: Some(now),
1051                        ..StepUpdate::default()
1052                    },
1053                )
1054                .await?;
1055
1056            self.last_step_ids = vec![step.id];
1057            info!(
1058                run_id = %self.run_id,
1059                step = %name,
1060                position,
1061                granted_in_attempt = granted_in,
1062                attempt = self.attempt,
1063                "approval carried over from a previous attempt"
1064            );
1065            return Ok(());
1066        }
1067
1068        // First execution: create the approval step and suspend.
1069        let step = self
1070            .store
1071            .create_step(NewStep {
1072                run_id: self.run_id,
1073                name: name.to_string(),
1074                kind: StepKind::Approval,
1075                position,
1076                input: Some(serde_json::to_value(&config)?),
1077                is_error_handler: false,
1078            })
1079            .await?;
1080
1081        self.start_step(step.id, Utc::now()).await?;
1082
1083        // Transition the step to AwaitingApproval so it reflects
1084        // the suspended state on the dashboard.
1085        self.store
1086            .update_step(
1087                step.id,
1088                StepUpdate {
1089                    status: Some(StepStatus::AwaitingApproval),
1090                    ..StepUpdate::default()
1091                },
1092            )
1093            .await?;
1094
1095        self.last_step_ids = vec![step.id];
1096
1097        Err(EngineError::ApprovalRequired {
1098            run_id: self.run_id,
1099            step_id: step.id,
1100            message: config.message().to_string(),
1101        })
1102    }
1103
1104    /// Record a step as explicitly skipped.
1105    ///
1106    /// Use this inside an `if`/`else` branch when a step should not execute
1107    /// but must still appear in the DAG and timeline with its reason.
1108    ///
1109    /// The step is created directly in [`StepStatus::Skipped`] state and the
1110    /// reason is stored in the output as `{"reason": "..."}`.
1111    ///
1112    /// # Errors
1113    ///
1114    /// Returns [`EngineError`] if the store fails.
1115    ///
1116    /// # Examples
1117    ///
1118    /// ```no_run
1119    /// use ironflow_engine::context::WorkflowContext;
1120    /// use ironflow_engine::error::EngineError;
1121    ///
1122    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1123    /// let tests_passed = false;
1124    /// if tests_passed {
1125    ///     // ctx.shell("deploy", ...).await?;
1126    /// } else {
1127    ///     ctx.skip("deploy", "tests failed").await?;
1128    /// }
1129    /// # Ok(())
1130    /// # }
1131    /// ```
1132    pub async fn skip(&mut self, name: &str, reason: &str) -> Result<(), EngineError> {
1133        let position = self.position;
1134        self.position += 1;
1135
1136        let step = self
1137            .store
1138            .create_step(NewStep {
1139                run_id: self.run_id,
1140                name: name.to_string(),
1141                kind: StepKind::Custom("skip".to_string()),
1142                position,
1143                input: None,
1144                is_error_handler: false,
1145            })
1146            .await?;
1147
1148        if !self.last_step_ids.is_empty() {
1149            let deps: Vec<NewStepDependency> = self
1150                .last_step_ids
1151                .iter()
1152                .map(|&depends_on| NewStepDependency {
1153                    step_id: step.id,
1154                    depends_on,
1155                })
1156                .collect();
1157            self.store.create_step_dependencies(deps).await?;
1158        }
1159
1160        let now = Utc::now();
1161        self.store
1162            .update_step(
1163                step.id,
1164                StepUpdate {
1165                    status: Some(StepStatus::Skipped),
1166                    output: Some(serde_json::json!({"reason": reason})),
1167                    completed_at: Some(now),
1168                    ..StepUpdate::default()
1169                },
1170            )
1171            .await?;
1172
1173        self.last_step_ids = vec![step.id];
1174
1175        info!(
1176            run_id = %self.run_id,
1177            step = %name,
1178            reason,
1179            "step skipped"
1180        );
1181
1182        Ok(())
1183    }
1184
1185    /// Execute a custom operation step.
1186    ///
1187    /// Runs a user-defined [`Operation`] with full step lifecycle management:
1188    /// creates the step record, transitions to Running, executes the operation,
1189    /// persists the output and duration, and marks the step Completed or Failed.
1190    ///
1191    /// The operation's [`kind()`](Operation::kind) is stored as
1192    /// [`StepKind::Custom`].
1193    ///
1194    /// # Errors
1195    ///
1196    /// Returns [`EngineError`] if the operation fails or the store errors.
1197    ///
1198    /// # Examples
1199    ///
1200    /// ```no_run
1201    /// use ironflow_engine::context::WorkflowContext;
1202    /// use ironflow_engine::operation::Operation;
1203    /// use ironflow_engine::error::EngineError;
1204    /// use serde_json::{Value, json};
1205    /// use std::pin::Pin;
1206    /// use std::future::Future;
1207    ///
1208    /// struct MyOp;
1209    /// impl Operation for MyOp {
1210    ///     fn kind(&self) -> &str { "my-service" }
1211    ///     fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
1212    ///         Box::pin(async { Ok(json!({"ok": true})) })
1213    ///     }
1214    /// }
1215    ///
1216    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1217    /// let result = ctx.operation("call-service", &MyOp).await?;
1218    /// println!("output: {}", result.output);
1219    /// # Ok(())
1220    /// # }
1221    /// ```
1222    pub async fn operation(
1223        &mut self,
1224        name: &str,
1225        op: &dyn Operation,
1226    ) -> Result<StepOutput, EngineError> {
1227        let kind = StepKind::Custom(op.kind().to_string());
1228        let position = self.position;
1229        self.position += 1;
1230
1231        let step = self
1232            .store
1233            .create_step(NewStep {
1234                run_id: self.run_id,
1235                name: name.to_string(),
1236                kind,
1237                position,
1238                input: op.input(),
1239                is_error_handler: false,
1240            })
1241            .await?;
1242
1243        self.start_step(step.id, Utc::now()).await?;
1244
1245        let start = Instant::now();
1246
1247        match op.execute().await {
1248            Ok(output_value) => {
1249                let duration_ms = start.elapsed().as_millis() as u64;
1250                self.total_duration_ms += duration_ms;
1251
1252                let completed_at = Utc::now();
1253                self.store
1254                    .update_step(
1255                        step.id,
1256                        StepUpdate {
1257                            status: Some(StepStatus::Completed),
1258                            output: Some(output_value.clone()),
1259                            duration_ms: Some(duration_ms),
1260                            cost_usd: Some(Decimal::ZERO),
1261                            completed_at: Some(completed_at),
1262                            ..StepUpdate::default()
1263                        },
1264                    )
1265                    .await?;
1266
1267                info!(
1268                    run_id = %self.run_id,
1269                    step = %name,
1270                    kind = op.kind(),
1271                    duration_ms,
1272                    "operation step completed"
1273                );
1274
1275                self.last_step_ids = vec![step.id];
1276
1277                Ok(StepOutput {
1278                    output: output_value,
1279                    duration_ms,
1280                    cost_usd: Decimal::ZERO,
1281                    input_tokens: None,
1282                    output_tokens: None,
1283                    model: None,
1284                    debug_messages: None,
1285                })
1286            }
1287            Err(err) => {
1288                let completed_at = Utc::now();
1289                if let Err(store_err) = self
1290                    .store
1291                    .update_step(
1292                        step.id,
1293                        StepUpdate {
1294                            status: Some(StepStatus::Failed),
1295                            error: Some(err.to_string()),
1296                            completed_at: Some(completed_at),
1297                            ..StepUpdate::default()
1298                        },
1299                    )
1300                    .await
1301                {
1302                    error!(step_id = %step.id, error = %store_err, "failed to persist step failure");
1303                }
1304
1305                Err(err)
1306            }
1307        }
1308    }
1309
1310    /// Execute a sub-workflow step.
1311    ///
1312    /// Creates a child run for the named workflow handler, executes it with
1313    /// its own steps and lifecycle, and returns a [`StepOutput`] containing
1314    /// the child run ID and aggregated metrics.
1315    ///
1316    /// Requires the context to be created with
1317    /// `with_handler_resolver`.
1318    ///
1319    /// # Errors
1320    ///
1321    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered
1322    /// with the given name, or if no handler resolver is available.
1323    ///
1324    /// # Examples
1325    ///
1326    /// ```no_run
1327    /// use ironflow_engine::context::WorkflowContext;
1328    /// use ironflow_engine::error::EngineError;
1329    /// use serde_json::json;
1330    ///
1331    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1332    /// // let result = ctx.workflow(&MySubWorkflow, json!({})).await?;
1333    /// # Ok(())
1334    /// # }
1335    /// ```
1336    pub async fn workflow(
1337        &mut self,
1338        handler: &dyn WorkflowHandler,
1339        payload: Value,
1340    ) -> Result<StepOutput, EngineError> {
1341        let config = WorkflowStepConfig::new(handler.name(), payload);
1342        let position = self.position;
1343        self.position += 1;
1344
1345        let step = self
1346            .store
1347            .create_step(NewStep {
1348                run_id: self.run_id,
1349                name: config.workflow_name.clone(),
1350                kind: StepKind::Workflow,
1351                position,
1352                input: Some(serde_json::to_value(&config)?),
1353                is_error_handler: false,
1354            })
1355            .await?;
1356
1357        self.start_step(step.id, Utc::now()).await?;
1358
1359        match self.execute_child_workflow(&config).await {
1360            Ok((output, child_had_allowed_failure)) => {
1361                self.total_cost_usd += output.cost_usd;
1362                self.total_duration_ms += output.duration_ms;
1363                if child_had_allowed_failure {
1364                    self.has_allowed_failure = true;
1365                }
1366
1367                let completed_at = Utc::now();
1368                self.store
1369                    .update_step(
1370                        step.id,
1371                        StepUpdate {
1372                            status: Some(StepStatus::Completed),
1373                            output: Some(output.output.clone()),
1374                            duration_ms: Some(output.duration_ms),
1375                            cost_usd: Some(output.cost_usd),
1376                            completed_at: Some(completed_at),
1377                            ..StepUpdate::default()
1378                        },
1379                    )
1380                    .await?;
1381
1382                info!(
1383                    run_id = %self.run_id,
1384                    child_workflow = %config.workflow_name,
1385                    duration_ms = output.duration_ms,
1386                    "workflow step completed"
1387                );
1388
1389                self.last_step_ids = vec![step.id];
1390
1391                Ok(output)
1392            }
1393            Err(err) => {
1394                let completed_at = Utc::now();
1395                if let Err(store_err) = self
1396                    .store
1397                    .update_step(
1398                        step.id,
1399                        StepUpdate {
1400                            status: Some(StepStatus::Failed),
1401                            error: Some(err.to_string()),
1402                            completed_at: Some(completed_at),
1403                            ..StepUpdate::default()
1404                        },
1405                    )
1406                    .await
1407                {
1408                    error!(step_id = %step.id, error = %store_err, "failed to persist step failure");
1409                }
1410
1411                Err(err)
1412            }
1413        }
1414    }
1415
1416    /// Execute a child workflow and return aggregated output plus whether
1417    /// at least one `allow_failure` step failed.
1418    async fn execute_child_workflow(
1419        &self,
1420        config: &WorkflowStepConfig,
1421    ) -> Result<(StepOutput, bool), EngineError> {
1422        let resolver = self.handler_resolver.as_ref().ok_or_else(|| {
1423            EngineError::InvalidWorkflow(
1424                "sub-workflow requires a handler resolver (use Engine to execute)".to_string(),
1425            )
1426        })?;
1427
1428        let handler = resolver(&config.workflow_name).ok_or_else(|| {
1429            EngineError::InvalidWorkflow(format!("no handler registered: {}", config.workflow_name))
1430        })?;
1431
1432        // A child run inherits both the parent labels and the parent author:
1433        // whoever triggered the parent workflow is accountable for its children.
1434        let parent = self.store.get_run(self.run_id).await?;
1435        let (parent_labels, parent_author) =
1436            parent.map(|r| (r.labels, r.created_by)).unwrap_or_default();
1437
1438        let child_run = self
1439            .store
1440            .create_run(NewRun {
1441                workflow_name: config.workflow_name.clone(),
1442                trigger: TriggerKind::Workflow,
1443                payload: config.payload.clone(),
1444                max_retries: 0,
1445                handler_version: None,
1446                labels: parent_labels,
1447                scheduled_at: None,
1448                created_by: parent_author,
1449                idempotency_key: None,
1450                // The child shares the parent's cap; it does not get its own budget.
1451                max_cost_usd: self.max_cost_usd,
1452            })
1453            .await?
1454            .into_run();
1455
1456        let child_run_id = child_run.id;
1457        info!(
1458            parent_run_id = %self.run_id,
1459            child_run_id = %child_run_id,
1460            workflow = %config.workflow_name,
1461            "child run created"
1462        );
1463
1464        self.store
1465            .update_run_status(child_run_id, RunStatus::Running)
1466            .await?;
1467
1468        let run_start = Instant::now();
1469        let mut child_ctx = WorkflowContext {
1470            run_id: child_run_id,
1471            store: self.store.clone(),
1472            provider: self.provider.clone(),
1473            handler_resolver: self.handler_resolver.clone(),
1474            position: 0,
1475            last_step_ids: Vec::new(),
1476            total_cost_usd: Decimal::ZERO,
1477            total_duration_ms: 0,
1478            max_cost_usd: self.max_cost_usd,
1479            // Everything the parent chain already spent counts against the
1480            // shared cap, so the child cannot restart the budget from zero.
1481            inherited_cost_usd: self.charged_cost_usd(),
1482            replay_steps: HashMap::new(),
1483            granted_approvals: HashMap::new(),
1484            // A child run is created fresh here; it is never itself retried.
1485            attempt: 1,
1486            carried_duration_ms: 0,
1487            log_sender: self.log_sender.clone(),
1488            // A child shares the storage backend but not the parent's artifacts:
1489            // input lookups are scoped to the child's own run.
1490            artifact_sink: self.artifact_sink.clone(),
1491            has_allowed_failure: false,
1492            error_handlers: Vec::new(),
1493        };
1494
1495        let result = handler.execute(&mut child_ctx).await;
1496        let total_duration = run_start.elapsed().as_millis() as u64;
1497        let completed_at = Utc::now();
1498
1499        match result {
1500            Ok(()) => {
1501                let child_status = if child_ctx.has_allowed_failure {
1502                    RunStatus::Warning
1503                } else {
1504                    RunStatus::Completed
1505                };
1506                self.store
1507                    .update_run(
1508                        child_run_id,
1509                        RunUpdate {
1510                            status: Some(child_status),
1511                            cost_usd: Some(child_ctx.total_cost_usd),
1512                            duration_ms: Some(total_duration),
1513                            completed_at: Some(completed_at),
1514                            ..RunUpdate::default()
1515                        },
1516                    )
1517                    .await?;
1518
1519                let child_had_allowed_failure = child_ctx.has_allowed_failure;
1520                Ok((
1521                    StepOutput {
1522                        output: serde_json::json!({
1523                            "run_id": child_run_id,
1524                            "workflow_name": config.workflow_name,
1525                            "status": child_status,
1526                            "cost_usd": child_ctx.total_cost_usd,
1527                            "duration_ms": total_duration,
1528                        }),
1529                        duration_ms: total_duration,
1530                        cost_usd: child_ctx.total_cost_usd,
1531                        input_tokens: None,
1532                        output_tokens: None,
1533                        model: None,
1534                        debug_messages: None,
1535                    },
1536                    child_had_allowed_failure,
1537                ))
1538            }
1539            Err(err) => {
1540                if let Err(store_err) = self
1541                    .store
1542                    .update_run(
1543                        child_run_id,
1544                        RunUpdate {
1545                            status: Some(RunStatus::Failed),
1546                            error: Some(err.to_string()),
1547                            cost_usd: Some(child_ctx.total_cost_usd),
1548                            duration_ms: Some(total_duration),
1549                            completed_at: Some(completed_at),
1550                            ..RunUpdate::default()
1551                        },
1552                    )
1553                    .await
1554                {
1555                    error!(
1556                        child_run_id = %child_run_id,
1557                        store_error = %store_err,
1558                        "failed to persist child run failure"
1559                    );
1560                }
1561
1562                Err(err)
1563            }
1564        }
1565    }
1566
1567    /// Try to replay a completed step from a previous execution.
1568    ///
1569    /// Returns `Some(StepOutput)` if a completed step exists at the given
1570    /// position, `None` otherwise.
1571    fn try_replay_step(&mut self, position: u32) -> Option<StepOutput> {
1572        let step = self.replay_steps.get(&position)?;
1573        if step.status.state != StepStatus::Completed {
1574            return None;
1575        }
1576        let output = StepOutput {
1577            output: step.output.clone().unwrap_or(Value::Null),
1578            duration_ms: step.duration_ms,
1579            cost_usd: step.cost_usd,
1580            input_tokens: step.input_tokens,
1581            output_tokens: step.output_tokens,
1582            model: None,
1583            debug_messages: None,
1584        };
1585        self.total_cost_usd += output.cost_usd;
1586        self.total_duration_ms += output.duration_ms;
1587        self.last_step_ids = vec![step.id];
1588        info!(
1589            run_id = %self.run_id,
1590            step = %step.name,
1591            position,
1592            "step replayed from previous execution"
1593        );
1594        Some(output)
1595    }
1596
1597    /// Internal: execute a step with full persistence lifecycle.
1598    #[tracing::instrument(
1599        name = "context.execute_step",
1600        skip_all,
1601        fields(
1602            run_id = %self.run_id,
1603            step.name = %name,
1604            step.kind,
1605            step.position = self.position,
1606        )
1607    )]
1608    async fn execute_step(
1609        &mut self,
1610        name: &str,
1611        kind: StepKind,
1612        config: StepConfig,
1613    ) -> Result<StepOutput, EngineError> {
1614        let kind_str: &'static str = match kind {
1615            StepKind::Shell => "shell",
1616            StepKind::Http => "http",
1617            StepKind::Agent => "agent",
1618            StepKind::Workflow => "workflow",
1619            StepKind::Approval => "approval",
1620            StepKind::Custom(_) => "custom",
1621        };
1622        Span::current().record("step.kind", kind_str);
1623
1624        let position = self.position;
1625        self.position += 1;
1626
1627        // Replay: if this step already completed in a prior execution, return cached output.
1628        if let Some(output) = self.try_replay_step(position) {
1629            return Ok(output);
1630        }
1631
1632        // Cost cap: refuse before creating the step record, so a run that hits
1633        // its cap never launches the work it cannot afford.
1634        if let StepConfig::Agent(ref agent_config) = config {
1635            self.check_run_budget(step_budget_usd(agent_config.max_budget_usd))?;
1636        }
1637
1638        // Create step record in Pending.
1639        let step = self
1640            .store
1641            .create_step(NewStep {
1642                run_id: self.run_id,
1643                name: name.to_string(),
1644                kind,
1645                position,
1646                input: Some(serde_json::to_value(&config)?),
1647                is_error_handler: false,
1648            })
1649            .await?;
1650
1651        self.start_step(step.id, Utc::now()).await?;
1652
1653        // Inputs must exist before the command runs. A failure here fails the
1654        // step: the command would otherwise run against missing files.
1655        if let Err(err) = self.prepare_step_inputs(&config, position).await {
1656            self.fail_step(step.id, &err).await;
1657            if config.allow_failure() {
1658                self.has_allowed_failure = true;
1659                self.last_step_ids = vec![step.id];
1660                info!(
1661                    run_id = %self.run_id,
1662                    step = %name,
1663                    error = %err,
1664                    "step input preparation failed but allow_failure is set, continuing"
1665                );
1666                return Ok(StepOutput {
1667                    output: json!({"error": err.to_string()}),
1668                    duration_ms: 0,
1669                    cost_usd: Decimal::ZERO,
1670                    input_tokens: None,
1671                    output_tokens: None,
1672                    model: None,
1673                    debug_messages: None,
1674                });
1675            }
1676            return Err(err);
1677        }
1678
1679        let step_log_sender = self
1680            .log_sender
1681            .as_ref()
1682            .map(|s| StepLogSender::new(s.clone(), self.run_id, step.id, name.to_string()));
1683
1684        let execution = execute_step_config(&config, &self.provider, step_log_sender).await;
1685
1686        let execution = self
1687            .retry_step_if_configured(name, kind_str, &config, step.id, execution)
1688            .await;
1689
1690        if let Err(err) = self
1691            .store_step_outputs(&config, step.id, name, execution.is_ok())
1692            .await
1693        {
1694            self.fail_step(step.id, &err).await;
1695            return Err(err);
1696        }
1697
1698        match execution {
1699            Ok(output) => {
1700                self.total_cost_usd += output.cost_usd;
1701                self.total_duration_ms += output.duration_ms;
1702
1703                let debug_messages_json = output.debug_messages_json();
1704
1705                let completed_at = Utc::now();
1706                self.store
1707                    .update_step(
1708                        step.id,
1709                        StepUpdate {
1710                            status: Some(StepStatus::Completed),
1711                            output: Some(output.output.clone()),
1712                            duration_ms: Some(output.duration_ms),
1713                            cost_usd: Some(output.cost_usd),
1714                            input_tokens: output.input_tokens,
1715                            output_tokens: output.output_tokens,
1716                            completed_at: Some(completed_at),
1717                            debug_messages: debug_messages_json,
1718                            ..StepUpdate::default()
1719                        },
1720                    )
1721                    .await?;
1722
1723                info!(
1724                    run_id = %self.run_id,
1725                    step = %name,
1726                    duration_ms = output.duration_ms,
1727                    "step completed"
1728                );
1729
1730                self.last_step_ids = vec![step.id];
1731
1732                Ok(output)
1733            }
1734            Err(err) => {
1735                let completed_at = Utc::now();
1736                let debug_messages_json = extract_debug_messages_from_error(&err);
1737                let partial = extract_partial_usage_from_error(&err);
1738                let raw_response_output = extract_raw_response_from_error(&err);
1739
1740                if let Some(ref usage) = partial {
1741                    if let Some(cost) = usage.cost_usd {
1742                        self.total_cost_usd += cost;
1743                    }
1744                    if let Some(dur) = usage.duration_ms {
1745                        self.total_duration_ms += dur;
1746                    }
1747                }
1748
1749                if let Err(store_err) = self
1750                    .store
1751                    .update_step(
1752                        step.id,
1753                        StepUpdate {
1754                            status: Some(StepStatus::Failed),
1755                            error: Some(err.to_string()),
1756                            output: raw_response_output.clone(),
1757                            completed_at: Some(completed_at),
1758                            debug_messages: debug_messages_json,
1759                            duration_ms: partial.as_ref().and_then(|p| p.duration_ms),
1760                            cost_usd: partial.as_ref().and_then(|p| p.cost_usd),
1761                            input_tokens: partial.as_ref().and_then(|p| p.input_tokens),
1762                            output_tokens: partial.as_ref().and_then(|p| p.output_tokens),
1763                            ..StepUpdate::default()
1764                        },
1765                    )
1766                    .await
1767                {
1768                    tracing::error!(step_id = %step.id, error = %store_err, "failed to persist step failure");
1769                }
1770
1771                let err_duration = partial.as_ref().and_then(|p| p.duration_ms).unwrap_or(0);
1772                self.fire_error_handlers(name, &err.to_string(), err_duration)
1773                    .await;
1774
1775                if config.allow_failure() {
1776                    self.has_allowed_failure = true;
1777                    self.last_step_ids = vec![step.id];
1778                    info!(
1779                        run_id = %self.run_id,
1780                        step = %name,
1781                        error = %err,
1782                        "step failed but allow_failure is set, continuing"
1783                    );
1784                    Ok(allowed_failure_output(
1785                        &err.to_string(),
1786                        raw_response_output,
1787                        partial.as_ref(),
1788                    ))
1789                } else {
1790                    Err(err)
1791                }
1792            }
1793        }
1794    }
1795
1796    /// Retry a failed step execution when a step-level retry policy is configured
1797    /// and the error is transient.
1798    ///
1799    /// Returns the original result unchanged when no retry policy is set, the
1800    /// first attempt succeeded, or the error is not retryable.
1801    #[cfg_attr(not(feature = "prometheus"), allow(unused_variables))]
1802    async fn retry_step_if_configured(
1803        &self,
1804        name: &str,
1805        kind_str: &str,
1806        config: &StepConfig,
1807        step_id: Uuid,
1808        first_result: Result<StepOutput, EngineError>,
1809    ) -> Result<StepOutput, EngineError> {
1810        let policy = match config.retry() {
1811            Some(p) => p,
1812            None => return first_result,
1813        };
1814
1815        let mut last_result = match first_result {
1816            Ok(output) => return Ok(output),
1817            Err(err) if !is_step_retryable(&err) => return Err(err),
1818            Err(err) => Err(err),
1819        };
1820
1821        let step_log_sender = self
1822            .log_sender
1823            .as_ref()
1824            .map(|s| StepLogSender::new(s.clone(), self.run_id, step_id, name.to_string()));
1825
1826        for attempt in 0..policy.max_retries() {
1827            if let StepConfig::Agent(agent_config) = config {
1828                self.check_run_budget(step_budget_usd(agent_config.max_budget_usd))?;
1829            }
1830
1831            let delay = policy.delay_for_attempt(attempt);
1832            info!(
1833                run_id = %self.run_id,
1834                step = %name,
1835                attempt = attempt + 1,
1836                max_retries = policy.max_retries(),
1837                delay_ms = delay.as_millis() as u64,
1838                "retrying step after transient failure"
1839            );
1840            tokio::time::sleep(delay).await;
1841
1842            record_retry_metric(kind_str, "retry");
1843
1844            match execute_step_config(config, &self.provider, step_log_sender.clone()).await {
1845                Ok(output) => return Ok(output),
1846                Err(err) if !is_step_retryable(&err) => return Err(err),
1847                err => last_result = err,
1848            }
1849        }
1850
1851        record_retry_metric(kind_str, "exhausted");
1852
1853        info!(
1854            run_id = %self.run_id,
1855            step = %name,
1856            max_retries = policy.max_retries(),
1857            "step retries exhausted"
1858        );
1859
1860        last_result
1861    }
1862
1863    /// Record dependency edges and transition a step to Running.
1864    ///
1865    /// Records edges from `step_id` to all `last_step_ids`, then
1866    /// transitions the step to `Running` with the given timestamp.
1867    async fn start_step(&self, step_id: Uuid, now: DateTime<Utc>) -> Result<(), EngineError> {
1868        if !self.last_step_ids.is_empty() {
1869            let deps: Vec<NewStepDependency> = self
1870                .last_step_ids
1871                .iter()
1872                .map(|&depends_on| NewStepDependency {
1873                    step_id,
1874                    depends_on,
1875                })
1876                .collect();
1877            self.store.create_step_dependencies(deps).await?;
1878        }
1879
1880        self.store
1881            .update_step(
1882                step_id,
1883                StepUpdate {
1884                    status: Some(StepStatus::Running),
1885                    started_at: Some(now),
1886                    ..StepUpdate::default()
1887                },
1888            )
1889            .await?;
1890
1891        Ok(())
1892    }
1893
1894    /// Mark a step as failed, best-effort.
1895    ///
1896    /// Used on paths that fail around the operation itself (artifact inputs and
1897    /// outputs), where the step record is already `Running` and the caller is
1898    /// about to propagate `err`. A store failure here is logged, never returned:
1899    /// it must not replace the error the caller is reporting.
1900    async fn fail_step(&self, step_id: Uuid, err: &EngineError) {
1901        if let Err(store_err) = self
1902            .store
1903            .update_step(
1904                step_id,
1905                StepUpdate {
1906                    status: Some(StepStatus::Failed),
1907                    error: Some(err.to_string()),
1908                    completed_at: Some(Utc::now()),
1909                    ..StepUpdate::default()
1910                },
1911            )
1912            .await
1913        {
1914            error!(
1915                step_id = %step_id,
1916                error = %store_err,
1917                "failed to persist step failure"
1918            );
1919        }
1920    }
1921
1922    /// Access the store directly (advanced usage).
1923    pub fn store(&self) -> &Arc<dyn Store> {
1924        &self.store
1925    }
1926
1927    /// Access the payload that triggered this run.
1928    ///
1929    /// Fetches the run from the store and returns its payload.
1930    ///
1931    /// # Errors
1932    ///
1933    /// Returns [`EngineError::Store`] if the run is not found.
1934    pub async fn payload(&self) -> Result<Value, EngineError> {
1935        let run = self
1936            .store
1937            .get_run(self.run_id)
1938            .await?
1939            .ok_or(EngineError::Store(
1940                ironflow_store::error::StoreError::RunNotFound(self.run_id),
1941            ))?;
1942        Ok(run.payload)
1943    }
1944
1945    /// Deserialize the run payload into a typed input struct.
1946    ///
1947    /// Shorthand for `serde_json::from_value(ctx.payload().await?)`.
1948    ///
1949    /// # Errors
1950    ///
1951    /// Returns [`EngineError::Store`] if the run is not found, or
1952    /// [`EngineError::Serialization`] if the payload does not match `T`.
1953    ///
1954    /// # Examples
1955    ///
1956    /// ```no_run
1957    /// # use ironflow_engine::context::WorkflowContext;
1958    /// # use ironflow_engine::error::EngineError;
1959    /// use serde::Deserialize;
1960    ///
1961    /// #[derive(Deserialize)]
1962    /// struct DeployInput {
1963    ///     environment: String,
1964    ///     dry_run: Option<bool>,
1965    /// }
1966    ///
1967    /// # async fn example(ctx: &WorkflowContext) -> Result<(), EngineError> {
1968    /// let input: DeployInput = ctx.input().await?;
1969    /// # Ok(())
1970    /// # }
1971    /// ```
1972    pub async fn input<T: serde::de::DeserializeOwned>(&self) -> Result<T, EngineError> {
1973        let payload = self.payload().await?;
1974        serde_json::from_value(payload).map_err(EngineError::Serialization)
1975    }
1976
1977    /// Register an error handler that fires when any subsequent step fails.
1978    ///
1979    /// The handler is consumed after firing (fire-once). Multiple handlers
1980    /// can be registered; they fire in registration order.
1981    ///
1982    /// Error handler execution is best-effort: if a handler fails, the error
1983    /// is logged but the original step error is preserved. Error handler steps
1984    /// appear in the run timeline with [`Step::is_error_handler`] set to `true`.
1985    ///
1986    /// # Examples
1987    ///
1988    /// ```no_run
1989    /// use ironflow_engine::context::WorkflowContext;
1990    /// use ironflow_engine::config::ShellConfig;
1991    /// use ironflow_engine::error::EngineError;
1992    ///
1993    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1994    /// ctx.on_error("cleanup", ShellConfig::new("rm -rf /tmp/build"));
1995    /// ctx.shell("build", ShellConfig::new("cargo build")).await?;
1996    /// # Ok(())
1997    /// # }
1998    /// ```
1999    pub fn on_error(&mut self, name: &str, config: impl Into<StepConfig>) {
2000        self.error_handlers.push(OnErrorHandler {
2001            name: name.to_string(),
2002            config: config.into(),
2003        });
2004    }
2005
2006    /// Remove all registered error handlers.
2007    ///
2008    /// # Examples
2009    ///
2010    /// ```no_run
2011    /// use ironflow_engine::context::WorkflowContext;
2012    /// use ironflow_engine::config::ShellConfig;
2013    /// use ironflow_engine::error::EngineError;
2014    ///
2015    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
2016    /// ctx.on_error("cleanup", ShellConfig::new("rm -rf /tmp/build"));
2017    /// ctx.shell("build", ShellConfig::new("cargo build")).await?;
2018    /// ctx.clear_error_handlers();
2019    /// // cleanup will NOT fire if deploy fails
2020    /// ctx.shell("deploy", ShellConfig::new("./deploy.sh")).await?;
2021    /// # Ok(())
2022    /// # }
2023    /// ```
2024    pub fn clear_error_handlers(&mut self) {
2025        self.error_handlers.clear();
2026    }
2027
2028    /// Execute all registered error handlers after a step failure.
2029    ///
2030    /// Drains the handler list (fire-once). Each handler creates its own
2031    /// step record with `is_error_handler = true`. Handler failures are
2032    /// logged but never propagated.
2033    async fn fire_error_handlers(
2034        &mut self,
2035        failed_step_name: &str,
2036        error_msg: &str,
2037        duration_ms: u64,
2038    ) {
2039        let handlers = std::mem::take(&mut self.error_handlers);
2040        if handlers.is_empty() {
2041            return;
2042        }
2043
2044        let error_context = json!({
2045            "failed_step": failed_step_name,
2046            "error": error_msg,
2047            "duration_ms": duration_ms,
2048        });
2049
2050        for handler in handlers {
2051            let mut config = handler.config.clone();
2052            inject_error_context(&mut config, failed_step_name, error_msg, duration_ms);
2053
2054            let position = self.position;
2055            self.position += 1;
2056
2057            let step = match self
2058                .store
2059                .create_step(NewStep {
2060                    run_id: self.run_id,
2061                    name: handler.name.clone(),
2062                    kind: config.kind(),
2063                    position,
2064                    input: Some(error_context.clone()),
2065                    is_error_handler: true,
2066                })
2067                .await
2068            {
2069                Ok(step) => step,
2070                Err(err) => {
2071                    warn!(
2072                        run_id = %self.run_id,
2073                        handler = %handler.name,
2074                        error = %err,
2075                        "failed to create error handler step"
2076                    );
2077                    continue;
2078                }
2079            };
2080
2081            if let Err(err) = self.start_step(step.id, Utc::now()).await {
2082                warn!(
2083                    run_id = %self.run_id,
2084                    handler = %handler.name,
2085                    error = %err,
2086                    "failed to start error handler step"
2087                );
2088                continue;
2089            }
2090
2091            let step_log_sender = self
2092                .log_sender
2093                .as_ref()
2094                .map(|s| StepLogSender::new(s.clone(), self.run_id, step.id, handler.name.clone()));
2095
2096            let start = Instant::now();
2097            let result = execute_step_config(&config, &self.provider, step_log_sender).await;
2098            let handler_duration = start.elapsed().as_millis() as u64;
2099            let completed_at = Utc::now();
2100
2101            match result {
2102                Ok(output) => {
2103                    if let Err(store_err) = self
2104                        .store
2105                        .update_step(
2106                            step.id,
2107                            StepUpdate {
2108                                status: Some(StepStatus::Completed),
2109                                output: Some(output.output),
2110                                duration_ms: Some(handler_duration),
2111                                cost_usd: Some(output.cost_usd),
2112                                completed_at: Some(completed_at),
2113                                ..StepUpdate::default()
2114                            },
2115                        )
2116                        .await
2117                    {
2118                        warn!(
2119                            run_id = %self.run_id,
2120                            handler = %handler.name,
2121                            error = %store_err,
2122                            "failed to persist error handler completion"
2123                        );
2124                    }
2125
2126                    info!(
2127                        run_id = %self.run_id,
2128                        handler = %handler.name,
2129                        duration_ms = handler_duration,
2130                        "error handler completed"
2131                    );
2132                }
2133                Err(err) => {
2134                    if let Err(store_err) = self
2135                        .store
2136                        .update_step(
2137                            step.id,
2138                            StepUpdate {
2139                                status: Some(StepStatus::Failed),
2140                                error: Some(err.to_string()),
2141                                duration_ms: Some(handler_duration),
2142                                completed_at: Some(completed_at),
2143                                ..StepUpdate::default()
2144                            },
2145                        )
2146                        .await
2147                    {
2148                        warn!(
2149                            run_id = %self.run_id,
2150                            handler = %handler.name,
2151                            error = %store_err,
2152                            "failed to persist error handler failure"
2153                        );
2154                    }
2155
2156                    warn!(
2157                        run_id = %self.run_id,
2158                        handler = %handler.name,
2159                        error = %err,
2160                        "error handler failed (original error preserved)"
2161                    );
2162                }
2163            }
2164        }
2165    }
2166}
2167
2168/// Inject error context into a step config before executing it as an error handler.
2169fn inject_error_context(
2170    config: &mut StepConfig,
2171    failed_step: &str,
2172    error_msg: &str,
2173    duration_ms: u64,
2174) {
2175    match config {
2176        StepConfig::Shell(shell) => {
2177            shell
2178                .env
2179                .push(("IRONFLOW_ERROR_STEP".to_string(), failed_step.to_string()));
2180            shell
2181                .env
2182                .push(("IRONFLOW_ERROR_MESSAGE".to_string(), error_msg.to_string()));
2183            shell.env.push((
2184                "IRONFLOW_ERROR_DURATION_MS".to_string(),
2185                duration_ms.to_string(),
2186            ));
2187        }
2188        StepConfig::Agent(agent) => {
2189            agent.prompt = format!(
2190                "[Error Context]\nStep \"{}\" failed after {}ms:\n{}\n\n{}",
2191                failed_step, duration_ms, error_msg, agent.prompt
2192            );
2193        }
2194        StepConfig::Http(http) => {
2195            http.headers
2196                .push(("X-Ironflow-Error-Step".to_string(), failed_step.to_string()));
2197            http.headers.push((
2198                "X-Ironflow-Error-Message".to_string(),
2199                error_msg.to_string(),
2200            ));
2201        }
2202        StepConfig::Workflow(_) | StepConfig::Approval(_) => {}
2203    }
2204}
2205
2206#[cfg(feature = "prometheus")]
2207fn record_retry_metric(kind: &str, outcome: &str) {
2208    use ironflow_core::metric_names::STEP_RETRIES_TOTAL;
2209    use metrics::counter;
2210    counter!(STEP_RETRIES_TOTAL, "kind" => kind.to_string(), "outcome" => outcome.to_string())
2211        .increment(1);
2212}
2213
2214#[cfg(not(feature = "prometheus"))]
2215fn record_retry_metric(_kind: &str, _outcome: &str) {}
2216
2217/// Step-level retryability: broader than operation-level retry because the user
2218/// explicitly opted in. Excludes only deterministic or financially wasteful
2219/// errors that retrying cannot fix.
2220fn is_step_retryable(err: &EngineError) -> bool {
2221    use ironflow_core::error::{AgentError, OperationError};
2222
2223    match err {
2224        EngineError::Operation(op) => match op {
2225            OperationError::Agent(AgentError::PromptTooLarge { .. }) => false,
2226            OperationError::Agent(AgentError::BudgetExceeded { .. }) => false,
2227            OperationError::Deserialize { .. } => false,
2228            OperationError::Http {
2229                status: Some(code), ..
2230            } if (400..500).contains(code) && *code != 429 => false,
2231            _ => true,
2232        },
2233        _ => false,
2234    }
2235}
2236
2237fn allowed_failure_output(
2238    error_msg: &str,
2239    raw_response: Option<Value>,
2240    partial: Option<&StepPartialUsage>,
2241) -> StepOutput {
2242    StepOutput {
2243        output: raw_response.unwrap_or_else(|| json!({"error": error_msg})),
2244        duration_ms: partial.and_then(|p| p.duration_ms).unwrap_or(0),
2245        cost_usd: partial.and_then(|p| p.cost_usd).unwrap_or(Decimal::ZERO),
2246        input_tokens: partial.and_then(|p| p.input_tokens),
2247        output_tokens: partial.and_then(|p| p.output_tokens),
2248        model: None,
2249        debug_messages: None,
2250    }
2251}
2252
2253impl fmt::Debug for WorkflowContext {
2254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2255        f.debug_struct("WorkflowContext")
2256            .field("run_id", &self.run_id)
2257            .field("position", &self.position)
2258            .field("total_cost_usd", &self.total_cost_usd)
2259            .field("inherited_cost_usd", &self.inherited_cost_usd)
2260            .field("max_cost_usd", &self.max_cost_usd)
2261            .finish_non_exhaustive()
2262    }
2263}
2264
2265/// Extract debug messages from an engine error, if it wraps a schema validation
2266/// failure that carries a verbose conversation trace.
2267fn extract_debug_messages_from_error(err: &EngineError) -> Option<Value> {
2268    if let EngineError::Operation(OperationError::Agent(AgentError::SchemaValidation {
2269        debug_messages,
2270        ..
2271    })) = err
2272        && !debug_messages.is_empty()
2273    {
2274        return serde_json::to_value(debug_messages).ok();
2275    }
2276    None
2277}
2278
2279/// Partial usage with `Decimal` cost, converted from the `f64` in [`PartialUsage`].
2280///
2281/// Exists only because `ironflow-store` uses [`Decimal`] for monetary values
2282/// while `ironflow-core` uses `f64` (the CLI's native type). The conversion
2283/// happens here, at the engine/store boundary.
2284struct StepPartialUsage {
2285    cost_usd: Option<Decimal>,
2286    duration_ms: Option<u64>,
2287    input_tokens: Option<u64>,
2288    output_tokens: Option<u64>,
2289}
2290
2291/// Extract the raw response text from a schema validation error.
2292///
2293/// When the agent produced text but structured output extraction failed,
2294/// this returns the truncated raw text so it can be persisted as the
2295/// step output for dashboard visibility.
2296fn extract_raw_response_from_error(err: &EngineError) -> Option<Value> {
2297    if let EngineError::Operation(OperationError::Agent(AgentError::SchemaValidation {
2298        raw_response: Some(text),
2299        ..
2300    })) = err
2301    {
2302        return Some(Value::String(text.clone()));
2303    }
2304    None
2305}
2306
2307fn extract_partial_usage_from_error(err: &EngineError) -> Option<StepPartialUsage> {
2308    if let EngineError::Operation(OperationError::Agent(AgentError::SchemaValidation {
2309        partial_usage,
2310        ..
2311    })) = err
2312        && (partial_usage.cost_usd.is_some() || partial_usage.duration_ms.is_some())
2313    {
2314        return Some(StepPartialUsage {
2315            cost_usd: partial_usage
2316                .cost_usd
2317                .and_then(|c| Decimal::try_from(c).ok()),
2318            duration_ms: partial_usage.duration_ms,
2319            input_tokens: partial_usage.input_tokens,
2320            output_tokens: partial_usage.output_tokens,
2321        });
2322    }
2323    None
2324}
2325
2326#[cfg(test)]
2327mod tests {
2328    use super::*;
2329    use ironflow_core::providers::claude::ClaudeCodeProvider;
2330    use ironflow_core::providers::record_replay::RecordReplayProvider;
2331    use ironflow_store::memory::InMemoryStore;
2332    use ironflow_store::models::{Run, RunActor, RunFilter};
2333    use ironflow_store::store::RunStore;
2334    use serde_json::json;
2335    use std::sync::Arc;
2336    use std::sync::atomic::{AtomicBool, Ordering};
2337    use uuid::Uuid;
2338
2339    /// Helper to create a test provider with fixtures
2340    fn create_test_provider() -> Arc<dyn ironflow_core::provider::AgentProvider> {
2341        let inner = ClaudeCodeProvider::new();
2342        Arc::new(RecordReplayProvider::replay(
2343            inner,
2344            "/tmp/ironflow-fixtures",
2345        ))
2346    }
2347
2348    /// Helper to create a test context
2349    fn create_test_context() -> WorkflowContext {
2350        let store = Arc::new(InMemoryStore::new());
2351        let provider = create_test_provider();
2352        let run_id = Uuid::now_v7();
2353        WorkflowContext::new(run_id, store, provider)
2354    }
2355
2356    #[test]
2357    fn context_new_initializes_correctly() {
2358        let ctx = create_test_context();
2359        assert_eq!(ctx.position, 0);
2360        assert_eq!(ctx.total_cost_usd, Decimal::ZERO);
2361        assert_eq!(ctx.total_duration_ms, 0);
2362        assert!(ctx.last_step_ids.is_empty());
2363        assert!(ctx.replay_steps.is_empty());
2364        assert!(ctx.log_sender.is_none());
2365    }
2366
2367    #[test]
2368    fn context_run_id_returns_correct_id() {
2369        let run_id = Uuid::now_v7();
2370        let store = Arc::new(InMemoryStore::new());
2371        let provider = create_test_provider();
2372        let ctx = WorkflowContext::new(run_id, store, provider);
2373        assert_eq!(ctx.run_id(), run_id);
2374    }
2375
2376    #[test]
2377    fn context_total_cost_usd_initially_zero() {
2378        let ctx = create_test_context();
2379        assert_eq!(ctx.total_cost_usd(), Decimal::ZERO);
2380    }
2381
2382    #[test]
2383    fn context_total_duration_ms_initially_zero() {
2384        let ctx = create_test_context();
2385        assert_eq!(ctx.total_duration_ms(), 0);
2386    }
2387
2388    #[test]
2389    fn context_with_handler_resolver_creates_context_with_resolver() {
2390        let store = Arc::new(InMemoryStore::new());
2391        let provider = create_test_provider();
2392        let run_id = Uuid::now_v7();
2393
2394        let called = Arc::new(AtomicBool::new(false));
2395        let called_clone = called.clone();
2396
2397        let resolver: HandlerResolver = Arc::new(move |_name: &str| {
2398            called_clone.store(true, Ordering::SeqCst);
2399            None
2400        });
2401
2402        let ctx = WorkflowContext::with_handler_resolver(run_id, store, provider, resolver);
2403
2404        assert_eq!(ctx.run_id(), run_id);
2405        assert!(ctx.handler_resolver.is_some());
2406    }
2407
2408    #[tokio::test]
2409    async fn context_set_log_sender_attaches_sender() {
2410        let mut ctx = create_test_context();
2411        let (sender, _receiver) = crate::log_sender::channel();
2412        ctx.set_log_sender(sender);
2413        assert!(ctx.log_sender.is_some());
2414    }
2415
2416    #[tokio::test]
2417    async fn context_skip_creates_skipped_step() {
2418        let store = Arc::new(InMemoryStore::new());
2419        let provider = create_test_provider();
2420
2421        // Create the run first using RunStore trait
2422        store
2423            .create_run(NewRun {
2424                created_by: None,
2425                workflow_name: "test".to_string(),
2426                trigger: TriggerKind::Manual,
2427                payload: json!({}),
2428                max_retries: 0,
2429                handler_version: None,
2430                labels: Default::default(),
2431                scheduled_at: None,
2432                idempotency_key: None,
2433                max_cost_usd: None,
2434            })
2435            .await
2436            .expect("failed to create run")
2437            .into_run();
2438
2439        // Get the created run to extract its ID
2440        let runs = store
2441            .list_runs(RunFilter::default(), 1, 10)
2442            .await
2443            .expect("failed to list runs");
2444        let created_run_id = runs.items[0].id;
2445
2446        let mut ctx = WorkflowContext::new(created_run_id, store.clone(), provider);
2447        let initial_position = ctx.position;
2448
2449        ctx.skip("skip-step", "condition not met")
2450            .await
2451            .expect("skip failed");
2452
2453        assert_eq!(ctx.position, initial_position + 1);
2454        assert!(!ctx.last_step_ids.is_empty());
2455
2456        // Verify the step was recorded with Skipped status
2457        let steps = store
2458            .list_steps(created_run_id)
2459            .await
2460            .expect("failed to list steps");
2461        assert_eq!(steps.len(), 1);
2462        assert_eq!(steps[0].status.state, StepStatus::Skipped);
2463    }
2464
2465    /// Sub-workflow handler that records no steps, so the child run reaches a
2466    /// terminal state without touching the filesystem or the network.
2467    struct NoopSubWorkflow;
2468
2469    impl WorkflowHandler for NoopSubWorkflow {
2470        fn name(&self) -> &str {
2471            "noop-sub"
2472        }
2473
2474        fn execute<'a>(
2475            &'a self,
2476            _ctx: &'a mut WorkflowContext,
2477        ) -> crate::handler::HandlerFuture<'a> {
2478            Box::pin(async move { Ok(()) })
2479        }
2480    }
2481
2482    /// Run a parent workflow authored by `created_by` and return the child run
2483    /// created by its sub-workflow step.
2484    async fn child_run_of_parent_authored_by(created_by: Option<RunActor>) -> Run {
2485        let store = Arc::new(InMemoryStore::new());
2486        let provider = create_test_provider();
2487
2488        let parent = store
2489            .create_run(NewRun {
2490                workflow_name: "parent".to_string(),
2491                trigger: TriggerKind::Api,
2492                payload: json!({}),
2493                max_retries: 0,
2494                handler_version: None,
2495                labels: Default::default(),
2496                scheduled_at: None,
2497                created_by,
2498                idempotency_key: None,
2499                max_cost_usd: None,
2500            })
2501            .await
2502            .expect("failed to create parent run")
2503            .into_run();
2504
2505        let resolver: HandlerResolver = Arc::new(|name: &str| match name {
2506            "noop-sub" => Some(Arc::new(NoopSubWorkflow) as Arc<dyn WorkflowHandler>),
2507            _ => None,
2508        });
2509
2510        let mut ctx =
2511            WorkflowContext::with_handler_resolver(parent.id, store.clone(), provider, resolver);
2512        ctx.workflow(&NoopSubWorkflow, json!({}))
2513            .await
2514            .expect("sub-workflow failed");
2515
2516        let runs = store
2517            .list_runs(RunFilter::default(), 1, 10)
2518            .await
2519            .expect("failed to list runs");
2520        runs.items
2521            .into_iter()
2522            .find(|r| r.workflow_name == "noop-sub")
2523            .expect("child run was created")
2524    }
2525
2526    #[tokio::test]
2527    async fn child_run_inherits_the_parent_author() {
2528        let user_id = Uuid::now_v7();
2529        let child = child_run_of_parent_authored_by(Some(RunActor::User { user_id })).await;
2530
2531        assert_eq!(child.created_by, Some(RunActor::User { user_id }));
2532    }
2533
2534    #[tokio::test]
2535    async fn child_run_of_an_unattributed_parent_has_no_author() {
2536        let child = child_run_of_parent_authored_by(None).await;
2537
2538        assert!(child.created_by.is_none());
2539    }
2540
2541    #[tokio::test]
2542    async fn context_parallel_empty_steps_returns_empty_vec() {
2543        let mut ctx = create_test_context();
2544        let results = ctx
2545            .parallel(vec![], true)
2546            .await
2547            .expect("parallel should not fail on empty input");
2548        assert!(results.is_empty());
2549    }
2550
2551    #[tokio::test]
2552    async fn context_approval_first_execution_returns_error() {
2553        let store = Arc::new(InMemoryStore::new());
2554        let provider = create_test_provider();
2555
2556        // Create the run first
2557        store
2558            .create_run(NewRun {
2559                created_by: None,
2560                workflow_name: "test".to_string(),
2561                trigger: TriggerKind::Manual,
2562                payload: json!({}),
2563                max_retries: 0,
2564                handler_version: None,
2565                labels: Default::default(),
2566                scheduled_at: None,
2567                idempotency_key: None,
2568                max_cost_usd: None,
2569            })
2570            .await
2571            .expect("failed to create run")
2572            .into_run();
2573
2574        // Get the created run to extract its ID
2575        let runs = store
2576            .list_runs(RunFilter::default(), 1, 10)
2577            .await
2578            .expect("failed to list runs");
2579        let created_run_id = runs.items[0].id;
2580
2581        let mut ctx = WorkflowContext::new(created_run_id, store.clone(), provider);
2582
2583        let result = ctx
2584            .approval(
2585                "approve-step",
2586                crate::config::ApprovalConfig::new("Continue?"),
2587            )
2588            .await;
2589
2590        // First execution should return ApprovalRequired error
2591        assert!(matches!(result, Err(EngineError::ApprovalRequired { .. })));
2592
2593        // Verify position incremented
2594        assert_eq!(ctx.position, 1);
2595
2596        // Verify step was created with AwaitingApproval status
2597        let steps = store
2598            .list_steps(created_run_id)
2599            .await
2600            .expect("failed to list steps");
2601        assert_eq!(steps.len(), 1);
2602        assert_eq!(steps[0].status.state, StepStatus::AwaitingApproval);
2603    }
2604
2605    #[tokio::test]
2606    async fn context_approval_replay_returns_ok() {
2607        let store = Arc::new(InMemoryStore::new());
2608        let provider = create_test_provider();
2609
2610        // Create the run first
2611        store
2612            .create_run(NewRun {
2613                created_by: None,
2614                workflow_name: "test".to_string(),
2615                trigger: TriggerKind::Manual,
2616                payload: json!({}),
2617                max_retries: 0,
2618                handler_version: None,
2619                labels: Default::default(),
2620                scheduled_at: None,
2621                idempotency_key: None,
2622                max_cost_usd: None,
2623            })
2624            .await
2625            .expect("failed to create run")
2626            .into_run();
2627
2628        // Get the created run to extract its ID
2629        let runs = store
2630            .list_runs(RunFilter::default(), 1, 10)
2631            .await
2632            .expect("failed to list runs");
2633        let created_run_id = runs.items[0].id;
2634
2635        // Create an approval step that's already in AwaitingApproval state
2636        let step = store
2637            .create_step(NewStep {
2638                run_id: created_run_id,
2639                name: "approval".to_string(),
2640                kind: StepKind::Approval,
2641                position: 0,
2642                input: None,
2643                is_error_handler: false,
2644            })
2645            .await
2646            .expect("failed to create step");
2647
2648        // Transition through proper states: Pending -> Running -> AwaitingApproval
2649        store
2650            .update_step(
2651                step.id,
2652                StepUpdate {
2653                    status: Some(StepStatus::Running),
2654                    started_at: Some(Utc::now()),
2655                    ..StepUpdate::default()
2656                },
2657            )
2658            .await
2659            .expect("failed to update step to Running");
2660
2661        store
2662            .update_step(
2663                step.id,
2664                StepUpdate {
2665                    status: Some(StepStatus::AwaitingApproval),
2666                    ..StepUpdate::default()
2667                },
2668            )
2669            .await
2670            .expect("failed to update step to AwaitingApproval");
2671
2672        // Create context and load replay steps
2673        let mut ctx = WorkflowContext::new(created_run_id, store.clone(), provider);
2674        ctx.load_replay_steps()
2675            .await
2676            .expect("failed to load replay steps");
2677
2678        // Now approval should succeed (replay)
2679        let result = ctx
2680            .approval("approval", crate::config::ApprovalConfig::new("Continue?"))
2681            .await;
2682
2683        assert!(result.is_ok());
2684
2685        // Verify the step was marked Completed
2686        let steps = store
2687            .list_steps(created_run_id)
2688            .await
2689            .expect("failed to list steps");
2690        assert_eq!(steps.len(), 1);
2691        assert_eq!(steps[0].status.state, StepStatus::Completed);
2692    }
2693
2694    #[tokio::test]
2695    async fn context_load_replay_steps_loads_completed_steps() {
2696        let store = Arc::new(InMemoryStore::new());
2697        let provider = create_test_provider();
2698
2699        // Create the run first
2700        store
2701            .create_run(NewRun {
2702                created_by: None,
2703                workflow_name: "test".to_string(),
2704                trigger: TriggerKind::Manual,
2705                payload: json!({}),
2706                max_retries: 0,
2707                handler_version: None,
2708                labels: Default::default(),
2709                scheduled_at: None,
2710                idempotency_key: None,
2711                max_cost_usd: None,
2712            })
2713            .await
2714            .expect("failed to create run")
2715            .into_run();
2716
2717        // Get the created run to extract its ID
2718        let runs = store
2719            .list_runs(RunFilter::default(), 1, 10)
2720            .await
2721            .expect("failed to list runs");
2722        let created_run_id = runs.items[0].id;
2723
2724        // Create multiple steps with different statuses
2725        let completed_step = store
2726            .create_step(NewStep {
2727                run_id: created_run_id,
2728                name: "completed".to_string(),
2729                kind: StepKind::Shell,
2730                position: 0,
2731                input: None,
2732                is_error_handler: false,
2733            })
2734            .await
2735            .expect("failed to create step");
2736
2737        // Transition to Running then Completed
2738        store
2739            .update_step(
2740                completed_step.id,
2741                StepUpdate {
2742                    status: Some(StepStatus::Running),
2743                    started_at: Some(Utc::now()),
2744                    ..StepUpdate::default()
2745                },
2746            )
2747            .await
2748            .expect("failed to update step to Running");
2749
2750        store
2751            .update_step(
2752                completed_step.id,
2753                StepUpdate {
2754                    status: Some(StepStatus::Completed),
2755                    completed_at: Some(Utc::now()),
2756                    ..StepUpdate::default()
2757                },
2758            )
2759            .await
2760            .expect("failed to update step to Completed");
2761
2762        let _pending_step = store
2763            .create_step(NewStep {
2764                run_id: created_run_id,
2765                name: "pending".to_string(),
2766                kind: StepKind::Shell,
2767                position: 1,
2768                input: None,
2769                is_error_handler: false,
2770            })
2771            .await
2772            .expect("failed to create step");
2773
2774        // Load replay steps
2775        let mut ctx = WorkflowContext::new(created_run_id, store, provider);
2776        ctx.load_replay_steps()
2777            .await
2778            .expect("failed to load replay steps");
2779
2780        // Only completed step should be in replay_steps
2781        assert_eq!(ctx.replay_steps.len(), 1);
2782        assert!(ctx.replay_steps.contains_key(&0));
2783        assert!(!ctx.replay_steps.contains_key(&1));
2784    }
2785
2786    #[tokio::test]
2787    async fn context_payload_returns_run_payload() {
2788        let store = Arc::new(InMemoryStore::new());
2789        let provider = create_test_provider();
2790        let test_payload = json!({"key": "value", "number": 42});
2791
2792        // Create the run first
2793        store
2794            .create_run(NewRun {
2795                created_by: None,
2796                workflow_name: "test".to_string(),
2797                trigger: TriggerKind::Manual,
2798                payload: test_payload.clone(),
2799                max_retries: 0,
2800                handler_version: None,
2801                labels: Default::default(),
2802                scheduled_at: None,
2803                idempotency_key: None,
2804                max_cost_usd: None,
2805            })
2806            .await
2807            .expect("failed to create run")
2808            .into_run();
2809
2810        // Get the created run to extract its ID
2811        let runs = store
2812            .list_runs(RunFilter::default(), 1, 10)
2813            .await
2814            .expect("failed to list runs");
2815        let created_run_id = runs.items[0].id;
2816
2817        let ctx = WorkflowContext::new(created_run_id, store, provider);
2818        let payload = ctx.payload().await.expect("failed to get payload");
2819
2820        assert_eq!(payload, test_payload);
2821    }
2822
2823    #[tokio::test]
2824    async fn context_payload_returns_error_for_nonexistent_run() {
2825        let store = Arc::new(InMemoryStore::new());
2826        let provider = create_test_provider();
2827        let run_id = Uuid::now_v7();
2828
2829        let ctx = WorkflowContext::new(run_id, store, provider);
2830        let result = ctx.payload().await;
2831
2832        assert!(result.is_err());
2833    }
2834
2835    #[tokio::test]
2836    async fn context_store_returns_reference() {
2837        let ctx = create_test_context();
2838        let _store = ctx.store();
2839        // store() returns a reference to the Arc<dyn Store>, which is always available
2840    }
2841
2842    #[test]
2843    fn context_debug_formatting() {
2844        let ctx = create_test_context();
2845        let debug_str = format!("{:?}", ctx);
2846        assert!(debug_str.contains("WorkflowContext"));
2847        assert!(debug_str.contains("run_id"));
2848    }
2849
2850    #[tokio::test]
2851    async fn context_last_step_ids_tracks_executed_steps() {
2852        let store = Arc::new(InMemoryStore::new());
2853        let provider = create_test_provider();
2854
2855        // Create the run first
2856        store
2857            .create_run(NewRun {
2858                created_by: None,
2859                workflow_name: "test".to_string(),
2860                trigger: TriggerKind::Manual,
2861                payload: json!({}),
2862                max_retries: 0,
2863                handler_version: None,
2864                labels: Default::default(),
2865                scheduled_at: None,
2866                idempotency_key: None,
2867                max_cost_usd: None,
2868            })
2869            .await
2870            .expect("failed to create run")
2871            .into_run();
2872
2873        // Get the created run to extract its ID
2874        let runs = store
2875            .list_runs(RunFilter::default(), 1, 10)
2876            .await
2877            .expect("failed to list runs");
2878        let created_run_id = runs.items[0].id;
2879
2880        let mut ctx = WorkflowContext::new(created_run_id, store, provider);
2881        assert!(ctx.last_step_ids.is_empty());
2882
2883        ctx.skip("step1", "reason").await.expect("skip failed");
2884
2885        assert_eq!(ctx.last_step_ids.len(), 1);
2886
2887        ctx.skip("step2", "reason").await.expect("skip failed");
2888
2889        // last_step_ids should now contain only step2's ID
2890        assert_eq!(ctx.last_step_ids.len(), 1);
2891    }
2892}