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