1use std::collections::{BTreeMap, BTreeSet};
2
3use runx_contracts::{
4 ApprovalGate, ExecutionEvent, JsonObject, JsonValue, ResolutionRequest, ResolutionResponse,
5 ResolutionResponseActor, sha256_prefixed,
6};
7use thiserror::Error;
8
9use crate::{Host, RuntimeError};
10
11#[derive(Debug, Error)]
12pub enum ApprovalError {
13 #[error(transparent)]
14 Runtime(#[from] RuntimeError),
15 #[error(
16 "approval response payload from {actor:?} must be a boolean or {{approved: bool, reason?: string}}, got {payload_type}"
17 )]
18 NonBooleanPayload {
19 actor: ResolutionResponseActor,
20 payload_type: &'static str,
21 },
22 #[error("approval gate serialization failed while {context}: {source}")]
23 Json {
24 context: String,
25 #[source]
26 source: serde_json::Error,
27 },
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub enum ApprovalResolution {
32 Approved {
33 actor: ResolutionResponseActor,
34 idempotency_key: String,
35 reason: Option<String>,
36 },
37 Denied {
38 actor: ResolutionResponseActor,
39 idempotency_key: String,
40 reason: Option<String>,
41 },
42 Pending {
43 idempotency_key: String,
44 },
45}
46
47impl ApprovalResolution {
48 #[must_use]
49 pub fn approved(&self) -> Option<bool> {
50 match self {
51 Self::Approved { .. } => Some(true),
52 Self::Denied { .. } => Some(false),
53 Self::Pending { .. } => None,
54 }
55 }
56
57 #[must_use]
58 pub fn actor(&self) -> Option<&ResolutionResponseActor> {
59 match self {
60 Self::Approved { actor, .. } | Self::Denied { actor, .. } => Some(actor),
61 Self::Pending { .. } => None,
62 }
63 }
64
65 #[must_use]
66 pub fn reason(&self) -> Option<&str> {
67 match self {
68 Self::Approved { reason, .. } | Self::Denied { reason, .. } => reason.as_deref(),
69 Self::Pending { .. } => None,
70 }
71 }
72
73 #[must_use]
74 pub fn idempotency_key(&self) -> &str {
75 match self {
76 Self::Approved {
77 idempotency_key, ..
78 }
79 | Self::Denied {
80 idempotency_key, ..
81 }
82 | Self::Pending { idempotency_key } => idempotency_key,
83 }
84 }
85}
86
87#[derive(Debug, Default)]
88pub struct LocalApprovalGateResolver {
89 requested: BTreeSet<String>,
90 resolved: BTreeMap<String, CachedApproval>,
91}
92
93impl LocalApprovalGateResolver {
94 #[must_use]
95 pub fn new() -> Self {
96 Self::default()
97 }
98
99 pub fn request_approval(
100 &mut self,
101 host: &mut dyn Host,
102 id: impl Into<String>,
103 gate: ApprovalGate,
104 ) -> Result<ApprovalResolution, ApprovalError> {
105 let id = id.into();
106 let idempotency_key = approval_idempotency_key(&gate)?;
107 if let Some(cached) = self.resolved.get(&idempotency_key) {
108 return Ok(cached.resolution(idempotency_key));
109 }
110
111 self.report_requested(host, &id, &gate, &idempotency_key)?;
112 let response = host.resolve(ResolutionRequest::Approval {
113 id: id.clone().into(),
114 gate: gate.clone(),
115 })?;
116 let Some(response) = response else {
117 return Ok(ApprovalResolution::Pending { idempotency_key });
118 };
119
120 self.resolve_response(host, &id, &gate, idempotency_key, response)
121 }
122
123 fn report_requested(
124 &mut self,
125 host: &mut dyn Host,
126 id: &str,
127 gate: &ApprovalGate,
128 idempotency_key: &str,
129 ) -> Result<(), ApprovalError> {
130 if self.requested.insert(idempotency_key.to_owned()) {
131 host.report(requested_event(id, gate, idempotency_key))?;
132 }
133 Ok(())
134 }
135
136 fn resolve_response(
137 &mut self,
138 host: &mut dyn Host,
139 id: &str,
140 gate: &ApprovalGate,
141 idempotency_key: String,
142 response: ResolutionResponse,
143 ) -> Result<ApprovalResolution, ApprovalError> {
144 let cached = CachedApproval::from_response(response)?;
145 host.report(resolved_event(id, gate, &idempotency_key, &cached))?;
146 let resolution = cached.resolution(idempotency_key.clone());
147 self.resolved.insert(idempotency_key, cached);
148 Ok(resolution)
149 }
150}
151
152pub fn request_approval(
153 host: &mut dyn Host,
154 id: impl Into<String>,
155 gate: ApprovalGate,
156) -> Result<ApprovalResolution, ApprovalError> {
157 LocalApprovalGateResolver::new().request_approval(host, id, gate)
158}
159
160pub fn approval_idempotency_key(gate: &ApprovalGate) -> Result<String, ApprovalError> {
161 let canonical = serde_json::to_string(gate).map_err(|source| ApprovalError::Json {
162 context: "serializing approval gate".to_owned(),
163 source,
164 })?;
165 Ok(sha256_prefixed(canonical.as_bytes()))
166}
167
168#[derive(Clone, Debug)]
169struct CachedApproval {
170 actor: ResolutionResponseActor,
171 approved: bool,
172 reason: Option<String>,
173}
174
175impl CachedApproval {
176 fn from_response(response: ResolutionResponse) -> Result<Self, ApprovalError> {
183 let payload_type = payload_type(&response.payload);
184 let actor = response.actor;
185 let reject = || ApprovalError::NonBooleanPayload {
186 actor: actor.clone(),
187 payload_type,
188 };
189 match response.payload {
190 JsonValue::Bool(approved) => Ok(Self {
191 actor,
192 approved,
193 reason: None,
194 }),
195 JsonValue::Object(object) => {
196 let Some(JsonValue::Bool(approved)) = object.get("approved") else {
197 return Err(reject());
198 };
199 let reason = match object.get("reason") {
200 None | Some(JsonValue::Null) => None,
201 Some(JsonValue::String(reason)) if !reason.trim().is_empty() => {
202 Some(reason.clone())
203 }
204 Some(_) => return Err(reject()),
205 };
206 Ok(Self {
207 actor,
208 approved: *approved,
209 reason,
210 })
211 }
212 _ => Err(reject()),
213 }
214 }
215
216 fn resolution(&self, idempotency_key: String) -> ApprovalResolution {
217 if self.approved {
218 ApprovalResolution::Approved {
219 actor: self.actor.clone(),
220 idempotency_key,
221 reason: self.reason.clone(),
222 }
223 } else {
224 ApprovalResolution::Denied {
225 actor: self.actor.clone(),
226 idempotency_key,
227 reason: self.reason.clone(),
228 }
229 }
230 }
231}
232
233fn requested_event(id: &str, gate: &ApprovalGate, idempotency_key: &str) -> ExecutionEvent {
234 ExecutionEvent::ResolutionRequested {
235 message: format!("approval {} requested", gate.id),
236 data: Some(JsonValue::Object(event_data(id, gate, idempotency_key))),
237 }
238}
239
240fn resolved_event(
241 id: &str,
242 gate: &ApprovalGate,
243 idempotency_key: &str,
244 approval: &CachedApproval,
245) -> ExecutionEvent {
246 let mut data = event_data(id, gate, idempotency_key);
247 data.insert(
248 "actor".to_owned(),
249 JsonValue::String(actor_name(&approval.actor)),
250 );
251 data.insert("approved".to_owned(), JsonValue::Bool(approval.approved));
252 if let Some(reason) = &approval.reason {
253 data.insert("reason".to_owned(), JsonValue::String(reason.clone()));
254 }
255 let decision = if approval.approved {
256 "approved"
257 } else {
258 "denied"
259 };
260 ExecutionEvent::ResolutionResolved {
261 message: format!("approval {} {decision}", gate.id),
262 data: Some(JsonValue::Object(data)),
263 }
264}
265
266fn event_data(id: &str, gate: &ApprovalGate, idempotency_key: &str) -> JsonObject {
267 let mut data = JsonObject::new();
268 data.insert("request_id".to_owned(), JsonValue::String(id.to_owned()));
269 data.insert(
270 "gate_id".to_owned(),
271 JsonValue::String(gate.id.as_str().to_owned()),
272 );
273 if let Some(gate_type) = &gate.gate_type {
274 data.insert("gate_type".to_owned(), JsonValue::String(gate_type.clone()));
275 }
276 data.insert(
277 "idempotency_key".to_owned(),
278 JsonValue::String(idempotency_key.to_owned()),
279 );
280 data
281}
282
283fn actor_name(actor: &ResolutionResponseActor) -> String {
284 match actor {
285 ResolutionResponseActor::Human => "human".to_owned(),
286 ResolutionResponseActor::Agent => "agent".to_owned(),
287 }
288}
289
290fn payload_type(payload: &JsonValue) -> &'static str {
291 match payload {
292 JsonValue::Null => "null",
293 JsonValue::Bool(_) => "boolean",
294 JsonValue::Number(_) => "number",
295 JsonValue::String(_) => "string",
296 JsonValue::Array(_) => "array",
297 JsonValue::Object(_) => "object",
298 }
299}