Skip to main content

teamctl_ui/
data.rs

1//! `TeamSnapshot` — point-in-time read of the dogfood team that the UI
2//! renders against. Built by walking up to the nearest `.team/`,
3//! parsing `team-compose.yaml`, querying the supervisor for each
4//! agent's process state, and aggregating a small set of mailbox
5//! counters (unread + pending approvals).
6//!
7//! Read by both `App::tick()` (live refresh every second) and the
8//! snapshot tests (constructed manually). The snapshot is intentionally
9//! cheap to build — every field is derived from a single SQL query
10//! per agent — so refresh cadence stays well under tmux's own
11//! `capture-pane` cost.
12
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16use anyhow::{Context, Result};
17use rusqlite::Connection;
18use team_core::compose::Compose;
19use team_core::supervisor::{AgentSpec, AgentState, Supervisor, TmuxSupervisor};
20
21/// Per-agent fields the UI reads to render the roster + drive
22/// selection / detail-pane streaming.
23#[derive(Debug, Clone)]
24pub struct AgentInfo {
25    /// `<project>:<agent>` — the canonical id used in `teamctl send`
26    /// targets, MCP tool calls, and `reports_to` chains.
27    pub id: String,
28    /// Short agent name within the project (the YAML key).
29    pub agent: String,
30    /// Project id this agent belongs to.
31    pub project: String,
32    /// Resolved tmux session name (`<prefix><project>-<agent>`) — fed
33    /// to the pane-capture call so the detail pane targets the right
34    /// session even when `tmux_prefix` rotates.
35    pub tmux_session: String,
36    /// Process state — `Running`, `Stopped`, or `Unknown` per the
37    /// supervisor trait. Drives the primary glyph in the roster.
38    pub state: AgentState,
39    /// Count of mailbox messages addressed to this agent that haven't
40    /// been ack'd yet. Surfaces the `✉` glyph when nonzero.
41    pub unread_mail: u32,
42    /// Count of `request_approval` rows still in `pending` state for
43    /// this agent. Surfaces the `!` glyph when nonzero (highest
44    /// priority — overrides the unread-mail glyph).
45    pub pending_approvals: u32,
46    /// `true` for managers (`is_manager: true` in compose), used when
47    /// the roster wants to draw a tier separator. Read but unused in
48    /// PR-UI-2; kept on the struct so PR-UI-4's approvals modal can
49    /// route based on tier without a second compose lookup.
50    pub is_manager: bool,
51}
52
53/// One channel exposed in `team-compose.yaml`. Used by PR-UI-6's
54/// per-channel broadcast picker and by the Mailbox-first layout's
55/// channel list. `id` is `<project>:<name>` (matches the broker's
56/// `channels.id`); `name` is the short label rendered as `#name`.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ChannelInfo {
59    pub id: String,
60    pub name: String,
61    pub project_id: String,
62}
63
64#[derive(Debug, Clone)]
65pub struct TeamSnapshot {
66    /// Path to the `.team/` discovered by walk-up (the compose root).
67    pub root: PathBuf,
68    /// Human label from `team-compose.yaml::projects[].project.name`
69    /// — falls back to the project id when name is empty.
70    pub team_name: String,
71    /// Agents in deterministic order: managers first, then workers,
72    /// each group sorted by id. Roster navigation (`↑` / `↓`) walks
73    /// this slice directly.
74    pub agents: Vec<AgentInfo>,
75    /// Channels declared across every project file. Drives the
76    /// PR-UI-6 broadcast picker + the Mailbox-first layout's
77    /// channel list.
78    pub channels: Vec<ChannelInfo>,
79}
80
81impl TeamSnapshot {
82    /// Build an empty snapshot rooted at the given path. Used by
83    /// tests and as the rendered shape when no `.team/` is reachable.
84    pub fn empty(root: PathBuf) -> Self {
85        Self {
86            root,
87            team_name: "(no team loaded)".into(),
88            agents: Vec::new(),
89            channels: Vec::new(),
90        }
91    }
92
93    /// Walk up from cwd to find the nearest `.team/`, parse the
94    /// compose tree, query supervisor + mailbox state per agent,
95    /// and return the assembled snapshot. Returns `Ok(None)` when
96    /// no `.team/` is reachable — the UI renders the empty state in
97    /// that case rather than panicking.
98    pub fn discover_and_load() -> Result<Option<Self>> {
99        let cwd = std::env::current_dir().context("get cwd")?;
100        match Compose::discover(&cwd) {
101            Ok(root) => Self::load(&root).map(Some),
102            Err(_) => Ok(None),
103        }
104    }
105
106    /// Build a snapshot for an explicit `.team/` root. Public so
107    /// integration tests can hand-feed a tempdir without going
108    /// through walk-up discovery.
109    pub fn load(root: &Path) -> Result<Self> {
110        let compose = Compose::load(root)?;
111        let mailbox = compose.root.join(&compose.global.broker.path);
112        let counts = mailbox_counts(&mailbox).unwrap_or_default();
113
114        let supervisor = TmuxSupervisor;
115        let team_name = compose
116            .projects
117            .first()
118            .map(|p| {
119                if p.project.name.is_empty() {
120                    p.project.id.clone()
121                } else {
122                    p.project.name.clone()
123                }
124            })
125            .unwrap_or_else(|| "(unnamed team)".into());
126
127        let mut agents = Vec::new();
128        for h in compose.agents() {
129            let spec =
130                AgentSpec::from_handle(h, &compose.root, &compose.global.supervisor.tmux_prefix);
131            let state = supervisor.state(&spec).unwrap_or(AgentState::Unknown);
132            let id = h.id();
133            let unread_mail = counts.unread.get(&id).copied().unwrap_or(0);
134            let pending_approvals = counts.pending.get(&id).copied().unwrap_or(0);
135            agents.push(AgentInfo {
136                id,
137                agent: h.agent.into(),
138                project: h.project.into(),
139                tmux_session: spec.tmux_session,
140                state,
141                unread_mail,
142                pending_approvals,
143                is_manager: h.is_manager,
144            });
145        }
146
147        // Managers first, then workers; deterministic within each.
148        agents.sort_by(|a, b| match (b.is_manager, a.is_manager) {
149            (x, y) if x == y => a.id.cmp(&b.id),
150            (true, false) => std::cmp::Ordering::Greater,
151            (false, true) => std::cmp::Ordering::Less,
152            _ => std::cmp::Ordering::Equal,
153        });
154
155        let mut channels = Vec::new();
156        for project in &compose.projects {
157            for ch in &project.channels {
158                channels.push(ChannelInfo {
159                    id: format!("{}:{}", project.project.id, ch.name),
160                    name: ch.name.clone(),
161                    project_id: project.project.id.clone(),
162                });
163            }
164        }
165        // Stable order for the picker — operators see the same
166        // sequence on every open.
167        channels.sort_by(|a, b| a.id.cmp(&b.id));
168
169        Ok(Self {
170            root: compose.root,
171            team_name,
172            agents,
173            channels,
174        })
175    }
176}
177
178#[derive(Debug, Default)]
179struct MailboxCounts {
180    unread: HashMap<String, u32>,
181    pending: HashMap<String, u32>,
182}
183
184/// Single sweep of the mailbox to populate per-agent counters. Read
185/// errors degrade silently to zeroes — a missing or unreadable DB
186/// is just "no team running yet" from the UI's perspective, not a
187/// fatal launch error.
188fn mailbox_counts(mailbox: &Path) -> Result<MailboxCounts> {
189    if !mailbox.is_file() {
190        return Ok(MailboxCounts::default());
191    }
192    let conn = Connection::open(mailbox)?;
193    let mut counts = MailboxCounts::default();
194
195    // Unread mail per recipient agent (channels excluded — channel
196    // messages ack independently per subscriber and would require a
197    // join we don't need in PR-UI-2).
198    //
199    // INVARIANT: every `messages.recipient` value falls into exactly
200    // one of three prefix classes — `<project>:<agent>` (DM, no
201    // scheme prefix; the channel-or-user split here relies on that
202    // absence), `channel:<channel_id>`, or `user:<handle>`. The two
203    // `NOT LIKE` clauses below treat anything outside the channel /
204    // user prefixes as a per-agent DM. If a fourth prefix class
205    // ever lands, every site that splits recipients (here,
206    // `mailbox::BrokerMailboxSource::*` queries, and the tail.rs
207    // follow loop) needs to learn it.
208    let mut stmt = conn.prepare(
209        "SELECT recipient, COUNT(*) FROM messages
210         WHERE acked_at IS NULL
211           AND recipient NOT LIKE 'channel:%'
212           AND recipient NOT LIKE 'user:%'
213         GROUP BY recipient",
214    )?;
215    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
216    for row in rows.flatten() {
217        counts.unread.insert(row.0, row.1.max(0) as u32);
218    }
219
220    // Pending approvals per requesting agent.
221    let mut stmt = conn.prepare(
222        "SELECT project_id || ':' || agent_id, COUNT(*) FROM approvals
223         WHERE status = 'pending'
224         GROUP BY project_id, agent_id",
225    )?;
226    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
227    for row in rows.flatten() {
228        counts.pending.insert(row.0, row.1.max(0) as u32);
229    }
230
231    Ok(counts)
232}
233
234/// Single-cell glyph for an agent's primary state — derived from the
235/// triplet (`state`, `pending_approvals`, `unread_mail`) in priority
236/// order: pending approval beats unread mail beats process state.
237/// Plain ASCII fallback when the caller signals a monochrome /
238/// no-symbol terminal.
239pub fn state_glyph(info: &AgentInfo, fallback_ascii: bool) -> &'static str {
240    match info.state {
241        AgentState::Stopped => {
242            if fallback_ascii {
243                "x"
244            } else {
245                "✕"
246            }
247        }
248        AgentState::Unknown => "?",
249        AgentState::Running => {
250            if info.pending_approvals > 0 {
251                "!"
252            } else if info.unread_mail > 0 {
253                if fallback_ascii {
254                    "@"
255                } else {
256                    "✉"
257                }
258            } else if fallback_ascii {
259                "*"
260            } else {
261                "●"
262            }
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn info(state: AgentState, unread: u32, pending: u32) -> AgentInfo {
272        AgentInfo {
273            id: "p:a".into(),
274            agent: "a".into(),
275            project: "p".into(),
276            tmux_session: "t-p-a".into(),
277            state,
278            unread_mail: unread,
279            pending_approvals: pending,
280            is_manager: false,
281        }
282    }
283
284    #[test]
285    fn state_glyph_priorities_pending_then_unread_then_running() {
286        assert_eq!(state_glyph(&info(AgentState::Running, 0, 0), false), "●");
287        assert_eq!(state_glyph(&info(AgentState::Running, 3, 0), false), "✉");
288        assert_eq!(state_glyph(&info(AgentState::Running, 3, 1), false), "!");
289    }
290
291    #[test]
292    fn state_glyph_stopped_and_unknown() {
293        assert_eq!(state_glyph(&info(AgentState::Stopped, 0, 0), false), "✕");
294        assert_eq!(state_glyph(&info(AgentState::Unknown, 0, 0), false), "?");
295    }
296
297    #[test]
298    fn state_glyph_ascii_fallback() {
299        assert_eq!(state_glyph(&info(AgentState::Running, 0, 0), true), "*");
300        assert_eq!(state_glyph(&info(AgentState::Running, 5, 0), true), "@");
301        assert_eq!(state_glyph(&info(AgentState::Stopped, 0, 0), true), "x");
302        // `!` and `?` are unchanged across the fallback boundary.
303        assert_eq!(state_glyph(&info(AgentState::Running, 0, 1), true), "!");
304        assert_eq!(state_glyph(&info(AgentState::Unknown, 0, 0), true), "?");
305    }
306}