Skip to main content

ironflow_engine/config/
approval.rs

1//! [`ApprovalConfig`] -- configuration for human approval gates.
2
3use std::time::Duration;
4
5use ironflow_store::entities::Assignee;
6use serde::{Deserialize, Serialize};
7
8use super::EscalationPolicy;
9
10/// Configuration for a human approval step.
11///
12/// When the workflow reaches an approval step, the run transitions to
13/// `AwaitingApproval` and waits for a human to approve or reject via
14/// the API.
15///
16/// A gate can carry an SLA: [`with_deadline`](Self::with_deadline) arms a timer
17/// persisted alongside the step, and [`on_timeout`](Self::on_timeout) says what
18/// happens when it fires. The timer lives in the database, so it survives an API
19/// or worker restart.
20///
21/// # Examples
22///
23/// ```
24/// use ironflow_engine::config::ApprovalConfig;
25///
26/// let config = ApprovalConfig::new("Deploy to production?");
27/// assert_eq!(config.message(), "Deploy to production?");
28/// assert!(config.timeout_seconds().is_none());
29/// ```
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ApprovalConfig {
32    message: String,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    timeout_seconds: Option<u64>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    deadline_secs: Option<u64>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    on_timeout: Option<EscalationPolicy>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    assignee: Option<Assignee>,
41}
42
43impl ApprovalConfig {
44    /// Create a new approval config with the given message.
45    ///
46    /// # Examples
47    ///
48    /// ```
49    /// use ironflow_engine::config::ApprovalConfig;
50    ///
51    /// let config = ApprovalConfig::new("Approve this deployment?");
52    /// assert_eq!(config.message(), "Approve this deployment?");
53    /// ```
54    pub fn new(message: &str) -> Self {
55        Self {
56            message: message.to_string(),
57            timeout_seconds: None,
58            deadline_secs: None,
59            on_timeout: None,
60            assignee: None,
61        }
62    }
63
64    /// Set an auto-reject timeout in seconds.
65    ///
66    /// If no approval or rejection is received within this duration,
67    /// the run is automatically rejected (marked as Failed).
68    ///
69    /// This is the legacy spelling of [`with_deadline_secs`](Self::with_deadline_secs)
70    /// with an implicit [`EscalationPolicy::AutoReject`]. It is now actually
71    /// enforced by the escalator; a config that sets both keeps the explicit
72    /// deadline.
73    ///
74    /// # Examples
75    ///
76    /// ```
77    /// use ironflow_engine::config::{ApprovalConfig, EscalationPolicy};
78    ///
79    /// let config = ApprovalConfig::new("Approve?")
80    ///     .with_timeout_seconds(3600);
81    /// assert_eq!(config.timeout_seconds(), Some(3600));
82    /// assert_eq!(config.effective_deadline_secs(), Some(3600));
83    /// assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
84    /// ```
85    pub fn with_timeout_seconds(mut self, seconds: u64) -> Self {
86        self.timeout_seconds = Some(seconds);
87        self
88    }
89
90    /// Set the SLA deadline of this gate.
91    ///
92    /// Sub-second precision is dropped: the deadline is stored in whole seconds.
93    ///
94    /// # Panics
95    ///
96    /// Panics if `deadline` rounds down to zero seconds.
97    ///
98    /// # Examples
99    ///
100    /// ```
101    /// use std::time::Duration;
102    /// use ironflow_engine::config::ApprovalConfig;
103    ///
104    /// let config = ApprovalConfig::new("Approve?")
105    ///     .with_deadline(Duration::from_secs(1800));
106    /// assert_eq!(config.deadline(), Some(Duration::from_secs(1800)));
107    /// ```
108    pub fn with_deadline(self, deadline: Duration) -> Self {
109        self.with_deadline_secs(deadline.as_secs())
110    }
111
112    /// Set the SLA deadline of this gate, in seconds.
113    ///
114    /// # Panics
115    ///
116    /// Panics if `secs` is zero: a gate that expires the instant it opens can
117    /// never be approved by a human.
118    ///
119    /// # Examples
120    ///
121    /// ```
122    /// use ironflow_engine::config::ApprovalConfig;
123    ///
124    /// let config = ApprovalConfig::new("Approve?").with_deadline_secs(1800);
125    /// assert_eq!(config.deadline_secs(), Some(1800));
126    /// ```
127    pub fn with_deadline_secs(mut self, secs: u64) -> Self {
128        assert!(secs > 0, "approval deadline must be greater than zero");
129        self.deadline_secs = Some(secs);
130        self
131    }
132
133    /// Set the policy applied when the deadline fires.
134    ///
135    /// # Examples
136    ///
137    /// ```
138    /// use ironflow_engine::config::{ApprovalConfig, EscalationPolicy};
139    ///
140    /// let config = ApprovalConfig::new("Approve?")
141    ///     .with_deadline_secs(3600)
142    ///     .on_timeout(EscalationPolicy::AutoApprove);
143    /// assert_eq!(config.effective_policy(), EscalationPolicy::AutoApprove);
144    /// ```
145    pub fn on_timeout(mut self, policy: EscalationPolicy) -> Self {
146        self.on_timeout = Some(policy);
147        self
148    }
149
150    /// Assign the gate to a user or group.
151    ///
152    /// # Panics
153    ///
154    /// Panics if the assignee name is empty or only whitespace.
155    ///
156    /// # Examples
157    ///
158    /// ```
159    /// use ironflow_engine::config::ApprovalConfig;
160    /// use ironflow_store::entities::Assignee;
161    ///
162    /// let config = ApprovalConfig::new("Approve?").assigned_to(Assignee::group("release-managers"));
163    /// assert_eq!(config.assignee(), Some(&Assignee::group("release-managers")));
164    /// ```
165    pub fn assigned_to(mut self, assignee: Assignee) -> Self {
166        assert!(
167            !assignee.name().trim().is_empty(),
168            "approval assignee must not be empty"
169        );
170        self.assignee = Some(assignee);
171        self
172    }
173
174    /// The approval message displayed to reviewers.
175    pub fn message(&self) -> &str {
176        &self.message
177    }
178
179    /// Optional auto-reject timeout in seconds.
180    pub fn timeout_seconds(&self) -> Option<u64> {
181        self.timeout_seconds
182    }
183
184    /// The configured SLA deadline, if any.
185    ///
186    /// # Examples
187    ///
188    /// ```
189    /// use std::time::Duration;
190    /// use ironflow_engine::config::ApprovalConfig;
191    ///
192    /// let config = ApprovalConfig::new("Approve?").with_deadline_secs(60);
193    /// assert_eq!(config.deadline(), Some(Duration::from_secs(60)));
194    /// ```
195    pub fn deadline(&self) -> Option<Duration> {
196        self.deadline_secs.map(Duration::from_secs)
197    }
198
199    /// The configured SLA deadline in seconds, if any.
200    pub fn deadline_secs(&self) -> Option<u64> {
201        self.deadline_secs
202    }
203
204    /// The configured escalation policy, if any.
205    pub fn on_timeout_policy(&self) -> Option<&EscalationPolicy> {
206        self.on_timeout.as_ref()
207    }
208
209    /// The user or group the gate is assigned to, if any.
210    pub fn assignee(&self) -> Option<&Assignee> {
211        self.assignee.as_ref()
212    }
213
214    /// The deadline actually enforced, in seconds.
215    ///
216    /// [`with_deadline_secs`](Self::with_deadline_secs) wins; the legacy
217    /// [`with_timeout_seconds`](Self::with_timeout_seconds) is honoured as a
218    /// fallback so configs written before escalation existed finally behave the
219    /// way their documentation always promised.
220    ///
221    /// # Examples
222    ///
223    /// ```
224    /// use ironflow_engine::config::ApprovalConfig;
225    ///
226    /// let legacy = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
227    /// assert_eq!(legacy.effective_deadline_secs(), Some(7200));
228    ///
229    /// let both = legacy.with_deadline_secs(60);
230    /// assert_eq!(both.effective_deadline_secs(), Some(60));
231    ///
232    /// assert_eq!(ApprovalConfig::new("Approve?").effective_deadline_secs(), None);
233    /// ```
234    pub fn effective_deadline_secs(&self) -> Option<u64> {
235        self.deadline_secs.or(self.timeout_seconds)
236    }
237
238    /// The policy applied when the deadline fires. Defaults to
239    /// [`EscalationPolicy::AutoReject`], matching the documented meaning of
240    /// `timeout_seconds`.
241    ///
242    /// # Examples
243    ///
244    /// ```
245    /// use ironflow_engine::config::{ApprovalConfig, EscalationPolicy};
246    ///
247    /// let config = ApprovalConfig::new("Approve?").with_deadline_secs(60);
248    /// assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
249    /// ```
250    pub fn effective_policy(&self) -> EscalationPolicy {
251        self.on_timeout
252            .clone()
253            .unwrap_or(EscalationPolicy::AutoReject)
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::config::NotificationTarget;
261
262    #[test]
263    fn new_sets_message() {
264        let config = ApprovalConfig::new("Deploy?");
265        assert_eq!(config.message(), "Deploy?");
266        assert!(config.timeout_seconds().is_none());
267        assert!(config.deadline_secs().is_none());
268        assert!(config.on_timeout_policy().is_none());
269        assert!(config.assignee().is_none());
270    }
271
272    #[test]
273    fn with_timeout() {
274        let config = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
275        assert_eq!(config.timeout_seconds(), Some(7200));
276    }
277
278    #[test]
279    fn with_deadline_stores_whole_seconds() {
280        let config = ApprovalConfig::new("Approve?").with_deadline(Duration::from_millis(90_500));
281        assert_eq!(config.deadline_secs(), Some(90));
282        assert_eq!(config.deadline(), Some(Duration::from_secs(90)));
283    }
284
285    #[test]
286    fn on_timeout_stores_the_policy() {
287        let config = ApprovalConfig::new("Approve?").on_timeout(EscalationPolicy::AutoApprove);
288        assert_eq!(
289            config.on_timeout_policy(),
290            Some(&EscalationPolicy::AutoApprove)
291        );
292    }
293
294    #[test]
295    fn assigned_to_stores_the_assignee() {
296        let config =
297            ApprovalConfig::new("Approve?").assigned_to(Assignee::group("release-managers"));
298        assert_eq!(
299            config.assignee(),
300            Some(&Assignee::group("release-managers"))
301        );
302    }
303
304    #[test]
305    fn effective_deadline_prefers_the_explicit_deadline() {
306        let config = ApprovalConfig::new("Approve?")
307            .with_timeout_seconds(7200)
308            .with_deadline_secs(60);
309        assert_eq!(config.effective_deadline_secs(), Some(60));
310    }
311
312    #[test]
313    fn effective_deadline_falls_back_to_the_legacy_timeout() {
314        let config = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
315        assert_eq!(config.effective_deadline_secs(), Some(7200));
316    }
317
318    #[test]
319    fn effective_deadline_is_none_without_any_timer() {
320        assert_eq!(
321            ApprovalConfig::new("Approve?").effective_deadline_secs(),
322            None
323        );
324    }
325
326    #[test]
327    fn effective_policy_defaults_to_auto_reject() {
328        let config = ApprovalConfig::new("Approve?").with_deadline_secs(60);
329        assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
330    }
331
332    #[test]
333    fn effective_policy_returns_the_configured_policy() {
334        let policy = EscalationPolicy::Chain(vec![
335            EscalationPolicy::Notify(vec![NotificationTarget::Webhook {
336                url: "https://example.com/sla".to_string(),
337            }]),
338            EscalationPolicy::AutoReject,
339        ]);
340        let config = ApprovalConfig::new("Approve?")
341            .with_deadline_secs(60)
342            .on_timeout(policy.clone());
343        assert_eq!(config.effective_policy(), policy);
344    }
345
346    #[test]
347    #[should_panic(expected = "approval deadline must be greater than zero")]
348    fn with_deadline_secs_rejects_zero() {
349        let _ = ApprovalConfig::new("Approve?").with_deadline_secs(0);
350    }
351
352    #[test]
353    #[should_panic(expected = "approval assignee must not be empty")]
354    fn assigned_to_rejects_blank() {
355        let _ = ApprovalConfig::new("Approve?").assigned_to(Assignee::group("  "));
356    }
357
358    #[test]
359    fn serde_roundtrip() {
360        let config = ApprovalConfig::new("Deploy to prod?")
361            .with_timeout_seconds(3600)
362            .with_deadline_secs(1800)
363            .on_timeout(EscalationPolicy::Escalate(Assignee::group("sre-oncall")))
364            .assigned_to(Assignee::group("release-managers"));
365
366        let json = serde_json::to_string(&config).expect("serialize");
367        let back: ApprovalConfig = serde_json::from_str(&json).expect("deserialize");
368
369        assert_eq!(back.message(), config.message());
370        assert_eq!(back.timeout_seconds(), config.timeout_seconds());
371        assert_eq!(back.deadline_secs(), config.deadline_secs());
372        assert_eq!(back.on_timeout_policy(), config.on_timeout_policy());
373        assert_eq!(back.assignee(), config.assignee());
374    }
375
376    #[test]
377    fn serde_minimal() {
378        let config = ApprovalConfig::new("Approve?");
379        let json = serde_json::to_string(&config).expect("serialize");
380        assert!(!json.contains("timeout_seconds"));
381        assert!(!json.contains("deadline_secs"));
382        assert!(!json.contains("on_timeout"));
383        assert!(!json.contains("assignee"));
384    }
385
386    #[test]
387    fn serde_accepts_a_config_written_before_escalation_existed() {
388        let config: ApprovalConfig =
389            serde_json::from_str(r#"{"message":"Approve?","timeout_seconds":60}"#)
390                .expect("deserialize");
391
392        assert_eq!(config.effective_deadline_secs(), Some(60));
393        assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
394    }
395}