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}