Skip to main content

ironflow_engine/
context.rs

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