Skip to main content

ironflow_engine/
context.rs

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