Skip to main content

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}