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