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