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 status: Option<String>,
23 pub repo: Option<String>,
24 pub branch: Option<String>,
25 pub activity: Option<String>,
26 pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
27 pub online: bool,
28}
29
30#[derive(sqlx::FromRow)]
31pub struct TaskRow {
32 pub key: String,
33 pub title: String,
34 pub status: String,
35 pub claimed_by: Option<String>,
36 pub result: Option<String>,
37 pub blocked: bool,
38 pub updated_at: chrono::DateTime<chrono::Utc>,
39}
40
41#[derive(sqlx::FromRow)]
42pub struct MessageRow {
43 pub id: i64,
44 pub channel: String,
45 pub sender: String,
46 pub body: String,
47 pub created_at: chrono::DateTime<chrono::Utc>,
48}
49
50#[derive(sqlx::FromRow)]
51pub struct LockRow {
52 pub name: String,
53 pub holder: String,
54 pub purpose: Option<String>,
55 pub expires_at: chrono::DateTime<chrono::Utc>,
56}
57
58#[derive(sqlx::FromRow)]
59pub struct NoteRow {
60 pub scope: String,
61 pub key: String,
62 pub updated_by: Option<String>,
63 pub updated_at: chrono::DateTime<chrono::Utc>,
64}
65
66pub struct Snapshot {
68 pub team: String,
69 pub totals: Totals,
70 pub agents: Vec<AgentRow>,
71 pub tasks: Vec<TaskRow>,
72 pub messages: Vec<MessageRow>,
73 pub locks: Vec<LockRow>,
74 pub notes: Vec<NoteRow>,
75}
76
77pub async fn load(pool: &PgPool, team_id: Uuid) -> Result<Snapshot, sqlx::Error> {
90 let (team, totals, agents, tasks, messages, locks, notes) = tokio::try_join!(
91 load_team(pool, team_id),
92 load_totals(pool, team_id),
93 load_agents(pool, team_id),
94 load_tasks(pool, team_id),
95 load_messages(pool, team_id),
96 load_locks(pool, team_id),
97 load_notes(pool, team_id),
98 )?;
99
100 Ok(Snapshot {
101 team,
102 totals,
103 agents,
104 tasks,
105 messages,
106 locks,
107 notes,
108 })
109}
110
111async fn load_team(pool: &PgPool, team_id: Uuid) -> Result<String, sqlx::Error> {
112 sqlx::query_scalar("SELECT slug FROM teams WHERE id = $1")
113 .bind(team_id)
114 .fetch_one(pool)
115 .await
116}
117
118async fn load_totals(pool: &PgPool, team_id: Uuid) -> Result<Totals, sqlx::Error> {
119 sqlx::query_as(
121 r#"
122 SELECT
123 (SELECT count(*) FROM agents a
124 JOIN agent_presence p ON p.agent_id = a.id
125 WHERE a.team_id = $1 AND p.expires_at > now()) AS agents_online,
126 (SELECT count(*) FROM tasks
127 WHERE team_id = $1 AND status = 'open') AS open_tasks,
128 (SELECT count(*) FROM tasks
129 WHERE team_id = $1 AND status = 'claimed') AS claimed_tasks,
130 (SELECT count(*) FROM messages
131 WHERE team_id = $1 AND channel_id IS NOT NULL
132 AND created_at > now() - interval '24 hours') AS messages_24h
133 "#,
134 )
135 .bind(team_id)
136 .fetch_one(pool)
137 .await
138}
139
140async fn load_agents(pool: &PgPool, team_id: Uuid) -> Result<Vec<AgentRow>, sqlx::Error> {
141 sqlx::query_as(
142 r#"
143 SELECT a.name,
144 p.status,
145 p.repo,
146 p.branch,
147 p.activity,
148 p.updated_at,
149 COALESCE(p.expires_at > now(), false) AS online
150 FROM agents a
151 LEFT JOIN agent_presence p ON p.agent_id = a.id
152 WHERE a.team_id = $1 AND a.disabled_at IS NULL
153 ORDER BY COALESCE(p.expires_at > now(), false) DESC, a.name
154 "#,
155 )
156 .bind(team_id)
157 .fetch_all(pool)
158 .await
159}
160
161async fn load_tasks(pool: &PgPool, team_id: Uuid) -> Result<Vec<TaskRow>, sqlx::Error> {
163 sqlx::query_as(
164 r#"
165 SELECT t.key,
166 t.title,
167 t.status,
168 cb.name AS claimed_by,
169 t.result,
170 EXISTS (
171 SELECT 1 FROM task_deps td
172 JOIN tasks d ON d.id = td.blocked_by_task_id
173 WHERE td.task_id = t.id AND d.status NOT IN ('done', 'cancelled')
174 ) AS blocked,
175 t.updated_at
176 FROM tasks t
177 LEFT JOIN agents cb ON cb.id = t.claimed_by
178 WHERE t.team_id = $1
179 ORDER BY CASE t.status WHEN 'claimed' THEN 0 WHEN 'open' THEN 1 ELSE 2 END,
180 t.updated_at DESC
181 LIMIT 30
182 "#,
183 )
184 .bind(team_id)
185 .fetch_all(pool)
186 .await
187}
188
189async fn load_messages(pool: &PgPool, team_id: Uuid) -> Result<Vec<MessageRow>, sqlx::Error> {
192 sqlx::query_as(
193 r#"
194 SELECT m.id, ch.name AS channel, s.name AS sender, left(m.body, 240) AS body, m.created_at
195 FROM messages m
196 JOIN channels ch ON ch.id = m.channel_id
197 JOIN agents s ON s.id = m.sender_agent_id
198 WHERE m.team_id = $1
199 ORDER BY m.id DESC
200 LIMIT 20
201 "#,
202 )
203 .bind(team_id)
204 .fetch_all(pool)
205 .await
206}
207
208async fn load_locks(pool: &PgPool, team_id: Uuid) -> Result<Vec<LockRow>, sqlx::Error> {
209 sqlx::query_as(
210 r#"
211 SELECT l.name, a.name AS holder, l.purpose, l.expires_at
212 FROM locks l
213 JOIN agents a ON a.id = l.holder_agent_id
214 WHERE l.team_id = $1 AND l.expires_at > now()
215 ORDER BY l.name
216 "#,
217 )
218 .bind(team_id)
219 .fetch_all(pool)
220 .await
221}
222
223async fn load_notes(pool: &PgPool, team_id: Uuid) -> Result<Vec<NoteRow>, sqlx::Error> {
224 sqlx::query_as(
225 r#"
226 SELECT n.scope, n.key, a.name AS updated_by, n.updated_at
227 FROM notes n
228 LEFT JOIN agents a ON a.id = n.updated_by
229 WHERE n.team_id = $1
230 ORDER BY n.updated_at DESC
231 LIMIT 15
232 "#,
233 )
234 .bind(team_id)
235 .fetch_all(pool)
236 .await
237}