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, and by
8//! [`WorkflowContext::human_input`](crate::context::WorkflowContext::human_input)
9//! before the input request suspends the run. Returning `Some(..)` short-circuits the
10//! step: no process is spawned, no request is sent, no human is asked.
11//!
12//! Production wiring leaves the hook unset. In practice the only implementor is
13//! [`crate::testing`], which uses it to run a handler's real logic against
14//! canned step results.
15
16use serde_json::Value;
17
18use crate::config::{ApprovalConfig, HumanInputConfig, StepConfig};
19use crate::error::EngineError;
20use crate::executor::StepOutput;
21
22/// Decision applied to an approval gate by a [`StepInterceptor`].
23///
24/// # Examples
25///
26/// ```
27/// use ironflow_engine::executor::ApprovalOutcome;
28///
29/// let granted = ApprovalOutcome::Approved;
30/// assert_eq!(granted, ApprovalOutcome::Approved);
31///
32/// let refused = ApprovalOutcome::Rejected { reason: "not on a Friday".to_string() };
33/// assert_ne!(granted, refused);
34/// ```
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum ApprovalOutcome {
37    /// The gate is granted; execution continues past it.
38    Approved,
39    /// The gate is refused; the run fails with [`EngineError::ApprovalRejected`].
40    Rejected {
41        /// Human-readable reason recorded on the step and on the run.
42        reason: String,
43    },
44}
45
46impl ApprovalOutcome {
47    /// Build an [`ApprovalOutcome::Rejected`] with the given reason.
48    ///
49    /// # Examples
50    ///
51    /// ```
52    /// use ironflow_engine::executor::ApprovalOutcome;
53    ///
54    /// let outcome = ApprovalOutcome::reject("budget freeze");
55    /// assert_eq!(
56    ///     outcome,
57    ///     ApprovalOutcome::Rejected { reason: "budget freeze".to_string() }
58    /// );
59    /// ```
60    pub fn reject(reason: &str) -> Self {
61        Self::Rejected {
62            reason: reason.to_string(),
63        }
64    }
65}
66
67/// Answer applied to a human input step by a [`StepInterceptor`].
68///
69/// # Examples
70///
71/// ```
72/// use ironflow_engine::executor::HumanInputOutcome;
73/// use serde_json::json;
74///
75/// let answered = HumanInputOutcome::Provided(json!({"answers": ["yes"]}));
76/// let refused = HumanInputOutcome::reject("out of scope");
77/// assert_ne!(answered, refused);
78/// ```
79#[derive(Debug, Clone, PartialEq)]
80pub enum HumanInputOutcome {
81    /// The input is answered with this value; it must match the expected type.
82    Provided(Value),
83    /// The input is refused; the handler receives
84    /// [`EngineError::HumanInputRejected`].
85    Rejected {
86        /// Human-readable reason recorded on the step.
87        reason: String,
88    },
89}
90
91impl HumanInputOutcome {
92    /// Build a [`HumanInputOutcome::Rejected`] with the given reason.
93    ///
94    /// # Examples
95    ///
96    /// ```
97    /// use ironflow_engine::executor::HumanInputOutcome;
98    ///
99    /// let outcome = HumanInputOutcome::reject("out of scope");
100    /// assert_eq!(
101    ///     outcome,
102    ///     HumanInputOutcome::Rejected { reason: "out of scope".to_string() }
103    /// );
104    /// ```
105    pub fn reject(reason: &str) -> Self {
106        Self::Rejected {
107            reason: reason.to_string(),
108        }
109    }
110}
111
112/// Resolves steps without executing them.
113///
114/// # Examples
115///
116/// ```
117/// use ironflow_engine::config::{ShellConfig, StepConfig};
118/// use ironflow_engine::error::EngineError;
119/// use ironflow_engine::executor::{StepArtifacts, StepInterceptor, StepOutput};
120/// use rust_decimal::Decimal;
121/// use serde_json::json;
122///
123/// struct AlwaysOk;
124///
125/// impl StepInterceptor for AlwaysOk {
126///     fn intercept(&self, config: &StepConfig) -> Option<Result<StepOutput, EngineError>> {
127///         match config {
128///             StepConfig::Shell(_) => Some(Ok(StepOutput {
129///                 output: json!({"stdout": "ok", "stderr": "", "exit_code": 0}),
130///                 duration_ms: 0,
131///                 cost_usd: Decimal::ZERO,
132///                 input_tokens: None,
133///                 cache_read_input_tokens: None,
134///                 cache_creation_input_tokens: None,
135///                 output_tokens: None,
136///                 model: None,
137///                 debug_messages: None,
138///                 artifacts: StepArtifacts::default(),
139///             })),
140///             _ => None,
141///         }
142///     }
143/// }
144///
145/// let config = StepConfig::Shell(ShellConfig::new("./deploy.sh"));
146/// let intercepted = AlwaysOk.intercept(&config).expect("shell is intercepted");
147/// assert_eq!(intercepted.expect("canned output").stdout(), "ok");
148/// ```
149pub trait StepInterceptor: Send + Sync {
150    /// Return `Some(result)` to short-circuit this step, `None` to execute it
151    /// for real.
152    fn intercept(&self, config: &StepConfig) -> Option<Result<StepOutput, EngineError>>;
153
154    /// Resolve an approval gate instead of suspending the run.
155    ///
156    /// The default implementation returns `None`: the gate suspends the run.
157    fn intercept_approval(&self, name: &str, config: &ApprovalConfig) -> Option<ApprovalOutcome> {
158        let _ = (name, config);
159        None
160    }
161
162    /// Answer a human input step instead of suspending the run.
163    ///
164    /// `schema` is the JSON schema of the expected answer. The default
165    /// implementation returns `None`: the input suspends the run.
166    fn intercept_human_input(
167        &self,
168        name: &str,
169        config: &HumanInputConfig,
170        schema: &Value,
171    ) -> Option<HumanInputOutcome> {
172        let _ = (name, config, schema);
173        None
174    }
175}