Skip to main content

ai_crew_sync/dashboard/
data.rs

1//! Everything the dashboard reads, and nothing about how it looks.
2//!
3//! Split out of the page builder so the queries can be read, changed and
4//! measured without scrolling past HTML — and so the rendering layer receives
5//! named fields instead of seven-element tuples whose meaning lived in the
6//! order of a SELECT.
7
8use 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    /// Working context of this row. Empty is the shared session; an agent with
23    /// several open produces one row each.
24    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    /// Posted as an announcement — worth marking, since it is what the team
50    /// was interrupted for.
51    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
72/// One refresh of the dashboard.
73pub 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
83/// Read everything the page shows for `team_id`.
84///
85/// Direct messages are excluded here, at the source, rather than being
86/// filtered later: the rendering layer never receives one, so it cannot leak
87/// one by accident.
88///
89/// The seven queries are independent, so they run concurrently on separate
90/// pool connections rather than one after another — the page refreshes every
91/// 15 seconds, and serialising them made the refresh as slow as their sum.
92/// Each sees its own snapshot, which is correct for a dashboard: the numbers
93/// are already stale by the time they reach a browser, and no derived value
94/// is computed across two of them.
95pub 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    // The three stat-tile numbers in one round trip instead of three.
126    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
177/// Claimed and open first — the work in flight is what a human came to see.
178async 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
205/// The JOIN on channels is what excludes direct messages: a DM has no
206/// channel_id, so it cannot appear here at all.
207async 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}