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