Skip to main content

ironflow_engine/
context.rs

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