agentic_workflow/governance/
approval.rs1use std::collections::HashMap;
2
3use chrono::Utc;
4use uuid::Uuid;
5
6use crate::types::{
7 ApprovalDecision, ApprovalGate, ApprovalReceipt, PendingApproval,
8 WorkflowError, WorkflowResult,
9};
10
11pub struct ApprovalEngine {
13 gates: HashMap<String, ApprovalGate>,
14 pending: HashMap<String, PendingApproval>,
15 receipts: Vec<ApprovalReceipt>,
16}
17
18impl ApprovalEngine {
19 pub fn new() -> Self {
20 Self {
21 gates: HashMap::new(),
22 pending: HashMap::new(),
23 receipts: Vec::new(),
24 }
25 }
26
27 pub fn define_gate(&mut self, gate: ApprovalGate) -> WorkflowResult<()> {
29 self.gates.insert(gate.id.clone(), gate);
30 Ok(())
31 }
32
33 pub fn request_approval(
35 &mut self,
36 gate_id: &str,
37 execution_id: &str,
38 step_id: &str,
39 context: serde_json::Value,
40 ) -> WorkflowResult<String> {
41 let gate = self
42 .gates
43 .get(gate_id)
44 .ok_or_else(|| WorkflowError::ApprovalRequired(gate_id.to_string()))?;
45
46 let now = Utc::now();
47 let current_approver = gate
48 .approver_chain
49 .first()
50 .map(|a| a.identity.clone())
51 .unwrap_or_else(|| "unknown".to_string());
52
53 let expires_at = gate
54 .timeout
55 .as_ref()
56 .map(|t| now + chrono::Duration::milliseconds(t.timeout_ms as i64));
57
58 let pending_id = Uuid::new_v4().to_string();
59 let pending = PendingApproval {
60 gate_id: gate_id.to_string(),
61 execution_id: execution_id.to_string(),
62 step_id: step_id.to_string(),
63 requested_at: now,
64 expires_at,
65 current_approver,
66 context,
67 };
68
69 self.pending.insert(pending_id.clone(), pending);
70 Ok(pending_id)
71 }
72
73 pub fn decide(
75 &mut self,
76 pending_id: &str,
77 decision: ApprovalDecision,
78 decided_by: &str,
79 reason: Option<String>,
80 ) -> WorkflowResult<ApprovalReceipt> {
81 let pending = self
82 .pending
83 .remove(pending_id)
84 .ok_or_else(|| WorkflowError::Internal(format!("No pending approval: {}", pending_id)))?;
85
86 let receipt = ApprovalReceipt {
87 gate_id: pending.gate_id,
88 execution_id: pending.execution_id,
89 decision,
90 decided_by: decided_by.to_string(),
91 decided_at: Utc::now(),
92 reason,
93 checksum: blake3::hash(pending_id.as_bytes()).to_hex().to_string(),
94 };
95
96 self.receipts.push(receipt.clone());
97 Ok(receipt)
98 }
99
100 pub fn list_pending(&self) -> Vec<(&str, &PendingApproval)> {
102 self.pending.iter().map(|(k, v)| (k.as_str(), v)).collect()
103 }
104
105 pub fn get_receipts(&self, gate_id: Option<&str>) -> Vec<&ApprovalReceipt> {
107 match gate_id {
108 Some(gid) => self.receipts.iter().filter(|r| r.gate_id == gid).collect(),
109 None => self.receipts.iter().collect(),
110 }
111 }
112
113 pub fn escalate(&mut self, pending_id: &str) -> WorkflowResult<()> {
115 let pending = self
116 .pending
117 .get_mut(pending_id)
118 .ok_or_else(|| WorkflowError::Internal(format!("No pending approval: {}", pending_id)))?;
119
120 let gate = self
121 .gates
122 .get(&pending.gate_id)
123 .ok_or_else(|| WorkflowError::Internal("Gate not found".to_string()))?;
124
125 let current_idx = gate
127 .approver_chain
128 .iter()
129 .position(|a| a.identity == pending.current_approver);
130
131 if let Some(idx) = current_idx {
132 if idx + 1 < gate.approver_chain.len() {
133 pending.current_approver = gate.approver_chain[idx + 1].identity.clone();
134 return Ok(());
135 }
136 }
137
138 Err(WorkflowError::Internal(
139 "No more approvers in chain".to_string(),
140 ))
141 }
142
143 pub fn get_gate(&self, gate_id: &str) -> Option<&ApprovalGate> {
145 self.gates.get(gate_id)
146 }
147}
148
149impl Default for ApprovalEngine {
150 fn default() -> Self {
151 Self::new()
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::types::approval::Approver;
159
160 #[test]
161 fn test_approval_flow() {
162 let mut engine = ApprovalEngine::new();
163
164 let gate = ApprovalGate {
165 id: "deploy-gate".into(),
166 step_id: "deploy-prod".into(),
167 workflow_id: "ci-cd".into(),
168 approver_chain: vec![
169 Approver { identity: "alice".into(), role: Some("lead".into()), priority: 1 },
170 Approver { identity: "bob".into(), role: Some("manager".into()), priority: 2 },
171 ],
172 condition: None,
173 timeout: None,
174 delegation: None,
175 };
176
177 engine.define_gate(gate).unwrap();
178
179 let pid = engine
180 .request_approval("deploy-gate", "exec-1", "deploy-prod", serde_json::json!({}))
181 .unwrap();
182
183 assert_eq!(engine.list_pending().len(), 1);
184
185 let receipt = engine
186 .decide(&pid, ApprovalDecision::Approved, "alice", Some("LGTM".into()))
187 .unwrap();
188
189 assert!(matches!(receipt.decision, ApprovalDecision::Approved));
190 assert_eq!(engine.list_pending().len(), 0);
191 }
192}