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    let name = normalize_channel(&auth.session);
207    let row: Option<(Uuid,)> =
208        sqlx::query_as("SELECT id FROM channels WHERE team_id = $1 AND name = $2")
209            .bind(auth.team_id)
210            .bind(&name)
211            .fetch_optional(pool)
212            .await?;
213    Ok(row.map(|r| (r.0, name)))
214}
215
216/// Read-cursor key for a scope, kept apart per session and per view.
217///
218/// Two windows of one person track their own reading position; sharing the
219/// cursor would mean reading in one marks the other's messages read. The
220/// cross-session view keeps its own for the same reason.
221///
222/// Collision-free by construction: `/` is the one character a session label
223/// may not contain, so it is the separator. Concatenating a mode suffix would
224/// not be — the per-session cursor for a session called `api+all-sessions`
225/// would be byte-identical to the cross-session cursor for `api`, and reading
226/// one view would silently skip messages in the other.
227///
228/// The shared session with the default view keeps the bare `base`, so cursors
229/// written before sessions existed are still the ones read.
230pub fn cursor_scope(base: &str, session: &str) -> String {
231    cursor_scope_for(base, session, false)
232}
233
234pub fn cursor_scope_for(base: &str, session: &str, all_sessions: bool) -> String {
235    match (session.is_empty(), all_sessions) {
236        (true, false) => base.to_owned(),
237        (_, false) => format!("{base}/s/{session}"),
238        (_, true) => format!("{base}/a/{session}"),
239    }
240}
241
242/// SQL predicate for "a direct message this session should see": addressed to
243/// the session by name, or to the person with no session named.
244///
245/// `$1` is the agent, `$2` the session label.
246pub const DM_FOR_SESSION: &str =
247    "m.recipient_agent_id = $1 AND (m.recipient_session IS NULL OR m.recipient_session = $2)";
248
249pub struct PostInput {
250    pub channel: Option<String>,
251    pub to: Option<String>,
252    /// Reach every session of the team even when they are focused on their own
253    /// channel. Channel messages only.
254    pub announce: bool,
255    pub body: String,
256    pub reply_to: Option<i64>,
257    pub metadata: Option<serde_json::Value>,
258    /// Already decoded and size-checked; inserted in the same transaction as
259    /// the message so readers never see the message without its files.
260    pub attachments: Vec<super::attachments::NewAttachment>,
261}
262
263pub async fn post_message(
264    pool: &PgPool,
265    auth: &AuthCtx,
266    input: PostInput,
267) -> BusResult<PostMessageResult> {
268    let body = input.body.trim().to_owned();
269    if body.is_empty() {
270        return Err(BusError::invalid("message body cannot be empty"));
271    }
272    if body.len() > MAX_BODY_BYTES {
273        return Err(BusError::invalid(format!(
274            "message body is {} bytes; the limit is {MAX_BODY_BYTES}. \
275             Attach the file instead of pasting it, or split the message.",
276            body.len()
277        )));
278    }
279
280    // A direct message already arrives unfiltered — the focus rule never
281    // applies to it — so the flag would promise something it does not change.
282    //
283    // Only when `to` is set *without* `channel`: with both set, the mutual
284    // exclusion below is the real problem, and reporting this one instead
285    // would send the caller to fix the wrong thing.
286    if input.announce && input.to.is_some() && input.channel.is_none() {
287        return Err(BusError::invalid(
288            "`announce` applies to channel messages. A direct message already reaches \
289             its recipient whatever they are focused on, so drop the flag, or post to \
290             a channel if the whole team needs to see this.",
291        ));
292    }
293
294    let (channel_id, recipient_id, recipient_session, delivered_to) = match (
295        &input.channel,
296        &input.to,
297    ) {
298        (Some(_), Some(_)) => {
299            return Err(BusError::invalid(
300                "set either `channel` or `to`, not both: a message is either broadcast or direct",
301            ));
302        }
303        (None, None) => match default_channel(pool, auth).await? {
304            // The session works in a repository and the team has a channel for
305            // it: naming it on every call is friction with one right answer.
306            Some((id, _name)) => {
307                let names: Vec<(String,)> = sqlx::query_as(
308                    "SELECT name FROM agents WHERE team_id = $1 AND disabled_at IS NULL ORDER BY name",
309                )
310                .bind(auth.team_id)
311                .fetch_all(pool)
312                .await?;
313                (
314                    Some(id),
315                    None,
316                    None,
317                    names.into_iter().map(|r| r.0).collect::<Vec<_>>(),
318                )
319            }
320            None if auth.session.is_empty() => {
321                return Err(BusError::invalid(
322                    "set `channel` to broadcast, or `to` to send a direct message",
323                ));
324            }
325            None => {
326                return Err(BusError::invalid(format!(
327                    "set `channel` to broadcast, or `to` to send a direct message. \
328                     This session is '{}', and there is no channel of that name to \
329                     fall back on — create_channel '{}' to make it the default here.",
330                    auth.session, auth.session
331                )));
332            }
333        },
334        (Some(channel), None) => {
335            let id = channel_id_by_name(pool, auth.team_id, channel).await?;
336            // Everyone in the team can read a channel, so report the roster.
337            let names: Vec<(String,)> = sqlx::query_as(
338                "SELECT name FROM agents WHERE team_id = $1 AND disabled_at IS NULL ORDER BY name",
339            )
340            .bind(auth.team_id)
341            .fetch_all(pool)
342            .await?;
343            (
344                Some(id),
345                None,
346                None,
347                names.into_iter().map(|r| r.0).collect::<Vec<_>>(),
348            )
349        }
350        (None, Some(to)) => {
351            let (agent, session) = parse_address(to)?;
352            let id = agent_id_by_name(pool, auth.team_id, &agent).await?;
353            // Talking to the window you are already in is a mistake worth
354            // naming: nothing would ever read it, and a note or a channel is
355            // what the caller actually wants.
356            // Only the exact calling window is refused. Addressing yourself as
357            // a *person* is allowed and useful: it reaches your other windows,
358            // including ones not open yet — the same reason a message to a
359            // session that is currently closed is accepted.
360            if id == auth.agent_id && session.as_deref() == Some(auth.session.as_str()) {
361                return Err(BusError::invalid(
362                    "that address is this session — a message to yourself here would \
363                     never be read. Use set_note to leave something durable, post to \
364                     a channel, or address another of your sessions as 'you/<session>'.",
365                ));
366            }
367            // Reported back so a mistyped session shows up in the response
368            // rather than becoming a message nobody ever opens.
369            let label = match &session {
370                Some(s) => format!("{agent}/{s}"),
371                None => agent.clone(),
372            };
373            (None, Some(id), session, vec![label])
374        }
375    };
376
377    // A reply must point at a message in the same team.
378    if let Some(reply_to) = input.reply_to {
379        let exists: Option<(i64,)> =
380            sqlx::query_as("SELECT id FROM messages WHERE id = $1 AND team_id = $2")
381                .bind(reply_to)
382                .bind(auth.team_id)
383                .fetch_optional(pool)
384                .await?;
385        if exists.is_none() {
386            return Err(BusError::not_found(format!("message {reply_to}")));
387        }
388    }
389
390    let metadata_in = super::normalize_metadata(input.metadata);
391    super::check_metadata("message", metadata_in.as_ref())?;
392    let metadata = metadata_in.unwrap_or_else(|| serde_json::Value::Object(Default::default()));
393
394    // Message + attachments commit together, so the NOTIFY that wakes
395    // teammates only fires once everything is readable.
396    let mut tx = pool.begin().await?;
397
398    let (id,): (i64,) = sqlx::query_as(
399        r#"
400        INSERT INTO messages
401            (team_id, channel_id, recipient_agent_id, recipient_session,
402             sender_agent_id, sender_session, body, reply_to, metadata, announce)
403        VALUES ($1, $2, $3, $8, $4, $9, $5, $6, $7, $10)
404        RETURNING id
405        "#,
406    )
407    .bind(auth.team_id)
408    .bind(channel_id)
409    .bind(recipient_id)
410    .bind(auth.agent_id)
411    .bind(&body)
412    .bind(input.reply_to)
413    .bind(&metadata)
414    .bind(recipient_session.as_deref())
415    // Recorded even on channel messages: a reply needs to know which window
416    // asked, or it goes back to whichever one happens to notice.
417    .bind(super::session_label(auth))
418    .bind(input.announce)
419    .fetch_one(&mut *tx)
420    .await?;
421
422    for att in &input.attachments {
423        super::attachments::insert_for_message(&mut tx, auth.team_id, id, auth.agent_id, att)
424            .await?;
425    }
426
427    let row: MessageRow =
428        sqlx::query_as(AssertSqlSafe(format!("{MESSAGE_SELECT} WHERE m.id = $1")))
429            .bind(id)
430            .fetch_one(&mut *tx)
431            .await?;
432
433    tx.commit().await?;
434
435    Ok(PostMessageResult {
436        message: row.into(),
437        delivered_to,
438    })
439}
440
441// ----------------------------------------------------------------- asking --
442
443/// The answer to a question DM: their explicit reply if there is one, else
444/// their first direct message to the asker after the question was sent.
445pub async fn find_answer(
446    pool: &PgPool,
447    auth: &AuthCtx,
448    target_id: Uuid,
449    target_session: Option<&str>,
450    question_id: i64,
451) -> BusResult<Option<MessageInfo>> {
452    // Addressed to this session or to the person: an answer sent to another of
453    // the asker's windows is not an answer to the window that is blocked here.
454    //
455    // And when the question named a session, only that session can answer it.
456    // Matching the agent alone let any later message from a *sibling* window
457    // of the target satisfy the wait and be returned as the answer from the
458    // one that was asked.
459    let row: Option<MessageRow> = sqlx::query_as(AssertSqlSafe(format!(
460        r#"{MESSAGE_SELECT}
461           WHERE m.sender_agent_id = $1
462             AND m.recipient_agent_id = $2
463             AND m.id > $3
464             AND (m.recipient_session IS NULL OR m.recipient_session = $4)
465             AND ($5::text IS NULL OR COALESCE(m.sender_session, '') = $5)
466           ORDER BY (m.reply_to = $3) DESC NULLS LAST, m.id
467           LIMIT 1"#
468    )))
469    .bind(target_id)
470    .bind(auth.agent_id)
471    .bind(question_id)
472    .bind(&auth.session)
473    .bind(target_session)
474    .fetch_optional(pool)
475    .await?;
476    Ok(row.map(Into::into))
477}
478
479/// A resumed ask must point at a question the caller actually sent to the
480/// target; anything else means a mixed-up id.
481pub async fn verify_question(
482    pool: &PgPool,
483    auth: &AuthCtx,
484    target_id: Uuid,
485    target_session: Option<&str>,
486    question_id: i64,
487) -> BusResult<()> {
488    // Sent by this session, not merely by this agent: resuming someone else's
489    // wait — even your own other window's — would hand them each other's
490    // answers.
491    // The addressee has to match too, or a question sent to one session could
492    // be resumed naming another and collect that one's answer instead.
493    let exists: Option<(i64,)> = sqlx::query_as(
494        "SELECT id FROM messages
495         WHERE id = $1 AND sender_agent_id = $2 AND recipient_agent_id = $3
496           AND COALESCE(sender_session, '') = $4
497           AND recipient_session IS NOT DISTINCT FROM $5",
498    )
499    .bind(question_id)
500    .bind(auth.agent_id)
501    .bind(target_id)
502    .bind(&auth.session)
503    .bind(target_session)
504    .fetch_optional(pool)
505    .await?;
506    if exists.is_none() {
507        return Err(BusError::invalid(format!(
508            "message {question_id} is not a question this session sent to that exact \
509             address; pass the question_message_id returned by ask_agent in this \
510             session, with the same `to`"
511        )));
512    }
513    Ok(())
514}
515
516// ---------------------------------------------------------------- reading --
517
518/// Normalised read scope plus the cursor key used to remember the read position.
519enum Scope {
520    All,
521    Inbox,
522    Channel { id: Uuid, name: String },
523}
524
525impl Scope {
526    /// Namespaced per session: two windows of one person each track their own
527    /// reading position, so catching up in one does not mark the other read.
528    fn cursor_key_for(&self, auth: &AuthCtx, all_sessions: bool) -> String {
529        let base = match self {
530            Scope::All => "all".to_owned(),
531            Scope::Inbox => "inbox".to_owned(),
532            Scope::Channel { id, .. } => format!("channel:{id}"),
533        };
534        cursor_scope_for(&base, &auth.session, all_sessions)
535    }
536    fn label(&self) -> String {
537        match self {
538            Scope::All => "all".into(),
539            Scope::Inbox => "inbox".into(),
540            Scope::Channel { name, .. } => format!("#{name}"),
541        }
542    }
543}
544
545async fn resolve_scope(pool: &PgPool, auth: &AuthCtx, raw: &str) -> BusResult<Scope> {
546    match raw.trim().to_lowercase().as_str() {
547        "" | "all" => Ok(Scope::All),
548        "inbox" | "dm" | "direct" => Ok(Scope::Inbox),
549        other => {
550            let name = normalize_channel(other);
551            let id = channel_id_by_name(pool, auth.team_id, &name).await?;
552            Ok(Scope::Channel { id, name })
553        }
554    }
555}
556
557pub struct ReadInput {
558    pub scope: String,
559    pub only_new: bool,
560    pub limit: i64,
561    /// Include direct messages addressed to your *other* sessions. Off by
562    /// default so a window sees its own work, on when you want the whole
563    /// picture — the bus never hides a person's own messages from them.
564    pub all_sessions: bool,
565}
566
567pub async fn read_messages(
568    pool: &PgPool,
569    auth: &AuthCtx,
570    input: ReadInput,
571) -> BusResult<MessageList> {
572    let scope = resolve_scope(pool, auth, &input.scope).await?;
573    let limit = input.limit.clamp(1, MAX_LIMIT);
574    // Reading across sessions is a different view of a different set of
575    // messages, so it keeps its own cursor rather than moving this session's.
576    let cursor_key = scope.cursor_key_for(auth, input.all_sessions);
577    // True widens the DM filter to every session of this agent; false keeps it
578    // to what is addressed to this one, or to the person.
579    let session_filter = input.all_sessions;
580
581    let since: i64 = if input.only_new {
582        sqlx::query_as::<_, (i64,)>(
583            "SELECT last_message_id FROM read_cursors WHERE agent_id = $1 AND scope = $2",
584        )
585        .bind(auth.agent_id)
586        .bind(&cursor_key)
587        .fetch_optional(pool)
588        .await?
589        .map(|r| r.0)
590        .unwrap_or(0)
591    } else {
592        0
593    };
594
595    // Ordered ascending so the agent reads the conversation in chronological
596    // order; when not filtering by cursor we take the newest `limit` and then
597    // flip them back, so "the last N messages" is what you get.
598    let rows: Vec<MessageRow> = match &scope {
599        Scope::All => {
600            sqlx::query_as(AssertSqlSafe(format!(
601                r#"{MESSAGE_SELECT}
602                   WHERE m.team_id = $1
603                     AND m.id > $2
604                     AND (m.channel_id IS NOT NULL
605                          OR (m.recipient_agent_id = $3
606                              AND ($5::bool
607                                   OR m.recipient_session IS NULL
608                                   OR m.recipient_session = $6))
609                          OR m.sender_agent_id = $3)
610                   ORDER BY m.id DESC
611                   LIMIT $4"#
612            )))
613            .bind(auth.team_id)
614            .bind(since)
615            .bind(auth.agent_id)
616            .bind(limit)
617            .bind(session_filter)
618            .bind(&auth.session)
619            .fetch_all(pool)
620            .await?
621        }
622        Scope::Inbox => {
623            sqlx::query_as(AssertSqlSafe(format!(
624                r#"{MESSAGE_SELECT}
625                   WHERE m.recipient_agent_id = $1
626                     AND m.id > $2
627                     AND ($4::bool
628                          OR m.recipient_session IS NULL
629                          OR m.recipient_session = $5)
630                   ORDER BY m.id DESC
631                   LIMIT $3"#
632            )))
633            .bind(auth.agent_id)
634            .bind(since)
635            .bind(limit)
636            .bind(session_filter)
637            .bind(&auth.session)
638            .fetch_all(pool)
639            .await?
640        }
641        Scope::Channel { id, .. } => {
642            sqlx::query_as(AssertSqlSafe(format!(
643                r#"{MESSAGE_SELECT}
644                   WHERE m.channel_id = $1 AND m.id > $2
645                   ORDER BY m.id DESC
646                   LIMIT $3"#
647            )))
648            .bind(*id)
649            .bind(since)
650            .bind(limit)
651            .fetch_all(pool)
652            .await?
653        }
654    };
655
656    let truncated = rows.len() as i64 == limit;
657    let mut messages: Vec<MessageInfo> = rows.into_iter().map(Into::into).collect();
658    messages.reverse();
659
660    let new_cursor = messages.iter().map(|m| m.id).max().unwrap_or(since);
661    if input.only_new && new_cursor > since {
662        sqlx::query(
663            r#"
664            INSERT INTO read_cursors (agent_id, scope, last_message_id)
665            VALUES ($1, $2, $3)
666            ON CONFLICT (agent_id, scope) DO UPDATE
667                SET last_message_id = GREATEST(read_cursors.last_message_id, EXCLUDED.last_message_id),
668                    updated_at = now()
669            "#,
670        )
671        .bind(auth.agent_id)
672        .bind(&cursor_key)
673        .bind(new_cursor)
674        .execute(pool)
675        .await?;
676    }
677
678    Ok(MessageList {
679        messages,
680        scope: scope.label(),
681        cursor: new_cursor,
682        truncated,
683    })
684}
685
686pub async fn search_messages(
687    pool: &PgPool,
688    auth: &AuthCtx,
689    query: &str,
690    limit: i64,
691) -> BusResult<MessageList> {
692    let query = query.trim();
693    if query.is_empty() {
694        return Err(BusError::invalid("search query cannot be empty"));
695    }
696    let limit = limit.clamp(1, MAX_LIMIT);
697
698    let rows: Vec<MessageRow> = sqlx::query_as(AssertSqlSafe(format!(
699        r#"{MESSAGE_SELECT}
700           WHERE m.team_id = $1
701             AND (m.channel_id IS NOT NULL
702                  OR m.recipient_agent_id = $2
703                  OR m.sender_agent_id = $2)
704             AND to_tsvector('simple', m.body) @@ plainto_tsquery('simple', $3)
705           ORDER BY m.id DESC
706           LIMIT $4"#
707    )))
708    .bind(auth.team_id)
709    .bind(auth.agent_id)
710    .bind(query)
711    .bind(limit)
712    .fetch_all(pool)
713    .await?;
714
715    let truncated = rows.len() as i64 == limit;
716    let messages: Vec<MessageInfo> = rows.into_iter().map(Into::into).collect();
717    let cursor = messages.iter().map(|m| m.id).max().unwrap_or(0);
718
719    Ok(MessageList {
720        messages,
721        scope: format!("search:{query}"),
722        cursor,
723        truncated,
724    })
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730
731    #[test]
732    fn an_address_validates_its_session_like_the_header_does() {
733        // A label the X-Crew-Session header would reject must not be reachable
734        // by addressing it instead.
735        assert!(parse_address(&format!("dani/{}", "x".repeat(1000))).is_err());
736        assert!(parse_address("dani/${BUS_SESSION}").is_err());
737        assert!(parse_address("dani/api\u{7f}").is_err());
738        assert!(parse_address("dani/").is_err());
739        assert!(parse_address("/api").is_err());
740    }
741
742    #[test]
743    fn an_address_normalises_its_session_like_the_header_does() {
744        assert_eq!(
745            parse_address("dani/ Market-Data ").unwrap(),
746            ("dani".to_owned(), Some("market-data".to_owned()))
747        );
748        assert_eq!(parse_address("dani").unwrap(), ("dani".to_owned(), None));
749    }
750
751    #[test]
752    fn cursor_keys_cannot_collide_between_a_session_and_a_view() {
753        // The trap: concatenating a mode suffix makes the per-session cursor
754        // for a session called `api+all-sessions` byte-identical to the
755        // cross-session cursor for `api`, so reading one view advances the
756        // other and silently skips messages.
757        let per_session_of_odd_label = cursor_scope_for("inbox", "api+all-sessions", false);
758        let all_sessions_of_api = cursor_scope_for("inbox", "api", true);
759        assert_ne!(per_session_of_odd_label, all_sessions_of_api);
760
761        // Every combination stays distinct.
762        let keys = [
763            cursor_scope_for("inbox", "", false),
764            cursor_scope_for("inbox", "", true),
765            cursor_scope_for("inbox", "api", false),
766            cursor_scope_for("inbox", "api", true),
767            per_session_of_odd_label,
768            cursor_scope_for("inbox", "api+all-sessions", true),
769        ];
770        let unique: std::collections::HashSet<&String> = keys.iter().collect();
771        assert_eq!(unique.len(), keys.len(), "{keys:?}");
772    }
773
774    #[test]
775    fn the_shared_session_keeps_the_cursor_it_already_had() {
776        // Cursors written before sessions existed must still be the ones read,
777        // or every agent re-reads its whole history once on upgrade.
778        assert_eq!(cursor_scope_for("inbox", "", false), "inbox");
779        assert_eq!(cursor_scope_for("all", "", false), "all");
780    }
781}