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