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}