1use async_trait::async_trait;
30use dashmap::DashMap;
31use std::sync::Arc;
32use std::time::Duration;
33use tokio::sync::oneshot;
34use tokio_util::sync::CancellationToken;
35
36#[derive(Debug, Clone)]
40pub struct InboundApprovalMessage {
41 pub channel: String,
43 pub account_id: String,
46 pub sender_id: String,
49 pub body: String,
50 pub received_at: i64,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum ApprovalCommand {
57 Approve {
58 patch_id: String,
59 },
60 Reject {
61 patch_id: String,
62 reason: Option<String>,
63 },
64}
65
66impl ApprovalCommand {
67 pub fn patch_id(&self) -> &str {
68 match self {
69 ApprovalCommand::Approve { patch_id } | ApprovalCommand::Reject { patch_id, .. } => {
70 patch_id.as_str()
71 }
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum ApprovalDecision {
79 Approved,
80 Rejected { reason: Option<String> },
81 Expired,
82}
83
84pub struct PendingApproval {
89 pub patch_id: String,
90 pub binding_id: String,
91 pub agent_id: String,
92 pub channel: String,
93 pub account_id: String,
94 pub sender_id: String,
95 pub created_at: i64,
96 pub expires_at: i64,
97}
98
99struct PendingEntry {
100 patch: PendingApproval,
101 responder: oneshot::Sender<ApprovalDecision>,
102}
103
104#[derive(Debug, Clone)]
105pub struct ApprovalCorrelatorConfig {
106 pub default_timeout: Duration,
107 pub reaper_interval: Duration,
108}
109
110impl Default for ApprovalCorrelatorConfig {
111 fn default() -> Self {
112 Self {
113 default_timeout: Duration::from_secs(86_400),
114 reaper_interval: Duration::from_secs(60),
115 }
116 }
117}
118
119pub struct ApprovalCorrelator {
123 pending: DashMap<String, PendingEntry>,
124 config: ApprovalCorrelatorConfig,
125 cancel: CancellationToken,
126}
127
128impl ApprovalCorrelator {
129 pub fn new(config: ApprovalCorrelatorConfig) -> Arc<Self> {
130 Arc::new(Self {
131 pending: DashMap::new(),
132 config,
133 cancel: CancellationToken::new(),
134 })
135 }
136
137 pub fn park(&self, patch: PendingApproval) -> oneshot::Receiver<ApprovalDecision> {
142 let (tx, rx) = oneshot::channel();
143 self.pending.insert(
144 patch.patch_id.clone(),
145 PendingEntry {
146 patch,
147 responder: tx,
148 },
149 );
150 rx
151 }
152
153 pub fn pending_count(&self) -> usize {
155 self.pending.len()
156 }
157
158 pub fn cancel_patch(&self, patch_id: &str) -> bool {
162 self.pending.remove(patch_id).is_some()
163 }
164
165 pub fn spawn_workers(self: &Arc<Self>, source: Arc<dyn ApprovalSource>) {
169 let me = Arc::clone(self);
171 let cancel = self.cancel.clone();
172 let src = Arc::clone(&source);
173 tokio::spawn(async move {
174 loop {
175 tokio::select! {
176 _ = cancel.cancelled() => break,
177 msg = src.next_message() => match msg {
178 Some(m) => me.on_inbound(m),
179 None => break,
180 }
181 }
182 }
183 });
184
185 let me = Arc::clone(self);
187 let cancel = self.cancel.clone();
188 let interval = self.config.reaper_interval;
189 tokio::spawn(async move {
190 loop {
191 tokio::select! {
192 _ = cancel.cancelled() => break,
193 _ = tokio::time::sleep(interval) => {
194 me.reap_expired();
195 }
196 }
197 }
198 });
199 }
200
201 pub fn on_inbound(&self, msg: InboundApprovalMessage) {
204 let cmd = match parse_approval_command(&msg.body) {
205 Some(c) => c,
206 None => return,
207 };
208 let patch_id = cmd.patch_id().to_string();
209 let Some((_, entry)) = self.pending.remove(&patch_id) else {
212 tracing::debug!(
213 target: "config::approval",
214 patch_id = %patch_id,
215 "[config] inbound matched no pending entry"
216 );
217 return;
218 };
219 if entry.patch.channel != msg.channel || entry.patch.account_id != msg.account_id {
220 tracing::warn!(
221 target: "config::approval_forgery_rejected",
222 patch_id = %patch_id,
223 expected_channel = %entry.patch.channel,
224 expected_account = %entry.patch.account_id,
225 got_channel = %msg.channel,
226 got_account = %msg.account_id,
227 "[config] approval came from wrong binding — discarded"
228 );
229 self.pending.insert(entry.patch.patch_id.clone(), entry);
231 return;
232 }
233 let decision = match cmd {
234 ApprovalCommand::Approve { .. } => ApprovalDecision::Approved,
235 ApprovalCommand::Reject { reason, .. } => ApprovalDecision::Rejected { reason },
236 };
237 let _ = entry.responder.send(decision);
238 }
239
240 fn reap_expired(&self) {
241 let now = chrono::Utc::now().timestamp();
242 let mut to_drop: Vec<String> = Vec::new();
243 for kv in self.pending.iter() {
244 if kv.value().patch.expires_at <= now {
245 to_drop.push(kv.key().clone());
246 }
247 }
248 for id in to_drop {
249 if let Some((_, entry)) = self.pending.remove(&id) {
250 tracing::info!(
251 target: "config::approval_expired",
252 patch_id = %id,
253 "[config] approval expired"
254 );
255 let _ = entry.responder.send(ApprovalDecision::Expired);
256 }
257 }
258 }
259
260 pub fn shutdown(&self) {
263 self.cancel.cancel();
264 }
265}
266
267#[async_trait]
271pub trait ApprovalSource: Send + Sync {
272 async fn next_message(&self) -> Option<InboundApprovalMessage>;
276}
277
278pub struct MockApprovalSource {
281 queue: tokio::sync::Mutex<std::collections::VecDeque<InboundApprovalMessage>>,
282 notify: tokio::sync::Notify,
283 closed: std::sync::atomic::AtomicBool,
284}
285
286impl Default for MockApprovalSource {
287 fn default() -> Self {
288 Self::new()
289 }
290}
291
292impl MockApprovalSource {
293 pub fn new() -> Self {
294 Self {
295 queue: tokio::sync::Mutex::new(std::collections::VecDeque::new()),
296 notify: tokio::sync::Notify::new(),
297 closed: std::sync::atomic::AtomicBool::new(false),
298 }
299 }
300
301 pub async fn inject(&self, msg: InboundApprovalMessage) {
302 self.queue.lock().await.push_back(msg);
303 self.notify.notify_one();
304 }
305
306 pub fn close(&self) {
307 self.closed
308 .store(true, std::sync::atomic::Ordering::Relaxed);
309 self.notify.notify_waiters();
310 }
311}
312
313#[async_trait]
314impl ApprovalSource for MockApprovalSource {
315 async fn next_message(&self) -> Option<InboundApprovalMessage> {
316 loop {
317 if let Some(m) = self.queue.lock().await.pop_front() {
318 return Some(m);
319 }
320 if self.closed.load(std::sync::atomic::Ordering::Relaxed) {
321 return None;
322 }
323 self.notify.notified().await;
324 }
325 }
326}
327
328pub fn parse_approval_command(body: &str) -> Option<ApprovalCommand> {
339 use std::sync::OnceLock;
340 static RE: OnceLock<regex::Regex> = OnceLock::new();
341 let re = RE.get_or_init(|| {
342 regex::Regex::new(
343 r"(?x)
344 ^\s*
345 \[
346 config-
347 (?P<verb>approve|reject)
348 \s+
349 patch_id=
350 (?P<id>[0-9A-HJKMNP-TV-Z]+)
351 (?:\s+reason=(?P<reason>.*?))?
352 \]
353 \s*$
354 ",
355 )
356 .expect("approval-command regex must compile")
357 });
358 let caps = re.captures(body.trim())?;
359 let verb = caps.name("verb")?.as_str();
360 let id = caps.name("id")?.as_str().to_string();
361 match verb {
362 "approve" => Some(ApprovalCommand::Approve { patch_id: id }),
363 "reject" => {
364 let reason = caps.name("reason").map(|m| m.as_str().trim().to_string());
365 Some(ApprovalCommand::Reject {
366 patch_id: id,
367 reason,
368 })
369 }
370 _ => None,
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use std::sync::atomic::Ordering;
378
379 fn fixture_pending(patch_id: &str) -> PendingApproval {
380 PendingApproval {
381 patch_id: patch_id.into(),
382 binding_id: "whatsapp:default".into(),
383 agent_id: "cody".into(),
384 channel: "whatsapp".into(),
385 account_id: "default".into(),
386 sender_id: "5511".into(),
387 created_at: 0,
388 expires_at: i64::MAX,
389 }
390 }
391
392 fn fixture_inbound(_patch_id: &str, body: &str) -> InboundApprovalMessage {
393 InboundApprovalMessage {
394 channel: "whatsapp".into(),
395 account_id: "default".into(),
396 sender_id: "5511".into(),
397 body: body.into(),
398 received_at: 0,
399 }
400 }
401
402 #[test]
403 fn parse_approve_minimal() {
404 let cmd = parse_approval_command("[config-approve patch_id=01J7HVK9MWXYZ]").unwrap();
405 assert_eq!(
406 cmd,
407 ApprovalCommand::Approve {
408 patch_id: "01J7HVK9MWXYZ".into()
409 }
410 );
411 }
412
413 #[test]
414 fn parse_reject_with_reason() {
415 let cmd = parse_approval_command(
416 "[config-reject patch_id=01J7HVK9MWXYZ reason=use sonnet por costo]",
417 )
418 .unwrap();
419 assert_eq!(
420 cmd,
421 ApprovalCommand::Reject {
422 patch_id: "01J7HVK9MWXYZ".into(),
423 reason: Some("use sonnet por costo".into())
424 }
425 );
426 }
427
428 #[test]
429 fn parse_reject_without_reason() {
430 let cmd = parse_approval_command("[config-reject patch_id=01J7HVK9MWXYZ]").unwrap();
431 assert_eq!(
432 cmd,
433 ApprovalCommand::Reject {
434 patch_id: "01J7HVK9MWXYZ".into(),
435 reason: None
436 }
437 );
438 }
439
440 #[test]
441 fn parse_ignores_garbage() {
442 assert!(parse_approval_command("hello world").is_none());
443 assert!(parse_approval_command("").is_none());
444 assert!(parse_approval_command("[config-approve]").is_none());
445 assert!(parse_approval_command("[config-approve patch_id=]").is_none());
446 assert!(parse_approval_command("[config-approve patch_id=foo]").is_none());
448 }
449
450 #[test]
451 fn parse_strict_anchors_full_message() {
452 assert!(
454 parse_approval_command("let's go [config-approve patch_id=01J7HVK9MWXYZ] thanks")
455 .is_none()
456 );
457 }
458
459 #[tokio::test]
460 async fn correlator_resolves_pending_on_match() {
461 let c = ApprovalCorrelator::new(ApprovalCorrelatorConfig::default());
462 let rx = c.park(fixture_pending("01J7HVK9MWXYZ"));
463 c.on_inbound(fixture_inbound(
464 "01J7HVK9MWXYZ",
465 "[config-approve patch_id=01J7HVK9MWXYZ]",
466 ));
467 let decision = rx.await.unwrap();
468 assert_eq!(decision, ApprovalDecision::Approved);
469 assert_eq!(c.pending_count(), 0);
470 }
471
472 #[tokio::test]
473 async fn correlator_resolves_with_reason_on_reject() {
474 let c = ApprovalCorrelator::new(ApprovalCorrelatorConfig::default());
475 let rx = c.park(fixture_pending("01J7HVK9MWXYZ"));
476 c.on_inbound(fixture_inbound(
477 "01J7HVK9MWXYZ",
478 "[config-reject patch_id=01J7HVK9MWXYZ reason=cost]",
479 ));
480 match rx.await.unwrap() {
481 ApprovalDecision::Rejected { reason } => assert_eq!(reason.as_deref(), Some("cost")),
482 other => panic!("expected Rejected, got {other:?}"),
483 }
484 }
485
486 #[tokio::test]
487 async fn correlator_rejects_cross_binding_message() {
488 let c = ApprovalCorrelator::new(ApprovalCorrelatorConfig::default());
489 let _rx = c.park(fixture_pending("01J7HVK9MWXYZ"));
490 let bad_binding = InboundApprovalMessage {
492 channel: "telegram".into(),
493 account_id: "other".into(),
494 sender_id: "abc".into(),
495 body: "[config-approve patch_id=01J7HVK9MWXYZ]".into(),
496 received_at: 0,
497 };
498 c.on_inbound(bad_binding);
499 assert_eq!(c.pending_count(), 1);
501 }
502
503 #[tokio::test]
504 async fn correlator_no_pending_entry_logs_and_returns() {
505 let c = ApprovalCorrelator::new(ApprovalCorrelatorConfig::default());
506 c.on_inbound(fixture_inbound(
507 "01J7UNKNOWNPATCHID",
508 "[config-approve patch_id=01J7UNKNOWNPATCHID]",
509 ));
510 assert_eq!(c.pending_count(), 0);
511 }
512
513 #[tokio::test(start_paused = true)]
514 async fn correlator_expires_pending_after_timeout() {
515 let cfg = ApprovalCorrelatorConfig {
516 default_timeout: Duration::from_secs(1),
517 reaper_interval: Duration::from_millis(50),
518 };
519 let c = ApprovalCorrelator::new(cfg);
520 let mut p = fixture_pending("01J7HVK9MWXYZ");
521 p.expires_at = chrono::Utc::now().timestamp() - 1; let rx = c.park(p);
523 c.reap_expired();
526 let decision = rx.await.unwrap();
527 assert_eq!(decision, ApprovalDecision::Expired);
528 assert_eq!(c.pending_count(), 0);
529 }
530
531 #[tokio::test]
532 async fn mock_source_round_trips_messages() {
533 let src = Arc::new(MockApprovalSource::new());
534 let inbound = fixture_inbound("01J7HVK9MWXYZ", "[config-approve patch_id=01J7HVK9MWXYZ]");
535 src.inject(inbound.clone()).await;
536 let got = src.next_message().await.unwrap();
537 assert_eq!(got.body, inbound.body);
538 src.close();
539 assert!(src.next_message().await.is_none());
541 assert!(src.closed.load(Ordering::Relaxed));
542 }
543}