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