1use std::sync::Arc;
27
28use chrono::{TimeDelta, Utc};
29use reqwest::Client;
30use serde_json::json;
31use tracing::{error, info, warn};
32use uuid::Uuid;
33
34use ironflow_store::models::{
35 Assignee, Run, RunStatus, RunUpdate, Step, StepKind, StepStatus, StepUpdate,
36};
37use strum::IntoStaticStr;
38
39use crate::config::{ApprovalConfig, EscalationPolicy, NotificationTarget};
40use crate::engine::{Engine, ExecutionMode};
41use crate::error::EngineError;
42use crate::notify::{
43 ApprovalEscalatedEvent, ApprovalGrantedEvent, ApprovalRejectedEvent, Event, RetryConfig,
44 deliver_with_retry, is_success_2xx,
45};
46
47pub const SYSTEM_TIMEOUT_ACTOR: &str = "system:timeout";
49
50pub const APPROVAL_TIMEOUT_ERROR: &str = "approval timeout";
52
53pub const DEFAULT_ESCALATION_BATCH_SIZE: u32 = 50;
55
56#[derive(Debug, Clone, PartialEq, Eq, IntoStaticStr)]
67#[strum(serialize_all = "snake_case")]
68pub enum EscalationAction {
69 Approved,
71 Rejected,
73 Notified(usize),
75 Reassigned(Assignee),
77 Exhausted,
79 Stale,
81}
82
83impl EscalationAction {
84 pub fn label(&self) -> &'static str {
95 self.into()
96 }
97}
98
99#[derive(Debug, Clone)]
117pub struct EscalationRecord {
118 pub run_id: Uuid,
120 pub step_id: Uuid,
122 pub stage: u32,
124 pub action: EscalationAction,
126 pub reason: String,
128}
129
130pub struct ApprovalEscalator {
156 engine: Arc<Engine>,
157 client: Client,
158 retry: RetryConfig,
159 batch_size: u32,
160}
161
162impl ApprovalEscalator {
163 pub fn new(engine: Arc<Engine>) -> Self {
185 let retry = RetryConfig::default();
186 Self {
187 engine,
188 client: retry.build_client(),
189 retry,
190 batch_size: DEFAULT_ESCALATION_BATCH_SIZE,
191 }
192 }
193
194 pub fn batch_size(mut self, batch_size: u32) -> Self {
211 self.batch_size = batch_size;
212 self
213 }
214
215 pub async fn tick(&self) -> Result<Vec<EscalationRecord>, EngineError> {
245 let steps = self
246 .engine
247 .store()
248 .claim_due_approval_deadlines(self.batch_size)
249 .await?;
250
251 let mut records = Vec::with_capacity(steps.len());
252 for step in &steps {
253 match self.escalate(step).await {
254 Ok(record) => records.push(record),
255 Err(err) => {
256 error!(
257 run_id = %step.run_id,
258 step_id = %step.id,
259 error = %err,
260 "failed to escalate an expired approval gate"
261 );
262 records.push(EscalationRecord {
263 run_id: step.run_id,
264 step_id: step.id,
265 stage: step.approval_stage,
266 action: EscalationAction::Stale,
267 reason: err.to_string(),
268 });
269 }
270 }
271 }
272
273 Ok(records)
274 }
275
276 async fn escalate(&self, step: &Step) -> Result<EscalationRecord, EngineError> {
278 let stage = step.approval_stage;
279
280 let Some(input) = step.input.as_ref() else {
281 error!(step_id = %step.id, "approval step has no stored configuration");
282 return Ok(self.stale(step, "approval step has no stored configuration"));
283 };
284 let config: ApprovalConfig = match serde_json::from_value(input.clone()) {
285 Ok(config) => config,
286 Err(err) => {
287 error!(step_id = %step.id, error = %err, "unreadable approval configuration");
288 return Ok(self.stale(step, "unreadable approval configuration"));
289 }
290 };
291
292 let run = self.engine.store().get_run(step.run_id).await?;
294 let Some(run) = run else {
295 return Ok(self.stale(step, "run no longer exists"));
296 };
297 if !awaits_gate(&run) || step.status.state != StepStatus::AwaitingApproval {
298 return Ok(self.stale(step, "gate already resolved"));
299 }
300 let paused = run.status.state == RunStatus::Paused;
301
302 let policy = config.effective_policy();
303 let reason = format!(
304 "approval deadline of {}s expired",
305 config.effective_deadline_secs().unwrap_or(0)
306 );
307
308 let Some(current) = resolve_stage(&policy, stage)? else {
309 warn!(
310 run_id = %step.run_id,
311 step_id = %step.id,
312 stage,
313 "escalation chain exhausted; the approval gate stays open with no timer"
314 );
315 self.publish(
316 step,
317 stage,
318 "chain",
319 &EscalationAction::Exhausted,
320 &reason,
321 None,
322 );
323 return Ok(EscalationRecord {
324 run_id: step.run_id,
325 step_id: step.id,
326 stage,
327 action: EscalationAction::Exhausted,
328 reason,
329 });
330 };
331
332 let action = match current {
333 EscalationPolicy::AutoApprove if step.kind == StepKind::HumanInput => {
338 self.auto_reject(step).await?
339 }
340 EscalationPolicy::AutoApprove => self.auto_approve(step, &reason, paused).await?,
341 EscalationPolicy::AutoReject => self.auto_reject(step).await?,
342 EscalationPolicy::Notify(targets) => {
343 self.notify(step, &config, &policy, targets, &reason)
344 .await?
345 }
346 EscalationPolicy::Escalate(assignee) => {
347 self.reassign(step, &config, &policy, assignee).await?
348 }
349 EscalationPolicy::Chain(_) => unreachable!("nested chains are rejected upfront"),
351 };
352
353 let assignee = match &action {
354 EscalationAction::Reassigned(to) => Some(to.clone()),
355 _ => step.approval_assignee.clone(),
356 };
357 self.publish(
358 step,
359 stage,
360 policy_label(current),
361 &action,
362 &reason,
363 assignee,
364 );
365
366 Ok(EscalationRecord {
367 run_id: step.run_id,
368 step_id: step.id,
369 stage,
370 action,
371 reason,
372 })
373 }
374
375 fn stale(&self, step: &Step, reason: &str) -> EscalationRecord {
377 EscalationRecord {
378 run_id: step.run_id,
379 step_id: step.id,
380 stage: step.approval_stage,
381 action: EscalationAction::Stale,
382 reason: reason.to_string(),
383 }
384 }
385
386 async fn auto_approve(
388 &self,
389 step: &Step,
390 reason: &str,
391 paused: bool,
392 ) -> Result<EscalationAction, EngineError> {
393 let now = Utc::now();
394 let store = self.engine.store();
395
396 store
397 .update_step(
398 step.id,
399 StepUpdate {
400 status: Some(StepStatus::Completed),
401 output: Some(json!({
402 "approved_by": SYSTEM_TIMEOUT_ACTOR,
403 "escalated_at": now,
404 "reason": reason,
405 })),
406 completed_at: Some(now),
407 clear_approval_deadline: true,
408 ..StepUpdate::default()
409 },
410 )
411 .await?;
412 if paused {
414 store
415 .update_run(
416 step.run_id,
417 RunUpdate {
418 resume_status: Some(RunStatus::Pending),
419 ..RunUpdate::default()
420 },
421 )
422 .await?;
423 } else {
424 let resume_status = match self.engine.execution_mode() {
425 ExecutionMode::Local => RunStatus::Running,
426 ExecutionMode::Workers => RunStatus::Pending,
427 };
428 store.update_run_status(step.run_id, resume_status).await?;
429 }
430
431 self.engine
432 .event_publisher()
433 .publish(Event::ApprovalGranted(ApprovalGrantedEvent {
434 run_id: step.run_id,
435 step_id: Some(step.id),
436 approved_by: SYSTEM_TIMEOUT_ACTOR.to_string(),
437 user_id: None,
440 approvals_received: step.approvals.len() as u32,
441 approvals_required: step
442 .approval_requirement
443 .as_ref()
444 .map_or(1, |r| r.required_approvers),
445 requirement: step.approval_requirement.clone(),
446 at: now,
447 }));
448
449 if paused {
455 info!(
456 run_id = %step.run_id,
457 "paused run will resume after its auto-approved gate"
458 );
459 } else if matches!(self.engine.execution_mode(), ExecutionMode::Local)
460 && let Err(err) = self.engine.resume_run(step.run_id).await
461 {
462 error!(
463 run_id = %step.run_id,
464 error = %err,
465 "failed to resume run after an auto-approved gate"
466 );
467 }
468
469 Ok(EscalationAction::Approved)
470 }
471
472 async fn auto_reject(&self, step: &Step) -> Result<EscalationAction, EngineError> {
475 let now = Utc::now();
476 let store = self.engine.store();
477
478 store
479 .update_step(
480 step.id,
481 StepUpdate {
482 status: Some(StepStatus::Failed),
483 error: Some(APPROVAL_TIMEOUT_ERROR.to_string()),
484 completed_at: Some(now),
485 clear_approval_deadline: true,
486 ..StepUpdate::default()
487 },
488 )
489 .await?;
490 store
491 .update_run(
492 step.run_id,
493 RunUpdate {
494 status: Some(RunStatus::Failed),
495 error: Some(APPROVAL_TIMEOUT_ERROR.to_string()),
496 completed_at: Some(now),
497 ..RunUpdate::default()
498 },
499 )
500 .await?;
501
502 self.engine
503 .event_publisher()
504 .publish(Event::ApprovalRejected(ApprovalRejectedEvent {
505 run_id: step.run_id,
506 step_id: Some(step.id),
507 rejected_by: SYSTEM_TIMEOUT_ACTOR.to_string(),
508 user_id: None,
511 reason: None,
512 requirement: step.approval_requirement.clone(),
513 at: now,
514 }));
515
516 if let Err(err) = self
519 .engine
520 .fail_ancestors(step.run_id, APPROVAL_TIMEOUT_ERROR)
521 .await
522 {
523 error!(
524 run_id = %step.run_id,
525 error = %err,
526 "failed to fail the ancestors of an auto-rejected run"
527 );
528 }
529
530 Ok(EscalationAction::Rejected)
531 }
532
533 async fn notify(
535 &self,
536 step: &Step,
537 config: &ApprovalConfig,
538 policy: &EscalationPolicy,
539 targets: &[NotificationTarget],
540 reason: &str,
541 ) -> Result<EscalationAction, EngineError> {
542 let event = self.escalated_event(
543 step,
544 step.approval_stage,
545 "notify",
546 EscalationAction::Notified(targets.len()).label(),
547 reason,
548 step.approval_assignee.clone(),
549 );
550 self.deliver_notifications(targets, &event).await;
551
552 self.rearm(step, config, policy, None).await?;
553 Ok(EscalationAction::Notified(targets.len()))
554 }
555
556 async fn reassign(
558 &self,
559 step: &Step,
560 config: &ApprovalConfig,
561 policy: &EscalationPolicy,
562 assignee: &Assignee,
563 ) -> Result<EscalationAction, EngineError> {
564 self.rearm(step, config, policy, Some(assignee)).await?;
565 Ok(EscalationAction::Reassigned(assignee.clone()))
566 }
567
568 async fn rearm(
574 &self,
575 step: &Step,
576 config: &ApprovalConfig,
577 policy: &EscalationPolicy,
578 assignee: Option<&Assignee>,
579 ) -> Result<(), EngineError> {
580 let next_stage = next_stage(policy, step.approval_stage);
581 let deadline = config
582 .effective_deadline_secs()
583 .map(|secs| Utc::now() + TimeDelta::seconds(secs as i64));
584
585 self.engine
586 .store()
587 .update_step(
588 step.id,
589 StepUpdate {
590 approval_deadline_at: deadline,
591 approval_stage: Some(next_stage),
592 approval_assignee: assignee.cloned(),
593 ..StepUpdate::default()
594 },
595 )
596 .await?;
597
598 Ok(())
599 }
600
601 fn escalated_event(
603 &self,
604 step: &Step,
605 stage: u32,
606 policy: &str,
607 action: &str,
608 reason: &str,
609 assignee: Option<Assignee>,
610 ) -> ApprovalEscalatedEvent {
611 ApprovalEscalatedEvent {
612 run_id: step.run_id,
613 step_id: step.id,
614 step_name: step.name.clone(),
615 stage,
616 policy: policy.to_string(),
617 action: action.to_string(),
618 reason: reason.to_string(),
619 assignee,
620 at: Utc::now(),
621 }
622 }
623
624 fn publish(
626 &self,
627 step: &Step,
628 stage: u32,
629 policy: &str,
630 action: &EscalationAction,
631 reason: &str,
632 assignee: Option<Assignee>,
633 ) {
634 let event = self.escalated_event(step, stage, policy, action.label(), reason, assignee);
635
636 info!(
637 run_id = %step.run_id,
638 step_id = %step.id,
639 stage,
640 policy,
641 action = action.label(),
642 reason,
643 "approval gate escalated"
644 );
645
646 self.engine
647 .event_publisher()
648 .publish(Event::ApprovalEscalated(event));
649 }
650
651 async fn deliver_notifications(
657 &self,
658 targets: &[NotificationTarget],
659 event: &ApprovalEscalatedEvent,
660 ) {
661 for target in targets {
662 match target {
663 NotificationTarget::Webhook { url } => {
664 deliver_with_retry(
665 &self.retry,
666 || self.client.post(url).json(event),
667 is_success_2xx,
668 "approval_escalation",
669 url,
670 )
671 .await;
672 }
673 NotificationTarget::Slack {
674 webhook_url,
675 channel,
676 } => {
677 let body = json!({
678 "channel": channel,
679 "text": format!(
680 "⏰ Approval gate `{}` on run {} hit its SLA: {}",
681 event.step_name, event.run_id, event.reason
682 ),
683 });
684 deliver_with_retry(
685 &self.retry,
686 || self.client.post(webhook_url).json(&body),
687 is_success_2xx,
688 "approval_escalation",
689 webhook_url,
690 )
691 .await;
692 }
693 }
694 }
695 }
696}
697
698fn awaits_gate(run: &Run) -> bool {
701 run.status.state == RunStatus::AwaitingApproval
702 || (run.status.state == RunStatus::Paused
703 && run.resume_status == Some(RunStatus::AwaitingApproval))
704}
705
706fn resolve_stage(
711 policy: &EscalationPolicy,
712 stage: u32,
713) -> Result<Option<&EscalationPolicy>, EngineError> {
714 match policy.stage(stage as usize) {
715 Some(EscalationPolicy::Chain(_)) => Err(EngineError::StepConfig(
716 "nested escalation chains are not supported".to_string(),
717 )),
718 other => Ok(other),
719 }
720}
721
722fn next_stage(policy: &EscalationPolicy, stage: u32) -> u32 {
727 match policy {
728 EscalationPolicy::Chain(_) => stage.saturating_add(1),
729 _ => stage,
730 }
731}
732
733fn policy_label(policy: &EscalationPolicy) -> &'static str {
735 match policy {
736 EscalationPolicy::AutoApprove => "auto_approve",
737 EscalationPolicy::AutoReject => "auto_reject",
738 EscalationPolicy::Notify(_) => "notify",
739 EscalationPolicy::Escalate(_) => "escalate",
740 EscalationPolicy::Chain(_) => "chain",
741 }
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 #[test]
749 fn actions_compare_by_value() {
750 assert_eq!(EscalationAction::Notified(2), EscalationAction::Notified(2));
751 assert_ne!(EscalationAction::Notified(2), EscalationAction::Notified(3));
752 assert_eq!(
753 EscalationAction::Reassigned(Assignee::group("sre")),
754 EscalationAction::Reassigned(Assignee::group("sre"))
755 );
756 assert_ne!(EscalationAction::Approved, EscalationAction::Rejected);
757 }
758
759 #[test]
760 fn action_labels_are_distinct() {
761 let labels = [
762 EscalationAction::Approved.label(),
763 EscalationAction::Rejected.label(),
764 EscalationAction::Notified(1).label(),
765 EscalationAction::Reassigned(Assignee::group("sre")).label(),
766 EscalationAction::Exhausted.label(),
767 EscalationAction::Stale.label(),
768 ];
769
770 for (i, a) in labels.iter().enumerate() {
771 for (j, b) in labels.iter().enumerate() {
772 if i != j {
773 assert_ne!(a, b, "labels {i} and {j} collide");
774 }
775 }
776 }
777 }
778
779 #[test]
780 fn a_bare_repeating_policy_stays_at_the_same_stage() {
781 let notify = EscalationPolicy::Notify(Vec::new());
782 assert_eq!(next_stage(¬ify, 0), 0);
783 assert_eq!(next_stage(¬ify, 7), 7);
784
785 let escalate = EscalationPolicy::Escalate(Assignee::group("sre"));
786 assert_eq!(next_stage(&escalate, 3), 3);
787 }
788
789 #[test]
790 fn a_chained_policy_advances_one_stage_per_expiry() {
791 let chain = EscalationPolicy::Chain(vec![
792 EscalationPolicy::Notify(Vec::new()),
793 EscalationPolicy::AutoReject,
794 ]);
795 assert_eq!(next_stage(&chain, 0), 1);
796 assert_eq!(next_stage(&chain, 1), 2);
797 assert_eq!(next_stage(&chain, u32::MAX), u32::MAX);
798 }
799
800 #[test]
801 fn resolve_stage_rejects_a_nested_chain() {
802 let policy = EscalationPolicy::Chain(vec![
803 EscalationPolicy::Chain(vec![EscalationPolicy::AutoReject]),
804 EscalationPolicy::AutoApprove,
805 ]);
806
807 let err = resolve_stage(&policy, 0).expect_err("nested chains are rejected");
808 assert!(
809 err.to_string().contains("nested escalation chains"),
810 "got {err}"
811 );
812
813 assert_eq!(
815 resolve_stage(&policy, 1).expect("valid stage"),
816 Some(&EscalationPolicy::AutoApprove)
817 );
818 }
819
820 #[test]
821 fn resolve_stage_reports_an_exhausted_chain_as_none() {
822 let policy = EscalationPolicy::Chain(vec![EscalationPolicy::AutoReject]);
823 assert!(resolve_stage(&policy, 1).expect("valid call").is_none());
824
825 let bare = EscalationPolicy::AutoApprove;
826 assert!(resolve_stage(&bare, 1).expect("valid call").is_none());
827 assert_eq!(
828 resolve_stage(&bare, 0).expect("valid call"),
829 Some(&EscalationPolicy::AutoApprove)
830 );
831 }
832
833 #[test]
834 fn policy_labels_match_the_wire_format() {
835 assert_eq!(policy_label(&EscalationPolicy::AutoApprove), "auto_approve");
836 assert_eq!(policy_label(&EscalationPolicy::AutoReject), "auto_reject");
837 assert_eq!(
838 policy_label(&EscalationPolicy::Notify(Vec::new())),
839 "notify"
840 );
841 assert_eq!(
842 policy_label(&EscalationPolicy::Escalate(Assignee::group("sre"))),
843 "escalate"
844 );
845 assert_eq!(policy_label(&EscalationPolicy::Chain(Vec::new())), "chain");
846 }
847}