Skip to main content

teamctl_ui/
approvals.rs

1//! Approvals — the conditional stripe + the `a`-key modal.
2//!
3//! Two abstractions live here:
4//!
5//! - `ApprovalSource` — the read side. Returns the current set of
6//!   pending `request_approval` rows for the operator to triage.
7//!   Production impl `BrokerApprovalSource` queries SQLite; tests
8//!   use `MockApprovalSource`.
9//! - `ApprovalDecider` — the write side. Routes Approve / Reject
10//!   through the existing `teamctl approve|deny` CLI so the
11//!   T-031 `delivered_at` contract stays honored (the CLI flips
12//!   `delivered_at` if it was null before recording the decision —
13//!   see `crates/teamctl/src/cmd/approval.rs::decide`). Tests inject
14//!   a `MockApprovalDecider` that records the calls.
15//!
16//! The CLI-routed write path is load-bearing: a direct SQLite
17//! `UPDATE approvals SET status='approved' WHERE id=…` from the UI
18//! would silently break the lifecycle invariant T-031 ships.
19
20use 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    /// Optional free-form payload — for now the modal just shows
34    /// the JSON if non-empty. PR-UI-4 doesn't try to pretty-print
35    /// the diff shape.
36    pub payload_json: String,
37}
38
39pub trait ApprovalSource: Send + Sync {
40    /// Snapshot of every approval still in `status='pending'`.
41    /// Empty vec when none. Errors fall back to empty in callers
42    /// (the stripe just stays hidden).
43    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    /// Approve or deny the row at `id`, optionally with a note.
93    /// Production impl shells out to the `teamctl` CLI so the
94    /// T-031 `delivered_at` flip rides for free; tests inject a
95    /// recorder.
96    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    //! Shared mocks — public so unit tests, integration tests, and
125    //! downstream coverage can wire them in without rolling their own.
126
127    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}