Skip to main content

ai_crew_sync/tools/
events.rs

1use std::time::Duration;
2
3use rmcp::{
4    ErrorData, Json, handler::server::wrapper::Parameters, service::RequestContext, tool,
5    tool_router,
6};
7use schemars::JsonSchema;
8use serde::Deserialize;
9use sqlx::PgPool;
10use uuid::Uuid;
11
12use super::{Bus, auth_of};
13use crate::{
14    auth::AuthCtx,
15    events::BusEvent,
16    model::{WaitEvent, WaitResult},
17};
18
19const DEFAULT_TIMEOUT_SECS: i64 = 25;
20const MAX_TIMEOUT_SECS: i64 = 55;
21
22#[derive(Debug, Deserialize, JsonSchema)]
23pub struct WaitArgs {
24    /// How long to wait before giving up, in seconds (5-55, default 25).
25    /// Kept under a minute so HTTP intermediaries do not cut the call.
26    #[serde(default)]
27    pub timeout_seconds: Option<i64>,
28    /// Restrict to certain event kinds: any of "message", "task", "lock",
29    /// "note". Omit to wake on anything relevant to you.
30    #[serde(default)]
31    pub kinds: Option<Vec<String>>,
32    /// Wake on channel messages from every channel, not only the one this
33    /// session works in. Ignored when your session has no matching channel,
34    /// where every channel already wakes you. Direct messages, tasks, locks
35    /// and notes always wake you either way.
36    #[serde(default)]
37    pub all_channels: bool,
38}
39
40async fn unread_dms(pool: &PgPool, auth: &AuthCtx) -> Result<i64, sqlx::Error> {
41    // This session's inbox: what is addressed to it by name, plus what is
42    // addressed to the person. Another window's mail is not this window's
43    // backlog, and each keeps its own cursor.
44    sqlx::query_scalar(
45        r#"
46        SELECT count(*)
47        FROM messages m
48        LEFT JOIN read_cursors c ON c.agent_id = $1 AND c.scope = $3
49        WHERE m.recipient_agent_id = $1
50          AND m.id > COALESCE(c.last_message_id, 0)
51          AND (m.recipient_session IS NULL OR m.recipient_session = $2)
52        "#,
53    )
54    .bind(auth.agent_id)
55    .bind(&auth.session)
56    .bind(crate::store::messaging::cursor_scope(
57        "inbox",
58        &auth.session,
59    ))
60    .fetch_one(pool)
61    .await
62}
63
64async fn unread_anything(
65    pool: &PgPool,
66    auth: &AuthCtx,
67    focus: Option<Uuid>,
68) -> Result<i64, sqlx::Error> {
69    sqlx::query_scalar(
70        r#"
71        SELECT count(*)
72        FROM messages m
73        LEFT JOIN read_cursors c ON c.agent_id = $1 AND c.scope = $4
74        WHERE m.team_id = $2
75          AND m.id > COALESCE(c.last_message_id, 0)
76          -- Same rule as the wake filter: only this session's own messages
77          -- are excluded, so a sibling window's announcement still counts.
78          AND NOT (m.sender_agent_id = $1 AND COALESCE(m.sender_session, '') = $3)
79          AND ((m.channel_id IS NOT NULL
80                -- Same rule as the wake filter: an announcement counts as
81                -- pending whatever this session is focused on. Reporting a
82                -- backlog the wait would not wake for is a lie the caller
83                -- cannot act on.
84                AND ($5::uuid IS NULL OR m.channel_id = $5 OR m.announce))
85               OR (m.recipient_agent_id = $1
86                   AND (m.recipient_session IS NULL OR m.recipient_session = $3)))
87        "#,
88    )
89    .bind(auth.agent_id)
90    .bind(auth.team_id)
91    .bind(&auth.session)
92    .bind(crate::store::messaging::cursor_scope("all", &auth.session))
93    .bind(focus)
94    .fetch_one(pool)
95    .await
96}
97
98/// Is this event inside the session's channel focus?
99///
100/// Only channel messages are filtered: a direct message, a task, a lock or a
101/// note is not tied to a channel, and silencing those would hide work rather
102/// than noise.
103fn in_focus(event: &BusEvent, focus: Option<Uuid>) -> bool {
104    // An announcement is the sender saying "this one is for everyone, even if
105    // you are concentrating". Filtering it by channel would silence exactly
106    // the message the flag exists to deliver.
107    if event.is_announcement() {
108        return true;
109    }
110    match (focus, event.channel_id()) {
111        (Some(channel), Some(posted_in)) => channel == posted_in,
112        _ => true,
113    }
114}
115
116/// Turn a raw bus event into a one-line summary, resolving ids to names.
117async fn describe(pool: &PgPool, event: &BusEvent) -> Option<WaitEvent> {
118    match event.kind() {
119        "message" => {
120            let id = event.message_id()?;
121            let row: (String, Option<String>, Option<String>, String) =
122                sqlx::query_as::<_, (String, Option<String>, Option<String>, String)>(
123                    r#"
124                    SELECT s.name, ch.name, r.name, left(m.body, 200)
125                    FROM messages m
126                    JOIN agents s ON s.id = m.sender_agent_id
127                    LEFT JOIN channels ch ON ch.id = m.channel_id
128                    LEFT JOIN agents r ON r.id = m.recipient_agent_id
129                    WHERE m.id = $1
130                    "#,
131                )
132                .bind(id)
133                .fetch_optional(pool)
134                .await
135                .ok()
136                .flatten()?;
137            let (sender, channel, recipient, body) = row;
138            let target = channel
139                .map(|c| format!("#{c}"))
140                .or(recipient.map(|r| format!("@{r}")))
141                .unwrap_or_default();
142            Some(WaitEvent {
143                kind: "message".into(),
144                summary: format!("{sender} → {target}: {body}"),
145            })
146        }
147        "task" => {
148            let key = event.0.get("key").and_then(|v| v.as_str())?;
149            let status = event
150                .0
151                .get("status")
152                .and_then(|v| v.as_str())
153                .unwrap_or("?");
154            let holder = match event.0.get("claimed_by").and_then(|v| v.as_str()) {
155                Some(uuid) => {
156                    sqlx::query_scalar::<_, String>("SELECT name FROM agents WHERE id = $1::uuid")
157                        .bind(uuid)
158                        .fetch_optional(pool)
159                        .await
160                        .ok()
161                        .flatten()
162                        .map(|n| format!(" by {n}"))
163                        .unwrap_or_default()
164                }
165                None => String::new(),
166            };
167            Some(WaitEvent {
168                kind: "task".into(),
169                summary: format!("task '{key}' is now {status}{holder}"),
170            })
171        }
172        "lock" => {
173            let name = event.0.get("name").and_then(|v| v.as_str())?;
174            let what = event
175                .0
176                .get("event")
177                .and_then(|v| v.as_str())
178                .unwrap_or("changed");
179            Some(WaitEvent {
180                kind: "lock".into(),
181                summary: format!("lock '{name}' {what}"),
182            })
183        }
184        "note" => {
185            let scope = event
186                .0
187                .get("scope")
188                .and_then(|v| v.as_str())
189                .unwrap_or("global");
190            let key = event.0.get("key").and_then(|v| v.as_str())?;
191            Some(WaitEvent {
192                kind: "note".into(),
193                summary: format!("note {scope}/{key} was updated"),
194            })
195        }
196        _ => None,
197    }
198}
199
200fn suggestion_for(events: &[WaitEvent], unread: i64) -> String {
201    if events.iter().any(|e| e.kind == "message") || unread > 0 {
202        "Call read_messages to fetch the new messages.".into()
203    } else if events.iter().any(|e| e.kind == "task") {
204        "Call list_tasks (or get_task) to see what changed.".into()
205    } else if events.iter().any(|e| e.kind == "lock") {
206        "Call list_locks (or retry acquire_lock) now.".into()
207    } else if events.iter().any(|e| e.kind == "note") {
208        "Call get_note to read the updated note.".into()
209    } else {
210        "Nothing happened; do other work or wait again.".into()
211    }
212}
213
214#[tool_router(router = events_router, vis = "pub")]
215impl Bus {
216    #[tool(
217        description = "Block until something happens on the bus that concerns you (a message \
218                       arrives, a task changes state, a lock is released, a note is updated) or \
219                       the timeout elapses. Use this instead of polling read_messages in a loop: \
220                       call it when you are waiting on teammates and idle. Returns immediately \
221                       if you already have unread messages."
222    )]
223    async fn wait_for_updates(
224        &self,
225        ctx: RequestContext<rmcp::RoleServer>,
226        Parameters(args): Parameters<WaitArgs>,
227    ) -> Result<Json<WaitResult>, ErrorData> {
228        let auth = auth_of(&ctx)?;
229        let timeout = args
230            .timeout_seconds
231            .unwrap_or(DEFAULT_TIMEOUT_SECS)
232            .clamp(5, MAX_TIMEOUT_SECS);
233        let kind_filter: Option<Vec<String>> = args
234            .kinds
235            .map(|ks| ks.into_iter().map(|k| k.trim().to_lowercase()).collect());
236        let wants = |kind: &str| {
237            kind_filter
238                .as_ref()
239                .map(|ks| ks.iter().any(|k| k == kind))
240                .unwrap_or(true)
241        };
242
243        // The channel this session works in, when it has one and the caller
244        // has not asked for the whole team. A window working on market-data
245        // should not be woken by core-manager chatter — that is half the point
246        // of naming a session after a repository.
247        let focus = match args.all_channels {
248            true => None,
249            false => crate::store::messaging::default_channel(&self.db, &auth)
250                .await?
251                .map(|(id, _)| id),
252        };
253
254        // Subscribe before checking the database so nothing slips between the
255        // check and the wait.
256        let mut rx = self.hub.subscribe();
257
258        // Same rule as the wake filter below: reporting a backlog the wait
259        // would not have woken for is a lie the caller cannot act on.
260        let pending = unread_anything(&self.db, &auth, focus).await.map_err(|e| {
261            tracing::error!(error = %e, "unread check failed");
262            ErrorData::internal_error("database error", None)
263        })?;
264        if pending > 0 && wants("message") {
265            let dms = unread_dms(&self.db, &auth).await.unwrap_or(0);
266            return Ok(Json(WaitResult {
267                woke: true,
268                timed_out: false,
269                events: vec![WaitEvent {
270                    kind: "message".into(),
271                    summary: format!("{pending} unread message(s) already waiting"),
272                }],
273                unread_direct_messages: dms,
274                suggestion: "Call read_messages to fetch them.".into(),
275            }));
276        }
277
278        let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout as u64);
279        let mut events: Vec<WaitEvent> = Vec::new();
280
281        while events.is_empty() {
282            let event = tokio::select! {
283                _ = tokio::time::sleep_until(deadline) => break,
284                recv = rx.recv() => match recv {
285                    Ok(ev) => ev,
286                    // Lagged: we missed events; report a generic wake so the
287                    // caller re-syncs from the database.
288                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
289                        events.push(WaitEvent {
290                            kind: "unknown".into(),
291                            summary: "event stream lagged; re-check the bus".into(),
292                        });
293                        break;
294                    }
295                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
296                },
297            };
298
299            if !event.visible_to(auth.team_id, auth.agent_id, &auth.session)
300                || !wants(event.kind())
301                || !in_focus(&event, focus)
302            {
303                continue;
304            }
305            // Your own messages are not news to you — but "you" is this
306            // session, not the person. Excluding by agent alone meant an
307            // announcement from your general window never woke the repository
308            // windows it was written for, which is the coordination pattern
309            // sessions exist to enable.
310            if event.kind() == "message"
311                && event.sender_agent_id() == Some(auth.agent_id)
312                && event.sender_session().unwrap_or("") == auth.session
313            {
314                continue;
315            }
316            if let Some(described) = describe(&self.db, &event).await {
317                events.push(described);
318                // Grace window: batch events that arrive together.
319                let grace = tokio::time::Instant::now() + Duration::from_millis(150);
320                while let Ok(Ok(more)) = tokio::time::timeout_at(grace, rx.recv()).await {
321                    if more.visible_to(auth.team_id, auth.agent_id, &auth.session)
322                        && in_focus(&more, focus)
323                        && wants(more.kind())
324                        && !(more.kind() == "message"
325                            && more.sender_agent_id() == Some(auth.agent_id))
326                        && let Some(d) = describe(&self.db, &more).await
327                    {
328                        events.push(d);
329                    }
330                }
331            }
332        }
333
334        let unread = unread_dms(&self.db, &auth).await.unwrap_or(0);
335        let woke = !events.is_empty();
336        Ok(Json(WaitResult {
337            woke,
338            timed_out: !woke,
339            suggestion: suggestion_for(&events, unread),
340            events,
341            unread_direct_messages: unread,
342        }))
343    }
344}