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}