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 async_trait::async_trait;
1479    /// use ironflow_engine::context::WorkflowContext;
1480    /// use ironflow_engine::operation::{Operation, OperationContext};
1481    /// use ironflow_core::error::OperationError;
1482    /// use ironflow_engine::error::EngineError;
1483    /// use serde_json::{Value, json};
1484    ///
1485    /// struct MyOp;
1486    /// #[async_trait]
1487    /// impl Operation for MyOp {
1488    ///     fn kind(&self) -> &str { "my-service" }
1489    ///     async fn execute(&self, _ctx: &OperationContext) -> Result<Value, OperationError> {
1490    ///         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    pub(crate) 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    pub(crate) async fn start_step(
2375        &self,
2376        step_id: Uuid,
2377        now: DateTime<Utc>,
2378    ) -> Result<(), EngineError> {
2379        if !self.last_step_ids.is_empty() {
2380            let deps: Vec<NewStepDependency> = self
2381                .last_step_ids
2382                .iter()
2383                .map(|&depends_on| NewStepDependency {
2384                    step_id,
2385                    depends_on,
2386                })
2387                .collect();
2388            self.store.create_step_dependencies(deps).await?;
2389        }
2390
2391        self.store
2392            .update_step(
2393                step_id,
2394                StepUpdate {
2395                    status: Some(StepStatus::Running),
2396                    started_at: Some(now),
2397                    ..StepUpdate::default()
2398                },
2399            )
2400            .await?;
2401
2402        Ok(())
2403    }
2404
2405    /// Mark a step as failed, best-effort.
2406    ///
2407    /// Used on paths that fail around the operation itself (artifact inputs and
2408    /// outputs), where the step record is already `Running` and the caller is
2409    /// about to propagate `err`. A store failure here is logged, never returned:
2410    /// it must not replace the error the caller is reporting.
2411    async fn fail_step(&self, step_id: Uuid, err: &EngineError) {
2412        if let Err(store_err) = self
2413            .store
2414            .update_step(
2415                step_id,
2416                StepUpdate {
2417                    status: Some(StepStatus::Failed),
2418                    error: Some(err.to_string()),
2419                    completed_at: Some(Utc::now()),
2420                    ..StepUpdate::default()
2421                },
2422            )
2423            .await
2424        {
2425            error!(
2426                step_id = %step_id,
2427                error = %store_err,
2428                "failed to persist step failure"
2429            );
2430        }
2431    }
2432
2433    /// Access the store directly (advanced usage).
2434    pub fn store(&self) -> &Arc<dyn Store> {
2435        &self.store
2436    }
2437
2438    /// Get and increment the current position counter.
2439    pub(crate) fn next_position(&mut self) -> u32 {
2440        let pos = self.position;
2441        self.position += 1;
2442        pos
2443    }
2444
2445    /// Access the replay steps from a previous execution.
2446    pub(crate) fn replay_steps(&self) -> &HashMap<u32, Step> {
2447        &self.replay_steps
2448    }
2449
2450    /// Set the last step IDs (for dependency tracking).
2451    pub(crate) fn set_last_step_ids(&mut self, ids: Vec<Uuid>) {
2452        self.last_step_ids = ids;
2453    }
2454
2455    /// Access the payload that triggered this run.
2456    ///
2457    /// Fetches the run from the store and returns its payload.
2458    ///
2459    /// # Errors
2460    ///
2461    /// Returns [`EngineError::Store`] if the run is not found.
2462    pub async fn payload(&self) -> Result<Value, EngineError> {
2463        let run = self
2464            .store
2465            .get_run(self.run_id)
2466            .await?
2467            .ok_or(EngineError::Store(
2468                ironflow_store::error::StoreError::RunNotFound(self.run_id),
2469            ))?;
2470        Ok(run.payload)
2471    }
2472
2473    /// Deserialize the run payload into a typed input struct.
2474    ///
2475    /// Shorthand for `serde_json::from_value(ctx.payload().await?)`.
2476    ///
2477    /// # Errors
2478    ///
2479    /// Returns [`EngineError::Store`] if the run is not found, or
2480    /// [`EngineError::Serialization`] if the payload does not match `T`.
2481    ///
2482    /// # Examples
2483    ///
2484    /// ```no_run
2485    /// # use ironflow_engine::context::WorkflowContext;
2486    /// # use ironflow_engine::error::EngineError;
2487    /// use serde::Deserialize;
2488    ///
2489    /// #[derive(Deserialize)]
2490    /// struct DeployInput {
2491    ///     environment: String,
2492    ///     dry_run: Option<bool>,
2493    /// }
2494    ///
2495    /// # async fn example(ctx: &WorkflowContext) -> Result<(), EngineError> {
2496    /// let input: DeployInput = ctx.input().await?;
2497    /// # Ok(())
2498    /// # }
2499    /// ```
2500    pub async fn input<T: serde::de::DeserializeOwned>(&self) -> Result<T, EngineError> {
2501        let payload = self.payload().await?;
2502        serde_json::from_value(payload).map_err(EngineError::Serialization)
2503    }
2504
2505    /// Register an error handler that fires when any subsequent step fails.
2506    ///
2507    /// The handler is consumed after firing (fire-once). Multiple handlers
2508    /// can be registered; they fire in registration order.
2509    ///
2510    /// Error handler execution is best-effort: if a handler fails, the error
2511    /// is logged but the original step error is preserved. Error handler steps
2512    /// appear in the run timeline with [`Step::is_error_handler`] set to `true`.
2513    ///
2514    /// # Examples
2515    ///
2516    /// ```no_run
2517    /// use ironflow_engine::context::WorkflowContext;
2518    /// use ironflow_engine::config::ShellConfig;
2519    /// use ironflow_engine::error::EngineError;
2520    ///
2521    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
2522    /// ctx.on_error("cleanup", ShellConfig::new("rm -rf /tmp/build"));
2523    /// ctx.shell("build", ShellConfig::new("cargo build")).await?;
2524    /// # Ok(())
2525    /// # }
2526    /// ```
2527    pub fn on_error(&mut self, name: &str, config: impl Into<StepConfig>) {
2528        self.error_handlers.push(OnErrorHandler {
2529            name: name.to_string(),
2530            config: config.into(),
2531        });
2532    }
2533
2534    /// Remove all registered error handlers.
2535    ///
2536    /// # Examples
2537    ///
2538    /// ```no_run
2539    /// use ironflow_engine::context::WorkflowContext;
2540    /// use ironflow_engine::config::ShellConfig;
2541    /// use ironflow_engine::error::EngineError;
2542    ///
2543    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
2544    /// ctx.on_error("cleanup", ShellConfig::new("rm -rf /tmp/build"));
2545    /// ctx.shell("build", ShellConfig::new("cargo build")).await?;
2546    /// ctx.clear_error_handlers();
2547    /// // cleanup will NOT fire if deploy fails
2548    /// ctx.shell("deploy", ShellConfig::new("./deploy.sh")).await?;
2549    /// # Ok(())
2550    /// # }
2551    /// ```
2552    pub fn clear_error_handlers(&mut self) {
2553        self.error_handlers.clear();
2554    }
2555
2556    /// Execute all registered error handlers after a step failure.
2557    ///
2558    /// Drains the handler list (fire-once). Each handler creates its own
2559    /// step record with `is_error_handler = true`. Handler failures are
2560    /// logged but never propagated.
2561    async fn fire_error_handlers(
2562        &mut self,
2563        failed_step_name: &str,
2564        error_msg: &str,
2565        duration_ms: u64,
2566    ) {
2567        let handlers = std::mem::take(&mut self.error_handlers);
2568        if handlers.is_empty() {
2569            return;
2570        }
2571
2572        let error_context = json!({
2573            "failed_step": failed_step_name,
2574            "error": error_msg,
2575            "duration_ms": duration_ms,
2576        });
2577
2578        for handler in handlers {
2579            let mut config = handler.config.clone();
2580            inject_error_context(&mut config, failed_step_name, error_msg, duration_ms);
2581
2582            let position = self.position;
2583            self.position += 1;
2584
2585            let trace_id = step_trace_id(self.run_id, &handler.name, position);
2586            let step = match self
2587                .store
2588                .create_step(NewStep {
2589                    run_id: self.run_id,
2590                    trace_id,
2591                    name: handler.name.clone(),
2592                    kind: config.kind(),
2593                    position,
2594                    input: Some(error_context.clone()),
2595                    is_error_handler: true,
2596                })
2597                .await
2598            {
2599                Ok(step) => step,
2600                Err(err) => {
2601                    warn!(
2602                        run_id = %self.run_id,
2603                        handler = %handler.name,
2604                        error = %err,
2605                        "failed to create error handler step"
2606                    );
2607                    continue;
2608                }
2609            };
2610
2611            if let Err(err) = self.start_step(step.id, Utc::now()).await {
2612                warn!(
2613                    run_id = %self.run_id,
2614                    handler = %handler.name,
2615                    error = %err,
2616                    "failed to start error handler step"
2617                );
2618                continue;
2619            }
2620
2621            let step_log_sender = self
2622                .log_sender
2623                .as_ref()
2624                .map(|s| StepLogSender::new(s.clone(), self.run_id, step.id, handler.name.clone()));
2625
2626            let start = Instant::now();
2627            let result = execute_step_config(&config, &self.provider, step_log_sender).await;
2628            let handler_duration = start.elapsed().as_millis() as u64;
2629            let completed_at = Utc::now();
2630
2631            match result {
2632                Ok(output) => {
2633                    if let Err(store_err) = self
2634                        .store
2635                        .update_step(
2636                            step.id,
2637                            StepUpdate {
2638                                status: Some(StepStatus::Completed),
2639                                output: Some(output.output),
2640                                duration_ms: Some(handler_duration),
2641                                cost_usd: Some(output.cost_usd),
2642                                completed_at: Some(completed_at),
2643                                ..StepUpdate::default()
2644                            },
2645                        )
2646                        .await
2647                    {
2648                        warn!(
2649                            run_id = %self.run_id,
2650                            handler = %handler.name,
2651                            error = %store_err,
2652                            "failed to persist error handler completion"
2653                        );
2654                    }
2655
2656                    info!(
2657                        run_id = %self.run_id,
2658                        handler = %handler.name,
2659                        duration_ms = handler_duration,
2660                        "error handler completed"
2661                    );
2662                }
2663                Err(err) => {
2664                    if let Err(store_err) = self
2665                        .store
2666                        .update_step(
2667                            step.id,
2668                            StepUpdate {
2669                                status: Some(StepStatus::Failed),
2670                                error: Some(err.to_string()),
2671                                duration_ms: Some(handler_duration),
2672                                completed_at: Some(completed_at),
2673                                ..StepUpdate::default()
2674                            },
2675                        )
2676                        .await
2677                    {
2678                        warn!(
2679                            run_id = %self.run_id,
2680                            handler = %handler.name,
2681                            error = %store_err,
2682                            "failed to persist error handler failure"
2683                        );
2684                    }
2685
2686                    warn!(
2687                        run_id = %self.run_id,
2688                        handler = %handler.name,
2689                        error = %err,
2690                        "error handler failed (original error preserved)"
2691                    );
2692                }
2693            }
2694        }
2695    }
2696}
2697
2698/// Inject error context into a step config before executing it as an error handler.
2699fn inject_error_context(
2700    config: &mut StepConfig,
2701    failed_step: &str,
2702    error_msg: &str,
2703    duration_ms: u64,
2704) {
2705    match config {
2706        StepConfig::Shell(shell) => {
2707            shell
2708                .env
2709                .push(("IRONFLOW_ERROR_STEP".to_string(), failed_step.to_string()));
2710            shell
2711                .env
2712                .push(("IRONFLOW_ERROR_MESSAGE".to_string(), error_msg.to_string()));
2713            shell.env.push((
2714                "IRONFLOW_ERROR_DURATION_MS".to_string(),
2715                duration_ms.to_string(),
2716            ));
2717        }
2718        StepConfig::Agent(agent) => {
2719            agent.prompt = format!(
2720                "[Error Context]\nStep \"{}\" failed after {}ms:\n{}\n\n{}",
2721                failed_step, duration_ms, error_msg, agent.prompt
2722            );
2723        }
2724        StepConfig::Http(http) => {
2725            http.headers
2726                .push(("X-Ironflow-Error-Step".to_string(), failed_step.to_string()));
2727            http.headers.push((
2728                "X-Ironflow-Error-Message".to_string(),
2729                error_msg.to_string(),
2730            ));
2731        }
2732        StepConfig::Workflow(_) | StepConfig::Approval(_) | StepConfig::Delay(_) => {}
2733    }
2734}
2735
2736#[cfg(feature = "prometheus")]
2737fn record_retry_metric(kind: &str, outcome: &str) {
2738    use ironflow_core::metric_names::STEP_RETRIES_TOTAL;
2739    use metrics::counter;
2740    counter!(STEP_RETRIES_TOTAL, "kind" => kind.to_string(), "outcome" => outcome.to_string())
2741        .increment(1);
2742}
2743
2744#[cfg(not(feature = "prometheus"))]
2745fn record_retry_metric(_kind: &str, _outcome: &str) {}
2746
2747/// Step-level retryability: broader than operation-level retry because the user
2748/// explicitly opted in. Excludes only deterministic or financially wasteful
2749/// errors that retrying cannot fix.
2750fn is_step_retryable(err: &EngineError) -> bool {
2751    use ironflow_core::error::{AgentError, OperationError};
2752
2753    match err {
2754        EngineError::Operation(op) => match op {
2755            OperationError::Agent(AgentError::PromptTooLarge { .. }) => false,
2756            OperationError::Agent(AgentError::BudgetExceeded { .. }) => false,
2757            OperationError::Deserialize { .. } => false,
2758            OperationError::Http {
2759                status: Some(code), ..
2760            } if (400..500).contains(code) && *code != 429 => false,
2761            _ => true,
2762        },
2763        _ => false,
2764    }
2765}
2766
2767fn allowed_failure_output(
2768    error_msg: &str,
2769    raw_response: Option<Value>,
2770    partial: Option<&StepPartialUsage>,
2771) -> StepOutput {
2772    StepOutput {
2773        output: raw_response.unwrap_or_else(|| json!({"error": error_msg})),
2774        duration_ms: partial.and_then(|p| p.duration_ms).unwrap_or(0),
2775        cost_usd: partial.and_then(|p| p.cost_usd).unwrap_or(Decimal::ZERO),
2776        input_tokens: partial.and_then(|p| p.input_tokens),
2777        output_tokens: partial.and_then(|p| p.output_tokens),
2778        model: None,
2779        debug_messages: None,
2780    }
2781}
2782
2783impl fmt::Debug for WorkflowContext {
2784    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2785        f.debug_struct("WorkflowContext")
2786            .field("run_id", &self.run_id)
2787            .field("position", &self.position)
2788            .field("total_cost_usd", &self.total_cost_usd)
2789            .field("inherited_cost_usd", &self.inherited_cost_usd)
2790            .field("max_cost_usd", &self.max_cost_usd)
2791            .finish_non_exhaustive()
2792    }
2793}
2794
2795/// Extract debug messages from an engine error, if it wraps a schema validation
2796/// failure that carries a verbose conversation trace.
2797fn extract_debug_messages_from_error(err: &EngineError) -> Option<Value> {
2798    if let EngineError::Operation(OperationError::Agent(AgentError::SchemaValidation {
2799        debug_messages,
2800        ..
2801    })) = err
2802        && !debug_messages.is_empty()
2803    {
2804        return serde_json::to_value(debug_messages).ok();
2805    }
2806    None
2807}
2808
2809/// Partial usage with `Decimal` cost, converted from the `f64` in [`PartialUsage`].
2810///
2811/// Exists only because `ironflow-store` uses [`Decimal`] for monetary values
2812/// while `ironflow-core` uses `f64` (the CLI's native type). The conversion
2813/// happens here, at the engine/store boundary.
2814struct StepPartialUsage {
2815    cost_usd: Option<Decimal>,
2816    duration_ms: Option<u64>,
2817    input_tokens: Option<u64>,
2818    output_tokens: Option<u64>,
2819}
2820
2821/// Extract the raw response text from a schema validation error.
2822///
2823/// When the agent produced text but structured output extraction failed,
2824/// this returns the truncated raw text so it can be persisted as the
2825/// step output for dashboard visibility.
2826fn extract_raw_response_from_error(err: &EngineError) -> Option<Value> {
2827    if let EngineError::Operation(OperationError::Agent(AgentError::SchemaValidation {
2828        raw_response: Some(text),
2829        ..
2830    })) = err
2831    {
2832        return Some(Value::String(text.clone()));
2833    }
2834    None
2835}
2836
2837fn extract_partial_usage_from_error(err: &EngineError) -> Option<StepPartialUsage> {
2838    if let EngineError::Operation(OperationError::Agent(AgentError::SchemaValidation {
2839        partial_usage,
2840        ..
2841    })) = err
2842        && (partial_usage.cost_usd.is_some() || partial_usage.duration_ms.is_some())
2843    {
2844        return Some(StepPartialUsage {
2845            cost_usd: partial_usage
2846                .cost_usd
2847                .and_then(|c| Decimal::try_from(c).ok()),
2848            duration_ms: partial_usage.duration_ms,
2849            input_tokens: partial_usage.input_tokens,
2850            output_tokens: partial_usage.output_tokens,
2851        });
2852    }
2853    None
2854}
2855
2856#[cfg(test)]
2857mod tests {
2858    use super::*;
2859    use ironflow_core::providers::claude::ClaudeCodeProvider;
2860    use ironflow_core::providers::record_replay::RecordReplayProvider;
2861    use ironflow_store::memory::InMemoryStore;
2862    use ironflow_store::models::{Run, RunActor, RunFilter};
2863    use ironflow_store::store::RunStore;
2864    use serde_json::json;
2865    use std::sync::Arc;
2866    use std::sync::atomic::{AtomicBool, Ordering};
2867    use uuid::Uuid;
2868
2869    /// Helper to create a test provider with fixtures
2870    fn create_test_provider() -> Arc<dyn ironflow_core::provider::AgentProvider> {
2871        let inner = ClaudeCodeProvider::new();
2872        Arc::new(RecordReplayProvider::replay(
2873            inner,
2874            "/tmp/ironflow-fixtures",
2875        ))
2876    }
2877
2878    /// Helper to create a test context
2879    fn create_test_context() -> WorkflowContext {
2880        let store = Arc::new(InMemoryStore::new());
2881        let provider = create_test_provider();
2882        let run_id = Uuid::now_v7();
2883        WorkflowContext::new(run_id, "test".to_string(), store, provider)
2884    }
2885
2886    #[test]
2887    fn context_new_initializes_correctly() {
2888        let ctx = create_test_context();
2889        assert_eq!(ctx.position, 0);
2890        assert_eq!(ctx.total_cost_usd, Decimal::ZERO);
2891        assert_eq!(ctx.total_duration_ms, 0);
2892        assert!(ctx.last_step_ids.is_empty());
2893        assert!(ctx.replay_steps.is_empty());
2894        assert!(ctx.log_sender.is_none());
2895    }
2896
2897    #[test]
2898    fn context_run_id_returns_correct_id() {
2899        let run_id = Uuid::now_v7();
2900        let store = Arc::new(InMemoryStore::new());
2901        let provider = create_test_provider();
2902        let ctx = WorkflowContext::new(run_id, "test".to_string(), store, provider);
2903        assert_eq!(ctx.run_id(), run_id);
2904    }
2905
2906    #[test]
2907    fn context_total_cost_usd_initially_zero() {
2908        let ctx = create_test_context();
2909        assert_eq!(ctx.total_cost_usd(), Decimal::ZERO);
2910    }
2911
2912    #[test]
2913    fn context_total_duration_ms_initially_zero() {
2914        let ctx = create_test_context();
2915        assert_eq!(ctx.total_duration_ms(), 0);
2916    }
2917
2918    #[test]
2919    fn context_with_handler_resolver_creates_context_with_resolver() {
2920        let store = Arc::new(InMemoryStore::new());
2921        let provider = create_test_provider();
2922        let run_id = Uuid::now_v7();
2923
2924        let called = Arc::new(AtomicBool::new(false));
2925        let called_clone = called.clone();
2926
2927        let resolver: HandlerResolver = Arc::new(move |_name: &str| {
2928            called_clone.store(true, Ordering::SeqCst);
2929            None
2930        });
2931
2932        let ctx = WorkflowContext::with_handler_resolver(
2933            run_id,
2934            "test".to_string(),
2935            store,
2936            provider,
2937            resolver,
2938        );
2939
2940        assert_eq!(ctx.run_id(), run_id);
2941        assert!(ctx.handler_resolver.is_some());
2942    }
2943
2944    #[tokio::test]
2945    async fn context_set_log_sender_attaches_sender() {
2946        let mut ctx = create_test_context();
2947        let (sender, _receiver) = crate::log_sender::channel();
2948        ctx.set_log_sender(sender);
2949        assert!(ctx.log_sender.is_some());
2950    }
2951
2952    #[tokio::test]
2953    async fn context_skip_creates_skipped_step() {
2954        let store = Arc::new(InMemoryStore::new());
2955        let provider = create_test_provider();
2956
2957        // Create the run first using RunStore trait
2958        store
2959            .create_run(NewRun {
2960                created_by: None,
2961                workflow_name: "test".to_string(),
2962                trigger: TriggerKind::Manual,
2963                payload: json!({}),
2964                max_retries: 0,
2965                handler_version: None,
2966                labels: Default::default(),
2967                scheduled_at: None,
2968                idempotency_key: None,
2969                max_cost_usd: None,
2970            })
2971            .await
2972            .expect("failed to create run")
2973            .into_run();
2974
2975        // Get the created run to extract its ID
2976        let runs = store
2977            .list_runs(RunFilter::default(), 1, 10)
2978            .await
2979            .expect("failed to list runs");
2980        let created_run_id = runs.items[0].id;
2981
2982        let mut ctx =
2983            WorkflowContext::new(created_run_id, "test".to_string(), store.clone(), provider);
2984        let initial_position = ctx.position;
2985
2986        ctx.skip("skip-step", "condition not met")
2987            .await
2988            .expect("skip failed");
2989
2990        assert_eq!(ctx.position, initial_position + 1);
2991        assert!(!ctx.last_step_ids.is_empty());
2992
2993        // Verify the step was recorded with Skipped status
2994        let steps = store
2995            .list_steps(created_run_id)
2996            .await
2997            .expect("failed to list steps");
2998        assert_eq!(steps.len(), 1);
2999        assert_eq!(steps[0].status.state, StepStatus::Skipped);
3000    }
3001
3002    /// Sub-workflow handler that records no steps, so the child run reaches a
3003    /// terminal state without touching the filesystem or the network.
3004    struct NoopSubWorkflow;
3005
3006    impl WorkflowHandler for NoopSubWorkflow {
3007        fn name(&self) -> &str {
3008            "noop-sub"
3009        }
3010
3011        fn execute<'a>(
3012            &'a self,
3013            _ctx: &'a mut WorkflowContext,
3014        ) -> crate::handler::HandlerFuture<'a> {
3015            Box::pin(async move { Ok(()) })
3016        }
3017    }
3018
3019    /// Run a parent workflow authored by `created_by` and return the child run
3020    /// created by its sub-workflow step.
3021    async fn child_run_of_parent_authored_by(created_by: Option<RunActor>) -> Run {
3022        let store = Arc::new(InMemoryStore::new());
3023        let provider = create_test_provider();
3024
3025        let parent = store
3026            .create_run(NewRun {
3027                workflow_name: "parent".to_string(),
3028                trigger: TriggerKind::Api,
3029                payload: json!({}),
3030                max_retries: 0,
3031                handler_version: None,
3032                labels: Default::default(),
3033                scheduled_at: None,
3034                created_by,
3035                idempotency_key: None,
3036                max_cost_usd: None,
3037            })
3038            .await
3039            .expect("failed to create parent run")
3040            .into_run();
3041
3042        let resolver: HandlerResolver = Arc::new(|name: &str| match name {
3043            "noop-sub" => Some(Arc::new(NoopSubWorkflow) as Arc<dyn WorkflowHandler>),
3044            _ => None,
3045        });
3046
3047        let mut ctx = WorkflowContext::with_handler_resolver(
3048            parent.id,
3049            "parent".to_string(),
3050            store.clone(),
3051            provider,
3052            resolver,
3053        );
3054        ctx.workflow(&NoopSubWorkflow, json!({}))
3055            .await
3056            .expect("sub-workflow failed");
3057
3058        let runs = store
3059            .list_runs(RunFilter::default(), 1, 10)
3060            .await
3061            .expect("failed to list runs");
3062        runs.items
3063            .into_iter()
3064            .find(|r| r.workflow_name == "noop-sub")
3065            .expect("child run was created")
3066    }
3067
3068    #[tokio::test]
3069    async fn child_run_inherits_the_parent_author() {
3070        let user_id = Uuid::now_v7();
3071        let child = child_run_of_parent_authored_by(Some(RunActor::User { user_id })).await;
3072
3073        assert_eq!(child.created_by, Some(RunActor::User { user_id }));
3074    }
3075
3076    #[tokio::test]
3077    async fn child_run_of_an_unattributed_parent_has_no_author() {
3078        let child = child_run_of_parent_authored_by(None).await;
3079
3080        assert!(child.created_by.is_none());
3081    }
3082
3083    #[tokio::test]
3084    async fn context_parallel_empty_steps_returns_empty_vec() {
3085        let mut ctx = create_test_context();
3086        let results = ctx
3087            .parallel(vec![], true)
3088            .await
3089            .expect("parallel should not fail on empty input");
3090        assert!(results.is_empty());
3091    }
3092
3093    #[tokio::test]
3094    async fn context_approval_first_execution_returns_error() {
3095        let store = Arc::new(InMemoryStore::new());
3096        let provider = create_test_provider();
3097
3098        // Create the run first
3099        store
3100            .create_run(NewRun {
3101                created_by: None,
3102                workflow_name: "test".to_string(),
3103                trigger: TriggerKind::Manual,
3104                payload: json!({}),
3105                max_retries: 0,
3106                handler_version: None,
3107                labels: Default::default(),
3108                scheduled_at: None,
3109                idempotency_key: None,
3110                max_cost_usd: None,
3111            })
3112            .await
3113            .expect("failed to create run")
3114            .into_run();
3115
3116        // Get the created run to extract its ID
3117        let runs = store
3118            .list_runs(RunFilter::default(), 1, 10)
3119            .await
3120            .expect("failed to list runs");
3121        let created_run_id = runs.items[0].id;
3122
3123        let mut ctx =
3124            WorkflowContext::new(created_run_id, "test".to_string(), store.clone(), provider);
3125
3126        let result = ctx
3127            .approval(
3128                "approve-step",
3129                crate::config::ApprovalConfig::new("Continue?"),
3130            )
3131            .await;
3132
3133        // First execution should return ApprovalRequired error
3134        assert!(matches!(result, Err(EngineError::ApprovalRequired { .. })));
3135
3136        // Verify position incremented
3137        assert_eq!(ctx.position, 1);
3138
3139        // Verify step was created with AwaitingApproval status
3140        let steps = store
3141            .list_steps(created_run_id)
3142            .await
3143            .expect("failed to list steps");
3144        assert_eq!(steps.len(), 1);
3145        assert_eq!(steps[0].status.state, StepStatus::AwaitingApproval);
3146    }
3147
3148    #[tokio::test]
3149    async fn context_approval_replay_returns_ok() {
3150        let store = Arc::new(InMemoryStore::new());
3151        let provider = create_test_provider();
3152
3153        // Create the run first
3154        store
3155            .create_run(NewRun {
3156                created_by: None,
3157                workflow_name: "test".to_string(),
3158                trigger: TriggerKind::Manual,
3159                payload: json!({}),
3160                max_retries: 0,
3161                handler_version: None,
3162                labels: Default::default(),
3163                scheduled_at: None,
3164                idempotency_key: None,
3165                max_cost_usd: None,
3166            })
3167            .await
3168            .expect("failed to create run")
3169            .into_run();
3170
3171        // Get the created run to extract its ID
3172        let runs = store
3173            .list_runs(RunFilter::default(), 1, 10)
3174            .await
3175            .expect("failed to list runs");
3176        let created_run_id = runs.items[0].id;
3177
3178        // Create an approval step that's already in AwaitingApproval state
3179        let step = store
3180            .create_step(NewStep {
3181                run_id: created_run_id,
3182                trace_id: step_trace_id(created_run_id, "approval", 0),
3183                name: "approval".to_string(),
3184                kind: StepKind::Approval,
3185                position: 0,
3186                input: None,
3187                is_error_handler: false,
3188            })
3189            .await
3190            .expect("failed to create step");
3191
3192        // Transition through proper states: Pending -> Running -> AwaitingApproval
3193        store
3194            .update_step(
3195                step.id,
3196                StepUpdate {
3197                    status: Some(StepStatus::Running),
3198                    started_at: Some(Utc::now()),
3199                    ..StepUpdate::default()
3200                },
3201            )
3202            .await
3203            .expect("failed to update step to Running");
3204
3205        store
3206            .update_step(
3207                step.id,
3208                StepUpdate {
3209                    status: Some(StepStatus::AwaitingApproval),
3210                    ..StepUpdate::default()
3211                },
3212            )
3213            .await
3214            .expect("failed to update step to AwaitingApproval");
3215
3216        // Create context and load replay steps
3217        let mut ctx =
3218            WorkflowContext::new(created_run_id, "test".to_string(), store.clone(), provider);
3219        ctx.load_replay_steps()
3220            .await
3221            .expect("failed to load replay steps");
3222
3223        // Now approval should succeed (replay)
3224        let result = ctx
3225            .approval("approval", crate::config::ApprovalConfig::new("Continue?"))
3226            .await;
3227
3228        assert!(result.is_ok());
3229
3230        // Verify the step was marked Completed
3231        let steps = store
3232            .list_steps(created_run_id)
3233            .await
3234            .expect("failed to list steps");
3235        assert_eq!(steps.len(), 1);
3236        assert_eq!(steps[0].status.state, StepStatus::Completed);
3237    }
3238
3239    #[tokio::test]
3240    async fn context_load_replay_steps_loads_completed_steps() {
3241        let store = Arc::new(InMemoryStore::new());
3242        let provider = create_test_provider();
3243
3244        // Create the run first
3245        store
3246            .create_run(NewRun {
3247                created_by: None,
3248                workflow_name: "test".to_string(),
3249                trigger: TriggerKind::Manual,
3250                payload: json!({}),
3251                max_retries: 0,
3252                handler_version: None,
3253                labels: Default::default(),
3254                scheduled_at: None,
3255                idempotency_key: None,
3256                max_cost_usd: None,
3257            })
3258            .await
3259            .expect("failed to create run")
3260            .into_run();
3261
3262        // Get the created run to extract its ID
3263        let runs = store
3264            .list_runs(RunFilter::default(), 1, 10)
3265            .await
3266            .expect("failed to list runs");
3267        let created_run_id = runs.items[0].id;
3268
3269        // Create multiple steps with different statuses
3270        let completed_step = store
3271            .create_step(NewStep {
3272                run_id: created_run_id,
3273                trace_id: step_trace_id(created_run_id, "completed", 0),
3274                name: "completed".to_string(),
3275                kind: StepKind::Shell,
3276                position: 0,
3277                input: None,
3278                is_error_handler: false,
3279            })
3280            .await
3281            .expect("failed to create step");
3282
3283        // Transition to Running then Completed
3284        store
3285            .update_step(
3286                completed_step.id,
3287                StepUpdate {
3288                    status: Some(StepStatus::Running),
3289                    started_at: Some(Utc::now()),
3290                    ..StepUpdate::default()
3291                },
3292            )
3293            .await
3294            .expect("failed to update step to Running");
3295
3296        store
3297            .update_step(
3298                completed_step.id,
3299                StepUpdate {
3300                    status: Some(StepStatus::Completed),
3301                    completed_at: Some(Utc::now()),
3302                    ..StepUpdate::default()
3303                },
3304            )
3305            .await
3306            .expect("failed to update step to Completed");
3307
3308        let _pending_step = store
3309            .create_step(NewStep {
3310                run_id: created_run_id,
3311                trace_id: step_trace_id(created_run_id, "pending", 1),
3312                name: "pending".to_string(),
3313                kind: StepKind::Shell,
3314                position: 1,
3315                input: None,
3316                is_error_handler: false,
3317            })
3318            .await
3319            .expect("failed to create step");
3320
3321        // Load replay steps
3322        let mut ctx = WorkflowContext::new(created_run_id, "test".to_string(), store, provider);
3323        ctx.load_replay_steps()
3324            .await
3325            .expect("failed to load replay steps");
3326
3327        // Only completed step should be in replay_steps
3328        assert_eq!(ctx.replay_steps.len(), 1);
3329        assert!(ctx.replay_steps.contains_key(&0));
3330        assert!(!ctx.replay_steps.contains_key(&1));
3331    }
3332
3333    #[tokio::test]
3334    async fn context_payload_returns_run_payload() {
3335        let store = Arc::new(InMemoryStore::new());
3336        let provider = create_test_provider();
3337        let test_payload = json!({"key": "value", "number": 42});
3338
3339        // Create the run first
3340        store
3341            .create_run(NewRun {
3342                created_by: None,
3343                workflow_name: "test".to_string(),
3344                trigger: TriggerKind::Manual,
3345                payload: test_payload.clone(),
3346                max_retries: 0,
3347                handler_version: None,
3348                labels: Default::default(),
3349                scheduled_at: None,
3350                idempotency_key: None,
3351                max_cost_usd: None,
3352            })
3353            .await
3354            .expect("failed to create run")
3355            .into_run();
3356
3357        // Get the created run to extract its ID
3358        let runs = store
3359            .list_runs(RunFilter::default(), 1, 10)
3360            .await
3361            .expect("failed to list runs");
3362        let created_run_id = runs.items[0].id;
3363
3364        let ctx = WorkflowContext::new(created_run_id, "test".to_string(), store, provider);
3365        let payload = ctx.payload().await.expect("failed to get payload");
3366
3367        assert_eq!(payload, test_payload);
3368    }
3369
3370    #[tokio::test]
3371    async fn context_payload_returns_error_for_nonexistent_run() {
3372        let store = Arc::new(InMemoryStore::new());
3373        let provider = create_test_provider();
3374        let run_id = Uuid::now_v7();
3375
3376        let ctx = WorkflowContext::new(run_id, "test".to_string(), store, provider);
3377        let result = ctx.payload().await;
3378
3379        assert!(result.is_err());
3380    }
3381
3382    #[tokio::test]
3383    async fn context_store_returns_reference() {
3384        let ctx = create_test_context();
3385        let _store = ctx.store();
3386        // store() returns a reference to the Arc<dyn Store>, which is always available
3387    }
3388
3389    #[test]
3390    fn context_debug_formatting() {
3391        let ctx = create_test_context();
3392        let debug_str = format!("{:?}", ctx);
3393        assert!(debug_str.contains("WorkflowContext"));
3394        assert!(debug_str.contains("run_id"));
3395    }
3396
3397    #[tokio::test]
3398    async fn context_last_step_ids_tracks_executed_steps() {
3399        let store = Arc::new(InMemoryStore::new());
3400        let provider = create_test_provider();
3401
3402        // Create the run first
3403        store
3404            .create_run(NewRun {
3405                created_by: None,
3406                workflow_name: "test".to_string(),
3407                trigger: TriggerKind::Manual,
3408                payload: json!({}),
3409                max_retries: 0,
3410                handler_version: None,
3411                labels: Default::default(),
3412                scheduled_at: None,
3413                idempotency_key: None,
3414                max_cost_usd: None,
3415            })
3416            .await
3417            .expect("failed to create run")
3418            .into_run();
3419
3420        // Get the created run to extract its ID
3421        let runs = store
3422            .list_runs(RunFilter::default(), 1, 10)
3423            .await
3424            .expect("failed to list runs");
3425        let created_run_id = runs.items[0].id;
3426
3427        let mut ctx = WorkflowContext::new(created_run_id, "test".to_string(), store, provider);
3428        assert!(ctx.last_step_ids.is_empty());
3429
3430        ctx.skip("step1", "reason").await.expect("skip failed");
3431
3432        assert_eq!(ctx.last_step_ids.len(), 1);
3433
3434        ctx.skip("step2", "reason").await.expect("skip failed");
3435
3436        // last_step_ids should now contain only step2's ID
3437        assert_eq!(ctx.last_step_ids.len(), 1);
3438    }
3439}