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    /// T-160: optional human-friendly label from
52    /// `team-compose.yaml`. When `Some`, the TUI renders this in place
53    /// of `id` everywhere an agent label surfaces to the operator
54    /// (roster, detail header, mailbox attribution, statusline,
55    /// approvals, compose modal). When `None`, label falls back to
56    /// `id`. The id stays canonical for routing/tmux/CLI.
57    pub display_name: Option<String>,
58}
59
60/// Return the operator-facing label for `agent_id`: the agent's
61/// `display_name` when set, otherwise `agent_id` itself. Read-only
62/// borrow into the snapshot — callers that need an owned `String`
63/// can `.to_string()` at the use-site. Unknown ids fall through to
64/// `agent_id` (the canonical id is always a valid label).
65pub fn agent_label<'a>(team: &'a TeamSnapshot, agent_id: &'a str) -> &'a str {
66    team.agents
67        .iter()
68        .find(|a| a.id == agent_id)
69        .and_then(|a| a.display_name.as_deref())
70        .unwrap_or(agent_id)
71}
72
73/// One channel exposed in `team-compose.yaml`. Used by PR-UI-6's
74/// per-channel broadcast picker and by the Mailbox-first layout's
75/// channel list. `id` is `<project>:<name>` (matches the broker's
76/// `channels.id`); `name` is the short label rendered as `#name`.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct ChannelInfo {
79    pub id: String,
80    pub name: String,
81    pub project_id: String,
82}
83
84#[derive(Debug, Clone)]
85pub struct TeamSnapshot {
86    /// Path to the `.team/` discovered by walk-up (the compose root).
87    pub root: PathBuf,
88    /// Human label from `team-compose.yaml::projects[].project.name`
89    /// — falls back to the project id when name is empty.
90    pub team_name: String,
91    /// Agents in deterministic order: managers first, then workers,
92    /// each group sorted by id. Roster navigation (`↑` / `↓`) walks
93    /// this slice directly.
94    pub agents: Vec<AgentInfo>,
95    /// Channels declared across every project file. Drives the
96    /// PR-UI-6 broadcast picker + the Mailbox-first layout's
97    /// channel list.
98    pub channels: Vec<ChannelInfo>,
99}
100
101impl TeamSnapshot {
102    /// Build an empty snapshot rooted at the given path. Used by
103    /// tests and as the rendered shape when no `.team/` is reachable.
104    pub fn empty(root: PathBuf) -> Self {
105        Self {
106            root,
107            team_name: "(no team loaded)".into(),
108            agents: Vec::new(),
109            channels: Vec::new(),
110        }
111    }
112
113    /// Walk up from cwd to find the nearest `.team/`, parse the
114    /// compose tree, query supervisor + mailbox state per agent,
115    /// and return the assembled snapshot. Returns `Ok(None)` when
116    /// no `.team/` is reachable — the UI renders the empty state in
117    /// that case rather than panicking.
118    pub fn discover_and_load() -> Result<Option<Self>> {
119        let cwd = std::env::current_dir().context("get cwd")?;
120        match Compose::discover(&cwd) {
121            Ok(root) => Self::load(&root).map(Some),
122            Err(_) => Ok(None),
123        }
124    }
125
126    /// Build a snapshot for an explicit `.team/` root. Public so
127    /// integration tests can hand-feed a tempdir without going
128    /// through walk-up discovery.
129    pub fn load(root: &Path) -> Result<Self> {
130        let compose = Compose::load(root)?;
131        let mailbox = compose.root.join(&compose.global.broker.path);
132        let counts = mailbox_counts(&mailbox).unwrap_or_default();
133
134        let supervisor = TmuxSupervisor;
135        let team_name = compose
136            .projects
137            .first()
138            .map(|p| {
139                if p.project.name.is_empty() {
140                    p.project.id.clone()
141                } else {
142                    p.project.name.clone()
143                }
144            })
145            .unwrap_or_else(|| "(unnamed team)".into());
146
147        let mut agents = Vec::new();
148        for h in compose.agents() {
149            let display_name = h.spec.display_name.clone();
150            let spec =
151                AgentSpec::from_handle(h, &compose.root, &compose.global.supervisor.tmux_prefix);
152            let state = supervisor.state(&spec).unwrap_or(AgentState::Unknown);
153            let id = h.id();
154            let unread_mail = counts.unread.get(&id).copied().unwrap_or(0);
155            let pending_approvals = counts.pending.get(&id).copied().unwrap_or(0);
156            agents.push(AgentInfo {
157                id,
158                agent: h.agent.into(),
159                project: h.project.into(),
160                tmux_session: spec.tmux_session,
161                state,
162                unread_mail,
163                pending_approvals,
164                is_manager: h.is_manager,
165                display_name,
166            });
167        }
168
169        // Managers first, then workers; deterministic within each.
170        agents.sort_by(|a, b| match (b.is_manager, a.is_manager) {
171            (x, y) if x == y => a.id.cmp(&b.id),
172            (true, false) => std::cmp::Ordering::Greater,
173            (false, true) => std::cmp::Ordering::Less,
174            _ => std::cmp::Ordering::Equal,
175        });
176
177        let mut channels = Vec::new();
178        for project in &compose.projects {
179            for ch in &project.channels {
180                channels.push(ChannelInfo {
181                    id: format!("{}:{}", project.project.id, ch.name),
182                    name: ch.name.clone(),
183                    project_id: project.project.id.clone(),
184                });
185            }
186        }
187        // Stable order for the picker — operators see the same
188        // sequence on every open.
189        channels.sort_by(|a, b| a.id.cmp(&b.id));
190
191        Ok(Self {
192            root: compose.root,
193            team_name,
194            agents,
195            channels,
196        })
197    }
198}
199
200#[derive(Debug, Default)]
201struct MailboxCounts {
202    unread: HashMap<String, u32>,
203    pending: HashMap<String, u32>,
204}
205
206/// Single sweep of the mailbox to populate per-agent counters. Read
207/// errors degrade silently to zeroes — a missing or unreadable DB
208/// is just "no team running yet" from the UI's perspective, not a
209/// fatal launch error.
210fn mailbox_counts(mailbox: &Path) -> Result<MailboxCounts> {
211    if !mailbox.is_file() {
212        return Ok(MailboxCounts::default());
213    }
214    let conn = Connection::open(mailbox)?;
215    let mut counts = MailboxCounts::default();
216
217    // Unread mail per recipient agent (channels excluded — channel
218    // messages ack independently per subscriber and would require a
219    // join we don't need in PR-UI-2).
220    //
221    // INVARIANT: every `messages.recipient` value falls into exactly
222    // one of three prefix classes — `<project>:<agent>` (DM, no
223    // scheme prefix; the channel-or-user split here relies on that
224    // absence), `channel:<channel_id>`, or `user:<handle>`. The two
225    // `NOT LIKE` clauses below treat anything outside the channel /
226    // user prefixes as a per-agent DM. If a fourth prefix class
227    // ever lands, every site that splits recipients (here,
228    // `mailbox::BrokerMailboxSource::*` queries, and the tail.rs
229    // follow loop) needs to learn it.
230    let mut stmt = conn.prepare(
231        "SELECT recipient, COUNT(*) FROM messages
232         WHERE acked_at IS NULL
233           AND recipient NOT LIKE 'channel:%'
234           AND recipient NOT LIKE 'user:%'
235         GROUP BY recipient",
236    )?;
237    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
238    for row in rows.flatten() {
239        counts.unread.insert(row.0, row.1.max(0) as u32);
240    }
241
242    // Pending approvals per requesting agent.
243    let mut stmt = conn.prepare(
244        "SELECT project_id || ':' || agent_id, COUNT(*) FROM approvals
245         WHERE status = 'pending'
246         GROUP BY project_id, agent_id",
247    )?;
248    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
249    for row in rows.flatten() {
250        counts.pending.insert(row.0, row.1.max(0) as u32);
251    }
252
253    Ok(counts)
254}
255
256/// Single-cell glyph for an agent's primary state — derived from the
257/// triplet (`state`, `pending_approvals`, `unread_mail`) in priority
258/// order: pending approval beats unread mail beats process state.
259/// Plain ASCII fallback when the caller signals a monochrome /
260/// no-symbol terminal.
261pub fn state_glyph(info: &AgentInfo, fallback_ascii: bool) -> &'static str {
262    match info.state {
263        AgentState::Stopped => {
264            if fallback_ascii {
265                "x"
266            } else {
267                "✕"
268            }
269        }
270        AgentState::Unknown => "?",
271        AgentState::Running => {
272            if info.pending_approvals > 0 {
273                "!"
274            } else if info.unread_mail > 0 {
275                if fallback_ascii {
276                    "@"
277                } else {
278                    "✉"
279                }
280            } else if fallback_ascii {
281                "*"
282            } else {
283                "●"
284            }
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn info(state: AgentState, unread: u32, pending: u32) -> AgentInfo {
294        AgentInfo {
295            id: "p:a".into(),
296            agent: "a".into(),
297            project: "p".into(),
298            tmux_session: "t-p-a".into(),
299            state,
300            unread_mail: unread,
301            pending_approvals: pending,
302            is_manager: false,
303            display_name: None,
304        }
305    }
306
307    #[test]
308    fn state_glyph_priorities_pending_then_unread_then_running() {
309        assert_eq!(state_glyph(&info(AgentState::Running, 0, 0), false), "●");
310        assert_eq!(state_glyph(&info(AgentState::Running, 3, 0), false), "✉");
311        assert_eq!(state_glyph(&info(AgentState::Running, 3, 1), false), "!");
312    }
313
314    #[test]
315    fn state_glyph_stopped_and_unknown() {
316        assert_eq!(state_glyph(&info(AgentState::Stopped, 0, 0), false), "✕");
317        assert_eq!(state_glyph(&info(AgentState::Unknown, 0, 0), false), "?");
318    }
319
320    #[test]
321    fn state_glyph_ascii_fallback() {
322        assert_eq!(state_glyph(&info(AgentState::Running, 0, 0), true), "*");
323        assert_eq!(state_glyph(&info(AgentState::Running, 5, 0), true), "@");
324        assert_eq!(state_glyph(&info(AgentState::Stopped, 0, 0), true), "x");
325        // `!` and `?` are unchanged across the fallback boundary.
326        assert_eq!(state_glyph(&info(AgentState::Running, 0, 1), true), "!");
327        assert_eq!(state_glyph(&info(AgentState::Unknown, 0, 0), true), "?");
328    }
329}