Skip to main content

ironflow_engine/context/steps/
approval.rs

1//! Human approval gate for [`WorkflowContext`].
2
3use chrono::{TimeDelta, Utc};
4use serde_json::{json, to_value};
5use tracing::{info, warn};
6
7use ironflow_store::models::{NewStep, Step, StepKind, StepStatus, StepUpdate, step_trace_id};
8
9use crate::config::{ApprovalConfig, Approvers};
10use crate::context::WorkflowContext;
11use crate::context::lifecycle::check_replay_identity;
12use crate::error::EngineError;
13use crate::executor::ApprovalOutcome;
14use crate::notify::{WorkflowApprovalRequiredEvent, WorkflowEvent};
15use crate::plan::lock_plan;
16
17impl WorkflowContext {
18    /// Create a human approval gate.
19    ///
20    /// On first execution, records an approval step and returns
21    /// [`EngineError::ApprovalRequired`] to suspend the run. The engine
22    /// transitions the run to `AwaitingApproval`.
23    ///
24    /// On resume, the approval step is replayed: it is marked `Completed`
25    /// only when it carries the votes it needs (one, or `required_approvers`
26    /// of its [`ApprovalRequirement`](crate::config::ApprovalRequirement)),
27    /// and execution continues past it. A gate replayed without them
28    /// suspends the run again with [`EngineError::ApprovalRequired`]. Multiple
29    /// approval gates in the same handler work -- each one pauses and resumes
30    /// independently.
31    ///
32    /// When the config carries an SLA
33    /// ([`ApprovalConfig::with_deadline`](crate::config::ApprovalConfig::with_deadline),
34    /// or the legacy `with_timeout_seconds`), the deadline is persisted on the
35    /// step so the API server's escalator can apply the configured
36    /// [`EscalationPolicy`](crate::config::EscalationPolicy) when it fires. The
37    /// timer is cleared as soon as the gate resolves.
38    ///
39    /// A [`StepInterceptor`](crate::executor::StepInterceptor) wired into the
40    /// context resolves the gate inline instead of suspending: the step is
41    /// recorded, then completed or rejected without waiting for a human. This
42    /// is what [`crate::testing::TestEngine`] uses to run gated handlers end to
43    /// end.
44    ///
45    /// When the config carries [`Approvers`]
46    /// ([`ApprovalConfig::requiring`]),
47    /// they are recorded on the step as an
48    /// [`ApprovalRequirement`](crate::config::ApprovalRequirement) when the gate
49    /// opens. That record stays the source of truth on replay and resume: the
50    /// approvers the handler computes on a later execution are ignored. A config
51    /// without approvers stores no requirement.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`EngineError::ApprovalRequired`] to pause the run on
56    /// first execution, and on a replay of a gate that does not carry the
57    /// votes it needs yet. Returns [`EngineError::ApprovalRejected`] when an
58    /// interceptor refuses the gate. Returns [`EngineError::ReplayDivergence`]
59    /// when the step recorded at this position has a different name or kind.
60    /// Returns other [`EngineError`] variants on store failures.
61    ///
62    /// # Examples
63    ///
64    /// ```no_run
65    /// use ironflow_engine::context::WorkflowContext;
66    /// use ironflow_engine::config::{ApprovalConfig, Approvers};
67    /// use ironflow_engine::error::EngineError;
68    /// use serde::Deserialize;
69    ///
70    /// #[derive(Deserialize)]
71    /// struct Payment {
72    ///     amount: u64,
73    /// }
74    ///
75    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
76    /// let payment: Payment = ctx.input().await?;
77    /// let approvers = match payment.amount {
78    ///     a if a > 10_000 => Approvers::at_least(2).from_groups(["finance"]).because("amount > 10k"),
79    ///     _ => Approvers::any(),
80    /// };
81    /// ctx.approval("payment-gate", ApprovalConfig::new("Release the payment?").requiring(approvers))
82    ///     .await?;
83    /// // Execution continues here after approval
84    /// # Ok(())
85    /// # }
86    /// ```
87    pub async fn approval(
88        &mut self,
89        name: &str,
90        config: ApprovalConfig,
91    ) -> Result<(), EngineError> {
92        // Plan mode: record the gate and continue. Planning must never suspend,
93        // so this comes before the `ApprovalRequired` path below.
94        if let Some(plan) = self.plan().cloned() {
95            self.position += 1;
96            let mut recorder = lock_plan(&plan);
97            recorder.record_or_stop(name, StepKind::Approval, &self.workflow_name, None)?;
98            recorder.set_last(vec![name.to_string()]);
99            return Ok(());
100        }
101
102        let position = self.position;
103        self.position += 1;
104
105        // Replay: if this approval step exists from a prior execution, it is
106        // completed only once it carries the votes it needs; a gate replayed
107        // without them (a run moved back to `Running` outside the approve
108        // route) suspends the run again instead of approving itself.
109        if let Some(existing) = self.replay_steps.get(&position) {
110            check_replay_identity(existing, position, name, &StepKind::Approval)?;
111
112            if existing.status.state == StepStatus::AwaitingApproval {
113                if !gate_has_its_votes(existing) {
114                    self.last_step_ids = vec![existing.id];
115                    warn!(
116                        run_id = %self.run_id,
117                        step = %name,
118                        position,
119                        approvals = existing.approvals.len(),
120                        required = required_votes(existing),
121                        "approval gate replayed without its votes, run suspended again"
122                    );
123                    return Err(EngineError::ApprovalRequired {
124                        run_id: self.run_id,
125                        step_id: existing.id,
126                        message: config.message().to_string(),
127                    });
128                }
129                self.store
130                    .update_step(
131                        existing.id,
132                        StepUpdate {
133                            status: Some(StepStatus::Completed),
134                            completed_at: Some(Utc::now()),
135                            // An approved gate must never be escalated afterwards.
136                            clear_approval_deadline: true,
137                            ..StepUpdate::default()
138                        },
139                    )
140                    .await?;
141            }
142
143            self.last_step_ids = vec![existing.id];
144            info!(
145                run_id = %self.run_id,
146                step = %name,
147                position,
148                "approval step replayed (approved)"
149            );
150            return Ok(());
151        }
152
153        // Carried over: a human already approved this gate in an earlier
154        // attempt. Record a fresh step in the current attempt so that each
155        // attempt keeps a complete, self-contained DAG, and continue.
156        if let Some(&granted_in) = self.granted_approvals.get(&position) {
157            let trace_id = step_trace_id(self.run_id, name, position);
158            let step = self
159                .store
160                .create_step(NewStep {
161                    run_id: self.run_id,
162                    trace_id,
163                    name: name.to_string(),
164                    kind: StepKind::Approval,
165                    position,
166                    input: Some(to_value(&config)?),
167                    is_error_handler: false,
168                })
169                .await?;
170
171            let now = Utc::now();
172            self.start_step(step.id, now).await?;
173            self.store
174                .update_step(
175                    step.id,
176                    StepUpdate {
177                        status: Some(StepStatus::Completed),
178                        output: Some(json!({"approved_in_attempt": granted_in})),
179                        completed_at: Some(now),
180                        ..StepUpdate::default()
181                    },
182                )
183                .await?;
184
185            self.last_step_ids = vec![step.id];
186            info!(
187                run_id = %self.run_id,
188                step = %name,
189                position,
190                granted_in_attempt = granted_in,
191                attempt = self.attempt,
192                "approval carried over from a previous attempt"
193            );
194            return Ok(());
195        }
196
197        // The approvers are recorded only when the gate opens, never on replay
198        // or carry-over: the stored requirement is the source of truth from here.
199        let requirement = config.approvers().map(Approvers::to_requirement);
200        if let Some(requirement) = &requirement {
201            info!(
202                run_id = %self.run_id,
203                step = %name,
204                reason = ?requirement.reason,
205                required_approvers = requirement.required_approvers,
206                approver_groups = ?requirement.approver_groups,
207                "approval requirement recorded"
208            );
209        }
210
211        // An interceptor resolves the gate inline: the run neither suspends nor
212        // waits for a human.
213        if let Some(interceptor) = self.interceptor.clone()
214            && let Some(outcome) = interceptor.intercept_approval(name, &config)
215        {
216            let trace_id = step_trace_id(self.run_id, name, position);
217            let step = self
218                .store
219                .create_step(NewStep {
220                    run_id: self.run_id,
221                    trace_id,
222                    name: name.to_string(),
223                    kind: StepKind::Approval,
224                    position,
225                    input: Some(to_value(&config)?),
226                    is_error_handler: false,
227                })
228                .await?;
229
230            let now = Utc::now();
231            self.start_step(step.id, now).await?;
232            self.last_step_ids = vec![step.id];
233
234            return match outcome {
235                ApprovalOutcome::Approved => {
236                    self.store
237                        .update_step(
238                            step.id,
239                            StepUpdate {
240                                status: Some(StepStatus::Completed),
241                                output: Some(json!({"approved_by": "step-interceptor"})),
242                                completed_at: Some(now),
243                                approval_requirement: requirement.clone(),
244                                ..StepUpdate::default()
245                            },
246                        )
247                        .await?;
248                    info!(
249                        run_id = %self.run_id,
250                        step = %name,
251                        position,
252                        "approval granted by the step interceptor"
253                    );
254                    Ok(())
255                }
256                ApprovalOutcome::Rejected { reason } => {
257                    // The step FSM only reaches Rejected from AwaitingApproval.
258                    self.store
259                        .update_step(
260                            step.id,
261                            StepUpdate {
262                                status: Some(StepStatus::AwaitingApproval),
263                                approval_requirement: requirement.clone(),
264                                ..StepUpdate::default()
265                            },
266                        )
267                        .await?;
268                    self.store
269                        .update_step(
270                            step.id,
271                            StepUpdate {
272                                status: Some(StepStatus::Rejected),
273                                error: Some(reason.clone()),
274                                completed_at: Some(Utc::now()),
275                                ..StepUpdate::default()
276                            },
277                        )
278                        .await?;
279                    info!(
280                        run_id = %self.run_id,
281                        step = %name,
282                        position,
283                        %reason,
284                        "approval rejected by the step interceptor"
285                    );
286                    Err(EngineError::ApprovalRejected {
287                        run_id: self.run_id,
288                        step_id: step.id,
289                        reason,
290                    })
291                }
292            };
293        }
294
295        // First execution: create the approval step and suspend.
296        let trace_id = step_trace_id(self.run_id, name, position);
297        let step = self
298            .store
299            .create_step(NewStep {
300                run_id: self.run_id,
301                trace_id,
302                name: name.to_string(),
303                kind: StepKind::Approval,
304                position,
305                input: Some(to_value(&config)?),
306                is_error_handler: false,
307            })
308            .await?;
309
310        self.start_step(step.id, Utc::now()).await?;
311
312        // Transition the step to AwaitingApproval so it reflects the suspended
313        // state on the dashboard, and arm the SLA timer in the same update. The
314        // deadline lives in the store, so it survives an API or worker restart.
315        let deadline_at = config
316            .effective_deadline_secs()
317            .map(|secs| Utc::now() + TimeDelta::seconds(secs as i64));
318
319        self.store
320            .update_step(
321                step.id,
322                StepUpdate {
323                    status: Some(StepStatus::AwaitingApproval),
324                    approval_deadline_at: deadline_at,
325                    approval_stage: Some(0),
326                    approval_assignee: config.assignee().cloned(),
327                    approval_requirement: requirement,
328                    ..StepUpdate::default()
329                },
330            )
331            .await?;
332
333        self.last_step_ids = vec![step.id];
334
335        if let Some(ref bus) = self.event_bus {
336            bus.publish(
337                self.run_id,
338                WorkflowEvent::ApprovalRequired(WorkflowApprovalRequiredEvent {
339                    step_name: name.to_string(),
340                    step_index: position,
341                    approval_id: step.id,
342                }),
343            );
344        }
345
346        Err(EngineError::ApprovalRequired {
347            run_id: self.run_id,
348            step_id: step.id,
349            message: config.message().to_string(),
350        })
351    }
352}
353
354/// Votes an approval gate needs: `required_approvers` of its requirement, or
355/// one for a gate opened without approvers.
356pub(crate) fn required_votes(step: &Step) -> u32 {
357    step.approval_requirement
358        .as_ref()
359        .map_or(1, |requirement| requirement.required_approvers)
360}
361
362/// Whether the votes recorded on an approval gate resolve it, counted like the
363/// approve route does.
364fn gate_has_its_votes(step: &Step) -> bool {
365    step.approvals.len() as u32 >= required_votes(step)
366}
367
368#[cfg(test)]
369mod tests {
370    use std::collections::HashMap;
371
372    use chrono::Utc;
373    use serde_json::json;
374    use uuid::Uuid;
375
376    use ironflow_store::memory::InMemoryStore;
377    use ironflow_store::models::{
378        ApprovalRequirement, NewRun, NewStep, Step, StepApproval, StepKind, StepUpdate,
379        TriggerKind, step_trace_id,
380    };
381    use ironflow_store::store::RunStore;
382
383    use super::gate_has_its_votes;
384
385    async fn gate(requirement: Option<u32>, votes: usize) -> Step {
386        let store = InMemoryStore::new();
387        let run = store
388            .create_run(NewRun {
389                workflow_name: "gated".to_string(),
390                trigger: TriggerKind::Manual,
391                payload: json!({}),
392                max_retries: 0,
393                handler_version: None,
394                labels: HashMap::new(),
395                scheduled_at: None,
396                created_by: None,
397                idempotency_key: None,
398                concurrency_key: None,
399                priority: 0,
400                concurrency_limits: Vec::new(),
401                max_cost_usd: None,
402                worker_tags: Vec::new(),
403            })
404            .await
405            .expect("create run")
406            .into_run();
407        let step = store
408            .create_step(NewStep {
409                run_id: run.id,
410                trace_id: step_trace_id(run.id, "gate", 0),
411                name: "gate".to_string(),
412                kind: StepKind::Approval,
413                position: 0,
414                input: None,
415                is_error_handler: false,
416            })
417            .await
418            .expect("create step");
419        store
420            .update_step(
421                step.id,
422                StepUpdate {
423                    approval_requirement: requirement.map(|required_approvers| {
424                        ApprovalRequirement {
425                            required_approvers,
426                            ..ApprovalRequirement::default()
427                        }
428                    }),
429                    ..StepUpdate::default()
430                },
431            )
432            .await
433            .expect("record the requirement");
434        for i in 0..votes {
435            store
436                .record_step_approval(
437                    step.id,
438                    StepApproval {
439                        user_id: Uuid::now_v7(),
440                        approved_by: format!("approver-{i}"),
441                        at: Utc::now(),
442                    },
443                )
444                .await
445                .expect("record vote");
446        }
447        store
448            .get_step(step.id)
449            .await
450            .expect("get step")
451            .expect("step exists")
452    }
453
454    #[tokio::test]
455    async fn gate_without_requirement_needs_one_vote() {
456        assert!(!gate_has_its_votes(&gate(None, 0).await));
457        assert!(gate_has_its_votes(&gate(None, 1).await));
458    }
459
460    #[tokio::test]
461    async fn gate_with_requirement_needs_its_required_approvers() {
462        assert!(!gate_has_its_votes(&gate(Some(2), 0).await));
463        assert!(!gate_has_its_votes(&gate(Some(2), 1).await));
464        assert!(gate_has_its_votes(&gate(Some(2), 2).await));
465        assert!(gate_has_its_votes(&gate(Some(2), 3).await));
466    }
467}