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