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