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}