1use std::path::PathBuf;
21use std::process::Command;
22
23use anyhow::{Context, Result};
24use rusqlite::Connection;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Approval {
28 pub id: i64,
29 pub project_id: String,
30 pub agent_id: String,
31 pub action: String,
32 pub summary: String,
33 pub payload_json: String,
37}
38
39pub trait ApprovalSource: Send + Sync {
40 fn pending(&self) -> Result<Vec<Approval>>;
44}
45
46#[derive(Debug, Clone)]
47pub struct BrokerApprovalSource {
48 pub db_path: PathBuf,
49}
50
51impl BrokerApprovalSource {
52 pub fn new(db_path: PathBuf) -> Self {
53 Self { db_path }
54 }
55}
56
57impl ApprovalSource for BrokerApprovalSource {
58 fn pending(&self) -> Result<Vec<Approval>> {
59 if !self.db_path.is_file() {
60 return Ok(Vec::new());
61 }
62 let conn = Connection::open(&self.db_path)?;
63 let mut stmt = conn.prepare(
64 "SELECT id, project_id, agent_id, action, summary, payload_json FROM approvals
65 WHERE status = 'pending'
66 ORDER BY id ASC",
67 )?;
68 let rows = stmt
69 .query_map([], |r| {
70 Ok(Approval {
71 id: r.get(0)?,
72 project_id: r.get(1)?,
73 agent_id: r.get(2)?,
74 action: r.get(3)?,
75 summary: r.get(4)?,
76 payload_json: r.get::<_, Option<String>>(5)?.unwrap_or_default(),
77 })
78 })?
79 .flatten()
80 .collect();
81 Ok(rows)
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Decision {
87 Approve,
88 Deny,
89}
90
91pub trait ApprovalDecider: Send + Sync {
92 fn decide(&self, root: &std::path::Path, id: i64, kind: Decision, note: &str) -> Result<()>;
97}
98
99#[derive(Debug, Default, Clone, Copy)]
100pub struct CliApprovalDecider;
101
102impl ApprovalDecider for CliApprovalDecider {
103 fn decide(&self, root: &std::path::Path, id: i64, kind: Decision, note: &str) -> Result<()> {
104 let verb = match kind {
105 Decision::Approve => "approve",
106 Decision::Deny => "deny",
107 };
108 let mut cmd = Command::new("teamctl");
109 cmd.arg("--root").arg(root).arg(verb).arg(id.to_string());
110 if !note.is_empty() {
111 cmd.arg("--note").arg(note);
112 }
113 let status = cmd
114 .status()
115 .with_context(|| format!("invoke teamctl {verb} {id}"))?;
116 if !status.success() {
117 anyhow::bail!("teamctl {verb} {id} exited {status}");
118 }
119 Ok(())
120 }
121}
122
123pub mod test_support {
124 use super::*;
128 use std::sync::Mutex;
129
130 #[derive(Default)]
131 pub struct MockApprovalSource {
132 pub rows: Mutex<Vec<Approval>>,
133 }
134
135 impl MockApprovalSource {
136 pub fn new(rows: Vec<Approval>) -> Self {
137 Self {
138 rows: Mutex::new(rows),
139 }
140 }
141 pub fn set(&self, rows: Vec<Approval>) {
142 *self.rows.lock().unwrap() = rows;
143 }
144 }
145
146 impl ApprovalSource for MockApprovalSource {
147 fn pending(&self) -> Result<Vec<Approval>> {
148 Ok(self.rows.lock().unwrap().clone())
149 }
150 }
151
152 #[derive(Default)]
153 pub struct MockApprovalDecider {
154 pub calls: Mutex<Vec<(i64, Decision, String)>>,
155 }
156
157 impl ApprovalDecider for MockApprovalDecider {
158 fn decide(
159 &self,
160 _root: &std::path::Path,
161 id: i64,
162 kind: Decision,
163 note: &str,
164 ) -> Result<()> {
165 self.calls.lock().unwrap().push((id, kind, note.into()));
166 Ok(())
167 }
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::test_support::*;
174 use super::*;
175
176 fn ap(id: i64, action: &str, summary: &str) -> Approval {
177 Approval {
178 id,
179 project_id: "p".into(),
180 agent_id: "p:m".into(),
181 action: action.into(),
182 summary: summary.into(),
183 payload_json: String::new(),
184 }
185 }
186
187 #[test]
188 fn mock_source_returns_what_it_was_seeded_with() {
189 let src = MockApprovalSource::new(vec![ap(1, "publish", "post the brief")]);
190 let rows = src.pending().unwrap();
191 assert_eq!(rows.len(), 1);
192 assert_eq!(rows[0].action, "publish");
193 }
194
195 #[test]
196 fn mock_decider_records_calls() {
197 let dec = MockApprovalDecider::default();
198 dec.decide(std::path::Path::new("/x"), 7, Decision::Approve, "ship it")
199 .unwrap();
200 dec.decide(std::path::Path::new("/x"), 7, Decision::Deny, "")
201 .unwrap();
202 let calls = dec.calls.lock().unwrap().clone();
203 assert_eq!(
204 calls,
205 vec![
206 (7, Decision::Approve, "ship it".to_string()),
207 (7, Decision::Deny, String::new()),
208 ]
209 );
210 }
211}