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}