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                    info!(
900                        run_id = %self.run_id,
901                        step = %step_name,
902                        trace_id = %step_trace,
903                        duration_ms = output.duration_ms,
904                        "parallel step completed"
905                    );
906
907                    indexed_results[idx] = Some(Ok(output));
908                }
909                Err(err) => {
910                    let err_msg = err.to_string();
911                    let debug_messages_json = extract_debug_messages_from_error(&err);
912                    let partial = extract_partial_usage_from_error(&err);
913                    let raw_response_output = extract_raw_response_from_error(&err);
914
915                    if let Some(ref usage) = partial {
916                        if let Some(cost) = usage.cost_usd {
917                            self.total_cost_usd += cost;
918                        }
919                        if let Some(dur) = usage.duration_ms {
920                            self.total_duration_ms += dur;
921                        }
922                    }
923
924                    if let Err(store_err) = self
925                        .store
926                        .update_step(
927                            *step_id,
928                            StepUpdate {
929                                status: Some(StepStatus::Failed),
930                                error: Some(err_msg.clone()),
931                                output: raw_response_output.clone(),
932                                completed_at: Some(completed_at),
933                                debug_messages: debug_messages_json,
934                                duration_ms: partial.as_ref().and_then(|p| p.duration_ms),
935                                cost_usd: partial.as_ref().and_then(|p| p.cost_usd),
936                                input_tokens: partial.as_ref().and_then(|p| p.input_tokens),
937                                output_tokens: partial.as_ref().and_then(|p| p.output_tokens),
938                                ..StepUpdate::default()
939                            },
940                        )
941                        .await
942                    {
943                        tracing::error!(
944                            step_id = %step_id,
945                            error = %store_err,
946                            "failed to persist parallel step failure"
947                        );
948                    }
949
950                    let err_duration = partial.as_ref().and_then(|p| p.duration_ms).unwrap_or(0);
951                    let err_cost = partial
952                        .as_ref()
953                        .and_then(|p| p.cost_usd)
954                        .unwrap_or(Decimal::ZERO);
955                    self.step_results.push(StepResult::from_failure(
956                        *step_trace,
957                        step_name,
958                        &err_msg,
959                        err_duration,
960                        err_cost,
961                    ));
962
963                    if step_config.allow_failure() {
964                        self.has_allowed_failure = true;
965                        info!(
966                            run_id = %self.run_id,
967                            step = %step_name,
968                            error = %err_msg,
969                            "parallel step failed but allow_failure is set, continuing"
970                        );
971                        indexed_results[idx] = Some(Ok(allowed_failure_output(
972                            &err_msg,
973                            raw_response_output,
974                            partial.as_ref(),
975                        )));
976                    } else {
977                        indexed_results[idx] = Some(Err(err_msg.clone()));
978
979                        if first_error.is_none() {
980                            first_error = Some(err);
981                        }
982
983                        if fail_fast {
984                            join_set.abort_all();
985                        }
986                    }
987                }
988            }
989        }
990
991        if let Some(err) = first_error {
992            return Err(err);
993        }
994
995        self.persist_progress().await;
996
997        self.last_step_ids = step_records.iter().map(|(id, _, _, _)| *id).collect();
998
999        // Build results in original order.
1000        let results: Vec<ParallelStepResult> = step_records
1001            .iter()
1002            .enumerate()
1003            .map(|(idx, (step_id, _trace_id, name, _))| {
1004                let output = match indexed_results[idx].take() {
1005                    Some(Ok(o)) => o,
1006                    _ => unreachable!("all steps succeeded if no error returned"),
1007                };
1008                ParallelStepResult {
1009                    name: name.clone(),
1010                    output,
1011                    step_id: *step_id,
1012                }
1013            })
1014            .collect();
1015
1016        Ok(results)
1017    }
1018
1019    /// Execute a shell step.
1020    ///
1021    /// Creates the step record, runs the command, persists the result,
1022    /// and returns the output for use in subsequent steps.
1023    ///
1024    /// # Errors
1025    ///
1026    /// Returns [`EngineError`] if the command fails or the store errors.
1027    ///
1028    /// # Examples
1029    ///
1030    /// ```no_run
1031    /// use ironflow_engine::context::WorkflowContext;
1032    /// use ironflow_engine::config::ShellConfig;
1033    /// use ironflow_engine::error::EngineError;
1034    ///
1035    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1036    /// let files = ctx.shell("list", ShellConfig::new("ls -la")).await?;
1037    /// println!("stdout: {}", files.output["stdout"]);
1038    /// # Ok(())
1039    /// # }
1040    /// ```
1041    pub async fn shell(
1042        &mut self,
1043        name: &str,
1044        config: ShellConfig,
1045    ) -> Result<StepOutput, EngineError> {
1046        self.execute_step(name, StepKind::Shell, StepConfig::Shell(config))
1047            .await
1048    }
1049
1050    /// Execute an HTTP step.
1051    ///
1052    /// # Errors
1053    ///
1054    /// Returns [`EngineError`] if the request fails or the store errors.
1055    ///
1056    /// # Examples
1057    ///
1058    /// ```no_run
1059    /// use ironflow_engine::context::WorkflowContext;
1060    /// use ironflow_engine::config::HttpConfig;
1061    /// use ironflow_engine::error::EngineError;
1062    ///
1063    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1064    /// let resp = ctx.http("health", HttpConfig::get("https://api.example.com/health")).await?;
1065    /// println!("status: {}", resp.output["status"]);
1066    /// # Ok(())
1067    /// # }
1068    /// ```
1069    pub async fn http(
1070        &mut self,
1071        name: &str,
1072        config: HttpConfig,
1073    ) -> Result<StepOutput, EngineError> {
1074        self.execute_step(name, StepKind::Http, StepConfig::Http(config))
1075            .await
1076    }
1077
1078    /// Execute an agent step.
1079    ///
1080    /// # Errors
1081    ///
1082    /// Returns [`EngineError`] if the agent invocation fails or the store errors.
1083    ///
1084    /// # Examples
1085    ///
1086    /// ```no_run
1087    /// use ironflow_engine::context::WorkflowContext;
1088    /// use ironflow_engine::config::AgentStepConfig;
1089    /// use ironflow_engine::error::EngineError;
1090    ///
1091    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1092    /// let review = ctx.agent("review", AgentStepConfig::new("Review the code")).await?;
1093    /// println!("review: {}", review.output);
1094    /// # Ok(())
1095    /// # }
1096    /// ```
1097    pub async fn agent(
1098        &mut self,
1099        name: &str,
1100        config: impl Into<AgentStepConfig>,
1101    ) -> Result<StepOutput, EngineError> {
1102        self.execute_step(name, StepKind::Agent, StepConfig::Agent(config.into()))
1103            .await
1104    }
1105
1106    /// Create a human approval gate.
1107    ///
1108    /// On first execution, records an approval step and returns
1109    /// [`EngineError::ApprovalRequired`] to suspend the run. The engine
1110    /// transitions the run to `AwaitingApproval`.
1111    ///
1112    /// On resume (after a human approved via the API), the approval step
1113    /// is replayed: it is marked as `Completed` and execution continues
1114    /// past it. Multiple approval gates in the same handler work -- each
1115    /// one pauses and resumes independently.
1116    ///
1117    /// # Errors
1118    ///
1119    /// Returns [`EngineError::ApprovalRequired`] to pause the run on
1120    /// first execution. Returns other [`EngineError`] variants on store
1121    /// failures.
1122    ///
1123    /// # Examples
1124    ///
1125    /// ```no_run
1126    /// use ironflow_engine::context::WorkflowContext;
1127    /// use ironflow_engine::config::ApprovalConfig;
1128    /// use ironflow_engine::error::EngineError;
1129    ///
1130    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1131    /// ctx.approval("deploy-gate", ApprovalConfig::new("Approve deployment?")).await?;
1132    /// // Execution continues here after approval
1133    /// # Ok(())
1134    /// # }
1135    /// ```
1136    pub async fn approval(
1137        &mut self,
1138        name: &str,
1139        config: ApprovalConfig,
1140    ) -> Result<(), EngineError> {
1141        let position = self.position;
1142        self.position += 1;
1143
1144        // Replay: if this approval step exists from a prior execution,
1145        // the run was approved -- mark it completed (if not already) and continue.
1146        if let Some(existing) = self.replay_steps.get(&position)
1147            && existing.kind == StepKind::Approval
1148        {
1149            if existing.status.state == StepStatus::AwaitingApproval {
1150                self.store
1151                    .update_step(
1152                        existing.id,
1153                        StepUpdate {
1154                            status: Some(StepStatus::Completed),
1155                            completed_at: Some(Utc::now()),
1156                            ..StepUpdate::default()
1157                        },
1158                    )
1159                    .await?;
1160            }
1161
1162            self.last_step_ids = vec![existing.id];
1163            info!(
1164                run_id = %self.run_id,
1165                step = %name,
1166                position,
1167                "approval step replayed (approved)"
1168            );
1169            return Ok(());
1170        }
1171
1172        // Carried over: a human already approved this gate in an earlier
1173        // attempt. Record a fresh step in the current attempt so that each
1174        // attempt keeps a complete, self-contained DAG, and continue.
1175        if let Some(&granted_in) = self.granted_approvals.get(&position) {
1176            let trace_id = step_trace_id(self.run_id, name, position);
1177            let step = self
1178                .store
1179                .create_step(NewStep {
1180                    run_id: self.run_id,
1181                    trace_id,
1182                    name: name.to_string(),
1183                    kind: StepKind::Approval,
1184                    position,
1185                    input: Some(serde_json::to_value(&config)?),
1186                    is_error_handler: false,
1187                })
1188                .await?;
1189
1190            let now = Utc::now();
1191            self.start_step(step.id, now).await?;
1192            self.store
1193                .update_step(
1194                    step.id,
1195                    StepUpdate {
1196                        status: Some(StepStatus::Completed),
1197                        output: Some(json!({"approved_in_attempt": granted_in})),
1198                        completed_at: Some(now),
1199                        ..StepUpdate::default()
1200                    },
1201                )
1202                .await?;
1203
1204            self.last_step_ids = vec![step.id];
1205            info!(
1206                run_id = %self.run_id,
1207                step = %name,
1208                position,
1209                granted_in_attempt = granted_in,
1210                attempt = self.attempt,
1211                "approval carried over from a previous attempt"
1212            );
1213            return Ok(());
1214        }
1215
1216        // First execution: create the approval step and suspend.
1217        let trace_id = step_trace_id(self.run_id, name, position);
1218        let step = self
1219            .store
1220            .create_step(NewStep {
1221                run_id: self.run_id,
1222                trace_id,
1223                name: name.to_string(),
1224                kind: StepKind::Approval,
1225                position,
1226                input: Some(serde_json::to_value(&config)?),
1227                is_error_handler: false,
1228            })
1229            .await?;
1230
1231        self.start_step(step.id, Utc::now()).await?;
1232
1233        // Transition the step to AwaitingApproval so it reflects
1234        // the suspended state on the dashboard.
1235        self.store
1236            .update_step(
1237                step.id,
1238                StepUpdate {
1239                    status: Some(StepStatus::AwaitingApproval),
1240                    ..StepUpdate::default()
1241                },
1242            )
1243            .await?;
1244
1245        self.last_step_ids = vec![step.id];
1246
1247        Err(EngineError::ApprovalRequired {
1248            run_id: self.run_id,
1249            step_id: step.id,
1250            message: config.message().to_string(),
1251        })
1252    }
1253
1254    /// Record a step as explicitly skipped.
1255    ///
1256    /// Use this inside an `if`/`else` branch when a step should not execute
1257    /// but must still appear in the DAG and timeline with its reason.
1258    ///
1259    /// The step is created directly in [`StepStatus::Skipped`] state and the
1260    /// reason is stored in the output as `{"reason": "..."}`.
1261    ///
1262    /// # Errors
1263    ///
1264    /// Returns [`EngineError`] if the store fails.
1265    ///
1266    /// # Examples
1267    ///
1268    /// ```no_run
1269    /// use ironflow_engine::context::WorkflowContext;
1270    /// use ironflow_engine::error::EngineError;
1271    ///
1272    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1273    /// let tests_passed = false;
1274    /// if tests_passed {
1275    ///     // ctx.shell("deploy", ...).await?;
1276    /// } else {
1277    ///     ctx.skip("deploy", "tests failed").await?;
1278    /// }
1279    /// # Ok(())
1280    /// # }
1281    /// ```
1282    pub async fn skip(&mut self, name: &str, reason: &str) -> Result<(), EngineError> {
1283        let position = self.position;
1284        self.position += 1;
1285
1286        let trace_id = step_trace_id(self.run_id, name, position);
1287        let step = self
1288            .store
1289            .create_step(NewStep {
1290                run_id: self.run_id,
1291                trace_id,
1292                name: name.to_string(),
1293                kind: StepKind::Custom("skip".to_string()),
1294                position,
1295                input: None,
1296                is_error_handler: false,
1297            })
1298            .await?;
1299
1300        if !self.last_step_ids.is_empty() {
1301            let deps: Vec<NewStepDependency> = self
1302                .last_step_ids
1303                .iter()
1304                .map(|&depends_on| NewStepDependency {
1305                    step_id: step.id,
1306                    depends_on,
1307                })
1308                .collect();
1309            self.store.create_step_dependencies(deps).await?;
1310        }
1311
1312        let now = Utc::now();
1313        self.store
1314            .update_step(
1315                step.id,
1316                StepUpdate {
1317                    status: Some(StepStatus::Skipped),
1318                    output: Some(serde_json::json!({"reason": reason})),
1319                    completed_at: Some(now),
1320                    ..StepUpdate::default()
1321                },
1322            )
1323            .await?;
1324
1325        self.last_step_ids = vec![step.id];
1326
1327        info!(
1328            run_id = %self.run_id,
1329            step = %name,
1330            reason,
1331            "step skipped"
1332        );
1333
1334        Ok(())
1335    }
1336
1337    /// Execute a custom operation step.
1338    ///
1339    /// Runs a user-defined [`Operation`] with full step lifecycle management:
1340    /// creates the step record, transitions to Running, executes the operation,
1341    /// persists the output and duration, and marks the step Completed or Failed.
1342    ///
1343    /// The operation's [`kind()`](Operation::kind) is stored as
1344    /// [`StepKind::Custom`].
1345    ///
1346    /// # Errors
1347    ///
1348    /// Returns [`EngineError`] if the operation fails or the store errors.
1349    ///
1350    /// # Examples
1351    ///
1352    /// ```no_run
1353    /// use ironflow_engine::context::WorkflowContext;
1354    /// use ironflow_engine::operation::Operation;
1355    /// use ironflow_engine::error::EngineError;
1356    /// use serde_json::{Value, json};
1357    /// use std::pin::Pin;
1358    /// use std::future::Future;
1359    ///
1360    /// struct MyOp;
1361    /// impl Operation for MyOp {
1362    ///     fn kind(&self) -> &str { "my-service" }
1363    ///     fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
1364    ///         Box::pin(async { Ok(json!({"ok": true})) })
1365    ///     }
1366    /// }
1367    ///
1368    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1369    /// let result = ctx.operation("call-service", &MyOp).await?;
1370    /// println!("output: {}", result.output);
1371    /// # Ok(())
1372    /// # }
1373    /// ```
1374    pub async fn operation(
1375        &mut self,
1376        name: &str,
1377        op: &dyn Operation,
1378    ) -> Result<StepOutput, EngineError> {
1379        let kind = StepKind::Custom(op.kind().to_string());
1380        let position = self.position;
1381        self.position += 1;
1382
1383        let trace_id = step_trace_id(self.run_id, name, position);
1384        let step = self
1385            .store
1386            .create_step(NewStep {
1387                run_id: self.run_id,
1388                trace_id,
1389                name: name.to_string(),
1390                kind,
1391                position,
1392                input: op.input(),
1393                is_error_handler: false,
1394            })
1395            .await?;
1396
1397        self.start_step(step.id, Utc::now()).await?;
1398
1399        let start = Instant::now();
1400
1401        match op.execute().await {
1402            Ok(output_value) => {
1403                let duration_ms = start.elapsed().as_millis() as u64;
1404                self.total_duration_ms += duration_ms;
1405
1406                let completed_at = Utc::now();
1407                self.store
1408                    .update_step(
1409                        step.id,
1410                        StepUpdate {
1411                            status: Some(StepStatus::Completed),
1412                            output: Some(output_value.clone()),
1413                            duration_ms: Some(duration_ms),
1414                            cost_usd: Some(Decimal::ZERO),
1415                            completed_at: Some(completed_at),
1416                            ..StepUpdate::default()
1417                        },
1418                    )
1419                    .await?;
1420
1421                info!(
1422                    run_id = %self.run_id,
1423                    step = %name,
1424                    kind = op.kind(),
1425                    duration_ms,
1426                    "operation step completed"
1427                );
1428
1429                self.last_step_ids = vec![step.id];
1430
1431                Ok(StepOutput {
1432                    output: output_value,
1433                    duration_ms,
1434                    cost_usd: Decimal::ZERO,
1435                    input_tokens: None,
1436                    output_tokens: None,
1437                    model: None,
1438                    debug_messages: None,
1439                })
1440            }
1441            Err(err) => {
1442                let completed_at = Utc::now();
1443                if let Err(store_err) = self
1444                    .store
1445                    .update_step(
1446                        step.id,
1447                        StepUpdate {
1448                            status: Some(StepStatus::Failed),
1449                            error: Some(err.to_string()),
1450                            completed_at: Some(completed_at),
1451                            ..StepUpdate::default()
1452                        },
1453                    )
1454                    .await
1455                {
1456                    error!(step_id = %step.id, error = %store_err, "failed to persist step failure");
1457                }
1458
1459                Err(err)
1460            }
1461        }
1462    }
1463
1464    /// Execute a sub-workflow step.
1465    ///
1466    /// Creates a child run for the named workflow handler, executes it with
1467    /// its own steps and lifecycle, and returns a [`StepOutput`] containing
1468    /// the child run ID and aggregated metrics.
1469    ///
1470    /// Requires the context to be created with
1471    /// `with_handler_resolver`.
1472    ///
1473    /// # Errors
1474    ///
1475    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered
1476    /// with the given name, or if no handler resolver is available.
1477    ///
1478    /// # Examples
1479    ///
1480    /// ```no_run
1481    /// use ironflow_engine::context::WorkflowContext;
1482    /// use ironflow_engine::error::EngineError;
1483    /// use serde_json::json;
1484    ///
1485    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1486    /// // let result = ctx.workflow(&MySubWorkflow, json!({})).await?;
1487    /// # Ok(())
1488    /// # }
1489    /// ```
1490    pub async fn workflow(
1491        &mut self,
1492        handler: &dyn WorkflowHandler,
1493        payload: Value,
1494    ) -> Result<StepOutput, EngineError> {
1495        // Guard check: verify limits before creating the step.
1496        if let (Some(guard_config), Some(guard_state)) = (&self.guard_config, &self.guard_state) {
1497            let state = guard_state
1498                .lock()
1499                .map_err(|_| WorkflowRejection::GuardUnavailable)?;
1500            state.check(guard_config, handler.name())?;
1501        }
1502
1503        let config = WorkflowStepConfig::new(handler.name(), payload);
1504        let position = self.position;
1505        self.position += 1;
1506
1507        let trace_id = step_trace_id(self.run_id, &config.workflow_name, position);
1508        let step = self
1509            .store
1510            .create_step(NewStep {
1511                run_id: self.run_id,
1512                trace_id,
1513                name: config.workflow_name.clone(),
1514                kind: StepKind::Workflow,
1515                position,
1516                input: Some(serde_json::to_value(&config)?),
1517                is_error_handler: false,
1518            })
1519            .await?;
1520
1521        self.start_step(step.id, Utc::now()).await?;
1522
1523        // Record invocation in guard state (fail-closed).
1524        if let Some(guard_state) = &self.guard_state {
1525            let mut state = guard_state
1526                .lock()
1527                .map_err(|_| WorkflowRejection::GuardUnavailable)?;
1528            state.record_invocation(handler.name());
1529        }
1530
1531        match self.execute_child_workflow(&config).await {
1532            Ok((output, child_had_allowed_failure)) => {
1533                self.total_cost_usd += output.cost_usd;
1534                self.total_duration_ms += output.duration_ms;
1535                if child_had_allowed_failure {
1536                    self.has_allowed_failure = true;
1537                }
1538
1539                let completed_at = Utc::now();
1540                self.store
1541                    .update_step(
1542                        step.id,
1543                        StepUpdate {
1544                            status: Some(StepStatus::Completed),
1545                            output: Some(output.output.clone()),
1546                            duration_ms: Some(output.duration_ms),
1547                            cost_usd: Some(output.cost_usd),
1548                            completed_at: Some(completed_at),
1549                            ..StepUpdate::default()
1550                        },
1551                    )
1552                    .await?;
1553
1554                info!(
1555                    run_id = %self.run_id,
1556                    child_workflow = %config.workflow_name,
1557                    duration_ms = output.duration_ms,
1558                    "workflow step completed"
1559                );
1560
1561                self.last_step_ids = vec![step.id];
1562
1563                self.guard_record_return();
1564                Ok(output)
1565            }
1566            Err(err) => {
1567                let completed_at = Utc::now();
1568                if let Err(store_err) = self
1569                    .store
1570                    .update_step(
1571                        step.id,
1572                        StepUpdate {
1573                            status: Some(StepStatus::Failed),
1574                            error: Some(err.to_string()),
1575                            completed_at: Some(completed_at),
1576                            ..StepUpdate::default()
1577                        },
1578                    )
1579                    .await
1580                {
1581                    error!(step_id = %step.id, error = %store_err, "failed to persist step failure");
1582                }
1583
1584                self.guard_record_return();
1585                Err(err)
1586            }
1587        }
1588    }
1589
1590    /// Decrement guard state after a sub-workflow returns (success or failure).
1591    ///
1592    /// Logs on poison rather than propagating, because this runs on error
1593    /// paths where the workflow is already failing.
1594    fn guard_record_return(&self) {
1595        if let Some(guard_state) = &self.guard_state {
1596            match guard_state.lock() {
1597                Ok(mut state) => state.record_return(),
1598                Err(_) => {
1599                    error!(
1600                        run_id = %self.run_id,
1601                        "guard state mutex poisoned in record_return"
1602                    );
1603                }
1604            }
1605        }
1606    }
1607
1608    /// Wrap step execution with the guard's remaining timeout.
1609    ///
1610    /// When no guard is configured the step runs without a timeout wrapper.
1611    async fn execute_with_guard_timeout(
1612        &self,
1613        config: &StepConfig,
1614        step_log_sender: Option<StepLogSender>,
1615    ) -> Result<StepOutput, EngineError> {
1616        let remaining = self.guard_remaining_timeout();
1617        match remaining {
1618            Some(dur) => {
1619                use tokio::time::timeout;
1620                match timeout(
1621                    dur,
1622                    execute_step_config(config, &self.provider, step_log_sender),
1623                )
1624                .await
1625                {
1626                    Ok(result) => result,
1627                    Err(_elapsed) => {
1628                        let config_secs = self
1629                            .guard_config
1630                            .as_ref()
1631                            .map_or(0, |c| c.workflow_timeout_secs);
1632                        Err(WorkflowRejection::WorkflowTimeout {
1633                            elapsed_secs: config_secs,
1634                            max: config_secs,
1635                        }
1636                        .into())
1637                    }
1638                }
1639            }
1640            None => execute_step_config(config, &self.provider, step_log_sender).await,
1641        }
1642    }
1643
1644    /// Compute the remaining timeout duration from the guard, if any.
1645    fn guard_remaining_timeout(&self) -> Option<std::time::Duration> {
1646        let config = self.guard_config.as_ref()?;
1647        let guard_state = self.guard_state.as_ref()?;
1648        let state = guard_state.lock().ok()?;
1649        let elapsed = state.elapsed_secs();
1650        let max = config.workflow_timeout_secs;
1651        if elapsed >= max {
1652            Some(std::time::Duration::ZERO)
1653        } else {
1654            Some(std::time::Duration::from_secs(max - elapsed))
1655        }
1656    }
1657
1658    /// Check the guard timeout before every step.
1659    fn check_guard_timeout(&self) -> Result<(), EngineError> {
1660        if let (Some(config), Some(guard_state)) = (&self.guard_config, &self.guard_state) {
1661            let state = guard_state
1662                .lock()
1663                .map_err(|_| WorkflowRejection::GuardUnavailable)?;
1664            let elapsed = state.elapsed_secs();
1665            if elapsed >= config.workflow_timeout_secs {
1666                return Err(WorkflowRejection::WorkflowTimeout {
1667                    elapsed_secs: elapsed,
1668                    max: config.workflow_timeout_secs,
1669                }
1670                .into());
1671            }
1672        }
1673        Ok(())
1674    }
1675
1676    /// Record token usage from an agent step in the guard state.
1677    fn guard_record_tokens(&self, tokens: u64) -> Result<(), EngineError> {
1678        if let (Some(config), Some(guard_state)) = (&self.guard_config, &self.guard_state) {
1679            let mut state = guard_state
1680                .lock()
1681                .map_err(|_| WorkflowRejection::GuardUnavailable)?;
1682            state.record_tokens(config, tokens)?;
1683        }
1684        Ok(())
1685    }
1686
1687    /// Execute a child workflow and return aggregated output plus whether
1688    /// at least one `allow_failure` step failed.
1689    async fn execute_child_workflow(
1690        &self,
1691        config: &WorkflowStepConfig,
1692    ) -> Result<(StepOutput, bool), EngineError> {
1693        let resolver = self.handler_resolver.as_ref().ok_or_else(|| {
1694            EngineError::InvalidWorkflow(
1695                "sub-workflow requires a handler resolver (use Engine to execute)".to_string(),
1696            )
1697        })?;
1698
1699        let handler = resolver(&config.workflow_name).ok_or_else(|| {
1700            EngineError::InvalidWorkflow(format!("no handler registered: {}", config.workflow_name))
1701        })?;
1702
1703        // A child run inherits both the parent labels and the parent author:
1704        // whoever triggered the parent workflow is accountable for its children.
1705        let parent = self.store.get_run(self.run_id).await?;
1706        let (parent_labels, parent_author) =
1707            parent.map(|r| (r.labels, r.created_by)).unwrap_or_default();
1708
1709        let child_run = self
1710            .store
1711            .create_run(NewRun {
1712                workflow_name: config.workflow_name.clone(),
1713                trigger: TriggerKind::Workflow,
1714                payload: config.payload.clone(),
1715                max_retries: 0,
1716                handler_version: None,
1717                labels: parent_labels,
1718                scheduled_at: None,
1719                created_by: parent_author,
1720                idempotency_key: None,
1721                // The child shares the parent's cap; it does not get its own budget.
1722                max_cost_usd: self.max_cost_usd,
1723            })
1724            .await?
1725            .into_run();
1726
1727        let child_run_id = child_run.id;
1728        info!(
1729            parent_run_id = %self.run_id,
1730            child_run_id = %child_run_id,
1731            workflow = %config.workflow_name,
1732            "child run created"
1733        );
1734
1735        self.store
1736            .update_run_status(child_run_id, RunStatus::Running)
1737            .await?;
1738
1739        let run_start = Instant::now();
1740        let mut child_ctx = WorkflowContext {
1741            run_id: child_run_id,
1742            store: self.store.clone(),
1743            provider: self.provider.clone(),
1744            handler_resolver: self.handler_resolver.clone(),
1745            position: 0,
1746            last_step_ids: Vec::new(),
1747            total_cost_usd: Decimal::ZERO,
1748            total_duration_ms: 0,
1749            max_cost_usd: self.max_cost_usd,
1750            // Everything the parent chain already spent counts against the
1751            // shared cap, so the child cannot restart the budget from zero.
1752            inherited_cost_usd: self.charged_cost_usd(),
1753            replay_steps: HashMap::new(),
1754            granted_approvals: HashMap::new(),
1755            // A child run is created fresh here; it is never itself retried.
1756            attempt: 1,
1757            carried_duration_ms: 0,
1758            log_sender: self.log_sender.clone(),
1759            // A child shares the storage backend but not the parent's artifacts:
1760            // input lookups are scoped to the child's own run.
1761            artifact_sink: self.artifact_sink.clone(),
1762            has_allowed_failure: false,
1763            error_handlers: Vec::new(),
1764            guard_state: self.guard_state.clone(),
1765            guard_config: self.guard_config.clone(),
1766            step_results: Vec::new(),
1767            event_bus: self.event_bus.clone(),
1768        };
1769
1770        let result = handler.execute(&mut child_ctx).await;
1771        let total_duration = run_start.elapsed().as_millis() as u64;
1772        let completed_at = Utc::now();
1773
1774        match result {
1775            Ok(()) => {
1776                let child_status = if child_ctx.has_allowed_failure {
1777                    RunStatus::Warning
1778                } else {
1779                    RunStatus::Completed
1780                };
1781                self.store
1782                    .update_run(
1783                        child_run_id,
1784                        RunUpdate {
1785                            status: Some(child_status),
1786                            cost_usd: Some(child_ctx.total_cost_usd),
1787                            duration_ms: Some(total_duration),
1788                            completed_at: Some(completed_at),
1789                            ..RunUpdate::default()
1790                        },
1791                    )
1792                    .await?;
1793
1794                let child_had_allowed_failure = child_ctx.has_allowed_failure;
1795                Ok((
1796                    StepOutput {
1797                        output: serde_json::json!({
1798                            "run_id": child_run_id,
1799                            "workflow_name": config.workflow_name,
1800                            "status": child_status,
1801                            "cost_usd": child_ctx.total_cost_usd,
1802                            "duration_ms": total_duration,
1803                        }),
1804                        duration_ms: total_duration,
1805                        cost_usd: child_ctx.total_cost_usd,
1806                        input_tokens: None,
1807                        output_tokens: None,
1808                        model: None,
1809                        debug_messages: None,
1810                    },
1811                    child_had_allowed_failure,
1812                ))
1813            }
1814            Err(err) => {
1815                if let Err(store_err) = self
1816                    .store
1817                    .update_run(
1818                        child_run_id,
1819                        RunUpdate {
1820                            status: Some(RunStatus::Failed),
1821                            error: Some(err.to_string()),
1822                            cost_usd: Some(child_ctx.total_cost_usd),
1823                            duration_ms: Some(total_duration),
1824                            completed_at: Some(completed_at),
1825                            ..RunUpdate::default()
1826                        },
1827                    )
1828                    .await
1829                {
1830                    error!(
1831                        child_run_id = %child_run_id,
1832                        store_error = %store_err,
1833                        "failed to persist child run failure"
1834                    );
1835                }
1836
1837                Err(err)
1838            }
1839        }
1840    }
1841
1842    /// Try to replay a completed step from a previous execution.
1843    ///
1844    /// Returns `Some(StepOutput)` if a completed step exists at the given
1845    /// position, `None` otherwise.
1846    fn try_replay_step(&mut self, position: u32) -> Option<StepOutput> {
1847        let step = self.replay_steps.get(&position)?;
1848        if step.status.state != StepStatus::Completed {
1849            return None;
1850        }
1851        let output = StepOutput {
1852            output: step.output.clone().unwrap_or(Value::Null),
1853            duration_ms: step.duration_ms,
1854            cost_usd: step.cost_usd,
1855            input_tokens: step.input_tokens,
1856            output_tokens: step.output_tokens,
1857            model: None,
1858            debug_messages: None,
1859        };
1860        self.total_cost_usd += output.cost_usd;
1861        self.total_duration_ms += output.duration_ms;
1862        self.last_step_ids = vec![step.id];
1863        info!(
1864            run_id = %self.run_id,
1865            step = %step.name,
1866            position,
1867            "step replayed from previous execution"
1868        );
1869        Some(output)
1870    }
1871
1872    /// Internal: execute a step with full persistence lifecycle.
1873    #[tracing::instrument(
1874        name = "context.execute_step",
1875        skip_all,
1876        fields(
1877            run_id = %self.run_id,
1878            step.name = %name,
1879            step.kind,
1880            step.position = self.position,
1881            step.trace_id,
1882        )
1883    )]
1884    async fn execute_step(
1885        &mut self,
1886        name: &str,
1887        kind: StepKind,
1888        config: StepConfig,
1889    ) -> Result<StepOutput, EngineError> {
1890        let kind_str: &'static str = match kind {
1891            StepKind::Shell => "shell",
1892            StepKind::Http => "http",
1893            StepKind::Agent => "agent",
1894            StepKind::Workflow => "workflow",
1895            StepKind::Approval => "approval",
1896            StepKind::Custom(_) => "custom",
1897        };
1898        Span::current().record("step.kind", kind_str);
1899
1900        // Guard timeout: checked before every step, not just sub-workflows.
1901        self.check_guard_timeout()?;
1902
1903        let position = self.position;
1904        self.position += 1;
1905
1906        // Replay: if this step already completed in a prior execution, return cached output.
1907        if let Some(output) = self.try_replay_step(position) {
1908            return Ok(output);
1909        }
1910
1911        // Cost cap: refuse before creating the step record, so a run that hits
1912        // its cap never launches the work it cannot afford.
1913        if let StepConfig::Agent(ref agent_config) = config {
1914            self.check_run_budget(step_budget_usd(agent_config.max_budget_usd))?;
1915        }
1916
1917        // Create step record in Pending.
1918        let trace_id = step_trace_id(self.run_id, name, position);
1919        Span::current().record("step.trace_id", trace_id.to_string().as_str());
1920        let step = self
1921            .store
1922            .create_step(NewStep {
1923                run_id: self.run_id,
1924                trace_id,
1925                name: name.to_string(),
1926                kind,
1927                position,
1928                input: Some(serde_json::to_value(&config)?),
1929                is_error_handler: false,
1930            })
1931            .await?;
1932
1933        self.start_step(step.id, Utc::now()).await?;
1934
1935        if let Some(ref bus) = self.event_bus {
1936            bus.publish(
1937                self.run_id,
1938                crate::notify::WorkflowEvent::StepStarted {
1939                    step_name: name.to_string(),
1940                    step_index: position,
1941                    timestamp: Utc::now(),
1942                },
1943            );
1944        }
1945
1946        // Inputs must exist before the command runs. A failure here fails the
1947        // step: the command would otherwise run against missing files.
1948        if let Err(err) = self.prepare_step_inputs(&config, position).await {
1949            self.fail_step(step.id, &err).await;
1950            if config.allow_failure() {
1951                self.has_allowed_failure = true;
1952                self.last_step_ids = vec![step.id];
1953                info!(
1954                    run_id = %self.run_id,
1955                    step = %name,
1956                    error = %err,
1957                    "step input preparation failed but allow_failure is set, continuing"
1958                );
1959                return Ok(StepOutput {
1960                    output: json!({"error": err.to_string()}),
1961                    duration_ms: 0,
1962                    cost_usd: Decimal::ZERO,
1963                    input_tokens: None,
1964                    output_tokens: None,
1965                    model: None,
1966                    debug_messages: None,
1967                });
1968            }
1969            return Err(err);
1970        }
1971
1972        let step_log_sender = self
1973            .log_sender
1974            .as_ref()
1975            .map(|s| StepLogSender::new(s.clone(), self.run_id, step.id, name.to_string()));
1976
1977        let execution = self
1978            .execute_with_guard_timeout(&config, step_log_sender)
1979            .await;
1980
1981        let execution = self
1982            .retry_step_if_configured(name, kind_str, &config, step.id, execution)
1983            .await;
1984
1985        if let Err(err) = self
1986            .store_step_outputs(&config, step.id, name, execution.is_ok())
1987            .await
1988        {
1989            self.fail_step(step.id, &err).await;
1990            return Err(err);
1991        }
1992
1993        match execution {
1994            Ok(output) => {
1995                self.total_cost_usd += output.cost_usd;
1996                self.total_duration_ms += output.duration_ms;
1997
1998                // Record token usage in the guard for agent steps.
1999                if matches!(config, StepConfig::Agent(_)) {
2000                    let tokens = output
2001                        .input_tokens
2002                        .unwrap_or(0)
2003                        .saturating_add(output.output_tokens.unwrap_or(0));
2004                    if tokens > 0 {
2005                        self.guard_record_tokens(tokens)?;
2006                    }
2007                }
2008
2009                let debug_messages_json = output.debug_messages_json();
2010
2011                let completed_at = Utc::now();
2012                self.store
2013                    .update_step(
2014                        step.id,
2015                        StepUpdate {
2016                            status: Some(StepStatus::Completed),
2017                            output: Some(output.output.clone()),
2018                            duration_ms: Some(output.duration_ms),
2019                            cost_usd: Some(output.cost_usd),
2020                            input_tokens: output.input_tokens,
2021                            output_tokens: output.output_tokens,
2022                            completed_at: Some(completed_at),
2023                            debug_messages: debug_messages_json,
2024                            ..StepUpdate::default()
2025                        },
2026                    )
2027                    .await?;
2028
2029                self.step_results
2030                    .push(StepResult::from_success(trace_id, name, &output));
2031                self.persist_progress().await;
2032
2033                info!(
2034                    run_id = %self.run_id,
2035                    step = %name,
2036                    trace_id = %trace_id,
2037                    duration_ms = output.duration_ms,
2038                    "step completed"
2039                );
2040
2041                if let Some(ref bus) = self.event_bus {
2042                    bus.publish(
2043                        self.run_id,
2044                        crate::notify::WorkflowEvent::StepCompleted {
2045                            step_name: name.to_string(),
2046                            step_index: position,
2047                            duration_ms: output.duration_ms,
2048                            output_summary: None,
2049                        },
2050                    );
2051                }
2052
2053                self.last_step_ids = vec![step.id];
2054
2055                Ok(output)
2056            }
2057            Err(err) => {
2058                let completed_at = Utc::now();
2059                let debug_messages_json = extract_debug_messages_from_error(&err);
2060                let partial = extract_partial_usage_from_error(&err);
2061                let raw_response_output = extract_raw_response_from_error(&err);
2062
2063                if let Some(ref usage) = partial {
2064                    if let Some(cost) = usage.cost_usd {
2065                        self.total_cost_usd += cost;
2066                    }
2067                    if let Some(dur) = usage.duration_ms {
2068                        self.total_duration_ms += dur;
2069                    }
2070                }
2071
2072                if let Err(store_err) = self
2073                    .store
2074                    .update_step(
2075                        step.id,
2076                        StepUpdate {
2077                            status: Some(StepStatus::Failed),
2078                            error: Some(err.to_string()),
2079                            output: raw_response_output.clone(),
2080                            completed_at: Some(completed_at),
2081                            debug_messages: debug_messages_json,
2082                            duration_ms: partial.as_ref().and_then(|p| p.duration_ms),
2083                            cost_usd: partial.as_ref().and_then(|p| p.cost_usd),
2084                            input_tokens: partial.as_ref().and_then(|p| p.input_tokens),
2085                            output_tokens: partial.as_ref().and_then(|p| p.output_tokens),
2086                            ..StepUpdate::default()
2087                        },
2088                    )
2089                    .await
2090                {
2091                    tracing::error!(step_id = %step.id, error = %store_err, "failed to persist step failure");
2092                }
2093
2094                let err_duration = partial.as_ref().and_then(|p| p.duration_ms).unwrap_or(0);
2095                let err_cost = partial
2096                    .as_ref()
2097                    .and_then(|p| p.cost_usd)
2098                    .unwrap_or(Decimal::ZERO);
2099                self.step_results.push(StepResult::from_failure(
2100                    trace_id,
2101                    name,
2102                    &err.to_string(),
2103                    err_duration,
2104                    err_cost,
2105                ));
2106                self.persist_progress().await;
2107
2108                if let Some(ref bus) = self.event_bus {
2109                    bus.publish(
2110                        self.run_id,
2111                        crate::notify::WorkflowEvent::StepFailed {
2112                            step_name: name.to_string(),
2113                            step_index: position,
2114                            error: err.to_string(),
2115                            duration_ms: err_duration,
2116                        },
2117                    );
2118                }
2119
2120                self.fire_error_handlers(name, &err.to_string(), err_duration)
2121                    .await;
2122
2123                if config.allow_failure() {
2124                    self.has_allowed_failure = true;
2125                    self.last_step_ids = vec![step.id];
2126                    info!(
2127                        run_id = %self.run_id,
2128                        step = %name,
2129                        error = %err,
2130                        "step failed but allow_failure is set, continuing"
2131                    );
2132                    Ok(allowed_failure_output(
2133                        &err.to_string(),
2134                        raw_response_output,
2135                        partial.as_ref(),
2136                    ))
2137                } else {
2138                    Err(err)
2139                }
2140            }
2141        }
2142    }
2143
2144    /// Retry a failed step execution when a step-level retry policy is configured
2145    /// and the error is transient.
2146    ///
2147    /// Returns the original result unchanged when no retry policy is set, the
2148    /// first attempt succeeded, or the error is not retryable.
2149    #[cfg_attr(not(feature = "prometheus"), allow(unused_variables))]
2150    async fn retry_step_if_configured(
2151        &self,
2152        name: &str,
2153        kind_str: &str,
2154        config: &StepConfig,
2155        step_id: Uuid,
2156        first_result: Result<StepOutput, EngineError>,
2157    ) -> Result<StepOutput, EngineError> {
2158        let policy = match config.retry() {
2159            Some(p) => p,
2160            None => return first_result,
2161        };
2162
2163        let mut last_result = match first_result {
2164            Ok(output) => return Ok(output),
2165            Err(err) if !is_step_retryable(&err) => return Err(err),
2166            Err(err) => Err(err),
2167        };
2168
2169        let step_log_sender = self
2170            .log_sender
2171            .as_ref()
2172            .map(|s| StepLogSender::new(s.clone(), self.run_id, step_id, name.to_string()));
2173
2174        for attempt in 0..policy.max_retries() {
2175            if let StepConfig::Agent(agent_config) = config {
2176                self.check_run_budget(step_budget_usd(agent_config.max_budget_usd))?;
2177            }
2178
2179            let delay = policy.delay_for_attempt(attempt);
2180            info!(
2181                run_id = %self.run_id,
2182                step = %name,
2183                attempt = attempt + 1,
2184                max_retries = policy.max_retries(),
2185                delay_ms = delay.as_millis() as u64,
2186                "retrying step after transient failure"
2187            );
2188            tokio::time::sleep(delay).await;
2189
2190            record_retry_metric(kind_str, "retry");
2191
2192            match execute_step_config(config, &self.provider, step_log_sender.clone()).await {
2193                Ok(output) => return Ok(output),
2194                Err(err) if !is_step_retryable(&err) => return Err(err),
2195                err => last_result = err,
2196            }
2197        }
2198
2199        record_retry_metric(kind_str, "exhausted");
2200
2201        info!(
2202            run_id = %self.run_id,
2203            step = %name,
2204            max_retries = policy.max_retries(),
2205            "step retries exhausted"
2206        );
2207
2208        last_result
2209    }
2210
2211    /// Record dependency edges and transition a step to Running.
2212    ///
2213    /// Records edges from `step_id` to all `last_step_ids`, then
2214    /// transitions the step to `Running` with the given timestamp.
2215    async fn start_step(&self, step_id: Uuid, now: DateTime<Utc>) -> Result<(), EngineError> {
2216        if !self.last_step_ids.is_empty() {
2217            let deps: Vec<NewStepDependency> = self
2218                .last_step_ids
2219                .iter()
2220                .map(|&depends_on| NewStepDependency {
2221                    step_id,
2222                    depends_on,
2223                })
2224                .collect();
2225            self.store.create_step_dependencies(deps).await?;
2226        }
2227
2228        self.store
2229            .update_step(
2230                step_id,
2231                StepUpdate {
2232                    status: Some(StepStatus::Running),
2233                    started_at: Some(now),
2234                    ..StepUpdate::default()
2235                },
2236            )
2237            .await?;
2238
2239        Ok(())
2240    }
2241
2242    /// Mark a step as failed, best-effort.
2243    ///
2244    /// Used on paths that fail around the operation itself (artifact inputs and
2245    /// outputs), where the step record is already `Running` and the caller is
2246    /// about to propagate `err`. A store failure here is logged, never returned:
2247    /// it must not replace the error the caller is reporting.
2248    async fn fail_step(&self, step_id: Uuid, err: &EngineError) {
2249        if let Err(store_err) = self
2250            .store
2251            .update_step(
2252                step_id,
2253                StepUpdate {
2254                    status: Some(StepStatus::Failed),
2255                    error: Some(err.to_string()),
2256                    completed_at: Some(Utc::now()),
2257                    ..StepUpdate::default()
2258                },
2259            )
2260            .await
2261        {
2262            error!(
2263                step_id = %step_id,
2264                error = %store_err,
2265                "failed to persist step failure"
2266            );
2267        }
2268    }
2269
2270    /// Access the store directly (advanced usage).
2271    pub fn store(&self) -> &Arc<dyn Store> {
2272        &self.store
2273    }
2274
2275    /// Access the payload that triggered this run.
2276    ///
2277    /// Fetches the run from the store and returns its payload.
2278    ///
2279    /// # Errors
2280    ///
2281    /// Returns [`EngineError::Store`] if the run is not found.
2282    pub async fn payload(&self) -> Result<Value, EngineError> {
2283        let run = self
2284            .store
2285            .get_run(self.run_id)
2286            .await?
2287            .ok_or(EngineError::Store(
2288                ironflow_store::error::StoreError::RunNotFound(self.run_id),
2289            ))?;
2290        Ok(run.payload)
2291    }
2292
2293    /// Deserialize the run payload into a typed input struct.
2294    ///
2295    /// Shorthand for `serde_json::from_value(ctx.payload().await?)`.
2296    ///
2297    /// # Errors
2298    ///
2299    /// Returns [`EngineError::Store`] if the run is not found, or
2300    /// [`EngineError::Serialization`] if the payload does not match `T`.
2301    ///
2302    /// # Examples
2303    ///
2304    /// ```no_run
2305    /// # use ironflow_engine::context::WorkflowContext;
2306    /// # use ironflow_engine::error::EngineError;
2307    /// use serde::Deserialize;
2308    ///
2309    /// #[derive(Deserialize)]
2310    /// struct DeployInput {
2311    ///     environment: String,
2312    ///     dry_run: Option<bool>,
2313    /// }
2314    ///
2315    /// # async fn example(ctx: &WorkflowContext) -> Result<(), EngineError> {
2316    /// let input: DeployInput = ctx.input().await?;
2317    /// # Ok(())
2318    /// # }
2319    /// ```
2320    pub async fn input<T: serde::de::DeserializeOwned>(&self) -> Result<T, EngineError> {
2321        let payload = self.payload().await?;
2322        serde_json::from_value(payload).map_err(EngineError::Serialization)
2323    }
2324
2325    /// Register an error handler that fires when any subsequent step fails.
2326    ///
2327    /// The handler is consumed after firing (fire-once). Multiple handlers
2328    /// can be registered; they fire in registration order.
2329    ///
2330    /// Error handler execution is best-effort: if a handler fails, the error
2331    /// is logged but the original step error is preserved. Error handler steps
2332    /// appear in the run timeline with [`Step::is_error_handler`] set to `true`.
2333    ///
2334    /// # Examples
2335    ///
2336    /// ```no_run
2337    /// use ironflow_engine::context::WorkflowContext;
2338    /// use ironflow_engine::config::ShellConfig;
2339    /// use ironflow_engine::error::EngineError;
2340    ///
2341    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
2342    /// ctx.on_error("cleanup", ShellConfig::new("rm -rf /tmp/build"));
2343    /// ctx.shell("build", ShellConfig::new("cargo build")).await?;
2344    /// # Ok(())
2345    /// # }
2346    /// ```
2347    pub fn on_error(&mut self, name: &str, config: impl Into<StepConfig>) {
2348        self.error_handlers.push(OnErrorHandler {
2349            name: name.to_string(),
2350            config: config.into(),
2351        });
2352    }
2353
2354    /// Remove all registered error handlers.
2355    ///
2356    /// # Examples
2357    ///
2358    /// ```no_run
2359    /// use ironflow_engine::context::WorkflowContext;
2360    /// use ironflow_engine::config::ShellConfig;
2361    /// use ironflow_engine::error::EngineError;
2362    ///
2363    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
2364    /// ctx.on_error("cleanup", ShellConfig::new("rm -rf /tmp/build"));
2365    /// ctx.shell("build", ShellConfig::new("cargo build")).await?;
2366    /// ctx.clear_error_handlers();
2367    /// // cleanup will NOT fire if deploy fails
2368    /// ctx.shell("deploy", ShellConfig::new("./deploy.sh")).await?;
2369    /// # Ok(())
2370    /// # }
2371    /// ```
2372    pub fn clear_error_handlers(&mut self) {
2373        self.error_handlers.clear();
2374    }
2375
2376    /// Execute all registered error handlers after a step failure.
2377    ///
2378    /// Drains the handler list (fire-once). Each handler creates its own
2379    /// step record with `is_error_handler = true`. Handler failures are
2380    /// logged but never propagated.
2381    async fn fire_error_handlers(
2382        &mut self,
2383        failed_step_name: &str,
2384        error_msg: &str,
2385        duration_ms: u64,
2386    ) {
2387        let handlers = std::mem::take(&mut self.error_handlers);
2388        if handlers.is_empty() {
2389            return;
2390        }
2391
2392        let error_context = json!({
2393            "failed_step": failed_step_name,
2394            "error": error_msg,
2395            "duration_ms": duration_ms,
2396        });
2397
2398        for handler in handlers {
2399            let mut config = handler.config.clone();
2400            inject_error_context(&mut config, failed_step_name, error_msg, duration_ms);
2401
2402            let position = self.position;
2403            self.position += 1;
2404
2405            let trace_id = step_trace_id(self.run_id, &handler.name, position);
2406            let step = match self
2407                .store
2408                .create_step(NewStep {
2409                    run_id: self.run_id,
2410                    trace_id,
2411                    name: handler.name.clone(),
2412                    kind: config.kind(),
2413                    position,
2414                    input: Some(error_context.clone()),
2415                    is_error_handler: true,
2416                })
2417                .await
2418            {
2419                Ok(step) => step,
2420                Err(err) => {
2421                    warn!(
2422                        run_id = %self.run_id,
2423                        handler = %handler.name,
2424                        error = %err,
2425                        "failed to create error handler step"
2426                    );
2427                    continue;
2428                }
2429            };
2430
2431            if let Err(err) = self.start_step(step.id, Utc::now()).await {
2432                warn!(
2433                    run_id = %self.run_id,
2434                    handler = %handler.name,
2435                    error = %err,
2436                    "failed to start error handler step"
2437                );
2438                continue;
2439            }
2440
2441            let step_log_sender = self
2442                .log_sender
2443                .as_ref()
2444                .map(|s| StepLogSender::new(s.clone(), self.run_id, step.id, handler.name.clone()));
2445
2446            let start = Instant::now();
2447            let result = execute_step_config(&config, &self.provider, step_log_sender).await;
2448            let handler_duration = start.elapsed().as_millis() as u64;
2449            let completed_at = Utc::now();
2450
2451            match result {
2452                Ok(output) => {
2453                    if let Err(store_err) = self
2454                        .store
2455                        .update_step(
2456                            step.id,
2457                            StepUpdate {
2458                                status: Some(StepStatus::Completed),
2459                                output: Some(output.output),
2460                                duration_ms: Some(handler_duration),
2461                                cost_usd: Some(output.cost_usd),
2462                                completed_at: Some(completed_at),
2463                                ..StepUpdate::default()
2464                            },
2465                        )
2466                        .await
2467                    {
2468                        warn!(
2469                            run_id = %self.run_id,
2470                            handler = %handler.name,
2471                            error = %store_err,
2472                            "failed to persist error handler completion"
2473                        );
2474                    }
2475
2476                    info!(
2477                        run_id = %self.run_id,
2478                        handler = %handler.name,
2479                        duration_ms = handler_duration,
2480                        "error handler completed"
2481                    );
2482                }
2483                Err(err) => {
2484                    if let Err(store_err) = self
2485                        .store
2486                        .update_step(
2487                            step.id,
2488                            StepUpdate {
2489                                status: Some(StepStatus::Failed),
2490                                error: Some(err.to_string()),
2491                                duration_ms: Some(handler_duration),
2492                                completed_at: Some(completed_at),
2493                                ..StepUpdate::default()
2494                            },
2495                        )
2496                        .await
2497                    {
2498                        warn!(
2499                            run_id = %self.run_id,
2500                            handler = %handler.name,
2501                            error = %store_err,
2502                            "failed to persist error handler failure"
2503                        );
2504                    }
2505
2506                    warn!(
2507                        run_id = %self.run_id,
2508                        handler = %handler.name,
2509                        error = %err,
2510                        "error handler failed (original error preserved)"
2511                    );
2512                }
2513            }
2514        }
2515    }
2516}
2517
2518/// Inject error context into a step config before executing it as an error handler.
2519fn inject_error_context(
2520    config: &mut StepConfig,
2521    failed_step: &str,
2522    error_msg: &str,
2523    duration_ms: u64,
2524) {
2525    match config {
2526        StepConfig::Shell(shell) => {
2527            shell
2528                .env
2529                .push(("IRONFLOW_ERROR_STEP".to_string(), failed_step.to_string()));
2530            shell
2531                .env
2532                .push(("IRONFLOW_ERROR_MESSAGE".to_string(), error_msg.to_string()));
2533            shell.env.push((
2534                "IRONFLOW_ERROR_DURATION_MS".to_string(),
2535                duration_ms.to_string(),
2536            ));
2537        }
2538        StepConfig::Agent(agent) => {
2539            agent.prompt = format!(
2540                "[Error Context]\nStep \"{}\" failed after {}ms:\n{}\n\n{}",
2541                failed_step, duration_ms, error_msg, agent.prompt
2542            );
2543        }
2544        StepConfig::Http(http) => {
2545            http.headers
2546                .push(("X-Ironflow-Error-Step".to_string(), failed_step.to_string()));
2547            http.headers.push((
2548                "X-Ironflow-Error-Message".to_string(),
2549                error_msg.to_string(),
2550            ));
2551        }
2552        StepConfig::Workflow(_) | StepConfig::Approval(_) => {}
2553    }
2554}
2555
2556#[cfg(feature = "prometheus")]
2557fn record_retry_metric(kind: &str, outcome: &str) {
2558    use ironflow_core::metric_names::STEP_RETRIES_TOTAL;
2559    use metrics::counter;
2560    counter!(STEP_RETRIES_TOTAL, "kind" => kind.to_string(), "outcome" => outcome.to_string())
2561        .increment(1);
2562}
2563
2564#[cfg(not(feature = "prometheus"))]
2565fn record_retry_metric(_kind: &str, _outcome: &str) {}
2566
2567/// Step-level retryability: broader than operation-level retry because the user
2568/// explicitly opted in. Excludes only deterministic or financially wasteful
2569/// errors that retrying cannot fix.
2570fn is_step_retryable(err: &EngineError) -> bool {
2571    use ironflow_core::error::{AgentError, OperationError};
2572
2573    match err {
2574        EngineError::Operation(op) => match op {
2575            OperationError::Agent(AgentError::PromptTooLarge { .. }) => false,
2576            OperationError::Agent(AgentError::BudgetExceeded { .. }) => false,
2577            OperationError::Deserialize { .. } => false,
2578            OperationError::Http {
2579                status: Some(code), ..
2580            } if (400..500).contains(code) && *code != 429 => false,
2581            _ => true,
2582        },
2583        _ => false,
2584    }
2585}
2586
2587fn allowed_failure_output(
2588    error_msg: &str,
2589    raw_response: Option<Value>,
2590    partial: Option<&StepPartialUsage>,
2591) -> StepOutput {
2592    StepOutput {
2593        output: raw_response.unwrap_or_else(|| json!({"error": error_msg})),
2594        duration_ms: partial.and_then(|p| p.duration_ms).unwrap_or(0),
2595        cost_usd: partial.and_then(|p| p.cost_usd).unwrap_or(Decimal::ZERO),
2596        input_tokens: partial.and_then(|p| p.input_tokens),
2597        output_tokens: partial.and_then(|p| p.output_tokens),
2598        model: None,
2599        debug_messages: None,
2600    }
2601}
2602
2603impl fmt::Debug for WorkflowContext {
2604    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2605        f.debug_struct("WorkflowContext")
2606            .field("run_id", &self.run_id)
2607            .field("position", &self.position)
2608            .field("total_cost_usd", &self.total_cost_usd)
2609            .field("inherited_cost_usd", &self.inherited_cost_usd)
2610            .field("max_cost_usd", &self.max_cost_usd)
2611            .finish_non_exhaustive()
2612    }
2613}
2614
2615/// Extract debug messages from an engine error, if it wraps a schema validation
2616/// failure that carries a verbose conversation trace.
2617fn extract_debug_messages_from_error(err: &EngineError) -> Option<Value> {
2618    if let EngineError::Operation(OperationError::Agent(AgentError::SchemaValidation {
2619        debug_messages,
2620        ..
2621    })) = err
2622        && !debug_messages.is_empty()
2623    {
2624        return serde_json::to_value(debug_messages).ok();
2625    }
2626    None
2627}
2628
2629/// Partial usage with `Decimal` cost, converted from the `f64` in [`PartialUsage`].
2630///
2631/// Exists only because `ironflow-store` uses [`Decimal`] for monetary values
2632/// while `ironflow-core` uses `f64` (the CLI's native type). The conversion
2633/// happens here, at the engine/store boundary.
2634struct StepPartialUsage {
2635    cost_usd: Option<Decimal>,
2636    duration_ms: Option<u64>,
2637    input_tokens: Option<u64>,
2638    output_tokens: Option<u64>,
2639}
2640
2641/// Extract the raw response text from a schema validation error.
2642///
2643/// When the agent produced text but structured output extraction failed,
2644/// this returns the truncated raw text so it can be persisted as the
2645/// step output for dashboard visibility.
2646fn extract_raw_response_from_error(err: &EngineError) -> Option<Value> {
2647    if let EngineError::Operation(OperationError::Agent(AgentError::SchemaValidation {
2648        raw_response: Some(text),
2649        ..
2650    })) = err
2651    {
2652        return Some(Value::String(text.clone()));
2653    }
2654    None
2655}
2656
2657fn extract_partial_usage_from_error(err: &EngineError) -> Option<StepPartialUsage> {
2658    if let EngineError::Operation(OperationError::Agent(AgentError::SchemaValidation {
2659        partial_usage,
2660        ..
2661    })) = err
2662        && (partial_usage.cost_usd.is_some() || partial_usage.duration_ms.is_some())
2663    {
2664        return Some(StepPartialUsage {
2665            cost_usd: partial_usage
2666                .cost_usd
2667                .and_then(|c| Decimal::try_from(c).ok()),
2668            duration_ms: partial_usage.duration_ms,
2669            input_tokens: partial_usage.input_tokens,
2670            output_tokens: partial_usage.output_tokens,
2671        });
2672    }
2673    None
2674}
2675
2676#[cfg(test)]
2677mod tests {
2678    use super::*;
2679    use ironflow_core::providers::claude::ClaudeCodeProvider;
2680    use ironflow_core::providers::record_replay::RecordReplayProvider;
2681    use ironflow_store::memory::InMemoryStore;
2682    use ironflow_store::models::{Run, RunActor, RunFilter};
2683    use ironflow_store::store::RunStore;
2684    use serde_json::json;
2685    use std::sync::Arc;
2686    use std::sync::atomic::{AtomicBool, Ordering};
2687    use uuid::Uuid;
2688
2689    /// Helper to create a test provider with fixtures
2690    fn create_test_provider() -> Arc<dyn ironflow_core::provider::AgentProvider> {
2691        let inner = ClaudeCodeProvider::new();
2692        Arc::new(RecordReplayProvider::replay(
2693            inner,
2694            "/tmp/ironflow-fixtures",
2695        ))
2696    }
2697
2698    /// Helper to create a test context
2699    fn create_test_context() -> WorkflowContext {
2700        let store = Arc::new(InMemoryStore::new());
2701        let provider = create_test_provider();
2702        let run_id = Uuid::now_v7();
2703        WorkflowContext::new(run_id, store, provider)
2704    }
2705
2706    #[test]
2707    fn context_new_initializes_correctly() {
2708        let ctx = create_test_context();
2709        assert_eq!(ctx.position, 0);
2710        assert_eq!(ctx.total_cost_usd, Decimal::ZERO);
2711        assert_eq!(ctx.total_duration_ms, 0);
2712        assert!(ctx.last_step_ids.is_empty());
2713        assert!(ctx.replay_steps.is_empty());
2714        assert!(ctx.log_sender.is_none());
2715    }
2716
2717    #[test]
2718    fn context_run_id_returns_correct_id() {
2719        let run_id = Uuid::now_v7();
2720        let store = Arc::new(InMemoryStore::new());
2721        let provider = create_test_provider();
2722        let ctx = WorkflowContext::new(run_id, store, provider);
2723        assert_eq!(ctx.run_id(), run_id);
2724    }
2725
2726    #[test]
2727    fn context_total_cost_usd_initially_zero() {
2728        let ctx = create_test_context();
2729        assert_eq!(ctx.total_cost_usd(), Decimal::ZERO);
2730    }
2731
2732    #[test]
2733    fn context_total_duration_ms_initially_zero() {
2734        let ctx = create_test_context();
2735        assert_eq!(ctx.total_duration_ms(), 0);
2736    }
2737
2738    #[test]
2739    fn context_with_handler_resolver_creates_context_with_resolver() {
2740        let store = Arc::new(InMemoryStore::new());
2741        let provider = create_test_provider();
2742        let run_id = Uuid::now_v7();
2743
2744        let called = Arc::new(AtomicBool::new(false));
2745        let called_clone = called.clone();
2746
2747        let resolver: HandlerResolver = Arc::new(move |_name: &str| {
2748            called_clone.store(true, Ordering::SeqCst);
2749            None
2750        });
2751
2752        let ctx = WorkflowContext::with_handler_resolver(run_id, store, provider, resolver);
2753
2754        assert_eq!(ctx.run_id(), run_id);
2755        assert!(ctx.handler_resolver.is_some());
2756    }
2757
2758    #[tokio::test]
2759    async fn context_set_log_sender_attaches_sender() {
2760        let mut ctx = create_test_context();
2761        let (sender, _receiver) = crate::log_sender::channel();
2762        ctx.set_log_sender(sender);
2763        assert!(ctx.log_sender.is_some());
2764    }
2765
2766    #[tokio::test]
2767    async fn context_skip_creates_skipped_step() {
2768        let store = Arc::new(InMemoryStore::new());
2769        let provider = create_test_provider();
2770
2771        // Create the run first using RunStore trait
2772        store
2773            .create_run(NewRun {
2774                created_by: None,
2775                workflow_name: "test".to_string(),
2776                trigger: TriggerKind::Manual,
2777                payload: json!({}),
2778                max_retries: 0,
2779                handler_version: None,
2780                labels: Default::default(),
2781                scheduled_at: None,
2782                idempotency_key: None,
2783                max_cost_usd: None,
2784            })
2785            .await
2786            .expect("failed to create run")
2787            .into_run();
2788
2789        // Get the created run to extract its ID
2790        let runs = store
2791            .list_runs(RunFilter::default(), 1, 10)
2792            .await
2793            .expect("failed to list runs");
2794        let created_run_id = runs.items[0].id;
2795
2796        let mut ctx = WorkflowContext::new(created_run_id, store.clone(), provider);
2797        let initial_position = ctx.position;
2798
2799        ctx.skip("skip-step", "condition not met")
2800            .await
2801            .expect("skip failed");
2802
2803        assert_eq!(ctx.position, initial_position + 1);
2804        assert!(!ctx.last_step_ids.is_empty());
2805
2806        // Verify the step was recorded with Skipped status
2807        let steps = store
2808            .list_steps(created_run_id)
2809            .await
2810            .expect("failed to list steps");
2811        assert_eq!(steps.len(), 1);
2812        assert_eq!(steps[0].status.state, StepStatus::Skipped);
2813    }
2814
2815    /// Sub-workflow handler that records no steps, so the child run reaches a
2816    /// terminal state without touching the filesystem or the network.
2817    struct NoopSubWorkflow;
2818
2819    impl WorkflowHandler for NoopSubWorkflow {
2820        fn name(&self) -> &str {
2821            "noop-sub"
2822        }
2823
2824        fn execute<'a>(
2825            &'a self,
2826            _ctx: &'a mut WorkflowContext,
2827        ) -> crate::handler::HandlerFuture<'a> {
2828            Box::pin(async move { Ok(()) })
2829        }
2830    }
2831
2832    /// Run a parent workflow authored by `created_by` and return the child run
2833    /// created by its sub-workflow step.
2834    async fn child_run_of_parent_authored_by(created_by: Option<RunActor>) -> Run {
2835        let store = Arc::new(InMemoryStore::new());
2836        let provider = create_test_provider();
2837
2838        let parent = store
2839            .create_run(NewRun {
2840                workflow_name: "parent".to_string(),
2841                trigger: TriggerKind::Api,
2842                payload: json!({}),
2843                max_retries: 0,
2844                handler_version: None,
2845                labels: Default::default(),
2846                scheduled_at: None,
2847                created_by,
2848                idempotency_key: None,
2849                max_cost_usd: None,
2850            })
2851            .await
2852            .expect("failed to create parent run")
2853            .into_run();
2854
2855        let resolver: HandlerResolver = Arc::new(|name: &str| match name {
2856            "noop-sub" => Some(Arc::new(NoopSubWorkflow) as Arc<dyn WorkflowHandler>),
2857            _ => None,
2858        });
2859
2860        let mut ctx =
2861            WorkflowContext::with_handler_resolver(parent.id, store.clone(), provider, resolver);
2862        ctx.workflow(&NoopSubWorkflow, json!({}))
2863            .await
2864            .expect("sub-workflow failed");
2865
2866        let runs = store
2867            .list_runs(RunFilter::default(), 1, 10)
2868            .await
2869            .expect("failed to list runs");
2870        runs.items
2871            .into_iter()
2872            .find(|r| r.workflow_name == "noop-sub")
2873            .expect("child run was created")
2874    }
2875
2876    #[tokio::test]
2877    async fn child_run_inherits_the_parent_author() {
2878        let user_id = Uuid::now_v7();
2879        let child = child_run_of_parent_authored_by(Some(RunActor::User { user_id })).await;
2880
2881        assert_eq!(child.created_by, Some(RunActor::User { user_id }));
2882    }
2883
2884    #[tokio::test]
2885    async fn child_run_of_an_unattributed_parent_has_no_author() {
2886        let child = child_run_of_parent_authored_by(None).await;
2887
2888        assert!(child.created_by.is_none());
2889    }
2890
2891    #[tokio::test]
2892    async fn context_parallel_empty_steps_returns_empty_vec() {
2893        let mut ctx = create_test_context();
2894        let results = ctx
2895            .parallel(vec![], true)
2896            .await
2897            .expect("parallel should not fail on empty input");
2898        assert!(results.is_empty());
2899    }
2900
2901    #[tokio::test]
2902    async fn context_approval_first_execution_returns_error() {
2903        let store = Arc::new(InMemoryStore::new());
2904        let provider = create_test_provider();
2905
2906        // Create the run first
2907        store
2908            .create_run(NewRun {
2909                created_by: None,
2910                workflow_name: "test".to_string(),
2911                trigger: TriggerKind::Manual,
2912                payload: json!({}),
2913                max_retries: 0,
2914                handler_version: None,
2915                labels: Default::default(),
2916                scheduled_at: None,
2917                idempotency_key: None,
2918                max_cost_usd: None,
2919            })
2920            .await
2921            .expect("failed to create run")
2922            .into_run();
2923
2924        // Get the created run to extract its ID
2925        let runs = store
2926            .list_runs(RunFilter::default(), 1, 10)
2927            .await
2928            .expect("failed to list runs");
2929        let created_run_id = runs.items[0].id;
2930
2931        let mut ctx = WorkflowContext::new(created_run_id, store.clone(), provider);
2932
2933        let result = ctx
2934            .approval(
2935                "approve-step",
2936                crate::config::ApprovalConfig::new("Continue?"),
2937            )
2938            .await;
2939
2940        // First execution should return ApprovalRequired error
2941        assert!(matches!(result, Err(EngineError::ApprovalRequired { .. })));
2942
2943        // Verify position incremented
2944        assert_eq!(ctx.position, 1);
2945
2946        // Verify step was created with AwaitingApproval status
2947        let steps = store
2948            .list_steps(created_run_id)
2949            .await
2950            .expect("failed to list steps");
2951        assert_eq!(steps.len(), 1);
2952        assert_eq!(steps[0].status.state, StepStatus::AwaitingApproval);
2953    }
2954
2955    #[tokio::test]
2956    async fn context_approval_replay_returns_ok() {
2957        let store = Arc::new(InMemoryStore::new());
2958        let provider = create_test_provider();
2959
2960        // Create the run first
2961        store
2962            .create_run(NewRun {
2963                created_by: None,
2964                workflow_name: "test".to_string(),
2965                trigger: TriggerKind::Manual,
2966                payload: json!({}),
2967                max_retries: 0,
2968                handler_version: None,
2969                labels: Default::default(),
2970                scheduled_at: None,
2971                idempotency_key: None,
2972                max_cost_usd: None,
2973            })
2974            .await
2975            .expect("failed to create run")
2976            .into_run();
2977
2978        // Get the created run to extract its ID
2979        let runs = store
2980            .list_runs(RunFilter::default(), 1, 10)
2981            .await
2982            .expect("failed to list runs");
2983        let created_run_id = runs.items[0].id;
2984
2985        // Create an approval step that's already in AwaitingApproval state
2986        let step = store
2987            .create_step(NewStep {
2988                run_id: created_run_id,
2989                trace_id: step_trace_id(created_run_id, "approval", 0),
2990                name: "approval".to_string(),
2991                kind: StepKind::Approval,
2992                position: 0,
2993                input: None,
2994                is_error_handler: false,
2995            })
2996            .await
2997            .expect("failed to create step");
2998
2999        // Transition through proper states: Pending -> Running -> AwaitingApproval
3000        store
3001            .update_step(
3002                step.id,
3003                StepUpdate {
3004                    status: Some(StepStatus::Running),
3005                    started_at: Some(Utc::now()),
3006                    ..StepUpdate::default()
3007                },
3008            )
3009            .await
3010            .expect("failed to update step to Running");
3011
3012        store
3013            .update_step(
3014                step.id,
3015                StepUpdate {
3016                    status: Some(StepStatus::AwaitingApproval),
3017                    ..StepUpdate::default()
3018                },
3019            )
3020            .await
3021            .expect("failed to update step to AwaitingApproval");
3022
3023        // Create context and load replay steps
3024        let mut ctx = WorkflowContext::new(created_run_id, store.clone(), provider);
3025        ctx.load_replay_steps()
3026            .await
3027            .expect("failed to load replay steps");
3028
3029        // Now approval should succeed (replay)
3030        let result = ctx
3031            .approval("approval", crate::config::ApprovalConfig::new("Continue?"))
3032            .await;
3033
3034        assert!(result.is_ok());
3035
3036        // Verify the step was marked Completed
3037        let steps = store
3038            .list_steps(created_run_id)
3039            .await
3040            .expect("failed to list steps");
3041        assert_eq!(steps.len(), 1);
3042        assert_eq!(steps[0].status.state, StepStatus::Completed);
3043    }
3044
3045    #[tokio::test]
3046    async fn context_load_replay_steps_loads_completed_steps() {
3047        let store = Arc::new(InMemoryStore::new());
3048        let provider = create_test_provider();
3049
3050        // Create the run first
3051        store
3052            .create_run(NewRun {
3053                created_by: None,
3054                workflow_name: "test".to_string(),
3055                trigger: TriggerKind::Manual,
3056                payload: json!({}),
3057                max_retries: 0,
3058                handler_version: None,
3059                labels: Default::default(),
3060                scheduled_at: None,
3061                idempotency_key: None,
3062                max_cost_usd: None,
3063            })
3064            .await
3065            .expect("failed to create run")
3066            .into_run();
3067
3068        // Get the created run to extract its ID
3069        let runs = store
3070            .list_runs(RunFilter::default(), 1, 10)
3071            .await
3072            .expect("failed to list runs");
3073        let created_run_id = runs.items[0].id;
3074
3075        // Create multiple steps with different statuses
3076        let completed_step = store
3077            .create_step(NewStep {
3078                run_id: created_run_id,
3079                trace_id: step_trace_id(created_run_id, "completed", 0),
3080                name: "completed".to_string(),
3081                kind: StepKind::Shell,
3082                position: 0,
3083                input: None,
3084                is_error_handler: false,
3085            })
3086            .await
3087            .expect("failed to create step");
3088
3089        // Transition to Running then Completed
3090        store
3091            .update_step(
3092                completed_step.id,
3093                StepUpdate {
3094                    status: Some(StepStatus::Running),
3095                    started_at: Some(Utc::now()),
3096                    ..StepUpdate::default()
3097                },
3098            )
3099            .await
3100            .expect("failed to update step to Running");
3101
3102        store
3103            .update_step(
3104                completed_step.id,
3105                StepUpdate {
3106                    status: Some(StepStatus::Completed),
3107                    completed_at: Some(Utc::now()),
3108                    ..StepUpdate::default()
3109                },
3110            )
3111            .await
3112            .expect("failed to update step to Completed");
3113
3114        let _pending_step = store
3115            .create_step(NewStep {
3116                run_id: created_run_id,
3117                trace_id: step_trace_id(created_run_id, "pending", 1),
3118                name: "pending".to_string(),
3119                kind: StepKind::Shell,
3120                position: 1,
3121                input: None,
3122                is_error_handler: false,
3123            })
3124            .await
3125            .expect("failed to create step");
3126
3127        // Load replay steps
3128        let mut ctx = WorkflowContext::new(created_run_id, store, provider);
3129        ctx.load_replay_steps()
3130            .await
3131            .expect("failed to load replay steps");
3132
3133        // Only completed step should be in replay_steps
3134        assert_eq!(ctx.replay_steps.len(), 1);
3135        assert!(ctx.replay_steps.contains_key(&0));
3136        assert!(!ctx.replay_steps.contains_key(&1));
3137    }
3138
3139    #[tokio::test]
3140    async fn context_payload_returns_run_payload() {
3141        let store = Arc::new(InMemoryStore::new());
3142        let provider = create_test_provider();
3143        let test_payload = json!({"key": "value", "number": 42});
3144
3145        // Create the run first
3146        store
3147            .create_run(NewRun {
3148                created_by: None,
3149                workflow_name: "test".to_string(),
3150                trigger: TriggerKind::Manual,
3151                payload: test_payload.clone(),
3152                max_retries: 0,
3153                handler_version: None,
3154                labels: Default::default(),
3155                scheduled_at: None,
3156                idempotency_key: None,
3157                max_cost_usd: None,
3158            })
3159            .await
3160            .expect("failed to create run")
3161            .into_run();
3162
3163        // Get the created run to extract its ID
3164        let runs = store
3165            .list_runs(RunFilter::default(), 1, 10)
3166            .await
3167            .expect("failed to list runs");
3168        let created_run_id = runs.items[0].id;
3169
3170        let ctx = WorkflowContext::new(created_run_id, store, provider);
3171        let payload = ctx.payload().await.expect("failed to get payload");
3172
3173        assert_eq!(payload, test_payload);
3174    }
3175
3176    #[tokio::test]
3177    async fn context_payload_returns_error_for_nonexistent_run() {
3178        let store = Arc::new(InMemoryStore::new());
3179        let provider = create_test_provider();
3180        let run_id = Uuid::now_v7();
3181
3182        let ctx = WorkflowContext::new(run_id, store, provider);
3183        let result = ctx.payload().await;
3184
3185        assert!(result.is_err());
3186    }
3187
3188    #[tokio::test]
3189    async fn context_store_returns_reference() {
3190        let ctx = create_test_context();
3191        let _store = ctx.store();
3192        // store() returns a reference to the Arc<dyn Store>, which is always available
3193    }
3194
3195    #[test]
3196    fn context_debug_formatting() {
3197        let ctx = create_test_context();
3198        let debug_str = format!("{:?}", ctx);
3199        assert!(debug_str.contains("WorkflowContext"));
3200        assert!(debug_str.contains("run_id"));
3201    }
3202
3203    #[tokio::test]
3204    async fn context_last_step_ids_tracks_executed_steps() {
3205        let store = Arc::new(InMemoryStore::new());
3206        let provider = create_test_provider();
3207
3208        // Create the run first
3209        store
3210            .create_run(NewRun {
3211                created_by: None,
3212                workflow_name: "test".to_string(),
3213                trigger: TriggerKind::Manual,
3214                payload: json!({}),
3215                max_retries: 0,
3216                handler_version: None,
3217                labels: Default::default(),
3218                scheduled_at: None,
3219                idempotency_key: None,
3220                max_cost_usd: None,
3221            })
3222            .await
3223            .expect("failed to create run")
3224            .into_run();
3225
3226        // Get the created run to extract its ID
3227        let runs = store
3228            .list_runs(RunFilter::default(), 1, 10)
3229            .await
3230            .expect("failed to list runs");
3231        let created_run_id = runs.items[0].id;
3232
3233        let mut ctx = WorkflowContext::new(created_run_id, store, provider);
3234        assert!(ctx.last_step_ids.is_empty());
3235
3236        ctx.skip("step1", "reason").await.expect("skip failed");
3237
3238        assert_eq!(ctx.last_step_ids.len(), 1);
3239
3240        ctx.skip("step2", "reason").await.expect("skip failed");
3241
3242        // last_step_ids should now contain only step2's ID
3243        assert_eq!(ctx.last_step_ids.len(), 1);
3244    }
3245}