use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rusqlite::Connection;
use team_core::compose::Compose;
use team_core::supervisor::{AgentSpec, AgentState, Supervisor, TmuxSupervisor};
#[derive(Debug, Clone)]
pub struct AgentInfo {
pub id: String,
pub agent: String,
pub project: String,
pub tmux_session: String,
pub state: AgentState,
pub unread_mail: u32,
pub pending_approvals: u32,
pub is_manager: bool,
pub display_name: Option<String>,
}
pub fn agent_label<'a>(team: &'a TeamSnapshot, agent_id: &'a str) -> &'a str {
team.agents
.iter()
.find(|a| a.id == agent_id)
.and_then(|a| a.display_name.as_deref())
.unwrap_or(agent_id)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChannelInfo {
pub id: String,
pub name: String,
pub project_id: String,
}
#[derive(Debug, Clone)]
pub struct TeamSnapshot {
pub root: PathBuf,
pub team_name: String,
pub agents: Vec<AgentInfo>,
pub channels: Vec<ChannelInfo>,
}
impl TeamSnapshot {
pub fn empty(root: PathBuf) -> Self {
Self {
root,
team_name: "(no team loaded)".into(),
agents: Vec::new(),
channels: Vec::new(),
}
}
pub fn discover_and_load() -> Result<Option<Self>> {
let cwd = std::env::current_dir().context("get cwd")?;
match Compose::discover(&cwd) {
Ok(root) => Self::load(&root).map(Some),
Err(_) => Ok(None),
}
}
pub fn load(root: &Path) -> Result<Self> {
let compose = Compose::load(root)?;
let mailbox = compose.root.join(&compose.global.broker.path);
let counts = mailbox_counts(&mailbox).unwrap_or_default();
let supervisor = TmuxSupervisor;
let team_name = compose
.projects
.first()
.map(|p| {
if p.project.name.is_empty() {
p.project.id.clone()
} else {
p.project.name.clone()
}
})
.unwrap_or_else(|| "(unnamed team)".into());
let mut agents = Vec::new();
for h in compose.agents() {
let display_name = h.spec.display_name.clone();
let spec =
AgentSpec::from_handle(h, &compose.root, &compose.global.supervisor.tmux_prefix);
let state = supervisor.state(&spec).unwrap_or(AgentState::Unknown);
let id = h.id();
let unread_mail = counts.unread.get(&id).copied().unwrap_or(0);
let pending_approvals = counts.pending.get(&id).copied().unwrap_or(0);
agents.push(AgentInfo {
id,
agent: h.agent.into(),
project: h.project.into(),
tmux_session: spec.tmux_session,
state,
unread_mail,
pending_approvals,
is_manager: h.is_manager,
display_name,
});
}
agents.sort_by(|a, b| match (b.is_manager, a.is_manager) {
(x, y) if x == y => a.id.cmp(&b.id),
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
_ => std::cmp::Ordering::Equal,
});
let mut channels = Vec::new();
for project in &compose.projects {
for ch in &project.channels {
channels.push(ChannelInfo {
id: format!("{}:{}", project.project.id, ch.name),
name: ch.name.clone(),
project_id: project.project.id.clone(),
});
}
}
channels.sort_by(|a, b| a.id.cmp(&b.id));
Ok(Self {
root: compose.root,
team_name,
agents,
channels,
})
}
}
#[derive(Debug, Default)]
struct MailboxCounts {
unread: HashMap<String, u32>,
pending: HashMap<String, u32>,
}
fn mailbox_counts(mailbox: &Path) -> Result<MailboxCounts> {
if !mailbox.is_file() {
return Ok(MailboxCounts::default());
}
let conn = Connection::open(mailbox)?;
let mut counts = MailboxCounts::default();
let mut stmt = conn.prepare(
"SELECT recipient, COUNT(*) FROM messages
WHERE acked_at IS NULL
AND recipient NOT LIKE 'channel:%'
AND recipient NOT LIKE 'user:%'
GROUP BY recipient",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
for row in rows.flatten() {
counts.unread.insert(row.0, row.1.max(0) as u32);
}
let mut stmt = conn.prepare(
"SELECT project_id || ':' || agent_id, COUNT(*) FROM approvals
WHERE status = 'pending'
GROUP BY project_id, agent_id",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
for row in rows.flatten() {
counts.pending.insert(row.0, row.1.max(0) as u32);
}
Ok(counts)
}
pub fn state_glyph(info: &AgentInfo, fallback_ascii: bool) -> &'static str {
match info.state {
AgentState::Stopped => {
if fallback_ascii {
"x"
} else {
"✕"
}
}
AgentState::Unknown => "?",
AgentState::Running => {
if info.pending_approvals > 0 {
"!"
} else if info.unread_mail > 0 {
if fallback_ascii {
"@"
} else {
"✉"
}
} else if fallback_ascii {
"*"
} else {
"●"
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn info(state: AgentState, unread: u32, pending: u32) -> AgentInfo {
AgentInfo {
id: "p:a".into(),
agent: "a".into(),
project: "p".into(),
tmux_session: "t-p-a".into(),
state,
unread_mail: unread,
pending_approvals: pending,
is_manager: false,
display_name: None,
}
}
#[test]
fn state_glyph_priorities_pending_then_unread_then_running() {
assert_eq!(state_glyph(&info(AgentState::Running, 0, 0), false), "●");
assert_eq!(state_glyph(&info(AgentState::Running, 3, 0), false), "✉");
assert_eq!(state_glyph(&info(AgentState::Running, 3, 1), false), "!");
}
#[test]
fn state_glyph_stopped_and_unknown() {
assert_eq!(state_glyph(&info(AgentState::Stopped, 0, 0), false), "✕");
assert_eq!(state_glyph(&info(AgentState::Unknown, 0, 0), false), "?");
}
#[test]
fn state_glyph_ascii_fallback() {
assert_eq!(state_glyph(&info(AgentState::Running, 0, 0), true), "*");
assert_eq!(state_glyph(&info(AgentState::Running, 5, 0), true), "@");
assert_eq!(state_glyph(&info(AgentState::Stopped, 0, 0), true), "x");
assert_eq!(state_glyph(&info(AgentState::Running, 0, 1), true), "!");
assert_eq!(state_glyph(&info(AgentState::Unknown, 0, 0), true), "?");
}
}