ironflow_engine/config/human_input.rs
1//! [`HumanInputConfig`] -- configuration for typed human input steps.
2
3use std::time::Duration;
4
5use ironflow_store::entities::Assignee;
6use serde::{Deserialize, Serialize};
7
8use super::{ApprovalConfig, Approvers, EscalationPolicy};
9
10/// Key of the JSON schema of the expected answer in the stored step input.
11///
12/// A human input step stores its flattened [`HumanInputConfig`] in
13/// `step.input`, plus the JSON schema of the answer type under this key. The
14/// API validates a submitted answer against it before the run resumes.
15///
16/// # Examples
17///
18/// ```
19/// use ironflow_engine::config::HUMAN_INPUT_SCHEMA_KEY;
20///
21/// assert_eq!(HUMAN_INPUT_SCHEMA_KEY, "schema");
22/// ```
23pub const HUMAN_INPUT_SCHEMA_KEY: &str = "schema";
24
25/// Configuration for a typed human input step.
26///
27/// When the workflow reaches a human input step, the run transitions to
28/// `AwaitingApproval` and waits for a human to submit an answer matching the
29/// JSON schema of the expected type, or to reject the request, via the API.
30///
31/// The step reuses the approval gate machinery: a deadline, an escalation
32/// policy, an assignee and the [`Approvers`] allowed to answer. The first valid
33/// answer wins; [`Approvers::at_least`] only restricts who may answer.
34///
35/// A human input cannot be auto-approved on timeout since there is no value to
36/// fill in: [`on_timeout`](Self::on_timeout) refuses
37/// [`EscalationPolicy::AutoApprove`].
38///
39/// # Examples
40///
41/// ```
42/// use std::time::Duration;
43/// use ironflow_engine::config::{EscalationPolicy, HumanInputConfig};
44///
45/// let config = HumanInputConfig::new("Answer the clarification questions")
46/// .with_deadline(Duration::from_secs(3600))
47/// .on_timeout(EscalationPolicy::AutoReject);
48/// assert_eq!(config.message(), "Answer the clarification questions");
49/// assert_eq!(config.deadline_secs(), Some(3600));
50/// ```
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct HumanInputConfig {
53 #[serde(flatten)]
54 gate: ApprovalConfig,
55}
56
57impl HumanInputConfig {
58 /// Create a new human input config with the given message.
59 ///
60 /// # Examples
61 ///
62 /// ```
63 /// use ironflow_engine::config::HumanInputConfig;
64 ///
65 /// let config = HumanInputConfig::new("Which environment?");
66 /// assert_eq!(config.message(), "Which environment?");
67 /// ```
68 pub fn new(message: &str) -> Self {
69 Self {
70 gate: ApprovalConfig::new(message),
71 }
72 }
73
74 /// Set the SLA deadline of this input.
75 ///
76 /// Sub-second precision is dropped: the deadline is stored in whole seconds.
77 ///
78 /// # Panics
79 ///
80 /// Panics if `deadline` rounds down to zero seconds.
81 ///
82 /// # Examples
83 ///
84 /// ```
85 /// use std::time::Duration;
86 /// use ironflow_engine::config::HumanInputConfig;
87 ///
88 /// let config = HumanInputConfig::new("Answer?").with_deadline(Duration::from_secs(1800));
89 /// assert_eq!(config.deadline(), Some(Duration::from_secs(1800)));
90 /// ```
91 pub fn with_deadline(self, deadline: Duration) -> Self {
92 Self {
93 gate: self.gate.with_deadline(deadline),
94 }
95 }
96
97 /// Set the SLA deadline of this input, in seconds.
98 ///
99 /// # Panics
100 ///
101 /// Panics if `secs` is zero.
102 ///
103 /// # Examples
104 ///
105 /// ```
106 /// use ironflow_engine::config::HumanInputConfig;
107 ///
108 /// let config = HumanInputConfig::new("Answer?").with_deadline_secs(1800);
109 /// assert_eq!(config.deadline_secs(), Some(1800));
110 /// ```
111 pub fn with_deadline_secs(self, secs: u64) -> Self {
112 Self {
113 gate: self.gate.with_deadline_secs(secs),
114 }
115 }
116
117 /// Set the policy applied when the deadline fires.
118 ///
119 /// # Panics
120 ///
121 /// Panics if the policy is [`EscalationPolicy::AutoApprove`] or a
122 /// [`EscalationPolicy::Chain`] containing it: a human input has no value
123 /// to fill in automatically.
124 ///
125 /// # Examples
126 ///
127 /// ```
128 /// use ironflow_engine::config::{EscalationPolicy, HumanInputConfig};
129 ///
130 /// let config = HumanInputConfig::new("Answer?")
131 /// .with_deadline_secs(3600)
132 /// .on_timeout(EscalationPolicy::AutoReject);
133 /// assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
134 /// ```
135 ///
136 /// ```should_panic
137 /// use ironflow_engine::config::{EscalationPolicy, HumanInputConfig};
138 ///
139 /// let _ = HumanInputConfig::new("Answer?").on_timeout(EscalationPolicy::AutoApprove);
140 /// ```
141 pub fn on_timeout(self, policy: EscalationPolicy) -> Self {
142 assert!(
143 !allows_auto_approve(&policy),
144 "a human input step cannot be auto-approved on timeout"
145 );
146 Self {
147 gate: self.gate.on_timeout(policy),
148 }
149 }
150
151 /// Assign the input to a user or group.
152 ///
153 /// # Panics
154 ///
155 /// Panics if the assignee name is empty or only whitespace.
156 ///
157 /// # Examples
158 ///
159 /// ```
160 /// use ironflow_engine::config::HumanInputConfig;
161 /// use ironflow_store::entities::Assignee;
162 ///
163 /// let config = HumanInputConfig::new("Answer?").assigned_to(Assignee::user("alice"));
164 /// assert_eq!(config.assignee(), Some(&Assignee::user("alice")));
165 /// ```
166 pub fn assigned_to(self, assignee: Assignee) -> Self {
167 Self {
168 gate: self.gate.assigned_to(assignee),
169 }
170 }
171
172 /// Restrict who may answer. A later call replaces an earlier one.
173 ///
174 /// The first valid answer resolves the step, whatever the required count.
175 ///
176 /// # Examples
177 ///
178 /// ```
179 /// use ironflow_engine::config::{Approvers, HumanInputConfig};
180 ///
181 /// let config = HumanInputConfig::new("Answer?")
182 /// .requiring(Approvers::any().from_groups(["product"]));
183 /// assert!(config.approvers().is_some());
184 /// ```
185 pub fn requiring(self, approvers: Approvers) -> Self {
186 Self {
187 gate: self.gate.requiring(approvers),
188 }
189 }
190
191 /// The message displayed to the person answering.
192 ///
193 /// # Examples
194 ///
195 /// ```
196 /// use ironflow_engine::config::HumanInputConfig;
197 ///
198 /// assert_eq!(HumanInputConfig::new("Answer?").message(), "Answer?");
199 /// ```
200 pub fn message(&self) -> &str {
201 self.gate.message()
202 }
203
204 /// The configured SLA deadline, if any.
205 ///
206 /// # Examples
207 ///
208 /// ```
209 /// use std::time::Duration;
210 /// use ironflow_engine::config::HumanInputConfig;
211 ///
212 /// let config = HumanInputConfig::new("Answer?").with_deadline_secs(60);
213 /// assert_eq!(config.deadline(), Some(Duration::from_secs(60)));
214 /// ```
215 pub fn deadline(&self) -> Option<Duration> {
216 self.gate.deadline()
217 }
218
219 /// The configured SLA deadline in seconds, if any.
220 ///
221 /// # Examples
222 ///
223 /// ```
224 /// use ironflow_engine::config::HumanInputConfig;
225 ///
226 /// assert!(HumanInputConfig::new("Answer?").deadline_secs().is_none());
227 /// ```
228 pub fn deadline_secs(&self) -> Option<u64> {
229 self.gate.deadline_secs()
230 }
231
232 /// The configured escalation policy, if any.
233 ///
234 /// # Examples
235 ///
236 /// ```
237 /// use ironflow_engine::config::HumanInputConfig;
238 ///
239 /// assert!(HumanInputConfig::new("Answer?").on_timeout_policy().is_none());
240 /// ```
241 pub fn on_timeout_policy(&self) -> Option<&EscalationPolicy> {
242 self.gate.on_timeout_policy()
243 }
244
245 /// The user or group the input is assigned to, if any.
246 ///
247 /// # Examples
248 ///
249 /// ```
250 /// use ironflow_engine::config::HumanInputConfig;
251 ///
252 /// assert!(HumanInputConfig::new("Answer?").assignee().is_none());
253 /// ```
254 pub fn assignee(&self) -> Option<&Assignee> {
255 self.gate.assignee()
256 }
257
258 /// The approvers allowed to answer, if any were set.
259 ///
260 /// # Examples
261 ///
262 /// ```
263 /// use ironflow_engine::config::HumanInputConfig;
264 ///
265 /// assert!(HumanInputConfig::new("Answer?").approvers().is_none());
266 /// ```
267 pub fn approvers(&self) -> Option<&Approvers> {
268 self.gate.approvers()
269 }
270
271 /// The deadline actually enforced, in seconds.
272 ///
273 /// # Examples
274 ///
275 /// ```
276 /// use ironflow_engine::config::HumanInputConfig;
277 ///
278 /// let config = HumanInputConfig::new("Answer?").with_deadline_secs(60);
279 /// assert_eq!(config.effective_deadline_secs(), Some(60));
280 /// ```
281 pub fn effective_deadline_secs(&self) -> Option<u64> {
282 self.gate.effective_deadline_secs()
283 }
284
285 /// The policy applied when the deadline fires. Defaults to
286 /// [`EscalationPolicy::AutoReject`].
287 ///
288 /// # Examples
289 ///
290 /// ```
291 /// use ironflow_engine::config::{EscalationPolicy, HumanInputConfig};
292 ///
293 /// let config = HumanInputConfig::new("Answer?").with_deadline_secs(60);
294 /// assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
295 /// ```
296 pub fn effective_policy(&self) -> EscalationPolicy {
297 self.gate.effective_policy()
298 }
299}
300
301/// Whether `policy` can end up auto-approving the step.
302fn allows_auto_approve(policy: &EscalationPolicy) -> bool {
303 match policy {
304 EscalationPolicy::AutoApprove => true,
305 EscalationPolicy::Chain(policies) => policies.iter().any(allows_auto_approve),
306 _ => false,
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use serde_json::{from_str, from_value, json, to_string, to_value};
313
314 use super::*;
315 use crate::config::NotificationTarget;
316
317 fn webhook() -> EscalationPolicy {
318 EscalationPolicy::Notify(vec![NotificationTarget::Webhook {
319 url: "https://example.com/sla".to_string(),
320 }])
321 }
322
323 #[test]
324 fn new_sets_message() {
325 let config = HumanInputConfig::new("Answer?");
326 assert_eq!(config.message(), "Answer?");
327 assert!(config.deadline_secs().is_none());
328 assert!(config.on_timeout_policy().is_none());
329 assert!(config.assignee().is_none());
330 assert!(config.approvers().is_none());
331 }
332
333 #[test]
334 fn builder_values_are_stored() {
335 let config = HumanInputConfig::new("Answer?")
336 .with_deadline(Duration::from_secs(120))
337 .on_timeout(EscalationPolicy::AutoReject)
338 .assigned_to(Assignee::group("product"))
339 .requiring(Approvers::at_least(2).from_groups(["product"]));
340
341 assert_eq!(config.deadline(), Some(Duration::from_secs(120)));
342 assert_eq!(config.effective_deadline_secs(), Some(120));
343 assert_eq!(
344 config.on_timeout_policy(),
345 Some(&EscalationPolicy::AutoReject)
346 );
347 assert_eq!(config.assignee(), Some(&Assignee::group("product")));
348 assert_eq!(config.approvers().map(Approvers::required), Some(2));
349 }
350
351 #[test]
352 fn with_deadline_secs_is_stored() {
353 let config = HumanInputConfig::new("Answer?").with_deadline_secs(30);
354 assert_eq!(config.deadline_secs(), Some(30));
355 }
356
357 #[test]
358 #[should_panic(expected = "approval deadline must be greater than zero")]
359 fn with_deadline_secs_rejects_zero() {
360 let _ = HumanInputConfig::new("Answer?").with_deadline_secs(0);
361 }
362
363 #[test]
364 fn on_timeout_accepts_policies_without_auto_approve() {
365 let reject = HumanInputConfig::new("Answer?").on_timeout(EscalationPolicy::AutoReject);
366 assert_eq!(reject.effective_policy(), EscalationPolicy::AutoReject);
367
368 let notify = HumanInputConfig::new("Answer?").on_timeout(webhook());
369 assert_eq!(notify.effective_policy(), webhook());
370
371 let chain = EscalationPolicy::Chain(vec![webhook(), EscalationPolicy::AutoReject]);
372 let chained = HumanInputConfig::new("Answer?").on_timeout(chain.clone());
373 assert_eq!(chained.effective_policy(), chain);
374 }
375
376 #[test]
377 #[should_panic(expected = "a human input step cannot be auto-approved on timeout")]
378 fn on_timeout_rejects_auto_approve() {
379 let _ = HumanInputConfig::new("Answer?").on_timeout(EscalationPolicy::AutoApprove);
380 }
381
382 #[test]
383 #[should_panic(expected = "a human input step cannot be auto-approved on timeout")]
384 fn on_timeout_rejects_a_chain_with_auto_approve() {
385 let _ = HumanInputConfig::new("Answer?").on_timeout(EscalationPolicy::Chain(vec![
386 webhook(),
387 EscalationPolicy::AutoApprove,
388 ]));
389 }
390
391 #[test]
392 fn serde_roundtrip() {
393 let config = HumanInputConfig::new("Answer?")
394 .with_deadline_secs(600)
395 .assigned_to(Assignee::user("alice"))
396 .requiring(Approvers::any().from_groups(["product"]));
397
398 let json = to_string(&config).expect("serialize");
399 let back: HumanInputConfig = from_str(&json).expect("deserialize");
400
401 assert_eq!(back.message(), config.message());
402 assert_eq!(back.deadline_secs(), config.deadline_secs());
403 assert_eq!(back.assignee(), config.assignee());
404 assert_eq!(back.approvers(), config.approvers());
405 assert_eq!(to_string(&back).expect("serialize"), json);
406 }
407
408 #[test]
409 fn serialized_config_reads_as_an_approval_config() {
410 let config = HumanInputConfig::new("Answer?").with_deadline_secs(600);
411 let mut value = to_value(&config).expect("serialize");
412 value.as_object_mut().expect("object").insert(
413 HUMAN_INPUT_SCHEMA_KEY.to_string(),
414 json!({"type": "object"}),
415 );
416
417 let approval: ApprovalConfig = from_value(value).expect("deserialize");
418 assert_eq!(approval.message(), "Answer?");
419 assert_eq!(approval.deadline_secs(), Some(600));
420 }
421}