1use sqlx::PgPool;
9use uuid::Uuid;
10
11#[derive(sqlx::FromRow)]
12pub struct Totals {
13 pub agents_online: i64,
14 pub open_tasks: i64,
15 pub claimed_tasks: i64,
16 pub messages_24h: i64,
17}
18
19#[derive(sqlx::FromRow)]
20pub struct AgentRow {
21 pub name: String,
22 pub session: String,
25 pub status: Option<String>,
26 pub repo: Option<String>,
27 pub branch: Option<String>,
28 pub activity: Option<String>,
29 pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
30 pub online: bool,
31}
32
33#[derive(sqlx::FromRow)]
34pub struct TaskRow {
35 pub key: String,
36 pub title: String,
37 pub status: String,
38 pub claimed_by: Option<String>,
39 pub result: Option<String>,
40 pub blocked: bool,
41 pub updated_at: chrono::DateTime<chrono::Utc>,
42}
43
44#[derive(sqlx::FromRow)]
45pub struct MessageRow {
46 pub id: i64,
47 pub channel: String,
48 pub sender: String,
49 pub announce: bool,
52 pub body: String,
53 pub created_at: chrono::DateTime<chrono::Utc>,
54}
55
56#[derive(sqlx::FromRow)]
57pub struct LockRow {
58 pub name: String,
59 pub holder: String,
60 pub purpose: Option<String>,
61 pub expires_at: chrono::DateTime<chrono::Utc>,
62}
63
64#[derive(sqlx::FromRow)]
65pub struct NoteRow {
66 pub scope: String,
67 pub key: String,
68 pub updated_by: Option<String>,
69 pub updated_at: chrono::DateTime<chrono::Utc>,
70}
71
72pub struct Snapshot {
74 pub team: String,
75 pub totals: Totals,
76 pub agents: Vec<AgentRow>,
77 pub tasks: Vec<TaskRow>,
78 pub messages: Vec<MessageRow>,
79 pub locks: Vec<LockRow>,
80 pub notes: Vec<NoteRow>,
81}
82
83pub async fn load(pool: &PgPool, team_id: Uuid) -> Result<Snapshot, sqlx::Error> {
96 let (team, totals, agents, tasks, messages, locks, notes) = tokio::try_join!(
97 load_team(pool, team_id),
98 load_totals(pool, team_id),
99 load_agents(pool, team_id),
100 load_tasks(pool, team_id),
101 load_messages(pool, team_id),
102 load_locks(pool, team_id),
103 load_notes(pool, team_id),
104 )?;
105
106 Ok(Snapshot {
107 team,
108 totals,
109 agents,
110 tasks,
111 messages,
112 locks,
113 notes,
114 })
115}
116
117async fn load_team(pool: &PgPool, team_id: Uuid) -> Result<String, sqlx::Error> {
118 sqlx::query_scalar("SELECT slug FROM teams WHERE id = $1")
119 .bind(team_id)
120 .fetch_one(pool)
121 .await
122}
123
124async fn load_totals(pool: &PgPool, team_id: Uuid) -> Result<Totals, sqlx::Error> {
125 sqlx::query_as(
127 r#"
128 SELECT
129 -- DISTINCT because an agent has one presence row per session:
130 -- someone working in three repositories is one teammate online,
131 -- not three.
132 (SELECT count(DISTINCT a.id) FROM agents a
133 JOIN agent_presence p ON p.agent_id = a.id
134 WHERE a.team_id = $1 AND p.expires_at > now()) AS agents_online,
135 (SELECT count(*) FROM tasks
136 WHERE team_id = $1 AND status = 'open') AS open_tasks,
137 (SELECT count(*) FROM tasks
138 WHERE team_id = $1 AND status = 'claimed') AS claimed_tasks,
139 (SELECT count(*) FROM messages
140 WHERE team_id = $1 AND channel_id IS NOT NULL
141 AND created_at > now() - interval '24 hours') AS messages_24h
142 "#,
143 )
144 .bind(team_id)
145 .fetch_one(pool)
146 .await
147}
148
149async fn load_agents(pool: &PgPool, team_id: Uuid) -> Result<Vec<AgentRow>, sqlx::Error> {
150 sqlx::query_as(
151 r#"
152 -- One row per working context: a teammate with three repositories open
153 -- appears three times, each with its own repo, branch and activity.
154 -- Collapsing them would put one of the three on screen and drop the
155 -- rest, which is the flapping board this whole change exists to fix.
156 SELECT a.name,
157 COALESCE(p.session, '') AS session,
158 p.status,
159 p.repo,
160 p.branch,
161 p.activity,
162 p.updated_at,
163 COALESCE(p.expires_at > now(), false) AS online
164 FROM agents a
165 LEFT JOIN agent_presence p ON p.agent_id = a.id
166 WHERE a.team_id = $1 AND a.disabled_at IS NULL
167 ORDER BY COALESCE(p.expires_at > now(), false) DESC,
168 a.name,
169 p.updated_at DESC NULLS LAST
170 "#,
171 )
172 .bind(team_id)
173 .fetch_all(pool)
174 .await
175}
176
177async fn load_tasks(pool: &PgPool, team_id: Uuid) -> Result<Vec<TaskRow>, sqlx::Error> {
179 sqlx::query_as(
180 r#"
181 SELECT t.key,
182 t.title,
183 t.status,
184 cb.name AS claimed_by,
185 t.result,
186 EXISTS (
187 SELECT 1 FROM task_deps td
188 JOIN tasks d ON d.id = td.blocked_by_task_id
189 WHERE td.task_id = t.id AND d.status NOT IN ('done', 'cancelled')
190 ) AS blocked,
191 t.updated_at
192 FROM tasks t
193 LEFT JOIN agents cb ON cb.id = t.claimed_by
194 WHERE t.team_id = $1
195 ORDER BY CASE t.status WHEN 'claimed' THEN 0 WHEN 'open' THEN 1 ELSE 2 END,
196 t.updated_at DESC
197 LIMIT 30
198 "#,
199 )
200 .bind(team_id)
201 .fetch_all(pool)
202 .await
203}
204
205async fn load_messages(pool: &PgPool, team_id: Uuid) -> Result<Vec<MessageRow>, sqlx::Error> {
208 sqlx::query_as(
209 r#"
210 SELECT m.id, ch.name AS channel, s.name AS sender, m.announce,
211 left(m.body, 240) AS body, m.created_at
212 FROM messages m
213 JOIN channels ch ON ch.id = m.channel_id
214 JOIN agents s ON s.id = m.sender_agent_id
215 WHERE m.team_id = $1
216 ORDER BY m.id DESC
217 LIMIT 20
218 "#,
219 )
220 .bind(team_id)
221 .fetch_all(pool)
222 .await
223}
224
225async fn load_locks(pool: &PgPool, team_id: Uuid) -> Result<Vec<LockRow>, sqlx::Error> {
226 sqlx::query_as(
227 r#"
228 SELECT l.name, a.name AS holder, l.purpose, l.expires_at
229 FROM locks l
230 JOIN agents a ON a.id = l.holder_agent_id
231 WHERE l.team_id = $1 AND l.expires_at > now()
232 ORDER BY l.name
233 "#,
234 )
235 .bind(team_id)
236 .fetch_all(pool)
237 .await
238}
239
240async fn load_notes(pool: &PgPool, team_id: Uuid) -> Result<Vec<NoteRow>, sqlx::Error> {
241 sqlx::query_as(
242 r#"
243 SELECT n.scope, n.key, a.name AS updated_by, n.updated_at
244 FROM notes n
245 LEFT JOIN agents a ON a.id = n.updated_by
246 WHERE n.team_id = $1
247 ORDER BY n.updated_at DESC
248 LIMIT 15
249 "#,
250 )
251 .bind(team_id)
252 .fetch_all(pool)
253 .await
254}