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::{error, info, warn};
37use uuid::Uuid;
38
39use ironflow_core::error::{AgentError, OperationError};
40use ironflow_core::provider::AgentProvider;
41use ironflow_store::models::{
42    ArtifactLookup, NewRun, NewStep, NewStepDependency, RunStatus, RunUpdate, Step, StepKind,
43    StepStatus, StepUpdate, TriggerKind,
44};
45use ironflow_store::store::Store;
46
47use ironflow_artifacts::name::guess_content_type;
48use ironflow_artifacts::stream_from_bytes;
49use ironflow_store::entities::Artifact;
50
51use crate::artifact::{
52    ArtifactSink, ArtifactUpload, StepLocation, collect_outputs, materialize_inputs,
53};
54use crate::budget::step_budget_usd;
55use crate::config::{
56    AgentStepConfig, ApprovalConfig, HttpConfig, ShellConfig, StepConfig, WorkflowStepConfig,
57};
58use crate::error::EngineError;
59use crate::executor::{ParallelStepResult, StepOutput, execute_step_config};
60use crate::handler::WorkflowHandler;
61use crate::log_sender::{LogSender, StepLogSender};
62use crate::operation::Operation;
63
64/// Callback type for resolving workflow handlers by name.
65pub(crate) type HandlerResolver =
66    Arc<dyn Fn(&str) -> Option<Arc<dyn WorkflowHandler>> + Send + Sync>;
67
68/// Execution context for a single workflow run.
69///
70/// Tracks the current step position and provides convenience methods
71/// for executing operations with automatic persistence.
72///
73/// # Examples
74///
75/// ```no_run
76/// use ironflow_engine::context::WorkflowContext;
77/// use ironflow_engine::config::ShellConfig;
78/// use ironflow_engine::error::EngineError;
79///
80/// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
81/// let result = ctx.shell("greet", ShellConfig::new("echo hello")).await?;
82/// assert!(result.output["stdout"].as_str().unwrap().contains("hello"));
83/// # Ok(())
84/// # }
85/// ```
86pub struct WorkflowContext {
87    run_id: Uuid,
88    store: Arc<dyn Store>,
89    provider: Arc<dyn AgentProvider>,
90    handler_resolver: Option<HandlerResolver>,
91    position: u32,
92    /// IDs of the last executed step(s) -- used to record DAG dependencies.
93    last_step_ids: Vec<Uuid>,
94    /// Accumulated cost across all steps in this run.
95    total_cost_usd: Decimal,
96    /// Accumulated duration across all steps.
97    total_duration_ms: u64,
98    /// Cumulative cost cap for this run, resolved at creation. `None` = no cap.
99    max_cost_usd: Option<Decimal>,
100    /// Cost already spent by ancestor runs when this context belongs to a
101    /// sub-workflow. Zero for a top-level run.
102    inherited_cost_usd: Decimal,
103    /// Steps from a previous execution of the *same* attempt, keyed by position.
104    /// Used when resuming after approval to replay completed steps.
105    replay_steps: HashMap<u32, Step>,
106    /// Approvals granted in an *earlier* attempt, keyed by position, holding the
107    /// attempt that granted them. An approval is carried by the run, not by the
108    /// attempt, so a retry never asks a human to approve the same gate twice.
109    granted_approvals: HashMap<u32, u32>,
110    /// Which run attempt this context is executing (1-based).
111    attempt: u32,
112    /// Wall-clock duration already recorded on the run by previous attempts.
113    /// Added to this attempt's duration when the run is finalized.
114    carried_duration_ms: u64,
115    /// Optional sender for real-time log streaming.
116    log_sender: Option<LogSender>,
117    /// Where artifact bytes are read and written. `None` when no artifact
118    /// storage is configured: steps that declare artifacts then fail explicitly
119    /// instead of silently dropping their files.
120    artifact_sink: Option<Arc<dyn ArtifactSink>>,
121    /// Error handlers registered via [`on_error`](Self::on_error).
122    error_handlers: Vec<OnErrorHandler>,
123}
124
125/// A registered error handler that fires when a subsequent step fails.
126struct OnErrorHandler {
127    name: String,
128    config: StepConfig,
129}
130
131impl WorkflowContext {
132    /// Create a new context for a run.
133    ///
134    /// Not typically called directly — the [`Engine`](crate::engine::Engine)
135    /// creates this when executing a [`WorkflowHandler`].
136    pub fn new(run_id: Uuid, store: Arc<dyn Store>, provider: Arc<dyn AgentProvider>) -> Self {
137        Self {
138            run_id,
139            store,
140            provider,
141            handler_resolver: None,
142            position: 0,
143            last_step_ids: Vec::new(),
144            total_cost_usd: Decimal::ZERO,
145            total_duration_ms: 0,
146            max_cost_usd: None,
147            inherited_cost_usd: Decimal::ZERO,
148            replay_steps: HashMap::new(),
149            granted_approvals: HashMap::new(),
150            attempt: 1,
151            carried_duration_ms: 0,
152            log_sender: None,
153            artifact_sink: None,
154            error_handlers: Vec::new(),
155        }
156    }
157
158    /// Create a new context with a handler resolver for sub-workflow support.
159    ///
160    /// The resolver is called when [`workflow`](Self::workflow) is invoked to
161    /// look up registered handlers by name.
162    pub(crate) fn with_handler_resolver(
163        run_id: Uuid,
164        store: Arc<dyn Store>,
165        provider: Arc<dyn AgentProvider>,
166        resolver: HandlerResolver,
167    ) -> Self {
168        Self {
169            run_id,
170            store,
171            provider,
172            handler_resolver: Some(resolver),
173            position: 0,
174            last_step_ids: Vec::new(),
175            total_cost_usd: Decimal::ZERO,
176            total_duration_ms: 0,
177            max_cost_usd: None,
178            inherited_cost_usd: Decimal::ZERO,
179            replay_steps: HashMap::new(),
180            granted_approvals: HashMap::new(),
181            attempt: 1,
182            carried_duration_ms: 0,
183            log_sender: None,
184            artifact_sink: None,
185            error_handlers: Vec::new(),
186        }
187    }
188
189    /// Attach a log sender for real-time step output streaming.
190    pub fn set_log_sender(&mut self, sender: LogSender) {
191        self.log_sender = Some(sender);
192    }
193
194    /// Attach the backend that stores and serves artifact bytes.
195    ///
196    /// Without one, any step that declares an output or calls
197    /// [`put_artifact`](Self::put_artifact) fails with
198    /// [`EngineError::ArtifactsUnavailable`]. Every other step is unaffected,
199    /// so an existing deployment keeps working until artifacts are configured.
200    ///
201    /// # Examples
202    ///
203    /// ```no_run
204    /// use std::sync::Arc;
205    ///
206    /// use ironflow_engine::artifact::ArtifactSink;
207    /// use ironflow_engine::context::WorkflowContext;
208    ///
209    /// # fn example(ctx: &mut WorkflowContext, sink: Arc<dyn ArtifactSink>) {
210    /// ctx.set_artifact_sink(sink);
211    /// # }
212    /// ```
213    pub fn set_artifact_sink(&mut self, sink: Arc<dyn ArtifactSink>) {
214        self.artifact_sink = Some(sink);
215    }
216
217    /// The artifact backend, or an explicit error when none is configured.
218    fn artifact_sink(&self) -> Result<&Arc<dyn ArtifactSink>, EngineError> {
219        self.artifact_sink.as_ref().ok_or_else(|| {
220            EngineError::ArtifactsUnavailable(
221                "no artifact storage is attached to this run".to_string(),
222            )
223        })
224    }
225
226    /// Store an in-memory payload as an artifact of the given step.
227    ///
228    /// The declarative [`ShellConfig::output`](crate::config::ShellConfig::output)
229    /// covers shell steps; this covers custom operations and agent steps, which
230    /// have no working directory to collect from.
231    ///
232    /// The MIME type is guessed from `name` unless `content_type` is set.
233    ///
234    /// # Errors
235    ///
236    /// Returns [`EngineError::ArtifactsUnavailable`] when no backend is
237    /// attached, [`EngineError::Artifact`] when the name is invalid or storage
238    /// fails, and [`EngineError::Store`] when the step already owns that name.
239    ///
240    /// # Examples
241    ///
242    /// ```no_run
243    /// use ironflow_engine::context::WorkflowContext;
244    /// use ironflow_engine::error::EngineError;
245    /// use uuid::Uuid;
246    ///
247    /// # async fn example(ctx: &WorkflowContext, step_id: Uuid) -> Result<(), EngineError> {
248    /// let artifact = ctx
249    ///     .put_artifact(step_id, "summary.json", None, br#"{"ok":true}"#.to_vec())
250    ///     .await?;
251    /// assert_eq!(artifact.content_type, "application/json");
252    /// # Ok(())
253    /// # }
254    /// ```
255    pub async fn put_artifact(
256        &self,
257        step_id: Uuid,
258        name: &str,
259        content_type: Option<&str>,
260        content: Vec<u8>,
261    ) -> Result<Artifact, EngineError> {
262        let sink = self.artifact_sink()?;
263        sink.put(
264            ArtifactUpload {
265                run_id: self.run_id,
266                step_id,
267                name: name.to_string(),
268                content_type: content_type
269                    .map(str::to_string)
270                    .unwrap_or_else(|| guess_content_type(name)),
271            },
272            stream_from_bytes(content),
273        )
274        .await
275    }
276
277    /// Read back an artifact produced earlier in this run.
278    ///
279    /// Resolution follows the same rule as a declared input: same run and
280    /// attempt, steps positioned strictly before the current one, closest
281    /// producer wins.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`EngineError::ArtifactNotFound`] when nothing matches,
286    /// [`EngineError::ArtifactsUnavailable`] when no backend is attached, and
287    /// [`EngineError::Artifact`] when the bytes cannot be read.
288    ///
289    /// # Examples
290    ///
291    /// ```no_run
292    /// use ironflow_engine::context::WorkflowContext;
293    /// use ironflow_engine::error::EngineError;
294    ///
295    /// # async fn example(ctx: &WorkflowContext) -> Result<(), EngineError> {
296    /// let bytes = ctx.get_artifact("build", "report.html").await?;
297    /// println!("{} bytes", bytes.len());
298    /// # Ok(())
299    /// # }
300    /// ```
301    pub async fn get_artifact(&self, step: &str, name: &str) -> Result<Vec<u8>, EngineError> {
302        let sink = self.artifact_sink()?;
303
304        let artifact = self
305            .store
306            .find_artifact_for_input(ArtifactLookup {
307                run_id: self.run_id,
308                attempt: self.attempt,
309                before_position: self.position,
310                step_name: step.to_string(),
311                name: name.to_string(),
312            })
313            .await?
314            .ok_or_else(|| EngineError::ArtifactNotFound {
315                step: step.to_string(),
316                name: name.to_string(),
317            })?;
318
319        let mut content = sink.get(&artifact).await?;
320        let mut buffer = Vec::with_capacity(artifact.size_bytes as usize);
321        while let Some(chunk) = content.next().await {
322            let chunk = chunk?;
323            buffer.extend_from_slice(chunk.as_ref());
324        }
325
326        Ok(buffer)
327    }
328
329    /// Place a shell step's declared inputs in its working directory.
330    ///
331    /// A step that declares none needs no backend, so the check for one only
332    /// happens when there is something to materialize.
333    async fn prepare_step_inputs(
334        &self,
335        config: &StepConfig,
336        position: u32,
337    ) -> Result<(), EngineError> {
338        let StepConfig::Shell(shell) = config else {
339            return Ok(());
340        };
341        if shell.inputs.is_empty() {
342            return Ok(());
343        }
344
345        materialize_inputs(
346            self.artifact_sink()?,
347            &self.store,
348            shell,
349            StepLocation {
350                run_id: self.run_id,
351                attempt: self.attempt,
352                position,
353            },
354        )
355        .await
356    }
357
358    /// Store a shell step's declared outputs.
359    ///
360    /// On a failed step this is best-effort: the collection error is logged and
361    /// swallowed so it never masks the failure that actually stopped the step.
362    async fn store_step_outputs(
363        &self,
364        config: &StepConfig,
365        step_id: Uuid,
366        step_name: &str,
367        step_succeeded: bool,
368    ) -> Result<(), EngineError> {
369        let StepConfig::Shell(shell) = config else {
370            return Ok(());
371        };
372        if shell.outputs.is_empty() {
373            return Ok(());
374        }
375
376        let sink = match self.artifact_sink() {
377            Ok(sink) => sink,
378            Err(err) if step_succeeded => return Err(err),
379            Err(err) => {
380                warn!(
381                    run_id = %self.run_id,
382                    step = %step_name,
383                    error = %err,
384                    "cannot collect outputs of a failed step"
385                );
386                return Ok(());
387            }
388        };
389
390        let collected =
391            collect_outputs(sink, shell, self.run_id, step_id, step_name, step_succeeded).await;
392
393        match collected {
394            Ok(()) => Ok(()),
395            Err(err) if step_succeeded => Err(err),
396            Err(err) => {
397                warn!(
398                    run_id = %self.run_id,
399                    step = %step_name,
400                    error = %err,
401                    "failed to collect outputs of a failed step"
402                );
403                Ok(())
404            }
405        }
406    }
407
408    /// Seed the context with the run's attempt number and the totals already
409    /// accumulated by previous attempts.
410    ///
411    /// Called by the engine before executing a handler. Steps created by this
412    /// context belong to `attempt`, and the cost and duration it reports at the
413    /// end cover the whole run, not just this attempt.
414    pub(crate) fn carry_over_run_totals(
415        &mut self,
416        attempt: u32,
417        cost_usd: Decimal,
418        duration_ms: u64,
419    ) {
420        self.attempt = attempt;
421        self.total_cost_usd = cost_usd;
422        self.carried_duration_ms = duration_ms;
423    }
424
425    /// Wall-clock duration already recorded on the run by previous attempts.
426    pub(crate) fn carried_duration_ms(&self) -> u64 {
427        self.carried_duration_ms
428    }
429
430    /// The run attempt this context is executing (1-based).
431    pub fn attempt(&self) -> u32 {
432        self.attempt
433    }
434
435    /// Set the cumulative cost cap enforced before every agent step.
436    ///
437    /// Called by the [`Engine`](crate::engine::Engine) with the run's persisted
438    /// `max_cost_usd`. `None` disables the check.
439    ///
440    /// # Examples
441    ///
442    /// ```no_run
443    /// use ironflow_engine::context::WorkflowContext;
444    /// use rust_decimal::Decimal;
445    ///
446    /// # fn example(ctx: &mut WorkflowContext) {
447    /// ctx.set_max_cost_usd(Some(Decimal::new(200, 2))); // $2.00
448    /// # }
449    /// ```
450    pub fn set_max_cost_usd(&mut self, cap: Option<Decimal>) {
451        self.max_cost_usd = cap;
452    }
453
454    /// The cumulative cost cap of this run, if any.
455    pub fn max_cost_usd(&self) -> Option<Decimal> {
456        self.max_cost_usd
457    }
458
459    /// Total cost charged against the cap: this run plus every ancestor run.
460    ///
461    /// For a top-level run this equals [`total_cost_usd`](Self::total_cost_usd).
462    /// For a sub-workflow it also includes what the parent chain already spent.
463    pub fn charged_cost_usd(&self) -> Decimal {
464        self.inherited_cost_usd + self.total_cost_usd
465    }
466
467    /// Reject the upcoming agent work when it would cross the run's cost cap.
468    ///
469    /// `step_budget` is the declared budget of the step (or the sum of budgets
470    /// for a parallel wave). Called *before* any step record is created so a
471    /// refused run never launches the work it could not afford.
472    ///
473    /// # Errors
474    ///
475    /// Returns [`EngineError::RunBudgetExceeded`] when
476    /// `charged_cost + step_budget` exceeds the cap.
477    fn check_run_budget(&self, step_budget: Decimal) -> Result<(), EngineError> {
478        let Some(limit) = self.max_cost_usd else {
479            return Ok(());
480        };
481
482        let spent = self.charged_cost_usd();
483        if spent + step_budget <= limit {
484            return Ok(());
485        }
486
487        error!(
488            run_id = %self.run_id,
489            limit_usd = %limit,
490            spent_usd = %spent,
491            step_budget_usd = %step_budget,
492            "run cost cap reached, refusing agent step"
493        );
494
495        Err(EngineError::RunBudgetExceeded {
496            run_id: self.run_id,
497            limit_usd: limit,
498            spent_usd: spent,
499            step_budget_usd: step_budget,
500        })
501    }
502
503    /// Load existing steps from the store for replay after approval.
504    ///
505    /// Called by the engine when resuming a run. All completed steps
506    /// and the approved approval step are indexed by position so that
507    /// `execute_step` and `approval` can skip them.
508    ///
509    /// Only steps of the current attempt are replayed: positions repeat across
510    /// attempts, so replaying an earlier attempt's steps would skip the whole
511    /// workflow. The one exception is an approval already granted in an earlier
512    /// attempt -- approval is carried by the run, not by the attempt, so a human
513    /// is never asked to approve the same gate twice.
514    pub(crate) async fn load_replay_steps(&mut self) -> Result<(), EngineError> {
515        let steps = self.store.list_steps(self.run_id).await?;
516        for step in steps {
517            let dominated = matches!(
518                step.status.state,
519                StepStatus::Completed | StepStatus::Running | StepStatus::AwaitingApproval
520            );
521            if !dominated {
522                continue;
523            }
524
525            if step.attempt == self.attempt {
526                self.replay_steps.insert(step.position, step);
527            } else if step.kind == StepKind::Approval && step.status.state == StepStatus::Completed
528            {
529                self.granted_approvals.insert(step.position, step.attempt);
530            }
531        }
532        Ok(())
533    }
534
535    /// The run ID this context is executing for.
536    pub fn run_id(&self) -> Uuid {
537        self.run_id
538    }
539
540    /// Accumulated cost across all executed steps so far.
541    pub fn total_cost_usd(&self) -> Decimal {
542        self.total_cost_usd
543    }
544
545    /// Accumulated duration across all executed steps so far.
546    pub fn total_duration_ms(&self) -> u64 {
547        self.total_duration_ms
548    }
549
550    /// Execute multiple steps concurrently (wait-all model).
551    ///
552    /// All steps in the batch execute in parallel via `tokio::JoinSet`.
553    /// Each step is recorded with the same `position` (execution wave).
554    /// Dependencies on previous steps are recorded automatically.
555    ///
556    /// When `fail_fast` is true, remaining steps are aborted on the first
557    /// failure. When false, all steps run to completion and the first
558    /// error is returned.
559    ///
560    /// # Errors
561    ///
562    /// Returns [`EngineError`] if any step fails.
563    ///
564    /// # Examples
565    ///
566    /// ```no_run
567    /// use ironflow_engine::context::WorkflowContext;
568    /// use ironflow_engine::config::{StepConfig, ShellConfig};
569    /// use ironflow_engine::error::EngineError;
570    ///
571    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
572    /// let results = ctx.parallel(
573    ///     vec![
574    ///         ("test-unit", StepConfig::Shell(ShellConfig::new("cargo test --lib"))),
575    ///         ("lint", StepConfig::Shell(ShellConfig::new("cargo clippy"))),
576    ///     ],
577    ///     true,
578    /// ).await?;
579    ///
580    /// for r in &results {
581    ///     println!("{}: {:?}", r.name, r.output.output);
582    /// }
583    /// # Ok(())
584    /// # }
585    /// ```
586    pub async fn parallel(
587        &mut self,
588        steps: Vec<(&str, StepConfig)>,
589        fail_fast: bool,
590    ) -> Result<Vec<ParallelStepResult>, EngineError> {
591        if steps.is_empty() {
592            return Ok(Vec::new());
593        }
594
595        // Cost cap: the whole wave is charged at once. Refused before any step
596        // record is created, so nothing in the wave starts.
597        let wave_budget: Decimal = steps
598            .iter()
599            .filter_map(|(_, config)| match config {
600                StepConfig::Agent(agent_config) => Some(agent_config.max_budget_usd),
601                _ => None,
602            })
603            .map(step_budget_usd)
604            .sum();
605        self.check_run_budget(wave_budget)?;
606
607        let wave_position = self.position;
608        self.position += 1;
609
610        let now = Utc::now();
611        let mut step_records: Vec<(Uuid, String, StepConfig)> = Vec::with_capacity(steps.len());
612
613        for (name, config) in &steps {
614            let kind = config.kind();
615            let step = self
616                .store
617                .create_step(NewStep {
618                    run_id: self.run_id,
619                    name: name.to_string(),
620                    kind,
621                    position: wave_position,
622                    input: Some(serde_json::to_value(config)?),
623                    is_error_handler: false,
624                })
625                .await?;
626
627            self.start_step(step.id, now).await?;
628
629            // Inputs are materialized before any step in the wave starts, so a
630            // missing one fails the wave rather than a half-run command.
631            if let Err(err) = self.prepare_step_inputs(config, wave_position).await {
632                self.fail_step(step.id, &err).await;
633                return Err(err);
634            }
635
636            step_records.push((step.id, name.to_string(), config.clone()));
637        }
638
639        let mut join_set = JoinSet::new();
640        let mut task_index: HashMap<Id, usize> = HashMap::new();
641        for (idx, (step_id, step_name, config)) in step_records.iter().enumerate() {
642            let provider = self.provider.clone();
643            let config = config.clone();
644            let step_log_sender = self
645                .log_sender
646                .as_ref()
647                .map(|s| StepLogSender::new(s.clone(), self.run_id, *step_id, step_name.clone()));
648            let handle = join_set.spawn(async move {
649                (
650                    idx,
651                    execute_step_config(&config, &provider, step_log_sender).await,
652                )
653            });
654            task_index.insert(handle.id(), idx);
655        }
656
657        // JoinSet returns in completion order; indexed_results restores input order.
658        let mut indexed_results: Vec<Option<Result<StepOutput, String>>> =
659            vec![None; step_records.len()];
660        let mut first_error: Option<EngineError> = None;
661
662        while let Some(join_result) = join_set.join_next().await {
663            let (idx, step_result) = match join_result {
664                Ok(r) => r,
665                Err(e) => {
666                    let error_msg = format!("join error: {e}");
667                    if let Some(&idx) = task_index.get(&e.id()) {
668                        let (step_id, step_name, _) = &step_records[idx];
669                        let completed_at = Utc::now();
670                        error!(
671                            run_id = %self.run_id,
672                            step = %step_name,
673                            error = %error_msg,
674                            "parallel step panicked or was cancelled"
675                        );
676                        if let Err(store_err) = self
677                            .store
678                            .update_step(
679                                *step_id,
680                                StepUpdate {
681                                    status: Some(StepStatus::Failed),
682                                    error: Some(error_msg.clone()),
683                                    completed_at: Some(completed_at),
684                                    ..StepUpdate::default()
685                                },
686                            )
687                            .await
688                        {
689                            error!(
690                                run_id = %self.run_id,
691                                step_id = %step_id,
692                                error = %store_err,
693                                "failed to persist JoinError for step"
694                            );
695                        }
696                        indexed_results[idx] = Some(Err(error_msg.clone()));
697                    }
698                    if first_error.is_none() {
699                        first_error = Some(EngineError::StepConfig(error_msg));
700                    }
701                    if fail_fast {
702                        join_set.abort_all();
703                    }
704                    continue;
705                }
706            };
707
708            let (step_id, step_name, step_config) = &step_records[idx];
709            let completed_at = Utc::now();
710
711            if let Err(err) = self
712                .store_step_outputs(step_config, *step_id, step_name, step_result.is_ok())
713                .await
714            {
715                self.fail_step(*step_id, &err).await;
716                indexed_results[idx] = Some(Err(err.to_string()));
717                if first_error.is_none() {
718                    first_error = Some(err);
719                }
720                if fail_fast {
721                    join_set.abort_all();
722                }
723                continue;
724            }
725
726            match step_result {
727                Ok(output) => {
728                    self.total_cost_usd += output.cost_usd;
729                    self.total_duration_ms += output.duration_ms;
730
731                    let debug_messages_json = output.debug_messages_json();
732
733                    self.store
734                        .update_step(
735                            *step_id,
736                            StepUpdate {
737                                status: Some(StepStatus::Completed),
738                                output: Some(output.output.clone()),
739                                duration_ms: Some(output.duration_ms),
740                                cost_usd: Some(output.cost_usd),
741                                input_tokens: output.input_tokens,
742                                output_tokens: output.output_tokens,
743                                completed_at: Some(completed_at),
744                                debug_messages: debug_messages_json,
745                                ..StepUpdate::default()
746                            },
747                        )
748                        .await?;
749
750                    info!(
751                        run_id = %self.run_id,
752                        step = %step_name,
753                        duration_ms = output.duration_ms,
754                        "parallel step completed"
755                    );
756
757                    indexed_results[idx] = Some(Ok(output));
758                }
759                Err(err) => {
760                    let err_msg = err.to_string();
761                    let debug_messages_json = extract_debug_messages_from_error(&err);
762                    let partial = extract_partial_usage_from_error(&err);
763                    let raw_response_output = extract_raw_response_from_error(&err);
764
765                    if let Some(ref usage) = partial {
766                        if let Some(cost) = usage.cost_usd {
767                            self.total_cost_usd += cost;
768                        }
769                        if let Some(dur) = usage.duration_ms {
770                            self.total_duration_ms += dur;
771                        }
772                    }
773
774                    if let Err(store_err) = self
775                        .store
776                        .update_step(
777                            *step_id,
778                            StepUpdate {
779                                status: Some(StepStatus::Failed),
780                                error: Some(err_msg.clone()),
781                                output: raw_response_output,
782                                completed_at: Some(completed_at),
783                                debug_messages: debug_messages_json,
784                                duration_ms: partial.as_ref().and_then(|p| p.duration_ms),
785                                cost_usd: partial.as_ref().and_then(|p| p.cost_usd),
786                                input_tokens: partial.as_ref().and_then(|p| p.input_tokens),
787                                output_tokens: partial.as_ref().and_then(|p| p.output_tokens),
788                                ..StepUpdate::default()
789                            },
790                        )
791                        .await
792                    {
793                        tracing::error!(
794                            step_id = %step_id,
795                            error = %store_err,
796                            "failed to persist parallel step failure"
797                        );
798                    }
799
800                    indexed_results[idx] = Some(Err(err_msg.clone()));
801
802                    if first_error.is_none() {
803                        first_error = Some(err);
804                    }
805
806                    if fail_fast {
807                        join_set.abort_all();
808                    }
809                }
810            }
811        }
812
813        if let Some(err) = first_error {
814            return Err(err);
815        }
816
817        self.last_step_ids = step_records.iter().map(|(id, _, _)| *id).collect();
818
819        // Build results in original order.
820        let results: Vec<ParallelStepResult> = step_records
821            .iter()
822            .enumerate()
823            .map(|(idx, (step_id, name, _))| {
824                let output = match indexed_results[idx].take() {
825                    Some(Ok(o)) => o,
826                    _ => unreachable!("all steps succeeded if no error returned"),
827                };
828                ParallelStepResult {
829                    name: name.clone(),
830                    output,
831                    step_id: *step_id,
832                }
833            })
834            .collect();
835
836        Ok(results)
837    }
838
839    /// Execute a shell step.
840    ///
841    /// Creates the step record, runs the command, persists the result,
842    /// and returns the output for use in subsequent steps.
843    ///
844    /// # Errors
845    ///
846    /// Returns [`EngineError`] if the command fails or the store errors.
847    ///
848    /// # Examples
849    ///
850    /// ```no_run
851    /// use ironflow_engine::context::WorkflowContext;
852    /// use ironflow_engine::config::ShellConfig;
853    /// use ironflow_engine::error::EngineError;
854    ///
855    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
856    /// let files = ctx.shell("list", ShellConfig::new("ls -la")).await?;
857    /// println!("stdout: {}", files.output["stdout"]);
858    /// # Ok(())
859    /// # }
860    /// ```
861    pub async fn shell(
862        &mut self,
863        name: &str,
864        config: ShellConfig,
865    ) -> Result<StepOutput, EngineError> {
866        self.execute_step(name, StepKind::Shell, StepConfig::Shell(config))
867            .await
868    }
869
870    /// Execute an HTTP step.
871    ///
872    /// # Errors
873    ///
874    /// Returns [`EngineError`] if the request fails or the store errors.
875    ///
876    /// # Examples
877    ///
878    /// ```no_run
879    /// use ironflow_engine::context::WorkflowContext;
880    /// use ironflow_engine::config::HttpConfig;
881    /// use ironflow_engine::error::EngineError;
882    ///
883    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
884    /// let resp = ctx.http("health", HttpConfig::get("https://api.example.com/health")).await?;
885    /// println!("status: {}", resp.output["status"]);
886    /// # Ok(())
887    /// # }
888    /// ```
889    pub async fn http(
890        &mut self,
891        name: &str,
892        config: HttpConfig,
893    ) -> Result<StepOutput, EngineError> {
894        self.execute_step(name, StepKind::Http, StepConfig::Http(config))
895            .await
896    }
897
898    /// Execute an agent step.
899    ///
900    /// # Errors
901    ///
902    /// Returns [`EngineError`] if the agent invocation fails or the store errors.
903    ///
904    /// # Examples
905    ///
906    /// ```no_run
907    /// use ironflow_engine::context::WorkflowContext;
908    /// use ironflow_engine::config::AgentStepConfig;
909    /// use ironflow_engine::error::EngineError;
910    ///
911    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
912    /// let review = ctx.agent("review", AgentStepConfig::new("Review the code")).await?;
913    /// println!("review: {}", review.output);
914    /// # Ok(())
915    /// # }
916    /// ```
917    pub async fn agent(
918        &mut self,
919        name: &str,
920        config: impl Into<AgentStepConfig>,
921    ) -> Result<StepOutput, EngineError> {
922        self.execute_step(name, StepKind::Agent, StepConfig::Agent(config.into()))
923            .await
924    }
925
926    /// Create a human approval gate.
927    ///
928    /// On first execution, records an approval step and returns
929    /// [`EngineError::ApprovalRequired`] to suspend the run. The engine
930    /// transitions the run to `AwaitingApproval`.
931    ///
932    /// On resume (after a human approved via the API), the approval step
933    /// is replayed: it is marked as `Completed` and execution continues
934    /// past it. Multiple approval gates in the same handler work -- each
935    /// one pauses and resumes independently.
936    ///
937    /// # Errors
938    ///
939    /// Returns [`EngineError::ApprovalRequired`] to pause the run on
940    /// first execution. Returns other [`EngineError`] variants on store
941    /// failures.
942    ///
943    /// # Examples
944    ///
945    /// ```no_run
946    /// use ironflow_engine::context::WorkflowContext;
947    /// use ironflow_engine::config::ApprovalConfig;
948    /// use ironflow_engine::error::EngineError;
949    ///
950    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
951    /// ctx.approval("deploy-gate", ApprovalConfig::new("Approve deployment?")).await?;
952    /// // Execution continues here after approval
953    /// # Ok(())
954    /// # }
955    /// ```
956    pub async fn approval(
957        &mut self,
958        name: &str,
959        config: ApprovalConfig,
960    ) -> Result<(), EngineError> {
961        let position = self.position;
962        self.position += 1;
963
964        // Replay: if this approval step exists from a prior execution,
965        // the run was approved -- mark it completed (if not already) and continue.
966        if let Some(existing) = self.replay_steps.get(&position)
967            && existing.kind == StepKind::Approval
968        {
969            if existing.status.state == StepStatus::AwaitingApproval {
970                self.store
971                    .update_step(
972                        existing.id,
973                        StepUpdate {
974                            status: Some(StepStatus::Completed),
975                            completed_at: Some(Utc::now()),
976                            ..StepUpdate::default()
977                        },
978                    )
979                    .await?;
980            }
981
982            self.last_step_ids = vec![existing.id];
983            info!(
984                run_id = %self.run_id,
985                step = %name,
986                position,
987                "approval step replayed (approved)"
988            );
989            return Ok(());
990        }
991
992        // Carried over: a human already approved this gate in an earlier
993        // attempt. Record a fresh step in the current attempt so that each
994        // attempt keeps a complete, self-contained DAG, and continue.
995        if let Some(&granted_in) = self.granted_approvals.get(&position) {
996            let step = self
997                .store
998                .create_step(NewStep {
999                    run_id: self.run_id,
1000                    name: name.to_string(),
1001                    kind: StepKind::Approval,
1002                    position,
1003                    input: Some(serde_json::to_value(&config)?),
1004                    is_error_handler: false,
1005                })
1006                .await?;
1007
1008            let now = Utc::now();
1009            self.start_step(step.id, now).await?;
1010            self.store
1011                .update_step(
1012                    step.id,
1013                    StepUpdate {
1014                        status: Some(StepStatus::Completed),
1015                        output: Some(json!({"approved_in_attempt": granted_in})),
1016                        completed_at: Some(now),
1017                        ..StepUpdate::default()
1018                    },
1019                )
1020                .await?;
1021
1022            self.last_step_ids = vec![step.id];
1023            info!(
1024                run_id = %self.run_id,
1025                step = %name,
1026                position,
1027                granted_in_attempt = granted_in,
1028                attempt = self.attempt,
1029                "approval carried over from a previous attempt"
1030            );
1031            return Ok(());
1032        }
1033
1034        // First execution: create the approval step and suspend.
1035        let step = self
1036            .store
1037            .create_step(NewStep {
1038                run_id: self.run_id,
1039                name: name.to_string(),
1040                kind: StepKind::Approval,
1041                position,
1042                input: Some(serde_json::to_value(&config)?),
1043                is_error_handler: false,
1044            })
1045            .await?;
1046
1047        self.start_step(step.id, Utc::now()).await?;
1048
1049        // Transition the step to AwaitingApproval so it reflects
1050        // the suspended state on the dashboard.
1051        self.store
1052            .update_step(
1053                step.id,
1054                StepUpdate {
1055                    status: Some(StepStatus::AwaitingApproval),
1056                    ..StepUpdate::default()
1057                },
1058            )
1059            .await?;
1060
1061        self.last_step_ids = vec![step.id];
1062
1063        Err(EngineError::ApprovalRequired {
1064            run_id: self.run_id,
1065            step_id: step.id,
1066            message: config.message().to_string(),
1067        })
1068    }
1069
1070    /// Record a step as explicitly skipped.
1071    ///
1072    /// Use this inside an `if`/`else` branch when a step should not execute
1073    /// but must still appear in the DAG and timeline with its reason.
1074    ///
1075    /// The step is created directly in [`StepStatus::Skipped`] state and the
1076    /// reason is stored in the output as `{"reason": "..."}`.
1077    ///
1078    /// # Errors
1079    ///
1080    /// Returns [`EngineError`] if the store fails.
1081    ///
1082    /// # Examples
1083    ///
1084    /// ```no_run
1085    /// use ironflow_engine::context::WorkflowContext;
1086    /// use ironflow_engine::error::EngineError;
1087    ///
1088    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1089    /// let tests_passed = false;
1090    /// if tests_passed {
1091    ///     // ctx.shell("deploy", ...).await?;
1092    /// } else {
1093    ///     ctx.skip("deploy", "tests failed").await?;
1094    /// }
1095    /// # Ok(())
1096    /// # }
1097    /// ```
1098    pub async fn skip(&mut self, name: &str, reason: &str) -> Result<(), EngineError> {
1099        let position = self.position;
1100        self.position += 1;
1101
1102        let step = self
1103            .store
1104            .create_step(NewStep {
1105                run_id: self.run_id,
1106                name: name.to_string(),
1107                kind: StepKind::Custom("skip".to_string()),
1108                position,
1109                input: None,
1110                is_error_handler: false,
1111            })
1112            .await?;
1113
1114        if !self.last_step_ids.is_empty() {
1115            let deps: Vec<NewStepDependency> = self
1116                .last_step_ids
1117                .iter()
1118                .map(|&depends_on| NewStepDependency {
1119                    step_id: step.id,
1120                    depends_on,
1121                })
1122                .collect();
1123            self.store.create_step_dependencies(deps).await?;
1124        }
1125
1126        let now = Utc::now();
1127        self.store
1128            .update_step(
1129                step.id,
1130                StepUpdate {
1131                    status: Some(StepStatus::Skipped),
1132                    output: Some(serde_json::json!({"reason": reason})),
1133                    completed_at: Some(now),
1134                    ..StepUpdate::default()
1135                },
1136            )
1137            .await?;
1138
1139        self.last_step_ids = vec![step.id];
1140
1141        info!(
1142            run_id = %self.run_id,
1143            step = %name,
1144            reason,
1145            "step skipped"
1146        );
1147
1148        Ok(())
1149    }
1150
1151    /// Execute a custom operation step.
1152    ///
1153    /// Runs a user-defined [`Operation`] with full step lifecycle management:
1154    /// creates the step record, transitions to Running, executes the operation,
1155    /// persists the output and duration, and marks the step Completed or Failed.
1156    ///
1157    /// The operation's [`kind()`](Operation::kind) is stored as
1158    /// [`StepKind::Custom`].
1159    ///
1160    /// # Errors
1161    ///
1162    /// Returns [`EngineError`] if the operation fails or the store errors.
1163    ///
1164    /// # Examples
1165    ///
1166    /// ```no_run
1167    /// use ironflow_engine::context::WorkflowContext;
1168    /// use ironflow_engine::operation::Operation;
1169    /// use ironflow_engine::error::EngineError;
1170    /// use serde_json::{Value, json};
1171    /// use std::pin::Pin;
1172    /// use std::future::Future;
1173    ///
1174    /// struct MyOp;
1175    /// impl Operation for MyOp {
1176    ///     fn kind(&self) -> &str { "my-service" }
1177    ///     fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
1178    ///         Box::pin(async { Ok(json!({"ok": true})) })
1179    ///     }
1180    /// }
1181    ///
1182    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1183    /// let result = ctx.operation("call-service", &MyOp).await?;
1184    /// println!("output: {}", result.output);
1185    /// # Ok(())
1186    /// # }
1187    /// ```
1188    pub async fn operation(
1189        &mut self,
1190        name: &str,
1191        op: &dyn Operation,
1192    ) -> Result<StepOutput, EngineError> {
1193        let kind = StepKind::Custom(op.kind().to_string());
1194        let position = self.position;
1195        self.position += 1;
1196
1197        let step = self
1198            .store
1199            .create_step(NewStep {
1200                run_id: self.run_id,
1201                name: name.to_string(),
1202                kind,
1203                position,
1204                input: op.input(),
1205                is_error_handler: false,
1206            })
1207            .await?;
1208
1209        self.start_step(step.id, Utc::now()).await?;
1210
1211        let start = Instant::now();
1212
1213        match op.execute().await {
1214            Ok(output_value) => {
1215                let duration_ms = start.elapsed().as_millis() as u64;
1216                self.total_duration_ms += duration_ms;
1217
1218                let completed_at = Utc::now();
1219                self.store
1220                    .update_step(
1221                        step.id,
1222                        StepUpdate {
1223                            status: Some(StepStatus::Completed),
1224                            output: Some(output_value.clone()),
1225                            duration_ms: Some(duration_ms),
1226                            cost_usd: Some(Decimal::ZERO),
1227                            completed_at: Some(completed_at),
1228                            ..StepUpdate::default()
1229                        },
1230                    )
1231                    .await?;
1232
1233                info!(
1234                    run_id = %self.run_id,
1235                    step = %name,
1236                    kind = op.kind(),
1237                    duration_ms,
1238                    "operation step completed"
1239                );
1240
1241                self.last_step_ids = vec![step.id];
1242
1243                Ok(StepOutput {
1244                    output: output_value,
1245                    duration_ms,
1246                    cost_usd: Decimal::ZERO,
1247                    input_tokens: None,
1248                    output_tokens: None,
1249                    model: None,
1250                    debug_messages: None,
1251                })
1252            }
1253            Err(err) => {
1254                let completed_at = Utc::now();
1255                if let Err(store_err) = self
1256                    .store
1257                    .update_step(
1258                        step.id,
1259                        StepUpdate {
1260                            status: Some(StepStatus::Failed),
1261                            error: Some(err.to_string()),
1262                            completed_at: Some(completed_at),
1263                            ..StepUpdate::default()
1264                        },
1265                    )
1266                    .await
1267                {
1268                    error!(step_id = %step.id, error = %store_err, "failed to persist step failure");
1269                }
1270
1271                Err(err)
1272            }
1273        }
1274    }
1275
1276    /// Execute a sub-workflow step.
1277    ///
1278    /// Creates a child run for the named workflow handler, executes it with
1279    /// its own steps and lifecycle, and returns a [`StepOutput`] containing
1280    /// the child run ID and aggregated metrics.
1281    ///
1282    /// Requires the context to be created with
1283    /// `with_handler_resolver`.
1284    ///
1285    /// # Errors
1286    ///
1287    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered
1288    /// with the given name, or if no handler resolver is available.
1289    ///
1290    /// # Examples
1291    ///
1292    /// ```no_run
1293    /// use ironflow_engine::context::WorkflowContext;
1294    /// use ironflow_engine::error::EngineError;
1295    /// use serde_json::json;
1296    ///
1297    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1298    /// // let result = ctx.workflow(&MySubWorkflow, json!({})).await?;
1299    /// # Ok(())
1300    /// # }
1301    /// ```
1302    pub async fn workflow(
1303        &mut self,
1304        handler: &dyn WorkflowHandler,
1305        payload: Value,
1306    ) -> Result<StepOutput, EngineError> {
1307        let config = WorkflowStepConfig::new(handler.name(), payload);
1308        let position = self.position;
1309        self.position += 1;
1310
1311        let step = self
1312            .store
1313            .create_step(NewStep {
1314                run_id: self.run_id,
1315                name: config.workflow_name.clone(),
1316                kind: StepKind::Workflow,
1317                position,
1318                input: Some(serde_json::to_value(&config)?),
1319                is_error_handler: false,
1320            })
1321            .await?;
1322
1323        self.start_step(step.id, Utc::now()).await?;
1324
1325        match self.execute_child_workflow(&config).await {
1326            Ok(output) => {
1327                self.total_cost_usd += output.cost_usd;
1328                self.total_duration_ms += output.duration_ms;
1329
1330                let completed_at = Utc::now();
1331                self.store
1332                    .update_step(
1333                        step.id,
1334                        StepUpdate {
1335                            status: Some(StepStatus::Completed),
1336                            output: Some(output.output.clone()),
1337                            duration_ms: Some(output.duration_ms),
1338                            cost_usd: Some(output.cost_usd),
1339                            completed_at: Some(completed_at),
1340                            ..StepUpdate::default()
1341                        },
1342                    )
1343                    .await?;
1344
1345                info!(
1346                    run_id = %self.run_id,
1347                    child_workflow = %config.workflow_name,
1348                    duration_ms = output.duration_ms,
1349                    "workflow step completed"
1350                );
1351
1352                self.last_step_ids = vec![step.id];
1353
1354                Ok(output)
1355            }
1356            Err(err) => {
1357                let completed_at = Utc::now();
1358                if let Err(store_err) = self
1359                    .store
1360                    .update_step(
1361                        step.id,
1362                        StepUpdate {
1363                            status: Some(StepStatus::Failed),
1364                            error: Some(err.to_string()),
1365                            completed_at: Some(completed_at),
1366                            ..StepUpdate::default()
1367                        },
1368                    )
1369                    .await
1370                {
1371                    error!(step_id = %step.id, error = %store_err, "failed to persist step failure");
1372                }
1373
1374                Err(err)
1375            }
1376        }
1377    }
1378
1379    /// Execute a child workflow and return aggregated output.
1380    async fn execute_child_workflow(
1381        &self,
1382        config: &WorkflowStepConfig,
1383    ) -> Result<StepOutput, EngineError> {
1384        let resolver = self.handler_resolver.as_ref().ok_or_else(|| {
1385            EngineError::InvalidWorkflow(
1386                "sub-workflow requires a handler resolver (use Engine to execute)".to_string(),
1387            )
1388        })?;
1389
1390        let handler = resolver(&config.workflow_name).ok_or_else(|| {
1391            EngineError::InvalidWorkflow(format!("no handler registered: {}", config.workflow_name))
1392        })?;
1393
1394        // A child run inherits both the parent labels and the parent author:
1395        // whoever triggered the parent workflow is accountable for its children.
1396        let parent = self.store.get_run(self.run_id).await?;
1397        let (parent_labels, parent_author) =
1398            parent.map(|r| (r.labels, r.created_by)).unwrap_or_default();
1399
1400        let child_run = self
1401            .store
1402            .create_run(NewRun {
1403                workflow_name: config.workflow_name.clone(),
1404                trigger: TriggerKind::Workflow,
1405                payload: config.payload.clone(),
1406                max_retries: 0,
1407                handler_version: None,
1408                labels: parent_labels,
1409                scheduled_at: None,
1410                created_by: parent_author,
1411                idempotency_key: None,
1412                // The child shares the parent's cap; it does not get its own budget.
1413                max_cost_usd: self.max_cost_usd,
1414            })
1415            .await?
1416            .into_run();
1417
1418        let child_run_id = child_run.id;
1419        info!(
1420            parent_run_id = %self.run_id,
1421            child_run_id = %child_run_id,
1422            workflow = %config.workflow_name,
1423            "child run created"
1424        );
1425
1426        self.store
1427            .update_run_status(child_run_id, RunStatus::Running)
1428            .await?;
1429
1430        let run_start = Instant::now();
1431        let mut child_ctx = WorkflowContext {
1432            run_id: child_run_id,
1433            store: self.store.clone(),
1434            provider: self.provider.clone(),
1435            handler_resolver: self.handler_resolver.clone(),
1436            position: 0,
1437            last_step_ids: Vec::new(),
1438            total_cost_usd: Decimal::ZERO,
1439            total_duration_ms: 0,
1440            max_cost_usd: self.max_cost_usd,
1441            // Everything the parent chain already spent counts against the
1442            // shared cap, so the child cannot restart the budget from zero.
1443            inherited_cost_usd: self.charged_cost_usd(),
1444            replay_steps: HashMap::new(),
1445            granted_approvals: HashMap::new(),
1446            // A child run is created fresh here; it is never itself retried.
1447            attempt: 1,
1448            carried_duration_ms: 0,
1449            log_sender: self.log_sender.clone(),
1450            // A child shares the storage backend but not the parent's artifacts:
1451            // input lookups are scoped to the child's own run.
1452            artifact_sink: self.artifact_sink.clone(),
1453            error_handlers: Vec::new(),
1454        };
1455
1456        let result = handler.execute(&mut child_ctx).await;
1457        let total_duration = run_start.elapsed().as_millis() as u64;
1458        let completed_at = Utc::now();
1459
1460        match result {
1461            Ok(()) => {
1462                self.store
1463                    .update_run(
1464                        child_run_id,
1465                        RunUpdate {
1466                            status: Some(RunStatus::Completed),
1467                            cost_usd: Some(child_ctx.total_cost_usd),
1468                            duration_ms: Some(total_duration),
1469                            completed_at: Some(completed_at),
1470                            ..RunUpdate::default()
1471                        },
1472                    )
1473                    .await?;
1474
1475                Ok(StepOutput {
1476                    output: serde_json::json!({
1477                        "run_id": child_run_id,
1478                        "workflow_name": config.workflow_name,
1479                        "status": RunStatus::Completed,
1480                        "cost_usd": child_ctx.total_cost_usd,
1481                        "duration_ms": total_duration,
1482                    }),
1483                    duration_ms: total_duration,
1484                    cost_usd: child_ctx.total_cost_usd,
1485                    input_tokens: None,
1486                    output_tokens: None,
1487                    model: None,
1488                    debug_messages: None,
1489                })
1490            }
1491            Err(err) => {
1492                if let Err(store_err) = self
1493                    .store
1494                    .update_run(
1495                        child_run_id,
1496                        RunUpdate {
1497                            status: Some(RunStatus::Failed),
1498                            error: Some(err.to_string()),
1499                            cost_usd: Some(child_ctx.total_cost_usd),
1500                            duration_ms: Some(total_duration),
1501                            completed_at: Some(completed_at),
1502                            ..RunUpdate::default()
1503                        },
1504                    )
1505                    .await
1506                {
1507                    error!(
1508                        child_run_id = %child_run_id,
1509                        store_error = %store_err,
1510                        "failed to persist child run failure"
1511                    );
1512                }
1513
1514                Err(err)
1515            }
1516        }
1517    }
1518
1519    /// Try to replay a completed step from a previous execution.
1520    ///
1521    /// Returns `Some(StepOutput)` if a completed step exists at the given
1522    /// position, `None` otherwise.
1523    fn try_replay_step(&mut self, position: u32) -> Option<StepOutput> {
1524        let step = self.replay_steps.get(&position)?;
1525        if step.status.state != StepStatus::Completed {
1526            return None;
1527        }
1528        let output = StepOutput {
1529            output: step.output.clone().unwrap_or(Value::Null),
1530            duration_ms: step.duration_ms,
1531            cost_usd: step.cost_usd,
1532            input_tokens: step.input_tokens,
1533            output_tokens: step.output_tokens,
1534            model: None,
1535            debug_messages: None,
1536        };
1537        self.total_cost_usd += output.cost_usd;
1538        self.total_duration_ms += output.duration_ms;
1539        self.last_step_ids = vec![step.id];
1540        info!(
1541            run_id = %self.run_id,
1542            step = %step.name,
1543            position,
1544            "step replayed from previous execution"
1545        );
1546        Some(output)
1547    }
1548
1549    /// Internal: execute a step with full persistence lifecycle.
1550    async fn execute_step(
1551        &mut self,
1552        name: &str,
1553        kind: StepKind,
1554        config: StepConfig,
1555    ) -> Result<StepOutput, EngineError> {
1556        let position = self.position;
1557        self.position += 1;
1558
1559        // Replay: if this step already completed in a prior execution, return cached output.
1560        if let Some(output) = self.try_replay_step(position) {
1561            return Ok(output);
1562        }
1563
1564        // Cost cap: refuse before creating the step record, so a run that hits
1565        // its cap never launches the work it cannot afford.
1566        if let StepConfig::Agent(ref agent_config) = config {
1567            self.check_run_budget(step_budget_usd(agent_config.max_budget_usd))?;
1568        }
1569
1570        // Create step record in Pending.
1571        let step = self
1572            .store
1573            .create_step(NewStep {
1574                run_id: self.run_id,
1575                name: name.to_string(),
1576                kind,
1577                position,
1578                input: Some(serde_json::to_value(&config)?),
1579                is_error_handler: false,
1580            })
1581            .await?;
1582
1583        self.start_step(step.id, Utc::now()).await?;
1584
1585        // Inputs must exist before the command runs. A failure here fails the
1586        // step: the command would otherwise run against missing files.
1587        if let Err(err) = self.prepare_step_inputs(&config, position).await {
1588            self.fail_step(step.id, &err).await;
1589            return Err(err);
1590        }
1591
1592        let step_log_sender = self
1593            .log_sender
1594            .as_ref()
1595            .map(|s| StepLogSender::new(s.clone(), self.run_id, step.id, name.to_string()));
1596
1597        let execution = execute_step_config(&config, &self.provider, step_log_sender).await;
1598
1599        if let Err(err) = self
1600            .store_step_outputs(&config, step.id, name, execution.is_ok())
1601            .await
1602        {
1603            self.fail_step(step.id, &err).await;
1604            return Err(err);
1605        }
1606
1607        match execution {
1608            Ok(output) => {
1609                self.total_cost_usd += output.cost_usd;
1610                self.total_duration_ms += output.duration_ms;
1611
1612                let debug_messages_json = output.debug_messages_json();
1613
1614                let completed_at = Utc::now();
1615                self.store
1616                    .update_step(
1617                        step.id,
1618                        StepUpdate {
1619                            status: Some(StepStatus::Completed),
1620                            output: Some(output.output.clone()),
1621                            duration_ms: Some(output.duration_ms),
1622                            cost_usd: Some(output.cost_usd),
1623                            input_tokens: output.input_tokens,
1624                            output_tokens: output.output_tokens,
1625                            completed_at: Some(completed_at),
1626                            debug_messages: debug_messages_json,
1627                            ..StepUpdate::default()
1628                        },
1629                    )
1630                    .await?;
1631
1632                info!(
1633                    run_id = %self.run_id,
1634                    step = %name,
1635                    duration_ms = output.duration_ms,
1636                    "step completed"
1637                );
1638
1639                self.last_step_ids = vec![step.id];
1640
1641                Ok(output)
1642            }
1643            Err(err) => {
1644                let completed_at = Utc::now();
1645                let debug_messages_json = extract_debug_messages_from_error(&err);
1646                let partial = extract_partial_usage_from_error(&err);
1647                let raw_response_output = extract_raw_response_from_error(&err);
1648
1649                if let Some(ref usage) = partial {
1650                    if let Some(cost) = usage.cost_usd {
1651                        self.total_cost_usd += cost;
1652                    }
1653                    if let Some(dur) = usage.duration_ms {
1654                        self.total_duration_ms += dur;
1655                    }
1656                }
1657
1658                if let Err(store_err) = self
1659                    .store
1660                    .update_step(
1661                        step.id,
1662                        StepUpdate {
1663                            status: Some(StepStatus::Failed),
1664                            error: Some(err.to_string()),
1665                            output: raw_response_output,
1666                            completed_at: Some(completed_at),
1667                            debug_messages: debug_messages_json,
1668                            duration_ms: partial.as_ref().and_then(|p| p.duration_ms),
1669                            cost_usd: partial.as_ref().and_then(|p| p.cost_usd),
1670                            input_tokens: partial.as_ref().and_then(|p| p.input_tokens),
1671                            output_tokens: partial.as_ref().and_then(|p| p.output_tokens),
1672                            ..StepUpdate::default()
1673                        },
1674                    )
1675                    .await
1676                {
1677                    tracing::error!(step_id = %step.id, error = %store_err, "failed to persist step failure");
1678                }
1679
1680                let err_duration = partial.as_ref().and_then(|p| p.duration_ms).unwrap_or(0);
1681                self.fire_error_handlers(name, &err.to_string(), err_duration)
1682                    .await;
1683
1684                Err(err)
1685            }
1686        }
1687    }
1688
1689    /// Record dependency edges and transition a step to Running.
1690    ///
1691    /// Records edges from `step_id` to all `last_step_ids`, then
1692    /// transitions the step to `Running` with the given timestamp.
1693    async fn start_step(&self, step_id: Uuid, now: DateTime<Utc>) -> Result<(), EngineError> {
1694        if !self.last_step_ids.is_empty() {
1695            let deps: Vec<NewStepDependency> = self
1696                .last_step_ids
1697                .iter()
1698                .map(|&depends_on| NewStepDependency {
1699                    step_id,
1700                    depends_on,
1701                })
1702                .collect();
1703            self.store.create_step_dependencies(deps).await?;
1704        }
1705
1706        self.store
1707            .update_step(
1708                step_id,
1709                StepUpdate {
1710                    status: Some(StepStatus::Running),
1711                    started_at: Some(now),
1712                    ..StepUpdate::default()
1713                },
1714            )
1715            .await?;
1716
1717        Ok(())
1718    }
1719
1720    /// Mark a step as failed, best-effort.
1721    ///
1722    /// Used on paths that fail around the operation itself (artifact inputs and
1723    /// outputs), where the step record is already `Running` and the caller is
1724    /// about to propagate `err`. A store failure here is logged, never returned:
1725    /// it must not replace the error the caller is reporting.
1726    async fn fail_step(&self, step_id: Uuid, err: &EngineError) {
1727        if let Err(store_err) = self
1728            .store
1729            .update_step(
1730                step_id,
1731                StepUpdate {
1732                    status: Some(StepStatus::Failed),
1733                    error: Some(err.to_string()),
1734                    completed_at: Some(Utc::now()),
1735                    ..StepUpdate::default()
1736                },
1737            )
1738            .await
1739        {
1740            error!(
1741                step_id = %step_id,
1742                error = %store_err,
1743                "failed to persist step failure"
1744            );
1745        }
1746    }
1747
1748    /// Access the store directly (advanced usage).
1749    pub fn store(&self) -> &Arc<dyn Store> {
1750        &self.store
1751    }
1752
1753    /// Access the payload that triggered this run.
1754    ///
1755    /// Fetches the run from the store and returns its payload.
1756    ///
1757    /// # Errors
1758    ///
1759    /// Returns [`EngineError::Store`] if the run is not found.
1760    pub async fn payload(&self) -> Result<Value, EngineError> {
1761        let run = self
1762            .store
1763            .get_run(self.run_id)
1764            .await?
1765            .ok_or(EngineError::Store(
1766                ironflow_store::error::StoreError::RunNotFound(self.run_id),
1767            ))?;
1768        Ok(run.payload)
1769    }
1770
1771    /// Register an error handler that fires when any subsequent step fails.
1772    ///
1773    /// The handler is consumed after firing (fire-once). Multiple handlers
1774    /// can be registered; they fire in registration order.
1775    ///
1776    /// Error handler execution is best-effort: if a handler fails, the error
1777    /// is logged but the original step error is preserved. Error handler steps
1778    /// appear in the run timeline with [`Step::is_error_handler`] set to `true`.
1779    ///
1780    /// # Examples
1781    ///
1782    /// ```no_run
1783    /// use ironflow_engine::context::WorkflowContext;
1784    /// use ironflow_engine::config::ShellConfig;
1785    /// use ironflow_engine::error::EngineError;
1786    ///
1787    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1788    /// ctx.on_error("cleanup", ShellConfig::new("rm -rf /tmp/build"));
1789    /// ctx.shell("build", ShellConfig::new("cargo build")).await?;
1790    /// # Ok(())
1791    /// # }
1792    /// ```
1793    pub fn on_error(&mut self, name: &str, config: impl Into<StepConfig>) {
1794        self.error_handlers.push(OnErrorHandler {
1795            name: name.to_string(),
1796            config: config.into(),
1797        });
1798    }
1799
1800    /// Remove all registered error handlers.
1801    ///
1802    /// # Examples
1803    ///
1804    /// ```no_run
1805    /// use ironflow_engine::context::WorkflowContext;
1806    /// use ironflow_engine::config::ShellConfig;
1807    /// use ironflow_engine::error::EngineError;
1808    ///
1809    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
1810    /// ctx.on_error("cleanup", ShellConfig::new("rm -rf /tmp/build"));
1811    /// ctx.shell("build", ShellConfig::new("cargo build")).await?;
1812    /// ctx.clear_error_handlers();
1813    /// // cleanup will NOT fire if deploy fails
1814    /// ctx.shell("deploy", ShellConfig::new("./deploy.sh")).await?;
1815    /// # Ok(())
1816    /// # }
1817    /// ```
1818    pub fn clear_error_handlers(&mut self) {
1819        self.error_handlers.clear();
1820    }
1821
1822    /// Execute all registered error handlers after a step failure.
1823    ///
1824    /// Drains the handler list (fire-once). Each handler creates its own
1825    /// step record with `is_error_handler = true`. Handler failures are
1826    /// logged but never propagated.
1827    async fn fire_error_handlers(
1828        &mut self,
1829        failed_step_name: &str,
1830        error_msg: &str,
1831        duration_ms: u64,
1832    ) {
1833        let handlers = std::mem::take(&mut self.error_handlers);
1834        if handlers.is_empty() {
1835            return;
1836        }
1837
1838        let error_context = json!({
1839            "failed_step": failed_step_name,
1840            "error": error_msg,
1841            "duration_ms": duration_ms,
1842        });
1843
1844        for handler in handlers {
1845            let mut config = handler.config.clone();
1846            inject_error_context(&mut config, failed_step_name, error_msg, duration_ms);
1847
1848            let position = self.position;
1849            self.position += 1;
1850
1851            let step = match self
1852                .store
1853                .create_step(NewStep {
1854                    run_id: self.run_id,
1855                    name: handler.name.clone(),
1856                    kind: config.kind(),
1857                    position,
1858                    input: Some(error_context.clone()),
1859                    is_error_handler: true,
1860                })
1861                .await
1862            {
1863                Ok(step) => step,
1864                Err(err) => {
1865                    warn!(
1866                        run_id = %self.run_id,
1867                        handler = %handler.name,
1868                        error = %err,
1869                        "failed to create error handler step"
1870                    );
1871                    continue;
1872                }
1873            };
1874
1875            if let Err(err) = self.start_step(step.id, Utc::now()).await {
1876                warn!(
1877                    run_id = %self.run_id,
1878                    handler = %handler.name,
1879                    error = %err,
1880                    "failed to start error handler step"
1881                );
1882                continue;
1883            }
1884
1885            let step_log_sender = self
1886                .log_sender
1887                .as_ref()
1888                .map(|s| StepLogSender::new(s.clone(), self.run_id, step.id, handler.name.clone()));
1889
1890            let start = Instant::now();
1891            let result = execute_step_config(&config, &self.provider, step_log_sender).await;
1892            let handler_duration = start.elapsed().as_millis() as u64;
1893            let completed_at = Utc::now();
1894
1895            match result {
1896                Ok(output) => {
1897                    if let Err(store_err) = self
1898                        .store
1899                        .update_step(
1900                            step.id,
1901                            StepUpdate {
1902                                status: Some(StepStatus::Completed),
1903                                output: Some(output.output),
1904                                duration_ms: Some(handler_duration),
1905                                cost_usd: Some(output.cost_usd),
1906                                completed_at: Some(completed_at),
1907                                ..StepUpdate::default()
1908                            },
1909                        )
1910                        .await
1911                    {
1912                        warn!(
1913                            run_id = %self.run_id,
1914                            handler = %handler.name,
1915                            error = %store_err,
1916                            "failed to persist error handler completion"
1917                        );
1918                    }
1919
1920                    info!(
1921                        run_id = %self.run_id,
1922                        handler = %handler.name,
1923                        duration_ms = handler_duration,
1924                        "error handler completed"
1925                    );
1926                }
1927                Err(err) => {
1928                    if let Err(store_err) = self
1929                        .store
1930                        .update_step(
1931                            step.id,
1932                            StepUpdate {
1933                                status: Some(StepStatus::Failed),
1934                                error: Some(err.to_string()),
1935                                duration_ms: Some(handler_duration),
1936                                completed_at: Some(completed_at),
1937                                ..StepUpdate::default()
1938                            },
1939                        )
1940                        .await
1941                    {
1942                        warn!(
1943                            run_id = %self.run_id,
1944                            handler = %handler.name,
1945                            error = %store_err,
1946                            "failed to persist error handler failure"
1947                        );
1948                    }
1949
1950                    warn!(
1951                        run_id = %self.run_id,
1952                        handler = %handler.name,
1953                        error = %err,
1954                        "error handler failed (original error preserved)"
1955                    );
1956                }
1957            }
1958        }
1959    }
1960}
1961
1962/// Inject error context into a step config before executing it as an error handler.
1963fn inject_error_context(
1964    config: &mut StepConfig,
1965    failed_step: &str,
1966    error_msg: &str,
1967    duration_ms: u64,
1968) {
1969    match config {
1970        StepConfig::Shell(shell) => {
1971            shell
1972                .env
1973                .push(("IRONFLOW_ERROR_STEP".to_string(), failed_step.to_string()));
1974            shell
1975                .env
1976                .push(("IRONFLOW_ERROR_MESSAGE".to_string(), error_msg.to_string()));
1977            shell.env.push((
1978                "IRONFLOW_ERROR_DURATION_MS".to_string(),
1979                duration_ms.to_string(),
1980            ));
1981        }
1982        StepConfig::Agent(agent) => {
1983            agent.prompt = format!(
1984                "[Error Context]\nStep \"{}\" failed after {}ms:\n{}\n\n{}",
1985                failed_step, duration_ms, error_msg, agent.prompt
1986            );
1987        }
1988        StepConfig::Http(http) => {
1989            http.headers
1990                .push(("X-Ironflow-Error-Step".to_string(), failed_step.to_string()));
1991            http.headers.push((
1992                "X-Ironflow-Error-Message".to_string(),
1993                error_msg.to_string(),
1994            ));
1995        }
1996        StepConfig::Workflow(_) | StepConfig::Approval(_) => {}
1997    }
1998}
1999
2000impl fmt::Debug for WorkflowContext {
2001    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2002        f.debug_struct("WorkflowContext")
2003            .field("run_id", &self.run_id)
2004            .field("position", &self.position)
2005            .field("total_cost_usd", &self.total_cost_usd)
2006            .field("inherited_cost_usd", &self.inherited_cost_usd)
2007            .field("max_cost_usd", &self.max_cost_usd)
2008            .finish_non_exhaustive()
2009    }
2010}
2011
2012/// Extract debug messages from an engine error, if it wraps a schema validation
2013/// failure that carries a verbose conversation trace.
2014fn extract_debug_messages_from_error(err: &EngineError) -> Option<Value> {
2015    if let EngineError::Operation(OperationError::Agent(AgentError::SchemaValidation {
2016        debug_messages,
2017        ..
2018    })) = err
2019        && !debug_messages.is_empty()
2020    {
2021        return serde_json::to_value(debug_messages).ok();
2022    }
2023    None
2024}
2025
2026/// Partial usage with `Decimal` cost, converted from the `f64` in [`PartialUsage`].
2027///
2028/// Exists only because `ironflow-store` uses [`Decimal`] for monetary values
2029/// while `ironflow-core` uses `f64` (the CLI's native type). The conversion
2030/// happens here, at the engine/store boundary.
2031struct StepPartialUsage {
2032    cost_usd: Option<Decimal>,
2033    duration_ms: Option<u64>,
2034    input_tokens: Option<u64>,
2035    output_tokens: Option<u64>,
2036}
2037
2038/// Extract the raw response text from a schema validation error.
2039///
2040/// When the agent produced text but structured output extraction failed,
2041/// this returns the truncated raw text so it can be persisted as the
2042/// step output for dashboard visibility.
2043fn extract_raw_response_from_error(err: &EngineError) -> Option<Value> {
2044    if let EngineError::Operation(OperationError::Agent(AgentError::SchemaValidation {
2045        raw_response: Some(text),
2046        ..
2047    })) = err
2048    {
2049        return Some(Value::String(text.clone()));
2050    }
2051    None
2052}
2053
2054fn extract_partial_usage_from_error(err: &EngineError) -> Option<StepPartialUsage> {
2055    if let EngineError::Operation(OperationError::Agent(AgentError::SchemaValidation {
2056        partial_usage,
2057        ..
2058    })) = err
2059        && (partial_usage.cost_usd.is_some() || partial_usage.duration_ms.is_some())
2060    {
2061        return Some(StepPartialUsage {
2062            cost_usd: partial_usage
2063                .cost_usd
2064                .and_then(|c| Decimal::try_from(c).ok()),
2065            duration_ms: partial_usage.duration_ms,
2066            input_tokens: partial_usage.input_tokens,
2067            output_tokens: partial_usage.output_tokens,
2068        });
2069    }
2070    None
2071}
2072
2073#[cfg(test)]
2074mod tests {
2075    use super::*;
2076    use ironflow_core::providers::claude::ClaudeCodeProvider;
2077    use ironflow_core::providers::record_replay::RecordReplayProvider;
2078    use ironflow_store::memory::InMemoryStore;
2079    use ironflow_store::models::{Run, RunActor, RunFilter};
2080    use ironflow_store::store::RunStore;
2081    use serde_json::json;
2082    use std::sync::Arc;
2083    use std::sync::atomic::{AtomicBool, Ordering};
2084    use uuid::Uuid;
2085
2086    /// Helper to create a test provider with fixtures
2087    fn create_test_provider() -> Arc<dyn ironflow_core::provider::AgentProvider> {
2088        let inner = ClaudeCodeProvider::new();
2089        Arc::new(RecordReplayProvider::replay(
2090            inner,
2091            "/tmp/ironflow-fixtures",
2092        ))
2093    }
2094
2095    /// Helper to create a test context
2096    fn create_test_context() -> WorkflowContext {
2097        let store = Arc::new(InMemoryStore::new());
2098        let provider = create_test_provider();
2099        let run_id = Uuid::now_v7();
2100        WorkflowContext::new(run_id, store, provider)
2101    }
2102
2103    #[test]
2104    fn context_new_initializes_correctly() {
2105        let ctx = create_test_context();
2106        assert_eq!(ctx.position, 0);
2107        assert_eq!(ctx.total_cost_usd, Decimal::ZERO);
2108        assert_eq!(ctx.total_duration_ms, 0);
2109        assert!(ctx.last_step_ids.is_empty());
2110        assert!(ctx.replay_steps.is_empty());
2111        assert!(ctx.log_sender.is_none());
2112    }
2113
2114    #[test]
2115    fn context_run_id_returns_correct_id() {
2116        let run_id = Uuid::now_v7();
2117        let store = Arc::new(InMemoryStore::new());
2118        let provider = create_test_provider();
2119        let ctx = WorkflowContext::new(run_id, store, provider);
2120        assert_eq!(ctx.run_id(), run_id);
2121    }
2122
2123    #[test]
2124    fn context_total_cost_usd_initially_zero() {
2125        let ctx = create_test_context();
2126        assert_eq!(ctx.total_cost_usd(), Decimal::ZERO);
2127    }
2128
2129    #[test]
2130    fn context_total_duration_ms_initially_zero() {
2131        let ctx = create_test_context();
2132        assert_eq!(ctx.total_duration_ms(), 0);
2133    }
2134
2135    #[test]
2136    fn context_with_handler_resolver_creates_context_with_resolver() {
2137        let store = Arc::new(InMemoryStore::new());
2138        let provider = create_test_provider();
2139        let run_id = Uuid::now_v7();
2140
2141        let called = Arc::new(AtomicBool::new(false));
2142        let called_clone = called.clone();
2143
2144        let resolver: HandlerResolver = Arc::new(move |_name: &str| {
2145            called_clone.store(true, Ordering::SeqCst);
2146            None
2147        });
2148
2149        let ctx = WorkflowContext::with_handler_resolver(run_id, store, provider, resolver);
2150
2151        assert_eq!(ctx.run_id(), run_id);
2152        assert!(ctx.handler_resolver.is_some());
2153    }
2154
2155    #[tokio::test]
2156    async fn context_set_log_sender_attaches_sender() {
2157        let mut ctx = create_test_context();
2158        let (sender, _receiver) = crate::log_sender::channel();
2159        ctx.set_log_sender(sender);
2160        assert!(ctx.log_sender.is_some());
2161    }
2162
2163    #[tokio::test]
2164    async fn context_skip_creates_skipped_step() {
2165        let store = Arc::new(InMemoryStore::new());
2166        let provider = create_test_provider();
2167
2168        // Create the run first using RunStore trait
2169        store
2170            .create_run(NewRun {
2171                created_by: None,
2172                workflow_name: "test".to_string(),
2173                trigger: TriggerKind::Manual,
2174                payload: json!({}),
2175                max_retries: 0,
2176                handler_version: None,
2177                labels: Default::default(),
2178                scheduled_at: None,
2179                idempotency_key: None,
2180                max_cost_usd: None,
2181            })
2182            .await
2183            .expect("failed to create run")
2184            .into_run();
2185
2186        // Get the created run to extract its ID
2187        let runs = store
2188            .list_runs(RunFilter::default(), 1, 10)
2189            .await
2190            .expect("failed to list runs");
2191        let created_run_id = runs.items[0].id;
2192
2193        let mut ctx = WorkflowContext::new(created_run_id, store.clone(), provider);
2194        let initial_position = ctx.position;
2195
2196        ctx.skip("skip-step", "condition not met")
2197            .await
2198            .expect("skip failed");
2199
2200        assert_eq!(ctx.position, initial_position + 1);
2201        assert!(!ctx.last_step_ids.is_empty());
2202
2203        // Verify the step was recorded with Skipped status
2204        let steps = store
2205            .list_steps(created_run_id)
2206            .await
2207            .expect("failed to list steps");
2208        assert_eq!(steps.len(), 1);
2209        assert_eq!(steps[0].status.state, StepStatus::Skipped);
2210    }
2211
2212    /// Sub-workflow handler that records no steps, so the child run reaches a
2213    /// terminal state without touching the filesystem or the network.
2214    struct NoopSubWorkflow;
2215
2216    impl WorkflowHandler for NoopSubWorkflow {
2217        fn name(&self) -> &str {
2218            "noop-sub"
2219        }
2220
2221        fn execute<'a>(
2222            &'a self,
2223            _ctx: &'a mut WorkflowContext,
2224        ) -> crate::handler::HandlerFuture<'a> {
2225            Box::pin(async move { Ok(()) })
2226        }
2227    }
2228
2229    /// Run a parent workflow authored by `created_by` and return the child run
2230    /// created by its sub-workflow step.
2231    async fn child_run_of_parent_authored_by(created_by: Option<RunActor>) -> Run {
2232        let store = Arc::new(InMemoryStore::new());
2233        let provider = create_test_provider();
2234
2235        let parent = store
2236            .create_run(NewRun {
2237                workflow_name: "parent".to_string(),
2238                trigger: TriggerKind::Api,
2239                payload: json!({}),
2240                max_retries: 0,
2241                handler_version: None,
2242                labels: Default::default(),
2243                scheduled_at: None,
2244                created_by,
2245                idempotency_key: None,
2246                max_cost_usd: None,
2247            })
2248            .await
2249            .expect("failed to create parent run")
2250            .into_run();
2251
2252        let resolver: HandlerResolver = Arc::new(|name: &str| match name {
2253            "noop-sub" => Some(Arc::new(NoopSubWorkflow) as Arc<dyn WorkflowHandler>),
2254            _ => None,
2255        });
2256
2257        let mut ctx =
2258            WorkflowContext::with_handler_resolver(parent.id, store.clone(), provider, resolver);
2259        ctx.workflow(&NoopSubWorkflow, json!({}))
2260            .await
2261            .expect("sub-workflow failed");
2262
2263        let runs = store
2264            .list_runs(RunFilter::default(), 1, 10)
2265            .await
2266            .expect("failed to list runs");
2267        runs.items
2268            .into_iter()
2269            .find(|r| r.workflow_name == "noop-sub")
2270            .expect("child run was created")
2271    }
2272
2273    #[tokio::test]
2274    async fn child_run_inherits_the_parent_author() {
2275        let user_id = Uuid::now_v7();
2276        let child = child_run_of_parent_authored_by(Some(RunActor::User { user_id })).await;
2277
2278        assert_eq!(child.created_by, Some(RunActor::User { user_id }));
2279    }
2280
2281    #[tokio::test]
2282    async fn child_run_of_an_unattributed_parent_has_no_author() {
2283        let child = child_run_of_parent_authored_by(None).await;
2284
2285        assert!(child.created_by.is_none());
2286    }
2287
2288    #[tokio::test]
2289    async fn context_parallel_empty_steps_returns_empty_vec() {
2290        let mut ctx = create_test_context();
2291        let results = ctx
2292            .parallel(vec![], true)
2293            .await
2294            .expect("parallel should not fail on empty input");
2295        assert!(results.is_empty());
2296    }
2297
2298    #[tokio::test]
2299    async fn context_approval_first_execution_returns_error() {
2300        let store = Arc::new(InMemoryStore::new());
2301        let provider = create_test_provider();
2302
2303        // Create the run first
2304        store
2305            .create_run(NewRun {
2306                created_by: None,
2307                workflow_name: "test".to_string(),
2308                trigger: TriggerKind::Manual,
2309                payload: json!({}),
2310                max_retries: 0,
2311                handler_version: None,
2312                labels: Default::default(),
2313                scheduled_at: None,
2314                idempotency_key: None,
2315                max_cost_usd: None,
2316            })
2317            .await
2318            .expect("failed to create run")
2319            .into_run();
2320
2321        // Get the created run to extract its ID
2322        let runs = store
2323            .list_runs(RunFilter::default(), 1, 10)
2324            .await
2325            .expect("failed to list runs");
2326        let created_run_id = runs.items[0].id;
2327
2328        let mut ctx = WorkflowContext::new(created_run_id, store.clone(), provider);
2329
2330        let result = ctx
2331            .approval(
2332                "approve-step",
2333                crate::config::ApprovalConfig::new("Continue?"),
2334            )
2335            .await;
2336
2337        // First execution should return ApprovalRequired error
2338        assert!(matches!(result, Err(EngineError::ApprovalRequired { .. })));
2339
2340        // Verify position incremented
2341        assert_eq!(ctx.position, 1);
2342
2343        // Verify step was created with AwaitingApproval status
2344        let steps = store
2345            .list_steps(created_run_id)
2346            .await
2347            .expect("failed to list steps");
2348        assert_eq!(steps.len(), 1);
2349        assert_eq!(steps[0].status.state, StepStatus::AwaitingApproval);
2350    }
2351
2352    #[tokio::test]
2353    async fn context_approval_replay_returns_ok() {
2354        let store = Arc::new(InMemoryStore::new());
2355        let provider = create_test_provider();
2356
2357        // Create the run first
2358        store
2359            .create_run(NewRun {
2360                created_by: None,
2361                workflow_name: "test".to_string(),
2362                trigger: TriggerKind::Manual,
2363                payload: json!({}),
2364                max_retries: 0,
2365                handler_version: None,
2366                labels: Default::default(),
2367                scheduled_at: None,
2368                idempotency_key: None,
2369                max_cost_usd: None,
2370            })
2371            .await
2372            .expect("failed to create run")
2373            .into_run();
2374
2375        // Get the created run to extract its ID
2376        let runs = store
2377            .list_runs(RunFilter::default(), 1, 10)
2378            .await
2379            .expect("failed to list runs");
2380        let created_run_id = runs.items[0].id;
2381
2382        // Create an approval step that's already in AwaitingApproval state
2383        let step = store
2384            .create_step(NewStep {
2385                run_id: created_run_id,
2386                name: "approval".to_string(),
2387                kind: StepKind::Approval,
2388                position: 0,
2389                input: None,
2390                is_error_handler: false,
2391            })
2392            .await
2393            .expect("failed to create step");
2394
2395        // Transition through proper states: Pending -> Running -> AwaitingApproval
2396        store
2397            .update_step(
2398                step.id,
2399                StepUpdate {
2400                    status: Some(StepStatus::Running),
2401                    started_at: Some(Utc::now()),
2402                    ..StepUpdate::default()
2403                },
2404            )
2405            .await
2406            .expect("failed to update step to Running");
2407
2408        store
2409            .update_step(
2410                step.id,
2411                StepUpdate {
2412                    status: Some(StepStatus::AwaitingApproval),
2413                    ..StepUpdate::default()
2414                },
2415            )
2416            .await
2417            .expect("failed to update step to AwaitingApproval");
2418
2419        // Create context and load replay steps
2420        let mut ctx = WorkflowContext::new(created_run_id, store.clone(), provider);
2421        ctx.load_replay_steps()
2422            .await
2423            .expect("failed to load replay steps");
2424
2425        // Now approval should succeed (replay)
2426        let result = ctx
2427            .approval("approval", crate::config::ApprovalConfig::new("Continue?"))
2428            .await;
2429
2430        assert!(result.is_ok());
2431
2432        // Verify the step was marked Completed
2433        let steps = store
2434            .list_steps(created_run_id)
2435            .await
2436            .expect("failed to list steps");
2437        assert_eq!(steps.len(), 1);
2438        assert_eq!(steps[0].status.state, StepStatus::Completed);
2439    }
2440
2441    #[tokio::test]
2442    async fn context_load_replay_steps_loads_completed_steps() {
2443        let store = Arc::new(InMemoryStore::new());
2444        let provider = create_test_provider();
2445
2446        // Create the run first
2447        store
2448            .create_run(NewRun {
2449                created_by: None,
2450                workflow_name: "test".to_string(),
2451                trigger: TriggerKind::Manual,
2452                payload: json!({}),
2453                max_retries: 0,
2454                handler_version: None,
2455                labels: Default::default(),
2456                scheduled_at: None,
2457                idempotency_key: None,
2458                max_cost_usd: None,
2459            })
2460            .await
2461            .expect("failed to create run")
2462            .into_run();
2463
2464        // Get the created run to extract its ID
2465        let runs = store
2466            .list_runs(RunFilter::default(), 1, 10)
2467            .await
2468            .expect("failed to list runs");
2469        let created_run_id = runs.items[0].id;
2470
2471        // Create multiple steps with different statuses
2472        let completed_step = store
2473            .create_step(NewStep {
2474                run_id: created_run_id,
2475                name: "completed".to_string(),
2476                kind: StepKind::Shell,
2477                position: 0,
2478                input: None,
2479                is_error_handler: false,
2480            })
2481            .await
2482            .expect("failed to create step");
2483
2484        // Transition to Running then Completed
2485        store
2486            .update_step(
2487                completed_step.id,
2488                StepUpdate {
2489                    status: Some(StepStatus::Running),
2490                    started_at: Some(Utc::now()),
2491                    ..StepUpdate::default()
2492                },
2493            )
2494            .await
2495            .expect("failed to update step to Running");
2496
2497        store
2498            .update_step(
2499                completed_step.id,
2500                StepUpdate {
2501                    status: Some(StepStatus::Completed),
2502                    completed_at: Some(Utc::now()),
2503                    ..StepUpdate::default()
2504                },
2505            )
2506            .await
2507            .expect("failed to update step to Completed");
2508
2509        let _pending_step = store
2510            .create_step(NewStep {
2511                run_id: created_run_id,
2512                name: "pending".to_string(),
2513                kind: StepKind::Shell,
2514                position: 1,
2515                input: None,
2516                is_error_handler: false,
2517            })
2518            .await
2519            .expect("failed to create step");
2520
2521        // Load replay steps
2522        let mut ctx = WorkflowContext::new(created_run_id, store, provider);
2523        ctx.load_replay_steps()
2524            .await
2525            .expect("failed to load replay steps");
2526
2527        // Only completed step should be in replay_steps
2528        assert_eq!(ctx.replay_steps.len(), 1);
2529        assert!(ctx.replay_steps.contains_key(&0));
2530        assert!(!ctx.replay_steps.contains_key(&1));
2531    }
2532
2533    #[tokio::test]
2534    async fn context_payload_returns_run_payload() {
2535        let store = Arc::new(InMemoryStore::new());
2536        let provider = create_test_provider();
2537        let test_payload = json!({"key": "value", "number": 42});
2538
2539        // Create the run first
2540        store
2541            .create_run(NewRun {
2542                created_by: None,
2543                workflow_name: "test".to_string(),
2544                trigger: TriggerKind::Manual,
2545                payload: test_payload.clone(),
2546                max_retries: 0,
2547                handler_version: None,
2548                labels: Default::default(),
2549                scheduled_at: None,
2550                idempotency_key: None,
2551                max_cost_usd: None,
2552            })
2553            .await
2554            .expect("failed to create run")
2555            .into_run();
2556
2557        // Get the created run to extract its ID
2558        let runs = store
2559            .list_runs(RunFilter::default(), 1, 10)
2560            .await
2561            .expect("failed to list runs");
2562        let created_run_id = runs.items[0].id;
2563
2564        let ctx = WorkflowContext::new(created_run_id, store, provider);
2565        let payload = ctx.payload().await.expect("failed to get payload");
2566
2567        assert_eq!(payload, test_payload);
2568    }
2569
2570    #[tokio::test]
2571    async fn context_payload_returns_error_for_nonexistent_run() {
2572        let store = Arc::new(InMemoryStore::new());
2573        let provider = create_test_provider();
2574        let run_id = Uuid::now_v7();
2575
2576        let ctx = WorkflowContext::new(run_id, store, provider);
2577        let result = ctx.payload().await;
2578
2579        assert!(result.is_err());
2580    }
2581
2582    #[tokio::test]
2583    async fn context_store_returns_reference() {
2584        let ctx = create_test_context();
2585        let _store = ctx.store();
2586        // store() returns a reference to the Arc<dyn Store>, which is always available
2587    }
2588
2589    #[test]
2590    fn context_debug_formatting() {
2591        let ctx = create_test_context();
2592        let debug_str = format!("{:?}", ctx);
2593        assert!(debug_str.contains("WorkflowContext"));
2594        assert!(debug_str.contains("run_id"));
2595    }
2596
2597    #[tokio::test]
2598    async fn context_last_step_ids_tracks_executed_steps() {
2599        let store = Arc::new(InMemoryStore::new());
2600        let provider = create_test_provider();
2601
2602        // Create the run first
2603        store
2604            .create_run(NewRun {
2605                created_by: None,
2606                workflow_name: "test".to_string(),
2607                trigger: TriggerKind::Manual,
2608                payload: json!({}),
2609                max_retries: 0,
2610                handler_version: None,
2611                labels: Default::default(),
2612                scheduled_at: None,
2613                idempotency_key: None,
2614                max_cost_usd: None,
2615            })
2616            .await
2617            .expect("failed to create run")
2618            .into_run();
2619
2620        // Get the created run to extract its ID
2621        let runs = store
2622            .list_runs(RunFilter::default(), 1, 10)
2623            .await
2624            .expect("failed to list runs");
2625        let created_run_id = runs.items[0].id;
2626
2627        let mut ctx = WorkflowContext::new(created_run_id, store, provider);
2628        assert!(ctx.last_step_ids.is_empty());
2629
2630        ctx.skip("step1", "reason").await.expect("skip failed");
2631
2632        assert_eq!(ctx.last_step_ids.len(), 1);
2633
2634        ctx.skip("step2", "reason").await.expect("skip failed");
2635
2636        // last_step_ids should now contain only step2's ID
2637        assert_eq!(ctx.last_step_ids.len(), 1);
2638    }
2639}