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}