Skip to main content

ai_crew_sync/store/
messaging.rs

1use sqlx::{AssertSqlSafe, PgPool};
2use uuid::Uuid;
3
4use super::{agent_id_by_name, channel_id_by_name, normalize_channel};
5use crate::{
6    auth::AuthCtx,
7    error::{BusError, BusResult},
8    model::{ChannelInfo, ChannelList, MessageInfo, MessageList, PostMessageResult, ts},
9};
10
11const MAX_LIMIT: i64 = 200;
12/// Bodies carry logs, diffs and generated reports, not just chat.
13const MAX_BODY_BYTES: usize = 1024 * 1024;
14/// A topic is one line describing what belongs in the channel.
15const MAX_TOPIC_BYTES: usize = 256;
16
17/// A joined message row as it comes back from Postgres.
18#[derive(sqlx::FromRow)]
19struct MessageRow {
20    id: i64,
21    sender: String,
22    sender_session: Option<String>,
23    announce: bool,
24    channel: Option<String>,
25    recipient: Option<String>,
26    recipient_session: Option<String>,
27    body: String,
28    reply_to: Option<i64>,
29    metadata: serde_json::Value,
30    attachments: serde_json::Value,
31    created_at: chrono::DateTime<chrono::Utc>,
32}
33
34impl From<MessageRow> for MessageInfo {
35    fn from(r: MessageRow) -> Self {
36        MessageInfo {
37            id: r.id,
38            from: r.sender,
39            from_session: r.sender_session.filter(|s| !s.is_empty()),
40            announce: r.announce,
41            channel: r.channel,
42            to: r.recipient,
43            to_session: r.recipient_session.filter(|s| !s.is_empty()),
44            body: r.body,
45            reply_to: r.reply_to,
46            metadata: r.metadata,
47            attachments: serde_json::from_value(r.attachments).unwrap_or_default(),
48            created_at: ts(r.created_at),
49        }
50    }
51}
52
53const MESSAGE_SELECT: &str = r#"
54    SELECT m.id,
55           s.name  AS sender,
56           m.sender_session,
57           m.announce,
58           ch.name AS channel,
59           r.name  AS recipient,
60           m.recipient_session,
61           m.body,
62           m.reply_to,
63           m.metadata,
64           COALESCE(
65               (SELECT json_agg(json_build_object(
66                           'id', a.id, 'filename', a.filename,
67                           'content_type', a.content_type, 'size_bytes', a.size_bytes)
68                       ORDER BY a.id)
69                FROM attachments a WHERE a.message_id = m.id),
70               '[]'::json
71           ) AS attachments,
72           m.created_at
73    FROM messages m
74    JOIN agents s ON s.id = m.sender_agent_id
75    LEFT JOIN channels ch ON ch.id = m.channel_id
76    LEFT JOIN agents r ON r.id = m.recipient_agent_id
77"#;
78
79// ---------------------------------------------------------------- channels --
80
81pub async fn create_channel(
82    pool: &PgPool,
83    auth: &AuthCtx,
84    name: &str,
85    topic: Option<String>,
86) -> BusResult<ChannelInfo> {
87    let name = normalize_channel(name);
88    if name.is_empty() {
89        return Err(BusError::invalid("channel name cannot be empty"));
90    }
91    if name.len() > 64 {
92        return Err(BusError::invalid(
93            "channel name is limited to 64 characters",
94        ));
95    }
96    let topic = match topic.as_deref() {
97        Some(t) => Some(super::check_text("channel topic", t, MAX_TOPIC_BYTES)?),
98        None => None,
99    };
100
101    let row: (Uuid, String, Option<String>, chrono::DateTime<chrono::Utc>) = sqlx::query_as(
102        r#"
103        INSERT INTO channels (team_id, name, topic, created_by)
104        VALUES ($1, $2, $3, $4)
105        ON CONFLICT (team_id, name) DO UPDATE
106            SET topic = COALESCE(EXCLUDED.topic, channels.topic)
107        RETURNING id, name, topic, created_at
108        "#,
109    )
110    .bind(auth.team_id)
111    .bind(&name)
112    .bind(topic)
113    .bind(auth.agent_id)
114    .fetch_one(pool)
115    .await?;
116
117    Ok(ChannelInfo {
118        name: row.1,
119        topic: row.2,
120        message_count: 0,
121        created_at: ts(row.3),
122    })
123}
124
125pub async fn list_channels(pool: &PgPool, auth: &AuthCtx) -> BusResult<ChannelList> {
126    let rows: Vec<(String, Option<String>, i64, chrono::DateTime<chrono::Utc>)> = sqlx::query_as(
127        r#"
128        SELECT c.name,
129               c.topic,
130               (SELECT count(*) FROM messages m WHERE m.channel_id = c.id) AS message_count,
131               c.created_at
132        FROM channels c
133        WHERE c.team_id = $1
134        ORDER BY c.name
135        "#,
136    )
137    .bind(auth.team_id)
138    .fetch_all(pool)
139    .await?;
140
141    Ok(ChannelList {
142        channels: rows
143            .into_iter()
144            .map(|(name, topic, message_count, created_at)| ChannelInfo {
145                name,
146                topic,
147                message_count,
148                created_at: ts(created_at),
149            })
150            .collect(),
151    })
152}
153
154// ---------------------------------------------------------------- posting --
155
156/// Split `agent[/session]` into its parts.
157///
158/// No slash means the person: every session of theirs sees it, which is what
159/// addressing a teammate has always meant. With a slash, one working context
160/// of theirs — including one of your own, which is how a coordinating session
161/// hands work to the window that has the repository open.
162///
163/// The session half is normalised exactly as the `X-Crew-Session` header is,
164/// so `joaquin/Market-Data` reaches the session that announced itself as
165/// `market-data`.
166pub fn parse_address(raw: &str) -> BusResult<(String, Option<String>)> {
167    let raw = raw.trim();
168    match raw.split_once('/') {
169        None => Ok((raw.to_owned(), None)),
170        Some((agent, session)) => {
171            let agent = agent.trim();
172            if agent.is_empty() {
173                return Err(BusError::invalid(
174                    "an address is 'agent' or 'agent/session'; the agent part is missing",
175                ));
176            }
177            // The same validator the X-Crew-Session header uses. A label a
178            // header would reject must not be reachable by addressing it
179            // instead — otherwise a caller could store sessions that can never
180            // connect, and unbounded strings with them.
181            let session = crate::auth::normalize_session(session)
182                .map_err(|why| BusError::invalid(format!("the session in '{raw}' {why}")))?;
183            if session.is_empty() {
184                return Err(BusError::invalid(format!(
185                    "'{raw}' has an empty session; write '{agent}' to reach every \
186                     session of theirs, or '{agent}/<session>' for one of them"
187                )));
188            }
189            Ok((agent.to_owned(), Some(session)))
190        }
191    }
192}
193
194/// The channel a session works in: the one named after it, if the team has
195/// one.
196///
197/// Resolved by name every time rather than stored. A binding table would be
198/// one more thing to keep in sync and to migrate, and it would go stale the
199/// moment a channel is renamed; a lookup cannot. A team that does not name
200/// channels after repositories simply gets no default and passes `channel`
201/// exactly as it does today.
202pub async fn default_channel(pool: &PgPool, auth: &AuthCtx) -> BusResult<Option<(Uuid, String)>> {
203    if auth.session.is_empty() {
204        return Ok(None);
205    }
206    // The project label first: a session whose label is an opaque id (the
207    // stdio proxy mints those) still posts to the channel named after the
208    // project it declared. Then the session name itself, which is how
209    // clients that name sessions after repositories have always worked.
210    // An opaque label matches no channel and gets no default, rather than
211    // one it never asked for.
212    let (project, _) = super::presence::labels_of(pool, auth).await?;
213    let mut candidates: Vec<String> = Vec::new();
214    if let Some(p) = project.filter(|p| !p.is_empty()) {
215        candidates.push(normalize_channel(&p));
216    }
217    let by_session = normalize_channel(&auth.session);
218    if !candidates.contains(&by_session) {
219        candidates.push(by_session);
220    }
221    for name in candidates {
222        let row: Option<(Uuid,)> =
223            sqlx::query_as("SELECT id FROM channels WHERE team_id = $1 AND name = $2")
224                .bind(auth.team_id)
225                .bind(&name)
226                .fetch_optional(pool)
227                .await?;
228        if let Some((id,)) = row {
229            return Ok(Some((id, name)));
230        }
231    }
232    Ok(None)
233}
234
235/// Read-cursor key for a scope, kept apart per session and per view.
236///
237/// Two windows of one person track their own reading position; sharing the
238/// cursor would mean reading in one marks the other's messages read. The
239/// cross-session view keeps its own for the same reason.
240///
241/// Collision-free by construction: `/` is the one character a session label
242/// may not contain, so it is the separator. Concatenating a mode suffix would
243/// not be — the per-session cursor for a session called `api+all-sessions`
244/// would be byte-identical to the cross-session cursor for `api`, and reading
245/// one view would silently skip messages in the other.
246///
247/// The shared session with the default view keeps the bare `base`, so cursors
248/// written before sessions existed are still the ones read.
249pub fn cursor_scope(base: &str, session: &str) -> String {
250    cursor_scope_for(base, session, false)
251}
252
253pub fn cursor_scope_for(base: &str, session: &str, all_sessions: bool) -> String {
254    match (session.is_empty(), all_sessions) {
255        (true, false) => base.to_owned(),
256        (_, false) => format!("{base}/s/{session}"),
257        (_, true) => format!("{base}/a/{session}"),
258    }
259}
260
261/// SQL predicate for "a direct message this session should see": addressed to
262/// the session by name, or to the person with no session named.
263///
264/// `$1` is the agent, `$2` the session label.
265pub const DM_FOR_SESSION: &str =
266    "m.recipient_agent_id = $1 AND (m.recipient_session IS NULL OR m.recipient_session = $2)";
267
268pub struct PostInput {
269    pub channel: Option<String>,
270    pub to: Option<String>,
271    /// Reach every session of the team even when they are focused on their own
272    /// channel. Channel messages only.
273    pub announce: bool,
274    pub body: String,
275    pub reply_to: Option<i64>,
276    pub metadata: Option<serde_json::Value>,
277    /// Already decoded and size-checked; inserted in the same transaction as
278    /// the message so readers never see the message without its files.
279    pub attachments: Vec<super::attachments::NewAttachment>,
280}
281
282pub async fn post_message(
283    pool: &PgPool,
284    auth: &AuthCtx,
285    input: PostInput,
286) -> BusResult<PostMessageResult> {
287    let body = input.body.trim().to_owned();
288    if body.is_empty() {
289        return Err(BusError::invalid("message body cannot be empty"));
290    }
291    if body.len() > MAX_BODY_BYTES {
292        return Err(BusError::invalid(format!(
293            "message body is {} bytes; the limit is {MAX_BODY_BYTES}. \
294             Attach the file instead of pasting it, or split the message.",
295            body.len()
296        )));
297    }
298
299    // A direct message already arrives unfiltered — the focus rule never
300    // applies to it — so the flag would promise something it does not change.
301    //
302    // Only when `to` is set *without* `channel`: with both set, the mutual
303    // exclusion below is the real problem, and reporting this one instead
304    // would send the caller to fix the wrong thing.
305    if input.announce && input.to.is_some() && input.channel.is_none() {
306        return Err(BusError::invalid(
307            "`announce` applies to channel messages. A direct message already reaches \
308             its recipient whatever they are focused on, so drop the flag, or post to \
309             a channel if the whole team needs to see this.",
310        ));
311    }
312
313    let (channel_id, recipient_id, recipient_session, delivered_to) = match (
314        &input.channel,
315        &input.to,
316    ) {
317        (Some(_), Some(_)) => {
318            return Err(BusError::invalid(
319                "set either `channel` or `to`, not both: a message is either broadcast or direct",
320            ));
321        }
322        (None, None) => match default_channel(pool, auth).await? {
323            // The session works in a repository and the team has a channel for
324            // it: naming it on every call is friction with one right answer.
325            Some((id, _name)) => {
326                let names: Vec<(String,)> = sqlx::query_as(
327                    "SELECT name FROM agents WHERE team_id = $1 AND disabled_at IS NULL ORDER BY name",
328                )
329                .bind(auth.team_id)
330                .fetch_all(pool)
331                .await?;
332                (
333                    Some(id),
334                    None,
335                    None,
336                    names.into_iter().map(|r| r.0).collect::<Vec<_>>(),
337                )
338            }
339            None if auth.session.is_empty() => {
340                return Err(BusError::invalid(
341                    "set `channel` to broadcast, or `to` to send a direct message",
342                ));
343            }
344            None => {
345                return Err(BusError::invalid(format!(
346                    "set `channel` to broadcast, or `to` to send a direct message. \
347                     This session is '{}', and there is no channel of that name to \
348                     fall back on — create_channel '{}' to make it the default here.",
349                    auth.session, auth.session
350                )));
351            }
352        },
353        (Some(channel), None) => {
354            let id = channel_id_by_name(pool, auth.team_id, channel).await?;
355            // Everyone in the team can read a channel, so report the roster.
356            let names: Vec<(String,)> = sqlx::query_as(
357                "SELECT name FROM agents WHERE team_id = $1 AND disabled_at IS NULL ORDER BY name",
358            )
359            .bind(auth.team_id)
360            .fetch_all(pool)
361            .await?;
362            (
363                Some(id),
364                None,
365                None,
366                names.into_iter().map(|r| r.0).collect::<Vec<_>>(),
367            )
368        }
369        (None, Some(to)) => {
370            let (agent, session) = parse_address(to)?;
371            let id = agent_id_by_name(pool, auth.team_id, &agent).await?;
372            // Talking to the window you are already in is a mistake worth
373            // naming: nothing would ever read it, and a note or a channel is
374            // what the caller actually wants.
375            // Only the exact calling window is refused. Addressing yourself as
376            // a *person* is allowed and useful: it reaches your other windows,
377            // including ones not open yet — the same reason a message to a
378            // session that is currently closed is accepted.
379            if id == auth.agent_id && session.as_deref() == Some(auth.session.as_str()) {
380                return Err(BusError::invalid(
381                    "that address is this session — a message to yourself here would \
382                     never be read. Use set_note to leave something durable, post to \
383                     a channel, or address another of your sessions as 'you/<session>'.",
384                ));
385            }
386            // Reported back so a mistyped session shows up in the response
387            // rather than becoming a message nobody ever opens.
388            let label = match &session {
389                Some(s) => format!("{agent}/{s}"),
390                None => agent.clone(),
391            };
392            (None, Some(id), session, vec![label])
393        }
394    };
395
396    // A reply must point at a message in the same team.
397    if let Some(reply_to) = input.reply_to {
398        let exists: Option<(i64,)> =
399            sqlx::query_as("SELECT id FROM messages WHERE id = $1 AND team_id = $2")
400                .bind(reply_to)
401                .bind(auth.team_id)
402                .fetch_optional(pool)
403                .await?;
404        if exists.is_none() {
405            return Err(BusError::not_found(format!("message {reply_to}")));
406        }
407    }
408
409    let metadata_in = super::normalize_metadata(input.metadata);
410    super::check_metadata("message", metadata_in.as_ref())?;
411    let metadata = metadata_in.unwrap_or_else(|| serde_json::Value::Object(Default::default()));
412
413    // Message + attachments commit together, so the NOTIFY that wakes
414    // teammates only fires once everything is readable.
415    let mut tx = pool.begin().await?;
416    // And a window that was resumed away cannot post as the session that
417    // replaced it: the check shares this transaction.
418    super::sessions::guard(&mut tx, auth).await?;
419
420    let (id,): (i64,) = sqlx::query_as(
421        r#"
422        INSERT INTO messages
423            (team_id, channel_id, recipient_agent_id, recipient_session,
424             sender_agent_id, sender_session, body, reply_to, metadata, announce)
425        VALUES ($1, $2, $3, $8, $4, $9, $5, $6, $7, $10)
426        RETURNING id
427        "#,
428    )
429    .bind(auth.team_id)
430    .bind(channel_id)
431    .bind(recipient_id)
432    .bind(auth.agent_id)
433    .bind(&body)
434    .bind(input.reply_to)
435    .bind(&metadata)
436    .bind(recipient_session.as_deref())
437    // Recorded even on channel messages: a reply needs to know which window
438    // asked, or it goes back to whichever one happens to notice.
439    .bind(super::session_label(auth))
440    .bind(input.announce)
441    .fetch_one(&mut *tx)
442    .await?;
443
444    for att in &input.attachments {
445        super::attachments::insert_for_message(&mut tx, auth.team_id, id, auth.agent_id, att)
446            .await?;
447    }
448
449    let row: MessageRow =
450        sqlx::query_as(AssertSqlSafe(format!("{MESSAGE_SELECT} WHERE m.id = $1")))
451            .bind(id)
452            .fetch_one(&mut *tx)
453            .await?;
454
455    tx.commit().await?;
456
457    Ok(PostMessageResult {
458        message: row.into(),
459        delivered_to,
460    })
461}
462
463// ----------------------------------------------------------------- asking --
464
465/// The answer to a question DM: their explicit reply if there is one, else
466/// their first direct message to the asker after the question was sent.
467pub async fn find_answer(
468    pool: &PgPool,
469    auth: &AuthCtx,
470    target_id: Uuid,
471    target_session: Option<&str>,
472    question_id: i64,
473) -> BusResult<Option<MessageInfo>> {
474    // Addressed to this session or to the person: an answer sent to another of
475    // the asker's windows is not an answer to the window that is blocked here.
476    //
477    // And when the question named a session, only that session can answer it.
478    // Matching the agent alone let any later message from a *sibling* window
479    // of the target satisfy the wait and be returned as the answer from the
480    // one that was asked.
481    let row: Option<MessageRow> = sqlx::query_as(AssertSqlSafe(format!(
482        r#"{MESSAGE_SELECT}
483           WHERE m.sender_agent_id = $1
484             AND m.recipient_agent_id = $2
485             AND m.id > $3
486             AND (m.recipient_session IS NULL OR m.recipient_session = $4)
487             AND ($5::text IS NULL OR COALESCE(m.sender_session, '') = $5)
488           ORDER BY (m.reply_to = $3) DESC NULLS LAST, m.id
489           LIMIT 1"#
490    )))
491    .bind(target_id)
492    .bind(auth.agent_id)
493    .bind(question_id)
494    .bind(&auth.session)
495    .bind(target_session)
496    .fetch_optional(pool)
497    .await?;
498    Ok(row.map(Into::into))
499}
500
501/// A resumed ask must point at a question the caller actually sent to the
502/// target; anything else means a mixed-up id.
503pub async fn verify_question(
504    pool: &PgPool,
505    auth: &AuthCtx,
506    target_id: Uuid,
507    target_session: Option<&str>,
508    question_id: i64,
509) -> BusResult<()> {
510    // Sent by this session, not merely by this agent: resuming someone else's
511    // wait — even your own other window's — would hand them each other's
512    // answers.
513    // The addressee has to match too, or a question sent to one session could
514    // be resumed naming another and collect that one's answer instead.
515    let exists: Option<(i64,)> = sqlx::query_as(
516        "SELECT id FROM messages
517         WHERE id = $1 AND sender_agent_id = $2 AND recipient_agent_id = $3
518           AND COALESCE(sender_session, '') = $4
519           AND recipient_session IS NOT DISTINCT FROM $5",
520    )
521    .bind(question_id)
522    .bind(auth.agent_id)
523    .bind(target_id)
524    .bind(&auth.session)
525    .bind(target_session)
526    .fetch_optional(pool)
527    .await?;
528    if exists.is_none() {
529        return Err(BusError::invalid(format!(
530            "message {question_id} is not a question this session sent to that exact \
531             address; pass the question_message_id returned by ask_agent in this \
532             session, with the same `to`"
533        )));
534    }
535    Ok(())
536}
537
538// ---------------------------------------------------------------- reading --
539
540/// Normalised read scope plus the cursor key used to remember the read position.
541enum Scope {
542    All,
543    Inbox,
544    Channel { id: Uuid, name: String },
545}
546
547impl Scope {
548    /// Namespaced per session: two windows of one person each track their own
549    /// reading position, so catching up in one does not mark the other read.
550    fn cursor_key_for(&self, auth: &AuthCtx, all_sessions: bool) -> String {
551        let base = match self {
552            Scope::All => "all".to_owned(),
553            Scope::Inbox => "inbox".to_owned(),
554            Scope::Channel { id, .. } => format!("channel:{id}"),
555        };
556        cursor_scope_for(&base, &auth.session, all_sessions)
557    }
558    fn label(&self) -> String {
559        match self {
560            Scope::All => "all".into(),
561            Scope::Inbox => "inbox".into(),
562            Scope::Channel { name, .. } => format!("#{name}"),
563        }
564    }
565}
566
567async fn resolve_scope(pool: &PgPool, auth: &AuthCtx, raw: &str) -> BusResult<Scope> {
568    match raw.trim().to_lowercase().as_str() {
569        "" | "all" => Ok(Scope::All),
570        "inbox" | "dm" | "direct" => Ok(Scope::Inbox),
571        other => {
572            let name = normalize_channel(other);
573            let id = channel_id_by_name(pool, auth.team_id, &name).await?;
574            Ok(Scope::Channel { id, name })
575        }
576    }
577}
578
579pub struct ReadInput {
580    pub scope: String,
581    pub only_new: bool,
582    pub limit: i64,
583    /// Include direct messages addressed to your *other* sessions. Off by
584    /// default so a window sees its own work, on when you want the whole
585    /// picture — the bus never hides a person's own messages from them.
586    pub all_sessions: bool,
587}
588
589pub async fn read_messages(
590    pool: &PgPool,
591    auth: &AuthCtx,
592    input: ReadInput,
593) -> BusResult<MessageList> {
594    let scope = resolve_scope(pool, auth, &input.scope).await?;
595    let limit = input.limit.clamp(1, MAX_LIMIT);
596    // Reading across sessions is a different view of a different set of
597    // messages, so it keeps its own cursor rather than moving this session's.
598    let cursor_key = scope.cursor_key_for(auth, input.all_sessions);
599    // True widens the DM filter to every session of this agent; false keeps it
600    // to what is addressed to this one, or to the person.
601    let session_filter = input.all_sessions;
602
603    let since: i64 = if input.only_new {
604        sqlx::query_as::<_, (i64,)>(
605            "SELECT last_message_id FROM read_cursors WHERE agent_id = $1 AND scope = $2",
606        )
607        .bind(auth.agent_id)
608        .bind(&cursor_key)
609        .fetch_optional(pool)
610        .await?
611        .map(|r| r.0)
612        .unwrap_or(0)
613    } else {
614        0
615    };
616
617    // Two different pages. A cursor read (`only_new`) is a backlog drained
618    // oldest first: the page is the oldest messages past the cursor, and
619    // the cursor moves to the end of that page, so what was not returned
620    // is still ahead of it. Taking the newest page there and moving the
621    // cursor past it silently lost everything older than the page. A
622    // history read (`only_new: false`) is "the last N": the newest page,
623    // flipped back into chronological order.
624    let order = if input.only_new { "ASC" } else { "DESC" };
625    let rows: Vec<MessageRow> = match &scope {
626        Scope::All => {
627            sqlx::query_as(AssertSqlSafe(format!(
628                r#"{MESSAGE_SELECT}
629                   WHERE m.team_id = $1
630                     AND m.id > $2
631                     AND (m.channel_id IS NOT NULL
632                          OR (m.recipient_agent_id = $3
633                              AND ($5::bool
634                                   OR m.recipient_session IS NULL
635                                   OR m.recipient_session = $6))
636                          OR m.sender_agent_id = $3)
637                   ORDER BY m.id {order}
638                   LIMIT $4"#
639            )))
640            .bind(auth.team_id)
641            .bind(since)
642            .bind(auth.agent_id)
643            .bind(limit)
644            .bind(session_filter)
645            .bind(&auth.session)
646            .fetch_all(pool)
647            .await?
648        }
649        Scope::Inbox => {
650            sqlx::query_as(AssertSqlSafe(format!(
651                r#"{MESSAGE_SELECT}
652                   WHERE m.recipient_agent_id = $1
653                     AND m.id > $2
654                     AND ($4::bool
655                          OR m.recipient_session IS NULL
656                          OR m.recipient_session = $5)
657                   ORDER BY m.id {order}
658                   LIMIT $3"#
659            )))
660            .bind(auth.agent_id)
661            .bind(since)
662            .bind(limit)
663            .bind(session_filter)
664            .bind(&auth.session)
665            .fetch_all(pool)
666            .await?
667        }
668        Scope::Channel { id, .. } => {
669            sqlx::query_as(AssertSqlSafe(format!(
670                r#"{MESSAGE_SELECT}
671                   WHERE m.channel_id = $1 AND m.id > $2
672                   ORDER BY m.id {order}
673                   LIMIT $3"#
674            )))
675            .bind(*id)
676            .bind(since)
677            .bind(limit)
678            .fetch_all(pool)
679            .await?
680        }
681    };
682
683    let truncated = rows.len() as i64 == limit;
684    let mut messages: Vec<MessageInfo> = rows.into_iter().map(Into::into).collect();
685    if !input.only_new {
686        messages.reverse();
687    }
688
689    let new_cursor = messages.iter().map(|m| m.id).max().unwrap_or(since);
690    if input.only_new && new_cursor > since {
691        // The cursor is this window's. Advanced by a connection that has
692        // been replaced, the live window never sees those messages: they
693        // are "already read" by a process that is gone.
694        let mut tx = pool.begin().await?;
695        super::sessions::guard(&mut tx, auth).await?;
696        sqlx::query(
697            r#"
698            INSERT INTO read_cursors (agent_id, scope, last_message_id)
699            VALUES ($1, $2, $3)
700            ON CONFLICT (agent_id, scope) DO UPDATE
701                SET last_message_id = GREATEST(read_cursors.last_message_id, EXCLUDED.last_message_id),
702                    updated_at = now()
703            "#,
704        )
705        .bind(auth.agent_id)
706        .bind(&cursor_key)
707        .bind(new_cursor)
708        .execute(&mut *tx)
709        .await?;
710        tx.commit().await?;
711    }
712
713    Ok(MessageList {
714        messages,
715        scope: scope.label(),
716        cursor: new_cursor,
717        truncated,
718    })
719}
720
721pub async fn search_messages(
722    pool: &PgPool,
723    auth: &AuthCtx,
724    query: &str,
725    limit: i64,
726) -> BusResult<MessageList> {
727    let query = query.trim();
728    if query.is_empty() {
729        return Err(BusError::invalid("search query cannot be empty"));
730    }
731    let limit = limit.clamp(1, MAX_LIMIT);
732
733    let rows: Vec<MessageRow> = sqlx::query_as(AssertSqlSafe(format!(
734        r#"{MESSAGE_SELECT}
735           WHERE m.team_id = $1
736             AND (m.channel_id IS NOT NULL
737                  OR m.recipient_agent_id = $2
738                  OR m.sender_agent_id = $2)
739             AND to_tsvector('simple', m.body) @@ plainto_tsquery('simple', $3)
740           ORDER BY m.id DESC
741           LIMIT $4"#
742    )))
743    .bind(auth.team_id)
744    .bind(auth.agent_id)
745    .bind(query)
746    .bind(limit)
747    .fetch_all(pool)
748    .await?;
749
750    let truncated = rows.len() as i64 == limit;
751    let messages: Vec<MessageInfo> = rows.into_iter().map(Into::into).collect();
752    let cursor = messages.iter().map(|m| m.id).max().unwrap_or(0);
753
754    Ok(MessageList {
755        messages,
756        scope: format!("search:{query}"),
757        cursor,
758        truncated,
759    })
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765
766    #[test]
767    fn an_address_validates_its_session_like_the_header_does() {
768        // A label the X-Crew-Session header would reject must not be reachable
769        // by addressing it instead.
770        assert!(parse_address(&format!("dani/{}", "x".repeat(1000))).is_err());
771        assert!(parse_address("dani/${BUS_SESSION}").is_err());
772        assert!(parse_address("dani/api\u{7f}").is_err());
773        assert!(parse_address("dani/").is_err());
774        assert!(parse_address("/api").is_err());
775    }
776
777    #[test]
778    fn an_address_normalises_its_session_like_the_header_does() {
779        assert_eq!(
780            parse_address("dani/ Market-Data ").unwrap(),
781            ("dani".to_owned(), Some("market-data".to_owned()))
782        );
783        assert_eq!(parse_address("dani").unwrap(), ("dani".to_owned(), None));
784    }
785
786    #[test]
787    fn cursor_keys_cannot_collide_between_a_session_and_a_view() {
788        // The trap: concatenating a mode suffix makes the per-session cursor
789        // for a session called `api+all-sessions` byte-identical to the
790        // cross-session cursor for `api`, so reading one view advances the
791        // other and silently skips messages.
792        let per_session_of_odd_label = cursor_scope_for("inbox", "api+all-sessions", false);
793        let all_sessions_of_api = cursor_scope_for("inbox", "api", true);
794        assert_ne!(per_session_of_odd_label, all_sessions_of_api);
795
796        // Every combination stays distinct.
797        let keys = [
798            cursor_scope_for("inbox", "", false),
799            cursor_scope_for("inbox", "", true),
800            cursor_scope_for("inbox", "api", false),
801            cursor_scope_for("inbox", "api", true),
802            per_session_of_odd_label,
803            cursor_scope_for("inbox", "api+all-sessions", true),
804        ];
805        let unique: std::collections::HashSet<&String> = keys.iter().collect();
806        assert_eq!(unique.len(), keys.len(), "{keys:?}");
807    }
808
809    #[test]
810    fn the_shared_session_keeps_the_cursor_it_already_had() {
811        // Cursors written before sessions existed must still be the ones read,
812        // or every agent re-reads its whole history once on upgrade.
813        assert_eq!(cursor_scope_for("inbox", "", false), "inbox");
814        assert_eq!(cursor_scope_for("all", "", false), "all");
815    }
816}