Skip to main content

ironflow_engine/executor/
interceptor.rs

1//! Hook that resolves a step without executing it.
2//!
3//! A [`StepInterceptor`] is consulted by
4//! [`execute_step_config_intercepted`](crate::executor::execute_step_config_intercepted)
5//! before the dispatcher picks an executor, and by
6//! [`WorkflowContext::approval`](crate::context::WorkflowContext::approval)
7//! before the gate suspends the run. Returning `Some(..)` short-circuits the
8//! step: no process is spawned, no request is sent, no human is asked.
9//!
10//! Production wiring leaves the hook unset. In practice the only implementor is
11//! [`crate::testing`], which uses it to run a handler's real logic against
12//! canned step results.
13
14use crate::config::{ApprovalConfig, StepConfig};
15use crate::error::EngineError;
16use crate::executor::StepOutput;
17
18/// Decision applied to an approval gate by a [`StepInterceptor`].
19///
20/// # Examples
21///
22/// ```
23/// use ironflow_engine::executor::ApprovalOutcome;
24///
25/// let granted = ApprovalOutcome::Approved;
26/// assert_eq!(granted, ApprovalOutcome::Approved);
27///
28/// let refused = ApprovalOutcome::Rejected { reason: "not on a Friday".to_string() };
29/// assert_ne!(granted, refused);
30/// ```
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ApprovalOutcome {
33    /// The gate is granted; execution continues past it.
34    Approved,
35    /// The gate is refused; the run fails with [`EngineError::ApprovalRejected`].
36    Rejected {
37        /// Human-readable reason recorded on the step and on the run.
38        reason: String,
39    },
40}
41
42impl ApprovalOutcome {
43    /// Build an [`ApprovalOutcome::Rejected`] with the given reason.
44    ///
45    /// # Examples
46    ///
47    /// ```
48    /// use ironflow_engine::executor::ApprovalOutcome;
49    ///
50    /// let outcome = ApprovalOutcome::reject("budget freeze");
51    /// assert_eq!(
52    ///     outcome,
53    ///     ApprovalOutcome::Rejected { reason: "budget freeze".to_string() }
54    /// );
55    /// ```
56    pub fn reject(reason: &str) -> Self {
57        Self::Rejected {
58            reason: reason.to_string(),
59        }
60    }
61}
62
63/// Resolves steps without executing them.
64///
65/// # Examples
66///
67/// ```
68/// use ironflow_engine::config::{ShellConfig, StepConfig};
69/// use ironflow_engine::error::EngineError;
70/// use ironflow_engine::executor::{StepInterceptor, StepOutput};
71/// use rust_decimal::Decimal;
72/// use serde_json::json;
73///
74/// struct AlwaysOk;
75///
76/// impl StepInterceptor for AlwaysOk {
77///     fn intercept(&self, config: &StepConfig) -> Option<Result<StepOutput, EngineError>> {
78///         match config {
79///             StepConfig::Shell(_) => Some(Ok(StepOutput {
80///                 output: json!({"stdout": "ok", "stderr": "", "exit_code": 0}),
81///                 duration_ms: 0,
82///                 cost_usd: Decimal::ZERO,
83///                 input_tokens: None,
84///                 output_tokens: None,
85///                 model: None,
86///                 debug_messages: None,
87///             })),
88///             _ => None,
89///         }
90///     }
91/// }
92///
93/// let config = StepConfig::Shell(ShellConfig::new("./deploy.sh"));
94/// let intercepted = AlwaysOk.intercept(&config).expect("shell is intercepted");
95/// assert_eq!(intercepted.expect("canned output").stdout(), "ok");
96/// ```
97pub trait StepInterceptor: Send + Sync {
98    /// Return `Some(result)` to short-circuit this step, `None` to execute it
99    /// for real.
100    fn intercept(&self, config: &StepConfig) -> Option<Result<StepOutput, EngineError>>;
101
102    /// Resolve an approval gate instead of suspending the run.
103    ///
104    /// The default implementation returns `None`: the gate suspends the run.
105    fn intercept_approval(&self, name: &str, config: &ApprovalConfig) -> Option<ApprovalOutcome> {
106        let _ = (name, config);
107        None
108    }
109}