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