1use sqlx::PgPool;
22use uuid::Uuid;
23
24use crate::{
25 auth::AuthCtx,
26 error::{BusError, BusResult},
27 model::{
28 ConversationActivity, ConversationInfo, ConversationMessage, ConversationRead,
29 MembershipInfo, MessageReceipts, ProjectInfo, ReceiptInfo, SentMessage, TransferResult, ts,
30 ts_opt,
31 },
32 store::backend::MessagingBackend,
33};
34
35pub const MAX_TITLE_BYTES: usize = 200;
37pub const MAX_BODY_BYTES: usize = 1024 * 1024;
39pub const MAX_PAGE: i64 = 200;
41pub const DEFAULT_PAGE: i64 = 50;
42pub const MAX_MEMBERS: i64 = 200;
44
45pub async fn capability_enabled(pool: &PgPool, auth: &AuthCtx) -> BusResult<bool> {
51 let enabled: Option<(bool,)> =
52 sqlx::query_as("SELECT conversations_enabled FROM teams WHERE id = $1")
53 .bind(auth.team_id)
54 .fetch_optional(pool)
55 .await?;
56 Ok(matches!(enabled, Some((true,))))
57}
58
59pub async fn require_capability(pool: &PgPool, auth: &AuthCtx) -> BusResult<()> {
60 let enabled: Option<(bool,)> =
61 sqlx::query_as("SELECT conversations_enabled FROM teams WHERE id = $1")
62 .bind(auth.team_id)
63 .fetch_optional(pool)
64 .await?;
65 match enabled {
66 Some((true,)) => Ok(()),
67 _ => Err(BusError::Forbidden(
68 "conversations are not enabled for this team. An operator turns them on with \
69 `ai-crew-sync team capability --team <slug> --conversations on`; until then use \
70 channels and direct messages."
71 .to_owned(),
72 )),
73 }
74}
75
76fn address_of(agent: &str, session: &str) -> String {
77 if session.is_empty() {
78 agent.to_owned()
79 } else {
80 format!("{agent}/{session}")
81 }
82}
83
84fn check_title(field: &str, raw: &str) -> BusResult<String> {
85 let value = raw.trim();
86 if value.is_empty() {
87 return Err(BusError::invalid(format!("{field} cannot be empty")));
88 }
89 if value.len() > MAX_TITLE_BYTES {
90 return Err(BusError::invalid(format!(
91 "{field} is {} bytes; the limit is {MAX_TITLE_BYTES}",
92 value.len()
93 )));
94 }
95 if value.chars().any(char::is_control) {
96 return Err(BusError::invalid(format!(
97 "{field} must not contain control characters"
98 )));
99 }
100 Ok(value.to_owned())
101}
102
103pub async fn create_project(pool: &PgPool, auth: &AuthCtx, name: &str) -> BusResult<ProjectInfo> {
106 require_capability(pool, auth).await?;
107 let name = crate::store::presence::normalize_label("project name", name)?;
108 if name.is_empty() {
109 return Err(BusError::invalid("a project name is required"));
110 }
111 let mut tx = pool.begin().await?;
112 crate::store::sessions::guard(&mut tx, auth).await?;
113 let row: Option<(Uuid,)> = sqlx::query_as(
114 "INSERT INTO projects (team_id, name, created_by) VALUES ($1, $2, $3)
115 ON CONFLICT (team_id, name) DO NOTHING RETURNING id",
116 )
117 .bind(auth.team_id)
118 .bind(&name)
119 .bind(auth.agent_id)
120 .fetch_optional(&mut *tx)
121 .await?;
122 let id = match row {
123 Some((id,)) => {
124 sqlx::query(
127 "INSERT INTO project_agent_access (project_id, agent_id, team_id, granted_by)
128 VALUES ($1, $2, $3, $2) ON CONFLICT DO NOTHING",
129 )
130 .bind(id)
131 .bind(auth.agent_id)
132 .bind(auth.team_id)
133 .execute(&mut *tx)
134 .await?;
135 audit(
136 &mut tx,
137 auth,
138 None,
139 "project.create",
140 Some(id),
141 serde_json::json!({ "name": name }),
142 )
143 .await?;
144 id
145 }
146 None => {
147 let (id,): (Uuid,) =
150 sqlx::query_as("SELECT id FROM projects WHERE team_id = $1 AND name = $2")
151 .bind(auth.team_id)
152 .bind(&name)
153 .fetch_one(&mut *tx)
154 .await?;
155 id
156 }
157 };
158 tx.commit().await?;
159 project_info(pool, auth, id).await
160}
161
162pub async fn project_info(pool: &PgPool, auth: &AuthCtx, id: Uuid) -> BusResult<ProjectInfo> {
164 let row: Option<(
165 String,
166 Option<chrono::DateTime<chrono::Utc>>,
167 chrono::DateTime<chrono::Utc>,
168 )> = sqlx::query_as(
169 "SELECT p.name, p.archived_at, p.created_at
170 FROM projects p
171 WHERE p.id = $1 AND p.team_id = $2
172 AND EXISTS (SELECT 1 FROM project_agent_access a
173 WHERE a.project_id = p.id AND a.agent_id = $3)",
174 )
175 .bind(id)
176 .bind(auth.team_id)
177 .bind(auth.agent_id)
178 .fetch_optional(pool)
179 .await?;
180 let Some((name, archived_at, created_at)) = row else {
181 return Err(BusError::not_found(
182 "no such project, or you have no access to it",
183 ));
184 };
185 let members: Vec<(String,)> = sqlx::query_as(
186 "SELECT ag.name FROM project_agent_access a
187 JOIN agents ag ON ag.id = a.agent_id
188 WHERE a.project_id = $1 ORDER BY ag.name",
189 )
190 .bind(id)
191 .fetch_all(pool)
192 .await?;
193 Ok(ProjectInfo {
194 id: id.to_string(),
195 name,
196 members: members.into_iter().map(|m| m.0).collect(),
197 archived: archived_at.is_some(),
198 created_at: ts(created_at),
199 })
200}
201
202pub async fn set_project_access(
206 pool: &PgPool,
207 auth: &AuthCtx,
208 project: &str,
209 agent: &str,
210 grant: bool,
211) -> BusResult<ProjectInfo> {
212 require_capability(pool, auth).await?;
213 let project_id = project_id_for(pool, auth, project).await?;
214 let target = crate::store::agent_id_by_name(pool, auth.team_id, agent).await?;
215 let mut tx = pool.begin().await?;
216 crate::store::sessions::guard(&mut tx, auth).await?;
217 let still_mine: Option<(Uuid,)> = sqlx::query_as(
222 "SELECT project_id FROM project_agent_access
223 WHERE project_id = $1 AND agent_id = $2 FOR UPDATE",
224 )
225 .bind(project_id)
226 .bind(auth.agent_id)
227 .fetch_optional(&mut *tx)
228 .await?;
229 if still_mine.is_none() {
230 return Err(BusError::Forbidden(
231 "your access to this project has been revoked, so you cannot change anyone else's. Nothing was written."
232 .to_owned(),
233 ));
234 }
235 if grant {
236 sqlx::query(
237 "INSERT INTO project_agent_access (project_id, agent_id, team_id, granted_by)
238 VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
239 )
240 .bind(project_id)
241 .bind(target)
242 .bind(auth.team_id)
243 .bind(auth.agent_id)
244 .execute(&mut *tx)
245 .await?;
246 } else {
247 if target == auth.agent_id {
248 return Err(BusError::invalid(
249 "you cannot revoke your own access; ask another member to do it",
250 ));
251 }
252 sqlx::query("DELETE FROM project_agent_access WHERE project_id = $1 AND agent_id = $2")
253 .bind(project_id)
254 .bind(target)
255 .execute(&mut *tx)
256 .await?;
257 }
258 audit(
259 &mut tx,
260 auth,
261 None,
262 if grant {
263 "project.grant"
264 } else {
265 "project.revoke"
266 },
267 Some(target),
268 serde_json::json!({ "agent": agent, "project": project }),
269 )
270 .await?;
271 tx.commit().await?;
272 project_info(pool, auth, project_id).await
273}
274
275async fn project_id_for(pool: &PgPool, auth: &AuthCtx, project: &str) -> BusResult<Uuid> {
277 let by_id = project.parse::<Uuid>().ok();
278 let row: Option<(Uuid,)> = sqlx::query_as(
279 "SELECT p.id FROM projects p
280 WHERE p.team_id = $1
281 AND ($2::uuid IS NOT NULL AND p.id = $2 OR p.name = $3)
282 AND EXISTS (SELECT 1 FROM project_agent_access a
283 WHERE a.project_id = p.id AND a.agent_id = $4)",
284 )
285 .bind(auth.team_id)
286 .bind(by_id)
287 .bind(project.trim().to_lowercase())
288 .bind(auth.agent_id)
289 .fetch_optional(pool)
290 .await?;
291 row.map(|r| r.0).ok_or_else(|| {
292 BusError::not_found(format!(
293 "no project '{project}' you have access to. Create it with create_project, or ask \
294 a member to grant you access."
295 ))
296 })
297}
298
299pub async fn list_projects(pool: &PgPool, auth: &AuthCtx) -> BusResult<Vec<ProjectInfo>> {
300 require_capability(pool, auth).await?;
301 let ids: Vec<(Uuid,)> = sqlx::query_as(
302 "SELECT p.id FROM projects p
303 JOIN project_agent_access a ON a.project_id = p.id AND a.agent_id = $2
304 WHERE p.team_id = $1 ORDER BY p.name",
305 )
306 .bind(auth.team_id)
307 .bind(auth.agent_id)
308 .fetch_all(pool)
309 .await?;
310 let mut out = Vec::with_capacity(ids.len());
311 for (id,) in ids {
312 out.push(project_info(pool, auth, id).await?);
313 }
314 Ok(out)
315}
316
317#[derive(Clone, Debug)]
321pub struct Access {
322 pub conversation_id: Uuid,
323 pub visibility: String,
324 pub archived: bool,
325 pub last_seq: i64,
326 pub title: String,
327 pub membership: Option<Membership>,
329 pub by_project: bool,
332}
333
334#[derive(Clone, Debug)]
335pub struct Membership {
336 pub id: Uuid,
337 pub role: String,
338 pub state: String,
339 pub history_from_seq: Option<i64>,
340}
341
342impl Access {
343 fn project_open(&self) -> bool {
349 self.visibility != "project" || self.by_project
350 }
351
352 pub fn can_read(&self) -> bool {
353 self.project_open()
354 && (self.by_project
355 || matches!(
356 self.membership.as_ref().map(|m| m.state.as_str()),
357 Some("active") | Some("invited")
358 ))
359 }
360 pub fn can_read_messages(&self) -> bool {
368 self.project_open()
369 && (self.by_project
370 || matches!(
371 self.membership.as_ref().map(|m| m.state.as_str()),
372 Some("active")
373 ))
374 }
375 pub fn can_send(&self) -> bool {
377 self.project_open()
378 && matches!(
379 self.membership
380 .as_ref()
381 .map(|m| (m.state.as_str(), m.role.as_str())),
382 Some(("active", "owner"))
383 | Some(("active", "moderator"))
384 | Some(("active", "participant"))
385 )
386 }
387 pub fn can_moderate(&self) -> bool {
388 self.project_open()
389 && matches!(
390 self.membership
391 .as_ref()
392 .map(|m| (m.state.as_str(), m.role.as_str())),
393 Some(("active", "owner")) | Some(("active", "moderator"))
394 )
395 }
396 pub fn is_owner(&self) -> bool {
397 self.project_open()
398 && matches!(
399 self.membership
400 .as_ref()
401 .map(|m| (m.state.as_str(), m.role.as_str())),
402 Some(("active", "owner"))
403 )
404 }
405}
406
407pub async fn access(pool: &PgPool, auth: &AuthCtx, conversation: Uuid) -> BusResult<Access> {
409 let row: Option<(
410 String,
411 String,
412 Option<chrono::DateTime<chrono::Utc>>,
413 i64,
414 Option<Uuid>,
415 bool,
416 )> = sqlx::query_as(
417 "SELECT c.visibility, c.title, c.archived_at, c.last_seq, c.project_id,
418 COALESCE(
419 -- Only a project-visible thread is readable by project
420 -- grant. A private thread that also names a project is
421 -- still private: the grant governs the project, not
422 -- everything that mentions it.
423 c.visibility = 'project' AND c.project_id IS NOT NULL AND EXISTS (
424 SELECT 1 FROM project_agent_access a
425 WHERE a.project_id = c.project_id AND a.agent_id = $3), false)
426 FROM conversations c
427 WHERE c.id = $1 AND c.team_id = $2",
428 )
429 .bind(conversation)
430 .bind(auth.team_id)
431 .bind(auth.agent_id)
432 .fetch_optional(pool)
433 .await?;
434 let Some((visibility, title, archived_at, last_seq, _project, by_project)) = row else {
435 return Err(BusError::not_found("no such conversation"));
438 };
439 let membership: Option<(Uuid, String, String, Option<i64>)> = sqlx::query_as(
445 "SELECT id, role, state, history_from_seq FROM conversation_memberships
446 WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
447 AND (session_id IS NULL OR $4::uuid IS NOT NULL)",
448 )
449 .bind(conversation)
450 .bind(auth.agent_id)
451 .bind(&auth.session)
452 .bind(auth.session_id)
453 .fetch_optional(pool)
454 .await?;
455 Ok(Access {
456 conversation_id: conversation,
457 visibility,
458 title,
459 archived: archived_at.is_some(),
460 last_seq,
461 by_project,
462 membership: membership.map(|(id, role, state, history_from_seq)| Membership {
463 id,
464 role,
465 state,
466 history_from_seq,
467 }),
468 })
469}
470
471async fn require_project_open(
479 tx: &mut sqlx::PgConnection,
480 auth: &AuthCtx,
481 conversation: Uuid,
482) -> BusResult<()> {
483 let open: Option<(bool,)> = sqlx::query_as(
484 "SELECT c.visibility <> 'project'
485 OR EXISTS (SELECT 1 FROM project_agent_access a
486 WHERE a.project_id = c.project_id AND a.agent_id = $2
487 FOR SHARE)
488 FROM conversations c WHERE c.id = $1 AND c.team_id = $3",
489 )
490 .bind(conversation)
491 .bind(auth.agent_id)
492 .bind(auth.team_id)
493 .fetch_optional(&mut *tx)
494 .await?;
495 match open {
496 Some((true,)) => Ok(()),
497 Some((false,)) => Err(BusError::Forbidden(
498 "your access to this project has been revoked, so this conversation is closed to \
499 you. Nothing was written; ask someone with access to grant it again."
500 .to_owned(),
501 )),
502 None => Err(BusError::not_found("no such conversation")),
503 }
504}
505
506pub fn body_digest(body: &str) -> String {
510 use sha2::{Digest, Sha256};
511 hex::encode(Sha256::digest(body.as_bytes()))
512}
513
514fn require_accepted(a: &Access) -> BusResult<()> {
516 if a.can_read_messages() {
517 return Ok(());
518 }
519 Err(BusError::Forbidden(
520 "you have been invited to this conversation and have not accepted. Call \
521 join_conversation first; an invitation is not membership, and it does not read \
522 what was said before you answered it."
523 .to_owned(),
524 ))
525}
526
527async fn readable(pool: &PgPool, auth: &AuthCtx, conversation: Uuid) -> BusResult<Access> {
528 let a = access(pool, auth, conversation).await?;
529 if !a.can_read() {
530 return Err(BusError::not_found("no such conversation"));
533 }
534 Ok(a)
535}
536
537async fn audit(
538 tx: &mut sqlx::PgConnection,
539 auth: &AuthCtx,
540 conversation: Option<Uuid>,
541 action: &str,
542 subject: Option<Uuid>,
543 detail: serde_json::Value,
544) -> BusResult<()> {
545 sqlx::query(
546 "INSERT INTO conversation_audit
547 (team_id, conversation_id, actor_agent, actor_session, action, subject, detail)
548 VALUES ($1, $2, $3, $4, $5, $6, $7)",
549 )
550 .bind(auth.team_id)
551 .bind(conversation)
552 .bind(auth.agent_id)
553 .bind(&auth.session)
554 .bind(action)
555 .bind(subject)
556 .bind(detail)
557 .execute(tx)
558 .await?;
559 Ok(())
560}
561
562pub struct CreateInput {
565 pub title: String,
566 pub project: Option<String>,
567 pub private: bool,
568 pub invite: Vec<String>,
569}
570
571pub async fn create_conversation(
572 pool: &PgPool,
573 auth: &AuthCtx,
574 input: CreateInput,
575) -> BusResult<ConversationInfo> {
576 require_capability(pool, auth).await?;
577 let title = check_title("title", &input.title)?;
578 let project_id = match (&input.project, input.private) {
579 (Some(_), true) => {
580 return Err(BusError::invalid(
584 "a conversation is either private or visible to a project, not both. Drop `project` for a members-only thread, or `private` for a project one.",
585 ));
586 }
587 (Some(p), false) => Some(project_id_for(pool, auth, p).await?),
588 (None, true) => None,
589 (None, false) => {
590 return Err(BusError::invalid(
591 "a project conversation needs `project`; pass `private: true` for a thread \
592 only its members can see",
593 ));
594 }
595 };
596 let visibility = if input.private { "private" } else { "project" };
597
598 let mut invites = Vec::new();
601 for raw in &input.invite {
602 let (agent, session) = crate::store::messaging::parse_address(raw)?;
603 let agent_id = crate::store::agent_id_by_name(pool, auth.team_id, &agent).await?;
604 invites.push((agent_id, session.unwrap_or_default(), raw.clone()));
605 }
606 if invites.len() as i64 > MAX_MEMBERS {
607 return Err(BusError::invalid(format!(
608 "a conversation holds at most {MAX_MEMBERS} members"
609 )));
610 }
611
612 let mut tx = pool.begin().await?;
613 crate::store::sessions::guard(&mut tx, auth).await?;
614 if let Some(project_id) = project_id {
618 let granted: Option<(Uuid,)> = sqlx::query_as(
619 "SELECT project_id FROM project_agent_access
620 WHERE project_id = $1 AND agent_id = $2 FOR SHARE",
621 )
622 .bind(project_id)
623 .bind(auth.agent_id)
624 .fetch_optional(&mut *tx)
625 .await?;
626 if granted.is_none() {
627 return Err(BusError::Forbidden(
628 "your access to this project has been revoked, so you cannot open a thread in \
629 it. Nothing was written."
630 .to_owned(),
631 ));
632 }
633 }
634 let (id,): (Uuid,) = sqlx::query_as(
638 "INSERT INTO conversations
639 (team_id, project_id, visibility, title, created_by, created_session,
640 backend, publication)
641 SELECT $1, $2, $3, $4, $5, $6, t.default_backend,
642 CASE WHEN t.default_backend = 'postgres' THEN 'sync' ELSE 'outbox' END
643 FROM teams t WHERE t.id = $1
644 RETURNING id",
645 )
646 .bind(auth.team_id)
647 .bind(project_id)
648 .bind(visibility)
649 .bind(&title)
650 .bind(auth.agent_id)
651 .bind(&auth.session)
652 .fetch_one(&mut *tx)
653 .await?;
654
655 sqlx::query(
657 "INSERT INTO conversation_memberships
658 (conversation_id, agent_id, session, session_id, role, state, history_from_seq,
659 invited_by, accepted_at)
660 VALUES ($1, $2, $3, $4, 'owner', 'active', NULL, $2, now())",
661 )
662 .bind(id)
663 .bind(auth.agent_id)
664 .bind(&auth.session)
665 .bind(auth.session_id)
666 .execute(&mut *tx)
667 .await?;
668
669 for (agent_id, session, raw) in &invites {
670 sqlx::query(
671 "INSERT INTO conversation_memberships
672 (conversation_id, agent_id, session, role, state, history_from_seq, invited_by)
673 VALUES ($1, $2, $3, 'participant', 'invited', 0, $4)
674 ON CONFLICT (conversation_id, agent_id, session) DO NOTHING",
675 )
676 .bind(id)
677 .bind(agent_id)
678 .bind(session)
679 .bind(auth.agent_id)
680 .execute(&mut *tx)
681 .await?;
682 audit(
683 &mut tx,
684 auth,
685 Some(id),
686 "member.invite",
687 Some(*agent_id),
688 serde_json::json!({ "address": raw }),
689 )
690 .await?;
691 }
692 audit(
693 &mut tx,
694 auth,
695 Some(id),
696 "conversation.create",
697 Some(id),
698 serde_json::json!({ "title": title, "visibility": visibility }),
699 )
700 .await?;
701 tx.commit().await?;
702 conversation_info(pool, auth, id).await
703}
704
705pub async fn conversation_info(
706 pool: &PgPool,
707 auth: &AuthCtx,
708 id: Uuid,
709) -> BusResult<ConversationInfo> {
710 let a = readable(pool, auth, id).await?;
711 let row: (
712 String,
713 String,
714 Option<String>,
715 String,
716 chrono::DateTime<chrono::Utc>,
717 Option<chrono::DateTime<chrono::Utc>>,
718 i64,
719 ) = sqlx::query_as(
720 "SELECT c.title, c.visibility, p.name, ag.name, c.created_at, c.archived_at, c.last_seq
721 FROM conversations c
722 LEFT JOIN projects p ON p.id = c.project_id
723 JOIN agents ag ON ag.id = c.created_by
724 WHERE c.id = $1",
725 )
726 .bind(id)
727 .fetch_one(pool)
728 .await?;
729 let members = if a.membership.is_some() {
733 members_of(pool, id).await?
734 } else {
735 Vec::new()
736 };
737 let mine = a.membership.as_ref().and_then(|m| {
738 members
739 .iter()
740 .find(|info| info.membership_id == m.id.to_string())
741 .cloned()
742 });
743 Ok(ConversationInfo {
744 id: id.to_string(),
745 title: row.0,
746 visibility: row.1,
747 project: row.2,
748 created_by: row.3,
749 created_at: ts(row.4),
750 archived: row.5.is_some(),
751 last_seq: row.6,
752 membership: mine,
753 members,
754 })
755}
756
757async fn members_of(pool: &PgPool, id: Uuid) -> BusResult<Vec<MembershipInfo>> {
758 let rows: Vec<(
759 Uuid,
760 String,
761 String,
762 String,
763 String,
764 Option<i64>,
765 chrono::DateTime<chrono::Utc>,
766 Option<chrono::DateTime<chrono::Utc>>,
767 )> = sqlx::query_as(
768 "SELECT m.id, ag.name, m.session, m.role, m.state, m.history_from_seq,
769 m.invited_at, m.accepted_at
770 FROM conversation_memberships m
771 JOIN agents ag ON ag.id = m.agent_id
772 WHERE m.conversation_id = $1
773 ORDER BY ag.name, m.session",
774 )
775 .bind(id)
776 .fetch_all(pool)
777 .await?;
778 Ok(rows
779 .into_iter()
780 .map(
781 |(mid, agent, session, role, state, history_from_seq, invited_at, accepted_at)| {
782 MembershipInfo {
783 membership_id: mid.to_string(),
784 address: address_of(&agent, &session),
785 session: (!session.is_empty()).then_some(session),
786 agent,
787 role,
788 state,
789 history_from_seq,
790 invited_at: ts(invited_at),
791 accepted_at: ts_opt(accepted_at),
792 }
793 },
794 )
795 .collect())
796}
797
798pub async fn list_conversations(
801 pool: &PgPool,
802 auth: &AuthCtx,
803 include_archived: bool,
804) -> BusResult<Vec<ConversationInfo>> {
805 require_capability(pool, auth).await?;
806 let ids = list_candidates(pool, auth, include_archived).await?;
807 list_conversations_among(pool, auth, ids).await
808}
809
810#[doc(hidden)]
823pub async fn list_candidates(
824 pool: &PgPool,
825 auth: &AuthCtx,
826 include_archived: bool,
827) -> BusResult<Vec<Uuid>> {
828 let ids: Vec<(Uuid,)> = sqlx::query_as(
829 "SELECT DISTINCT c.id
830 FROM conversations c
831 LEFT JOIN conversation_memberships m
832 ON m.conversation_id = c.id AND m.agent_id = $2 AND m.session = $3
833 AND (m.session_id IS NULL OR $5::uuid IS NOT NULL)
834 WHERE c.team_id = $1
835 AND ($4::bool OR c.archived_at IS NULL)
836 AND (
837 (m.state IN ('invited', 'active')
838 AND (c.visibility <> 'project' OR EXISTS (
839 SELECT 1 FROM project_agent_access a
840 WHERE a.project_id = c.project_id AND a.agent_id = $2)))
841 OR (c.visibility = 'project' AND c.project_id IS NOT NULL AND EXISTS (
842 SELECT 1 FROM project_agent_access a
843 WHERE a.project_id = c.project_id AND a.agent_id = $2))
844 )
845 ORDER BY c.id",
846 )
847 .bind(auth.team_id)
848 .bind(auth.agent_id)
849 .bind(&auth.session)
850 .bind(include_archived)
851 .bind(auth.session_id)
852 .fetch_all(pool)
853 .await?;
854 Ok(ids.into_iter().map(|(id,)| id).collect())
855}
856
857#[doc(hidden)]
863pub async fn list_conversations_among(
864 pool: &PgPool,
865 auth: &AuthCtx,
866 ids: Vec<Uuid>,
867) -> BusResult<Vec<ConversationInfo>> {
868 let mut out = Vec::with_capacity(ids.len());
869 for id in ids {
870 match conversation_info(pool, auth, id).await {
871 Ok(info) => out.push(info),
872 Err(BusError::NotFound(_)) | Err(BusError::Forbidden(_)) => continue,
873 Err(e) => return Err(e),
874 }
875 }
876 Ok(out)
877}
878
879pub async fn archive_conversation(
880 pool: &PgPool,
881 auth: &AuthCtx,
882 id: Uuid,
883) -> BusResult<ConversationInfo> {
884 require_capability(pool, auth).await?;
885 let a = readable(pool, auth, id).await?;
886 if !a.can_moderate() {
887 return Err(BusError::Forbidden(
888 "only an owner or moderator of this conversation can archive it".to_owned(),
889 ));
890 }
891 let mut tx = pool.begin().await?;
892 crate::store::sessions::guard(&mut tx, auth).await?;
893 require_project_open(&mut tx, auth, id).await?;
894 sqlx::query(
895 "UPDATE conversations SET archived_at = now() WHERE id = $1 AND archived_at IS NULL",
896 )
897 .bind(id)
898 .execute(&mut *tx)
899 .await?;
900 audit(
901 &mut tx,
902 auth,
903 Some(id),
904 "conversation.archive",
905 Some(id),
906 serde_json::json!({}),
907 )
908 .await?;
909 tx.commit().await?;
910 conversation_info(pool, auth, id).await
911}
912
913pub async fn invite(
918 pool: &PgPool,
919 auth: &AuthCtx,
920 id: Uuid,
921 address: &str,
922 role: Option<&str>,
923 history_from_start: bool,
924) -> BusResult<ConversationInfo> {
925 require_capability(pool, auth).await?;
926 let a = readable(pool, auth, id).await?;
927 if !a.can_moderate() {
928 return Err(BusError::Forbidden(
929 "only an owner or moderator of this conversation can invite".to_owned(),
930 ));
931 }
932 if a.archived {
933 return Err(BusError::conflict("this conversation is archived"));
934 }
935 let role = match role.map(str::trim).filter(|r| !r.is_empty()) {
936 None => "participant".to_owned(),
937 Some(r) => {
938 let r = r.to_lowercase();
939 if !["moderator", "participant", "observer"].contains(&r.as_str()) {
940 return Err(BusError::invalid(
941 "role must be moderator, participant or observer. An owner is the \
942 creator, or someone a transfer made one.",
943 ));
944 }
945 r
946 }
947 };
948 let (agent, session) = crate::store::messaging::parse_address(address)?;
949 let agent_id = crate::store::agent_id_by_name(pool, auth.team_id, &agent).await?;
950 let session = session.unwrap_or_default();
951
952 let mut tx = pool.begin().await?;
953 crate::store::sessions::guard(&mut tx, auth).await?;
954 let (last_seq,): (i64,) =
958 sqlx::query_as("SELECT last_seq FROM conversations WHERE id = $1 FOR UPDATE")
959 .bind(id)
960 .fetch_one(&mut *tx)
961 .await?;
962 require_project_open(&mut tx, auth, id).await?;
963 let inviter: Option<(String, String, Option<i64>)> = sqlx::query_as(
969 "SELECT role, state, history_from_seq FROM conversation_memberships
970 WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
971 FOR UPDATE",
972 )
973 .bind(id)
974 .bind(auth.agent_id)
975 .bind(&auth.session)
976 .fetch_optional(&mut *tx)
977 .await?;
978 let (inviter_role, inviter_floor) = match inviter {
979 Some((role, state, floor))
980 if state == "active" && (role == "owner" || role == "moderator") =>
981 {
982 (role, floor)
983 }
984 _ => {
985 return Err(BusError::Forbidden(
986 "your seat in this conversation changed while you were inviting: you no \
987 longer moderate it. Nothing was written."
988 .to_owned(),
989 ));
990 }
991 };
992 let (count,): (i64,) =
993 sqlx::query_as("SELECT count(*) FROM conversation_memberships WHERE conversation_id = $1")
994 .bind(id)
995 .fetch_one(&mut *tx)
996 .await?;
997 if count >= MAX_MEMBERS {
998 return Err(BusError::conflict(format!(
999 "this conversation already holds {MAX_MEMBERS} members"
1000 )));
1001 }
1002
1003 let seated: Option<(String, String)> = sqlx::query_as(
1008 "SELECT state, role FROM conversation_memberships
1009 WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
1010 FOR UPDATE",
1011 )
1012 .bind(id)
1013 .bind(agent_id)
1014 .bind(&session)
1015 .fetch_optional(&mut *tx)
1016 .await?;
1017 let readmitting = seated.as_ref().map(|r| r.0.as_str()) == Some("removed");
1018
1019 if let Some((state, role)) = &seated
1026 && (state == "active" || state == "invited")
1027 && role == "owner"
1028 && inviter_role != "owner"
1029 {
1030 return Err(BusError::Forbidden(format!(
1031 "'{address}' is an owner of this conversation, and only an owner can change or \
1032 remove another owner. Nothing was written."
1033 )));
1034 }
1035
1036 let from_seq: Option<i64> = if readmitting || !history_from_start {
1043 Some(last_seq)
1044 } else {
1045 inviter_floor
1046 };
1047 sqlx::query(
1048 "INSERT INTO conversation_memberships
1049 (conversation_id, agent_id, session, role, state, history_from_seq, invited_by)
1050 VALUES ($1, $2, $3, $4, 'invited', $5, $6)
1051 ON CONFLICT (conversation_id, agent_id, session) DO UPDATE SET
1052 role = EXCLUDED.role,
1053 state = CASE WHEN conversation_memberships.state IN ('left', 'removed')
1054 THEN 'invited' ELSE conversation_memberships.state END,
1055 -- A seat that is offered again gets the floor decided now: the
1056 -- one it had when it left is not this inviter's to give back.
1057 -- A seat that stays active or invited keeps its own.
1058 history_from_seq = CASE WHEN conversation_memberships.state IN ('left', 'removed')
1059 THEN EXCLUDED.history_from_seq
1060 ELSE conversation_memberships.history_from_seq END,
1061 -- A seat that is actually being re-offered is not the seat the
1062 -- old window held, so whoever accepts proves it is them again.
1063 -- A seat that stays active keeps its binding: clearing it on a
1064 -- repeated invitation would quietly downgrade a protected
1065 -- window to a legacy one, and the parent agent token would be
1066 -- back in the room.
1067 session_id = CASE WHEN conversation_memberships.state IN ('left', 'removed')
1068 THEN NULL ELSE conversation_memberships.session_id END,
1069 invited_by = EXCLUDED.invited_by,
1070 invited_at = now(),
1071 ended_at = NULL",
1072 )
1073 .bind(id)
1074 .bind(agent_id)
1075 .bind(&session)
1076 .bind(&role)
1077 .bind(from_seq)
1078 .bind(auth.agent_id)
1079 .execute(&mut *tx)
1080 .await?;
1081 audit(
1082 &mut tx,
1083 auth,
1084 Some(id),
1085 if readmitting {
1086 "member.readmit"
1087 } else {
1088 "member.invite"
1089 },
1090 Some(agent_id),
1091 serde_json::json!({
1092 "address": address,
1093 "role": role,
1094 "history_from_seq": from_seq,
1095 "history_from_start_requested": history_from_start,
1096 }),
1097 )
1098 .await?;
1099 tx.commit().await?;
1100 conversation_info(pool, auth, id).await
1101}
1102
1103pub async fn join(pool: &PgPool, auth: &AuthCtx, id: Uuid) -> BusResult<ConversationInfo> {
1106 require_capability(pool, auth).await?;
1107 crate::store::sessions::require_window(pool, auth).await?;
1113 let mut tx = pool.begin().await?;
1114 crate::store::sessions::guard(&mut tx, auth).await?;
1115 require_project_open(&mut tx, auth, id).await?;
1119 let updated: Option<(Uuid,)> = sqlx::query_as(
1120 "UPDATE conversation_memberships
1121 SET state = 'active', accepted_at = now(), session_id = $4
1122 WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
1123 AND state = 'invited'
1124 RETURNING id",
1125 )
1126 .bind(id)
1127 .bind(auth.agent_id)
1128 .bind(&auth.session)
1129 .bind(auth.session_id)
1130 .fetch_optional(&mut *tx)
1131 .await?;
1132 if updated.is_none() {
1133 let a = access(pool, auth, id).await?;
1134 return Err(match a.membership.as_ref().map(|m| m.state.as_str()) {
1135 Some("active") => BusError::conflict("you are already in this conversation"),
1136 Some("removed") => {
1137 BusError::Forbidden("you were removed from this conversation".to_owned())
1138 }
1139 _ => BusError::not_found(
1140 "no invitation for this window. An invitation is addressed to one \
1141 agent/session; a sibling window cannot accept it for you.",
1142 ),
1143 });
1144 }
1145 if let Some((membership_id,)) = updated {
1146 supersede_predecessor(&mut tx, auth, id, membership_id).await?;
1149 }
1150 audit(
1151 &mut tx,
1152 auth,
1153 Some(id),
1154 "member.join",
1155 None,
1156 serde_json::json!({}),
1157 )
1158 .await?;
1159 tx.commit().await?;
1160 conversation_info(pool, auth, id).await
1161}
1162
1163pub async fn leave(pool: &PgPool, auth: &AuthCtx, id: Uuid) -> BusResult<()> {
1165 require_capability(pool, auth).await?;
1166 let mut tx = pool.begin().await?;
1167 crate::store::sessions::guard(&mut tx, auth).await?;
1168 let row: Option<(Uuid,)> = sqlx::query_as(
1169 "UPDATE conversation_memberships
1170 SET state = 'left', ended_at = now()
1171 WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
1172 AND state IN ('invited', 'active')
1173 RETURNING id",
1174 )
1175 .bind(id)
1176 .bind(auth.agent_id)
1177 .bind(&auth.session)
1178 .fetch_optional(&mut *tx)
1179 .await?;
1180 if row.is_none() {
1181 return Err(BusError::not_found("you are not in this conversation"));
1182 }
1183 audit(
1184 &mut tx,
1185 auth,
1186 Some(id),
1187 "member.leave",
1188 None,
1189 serde_json::json!({}),
1190 )
1191 .await?;
1192 tx.commit().await?;
1193 Ok(())
1194}
1195
1196pub async fn remove_member(
1199 pool: &PgPool,
1200 auth: &AuthCtx,
1201 id: Uuid,
1202 address: &str,
1203) -> BusResult<ConversationInfo> {
1204 require_capability(pool, auth).await?;
1205 let a = readable(pool, auth, id).await?;
1206 if !a.can_moderate() {
1207 return Err(BusError::Forbidden(
1208 "only an owner or moderator of this conversation can remove a member".to_owned(),
1209 ));
1210 }
1211 let (agent, session) = crate::store::messaging::parse_address(address)?;
1212 let agent_id = crate::store::agent_id_by_name(pool, auth.team_id, &agent).await?;
1213 let session = session.unwrap_or_default();
1214 if agent_id == auth.agent_id && session == auth.session {
1215 return Err(BusError::invalid(
1216 "use leave_conversation to remove yourself",
1217 ));
1218 }
1219 let mut tx = pool.begin().await?;
1220 crate::store::sessions::guard(&mut tx, auth).await?;
1221 sqlx::query("SELECT id FROM conversations WHERE id = $1 FOR UPDATE")
1226 .bind(id)
1227 .execute(&mut *tx)
1228 .await?;
1229 require_project_open(&mut tx, auth, id).await?;
1230 let caller: Option<(String, String)> = sqlx::query_as(
1231 "SELECT role, state FROM conversation_memberships
1232 WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
1233 FOR UPDATE",
1234 )
1235 .bind(id)
1236 .bind(auth.agent_id)
1237 .bind(&auth.session)
1238 .fetch_optional(&mut *tx)
1239 .await?;
1240 let caller = caller
1241 .as_ref()
1242 .map(|(role, state)| (role.as_str(), state.as_str()));
1243 if !matches!(caller, Some(("owner" | "moderator", "active"))) {
1244 return Err(BusError::Forbidden(
1245 "your seat in this conversation changed while you were removing a member: you \
1246 no longer moderate it. Nothing was written."
1247 .to_owned(),
1248 ));
1249 }
1250 let caller_is_owner = matches!(caller, Some(("owner", "active")));
1251 let row: Option<(String,)> = sqlx::query_as(
1252 "UPDATE conversation_memberships
1253 SET state = 'removed', ended_at = now()
1254 WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
1255 AND state IN ('invited', 'active')
1256 RETURNING role",
1257 )
1258 .bind(id)
1259 .bind(agent_id)
1260 .bind(&session)
1261 .fetch_optional(&mut *tx)
1262 .await?;
1263 let Some((role,)) = row else {
1264 return Err(BusError::not_found(format!(
1265 "'{address}' is not in this conversation"
1266 )));
1267 };
1268 if role == "owner" && !caller_is_owner {
1269 return Err(BusError::Forbidden(
1270 "only an owner can remove another owner".to_owned(),
1271 ));
1272 }
1273 audit(
1274 &mut tx,
1275 auth,
1276 Some(id),
1277 "member.remove",
1278 Some(agent_id),
1279 serde_json::json!({ "address": address }),
1280 )
1281 .await?;
1282 tx.commit().await?;
1283 conversation_info(pool, auth, id).await
1284}
1285
1286pub struct SendInput {
1289 pub body: String,
1290 pub request_id: Uuid,
1291 pub reply_to: Option<Uuid>,
1292 pub metadata: Option<serde_json::Value>,
1293}
1294
1295pub async fn send(
1307 pool: &PgPool,
1308 auth: &AuthCtx,
1309 id: Uuid,
1310 input: SendInput,
1311) -> BusResult<SentMessage> {
1312 require_capability(pool, auth).await?;
1313 let a = readable(pool, auth, id).await?;
1314 if !a.can_send() {
1315 return Err(BusError::Forbidden(
1316 "you cannot post in this conversation: accept your invitation first, and note \
1317 that an observer reads and acknowledges but does not write"
1318 .to_owned(),
1319 ));
1320 }
1321 if a.archived {
1322 return Err(BusError::conflict(
1323 "this conversation is archived; its history stays readable",
1324 ));
1325 }
1326 let body = crate::store::check_text("message body", &input.body, MAX_BODY_BYTES)?;
1327 if body.is_empty() {
1328 return Err(BusError::invalid("a message body is required"));
1329 }
1330 let metadata = crate::store::normalize_metadata(input.metadata);
1331 crate::store::check_metadata("message", metadata.as_ref())?;
1332 let metadata = metadata.unwrap_or_else(|| serde_json::Value::Object(Default::default()));
1333
1334 let mut tx = pool.begin().await?;
1335 crate::store::sessions::guard(&mut tx, auth).await?;
1336
1337 let (archived_now, paused): (
1342 Option<chrono::DateTime<chrono::Utc>>,
1343 Option<chrono::DateTime<chrono::Utc>>,
1344 ) = sqlx::query_as(
1345 "SELECT archived_at, write_paused_at FROM conversations WHERE id = $1 FOR UPDATE",
1346 )
1347 .bind(id)
1348 .fetch_one(&mut *tx)
1349 .await?;
1350 if archived_now.is_some() {
1351 return Err(BusError::conflict(
1352 "this conversation is archived; its history stays readable",
1353 ));
1354 }
1355 if let Some(since) = paused {
1361 let secs = (chrono::Utc::now() - since).num_seconds().max(0);
1362 return Err(BusError::conflict(format!(
1363 "this conversation's storage is being moved by an operator and writes are \
1364 paused (for {secs}s so far). Reading still works. Try again in a moment; \
1365 nothing you have sent was lost."
1366 )));
1367 }
1368 require_project_open(&mut tx, auth, id).await?;
1371 let current: Option<(String, String)> = sqlx::query_as(
1375 "SELECT state, role FROM conversation_memberships
1376 WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
1377 AND (session_id IS NULL OR $4::uuid IS NOT NULL)
1378 FOR SHARE",
1379 )
1380 .bind(id)
1381 .bind(auth.agent_id)
1382 .bind(&auth.session)
1383 .bind(auth.session_id)
1384 .fetch_optional(&mut *tx)
1385 .await?;
1386 let can_send_now = matches!(
1387 current
1388 .as_ref()
1389 .map(|(state, role)| (state.as_str(), role.as_str())),
1390 Some(("active", "owner")) | Some(("active", "moderator")) | Some(("active", "participant"))
1391 );
1392 if !can_send_now {
1393 return Err(BusError::Forbidden(
1394 "your membership of this conversation is no longer one that can post. Nothing was written."
1395 .to_owned(),
1396 ));
1397 }
1398
1399 #[allow(clippy::type_complexity)]
1402 let existing: Option<(
1403 Uuid,
1404 i64,
1405 chrono::DateTime<chrono::Utc>,
1406 String,
1407 Option<String>,
1408 )> = sqlx::query_as(
1409 "SELECT id, seq, created_at, publication_state, body_sha256
1410 FROM conversation_messages
1411 WHERE conversation_id = $1 AND request_id = $2",
1412 )
1413 .bind(id)
1414 .bind(input.request_id)
1415 .fetch_optional(&mut *tx)
1416 .await?;
1417 if let Some((mid, seq, created_at, publication_state, digest)) = existing {
1418 if digest.as_deref() != Some(body_digest(&body).as_str()) {
1422 return Err(BusError::conflict(
1423 "this request_id already sent a different message. Use a fresh UUID for a \
1424 new message; reusing one is how a retry is recognised.",
1425 ));
1426 }
1427 let recipients = recipients_of(&mut tx, mid).await?;
1428 tx.commit().await?;
1429 return Ok(SentMessage {
1430 message_id: mid.to_string(),
1431 conversation_id: id.to_string(),
1432 seq,
1433 stored: publication_state == "stored",
1438 publication: publication_state,
1439 recipients,
1440 created_at: ts(created_at),
1441 });
1442 }
1443
1444 let (seq,): (i64,) = sqlx::query_as(
1447 "UPDATE conversations SET last_seq = last_seq + 1 WHERE id = $1 RETURNING last_seq",
1448 )
1449 .bind(id)
1450 .fetch_one(&mut *tx)
1451 .await?;
1452
1453 if let Some(reply_to) = input.reply_to {
1454 let ok: Option<(Uuid,)> = sqlx::query_as(
1455 "SELECT id FROM conversation_messages WHERE id = $1 AND conversation_id = $2",
1456 )
1457 .bind(reply_to)
1458 .bind(id)
1459 .fetch_optional(&mut *tx)
1460 .await?;
1461 if ok.is_none() {
1462 return Err(BusError::not_found(
1463 "reply_to is not a message of this conversation",
1464 ));
1465 }
1466 }
1467
1468 let (message_id,): (Uuid,) = sqlx::query_as(
1469 "INSERT INTO conversation_messages
1470 (conversation_id, seq, sender_agent, sender_session, body, reply_to, metadata,
1471 request_id, body_sha256, backend)
1472 SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, c.backend
1473 FROM conversations c WHERE c.id = $1
1474 RETURNING id",
1475 )
1476 .bind(id)
1477 .bind(seq)
1478 .bind(auth.agent_id)
1479 .bind(&auth.session)
1480 .bind(&body)
1481 .bind(input.reply_to)
1482 .bind(&metadata)
1483 .bind(input.request_id)
1484 .bind(body_digest(&body))
1485 .fetch_one(&mut *tx)
1486 .await?;
1487
1488 sqlx::query(
1495 "INSERT INTO message_recipients (message_id, membership_id, agent_id, session)
1496 SELECT $1, m.id, m.agent_id, m.session
1497 FROM conversation_memberships m
1498 JOIN conversations c ON c.id = m.conversation_id
1499 WHERE m.conversation_id = $2
1500 AND m.state = 'active'
1501 AND NOT (m.agent_id = $3 AND m.session = $4)
1502 -- Share-locked: a revocation in flight waits for this message
1503 -- to commit (and the recipient is owed it), or committed first
1504 -- and the recipient is not listed. Never a snapshot that
1505 -- becomes an obligation after the grant is gone.
1506 AND (c.visibility <> 'project' OR EXISTS (
1507 SELECT 1 FROM project_agent_access a
1508 WHERE a.project_id = c.project_id AND a.agent_id = m.agent_id
1509 FOR SHARE))",
1510 )
1511 .bind(message_id)
1512 .bind(id)
1513 .bind(auth.agent_id)
1514 .bind(&auth.session)
1515 .execute(&mut *tx)
1516 .await?;
1517
1518 let (publication,): (String,) =
1521 sqlx::query_as("SELECT publication FROM conversations WHERE id = $1")
1522 .bind(id)
1523 .fetch_one(&mut *tx)
1524 .await?;
1525 let asynchronous = publication == "outbox";
1526
1527 sqlx::query(
1532 "INSERT INTO message_receipts (message_id, membership_id, stored_at)
1533 SELECT message_id, membership_id,
1534 CASE WHEN $2 THEN NULL ELSE now() END
1535 FROM message_recipients WHERE message_id = $1",
1536 )
1537 .bind(message_id)
1538 .bind(asynchronous)
1539 .execute(&mut *tx)
1540 .await?;
1541
1542 if asynchronous {
1543 let (backend,): (String,) =
1544 sqlx::query_as("SELECT backend FROM conversations WHERE id = $1")
1545 .bind(id)
1546 .fetch_one(&mut *tx)
1547 .await?;
1548 crate::store::outbox::enqueue(&mut tx, message_id, id, auth.team_id, &backend, &body)
1549 .await?;
1550 }
1551
1552 audit(
1553 &mut tx,
1554 auth,
1555 Some(id),
1556 "message.send",
1557 Some(message_id),
1558 serde_json::json!({ "seq": seq }),
1559 )
1560 .await?;
1561
1562 let recipients = recipients_of(&mut tx, message_id).await?;
1563 let created_at: (chrono::DateTime<chrono::Utc>,) =
1564 sqlx::query_as("SELECT created_at FROM conversation_messages WHERE id = $1")
1565 .bind(message_id)
1566 .fetch_one(&mut *tx)
1567 .await?;
1568 tx.commit().await?;
1569
1570 Ok(SentMessage {
1571 message_id: message_id.to_string(),
1572 conversation_id: id.to_string(),
1573 seq,
1574 stored: !asynchronous,
1579 publication: if asynchronous {
1580 "pending_publication"
1581 } else {
1582 "stored"
1583 }
1584 .to_owned(),
1585 recipients,
1586 created_at: ts(created_at.0),
1587 })
1588}
1589
1590pub enum BodyState {
1597 Present(String),
1598 Missing {
1601 publication: &'static str,
1602 why: BusError,
1603 },
1604 Unreachable(BusError),
1610}
1611
1612struct BodyRow {
1616 body: String,
1617 state: String,
1618 locator: Option<String>,
1619 backend: String,
1622 tombstoned: bool,
1623 tombstone_reason: Option<String>,
1624}
1625
1626async fn resolve_body(
1632 pool: &PgPool,
1633 backends: &crate::store::routing::Backends,
1634 team_id: Uuid,
1635 message_id: Uuid,
1636 row: BodyRow,
1637) -> BusResult<BodyState> {
1638 if row.tombstoned {
1639 return Ok(BodyState::Missing {
1640 publication: "tombstoned",
1641 why: BusError::not_found(format!(
1642 "this message's body is no longer held by its backend ({}). Its place in \
1643 the thread, its recipients and its receipts remain.",
1644 row.tombstone_reason.as_deref().unwrap_or("retention")
1645 )),
1646 });
1647 }
1648 if row.backend == crate::store::backend::PostgresBackend::NAME || !row.body.is_empty() {
1651 return Ok(BodyState::Present(row.body));
1652 }
1653 match row.state.as_str() {
1654 "pending_publication" => Ok(BodyState::Missing {
1655 publication: "pending_publication",
1656 why: BusError::conflict(
1657 "this message has been accepted but its backend has not confirmed it yet. \
1658 It is not lost; read it again in a moment.",
1659 ),
1660 }),
1661 "failed" => Ok(BodyState::Missing {
1662 publication: "failed",
1663 why: BusError::not_found(
1664 "this message was never stored by its backend. Its slot is kept so the gap \
1665 is visible rather than silent.",
1666 ),
1667 }),
1668 _ => {
1669 let Some(locator) = row.locator else {
1670 return Ok(BodyState::Missing {
1671 publication: "failed",
1672 why: BusError::not_found(
1673 "this message has no body and no locator; there is nothing to read",
1674 ),
1675 });
1676 };
1677 let backend = match backends.for_message(&row.backend, team_id).await {
1685 Ok(backend) => backend,
1686 Err(why) => {
1687 tracing::warn!(error = %why, %message_id, "the body's backend cannot be opened");
1688 return Ok(BodyState::Unreachable(BusError::conflict(
1689 "the backend holding this body cannot be reached right now. The \
1690 message is not lost: its place in the thread, its recipients and \
1691 its receipts are here, and the body comes back when the backend \
1692 does.",
1693 )));
1694 }
1695 };
1696 let fetched = match backend
1697 .fetch(&crate::store::backend::Locator(locator), message_id)
1698 .await
1699 {
1700 Ok(fetched) => fetched,
1701 Err(why) => {
1702 tracing::warn!(error = %why, %message_id, "the body could not be read from its backend");
1703 return Ok(BodyState::Unreachable(BusError::conflict(
1704 "this body could not be read from its backend right now. It is not \
1705 lost: its place in the thread, its recipients and its receipts are \
1706 here, and the body comes back when the backend does.",
1707 )));
1708 }
1709 };
1710 match fetched {
1711 Some(body) => Ok(BodyState::Present(body)),
1712 None => {
1713 crate::store::outbox::tombstone(pool, message_id, "missing from backend")
1717 .await?;
1718 Ok(BodyState::Missing {
1719 publication: "tombstoned",
1720 why: BusError::not_found(
1721 "this message's body is no longer held by its backend. Its place \
1722 in the thread, its recipients and its receipts remain.",
1723 ),
1724 })
1725 }
1726 }
1727 }
1728 }
1729}
1730
1731pub async fn body_of(
1736 pool: &PgPool,
1737 backends: &crate::store::routing::Backends,
1738 message_id: Uuid,
1739) -> BusResult<String> {
1740 let row: Option<(
1741 String,
1742 String,
1743 Option<String>,
1744 String,
1745 Uuid,
1746 Option<chrono::DateTime<chrono::Utc>>,
1747 Option<String>,
1748 )> = sqlx::query_as(
1749 "SELECT m.body, m.publication_state, m.canonical_locator, m.backend, c.team_id,
1750 m.tombstoned_at, m.tombstone_reason
1751 FROM conversation_messages m
1752 JOIN conversations c ON c.id = m.conversation_id
1753 WHERE m.id = $1",
1754 )
1755 .bind(message_id)
1756 .fetch_optional(pool)
1757 .await?;
1758 let Some((body, state, locator, backend, team_id, tombstoned, tombstone_reason)) = row else {
1759 return Err(BusError::not_found("no such message"));
1760 };
1761 let resolved = resolve_body(
1762 pool,
1763 backends,
1764 team_id,
1765 message_id,
1766 BodyRow {
1767 body,
1768 state,
1769 locator,
1770 backend,
1771 tombstoned: tombstoned.is_some(),
1772 tombstone_reason,
1773 },
1774 )
1775 .await?;
1776 match resolved {
1777 BodyState::Present(body) => Ok(body),
1778 BodyState::Missing { why, .. } | BodyState::Unreachable(why) => Err(why),
1779 }
1780}
1781
1782async fn recipients_of(tx: &mut sqlx::PgConnection, message_id: Uuid) -> BusResult<Vec<String>> {
1783 let rows: Vec<(String, String)> = sqlx::query_as(
1784 "SELECT ag.name, r.session FROM message_recipients r
1785 JOIN agents ag ON ag.id = r.agent_id
1786 WHERE r.message_id = $1 ORDER BY ag.name, r.session",
1787 )
1788 .bind(message_id)
1789 .fetch_all(tx)
1790 .await?;
1791 Ok(rows
1792 .into_iter()
1793 .map(|(agent, session)| address_of(&agent, &session))
1794 .collect())
1795}
1796
1797pub async fn read(
1800 pool: &PgPool,
1801 backends: &crate::store::routing::Backends,
1802 auth: &AuthCtx,
1803 id: Uuid,
1804 after_seq: Option<i64>,
1805 limit: Option<i64>,
1806) -> BusResult<ConversationRead> {
1807 require_capability(pool, auth).await?;
1808 let a = readable(pool, auth, id).await?;
1809 require_accepted(&a)?;
1810 let limit = limit.unwrap_or(DEFAULT_PAGE).clamp(1, MAX_PAGE);
1811 let floor = a
1814 .membership
1815 .as_ref()
1816 .and_then(|m| m.history_from_seq)
1817 .unwrap_or(0);
1818 let after = after_seq.unwrap_or(0).max(floor);
1819
1820 #[allow(clippy::type_complexity)]
1821 let rows: Vec<(
1822 Uuid,
1823 i64,
1824 String,
1825 String,
1826 String,
1827 Option<Uuid>,
1828 serde_json::Value,
1829 chrono::DateTime<chrono::Utc>,
1830 String,
1831 Option<String>,
1832 Option<chrono::DateTime<chrono::Utc>>,
1833 Option<String>,
1834 String,
1835 )> = sqlx::query_as(
1836 "SELECT m.id, m.seq, ag.name, m.sender_session, m.body, m.reply_to, m.metadata,
1841 m.created_at, m.publication_state, m.canonical_locator, m.tombstoned_at,
1842 m.tombstone_reason, m.backend
1843 FROM conversation_messages m
1844 JOIN agents ag ON ag.id = m.sender_agent
1845 WHERE m.conversation_id = $1 AND m.seq > $2 AND m.deleted_at IS NULL
1846 ORDER BY m.seq
1847 LIMIT $3",
1848 )
1849 .bind(id)
1850 .bind(after)
1851 .bind(limit)
1852 .fetch_all(pool)
1853 .await?;
1854
1855 let a = readable(pool, auth, id).await?;
1860 require_accepted(&a)?;
1861
1862 let mut mine: std::collections::HashMap<Uuid, ReceiptInfo> = std::collections::HashMap::new();
1866 if let Some(m) = &a.membership {
1867 let ids: Vec<Uuid> = rows.iter().map(|r| r.0).collect();
1868 for (message_id, receipt) in receipts_for(pool, &ids, m.id).await? {
1869 mine.insert(message_id, receipt);
1870 }
1871 }
1872
1873 let mut messages = Vec::with_capacity(rows.len());
1874 for (
1875 mid,
1876 seq,
1877 from,
1878 from_session,
1879 body,
1880 reply_to,
1881 metadata,
1882 created_at,
1883 state,
1884 locator,
1885 tombstoned,
1886 tombstone_reason,
1887 message_backend,
1888 ) in rows
1889 {
1890 let my_receipt = mine.remove(&mid);
1891 let publication = state.clone();
1892 let resolved = resolve_body(
1893 pool,
1894 backends,
1895 auth.team_id,
1896 mid,
1897 BodyRow {
1898 body,
1899 state,
1900 locator,
1901 backend: message_backend,
1902 tombstoned: tombstoned.is_some(),
1903 tombstone_reason,
1904 },
1905 )
1906 .await?;
1907 let (body, publication, unavailable) = match resolved {
1908 BodyState::Present(body) => (body, publication, None),
1909 BodyState::Missing { publication, why } => {
1912 (String::new(), publication.to_owned(), Some(why.to_string()))
1913 }
1914 BodyState::Unreachable(why) => (String::new(), publication, Some(why.to_string())),
1916 };
1917 messages.push(ConversationMessage {
1918 message_id: mid.to_string(),
1919 seq,
1920 from_address: address_of(&from, &from_session),
1921 from,
1922 body,
1923 reply_to: reply_to.map(|r| r.to_string()),
1924 metadata,
1925 created_at: ts(created_at),
1926 my_receipt,
1927 publication,
1928 unavailable,
1929 });
1930 }
1931 let first_pending = messages
1935 .iter()
1936 .find(|m| m.publication == "pending_publication")
1937 .map(|m| m.seq);
1938 let next = messages
1939 .last()
1940 .map(|m| m.seq)
1941 .map(|last| match first_pending {
1942 Some(pending) => last.min(pending - 1),
1943 None => last,
1944 })
1945 .filter(|s| *s < a.last_seq && *s > 0);
1946 Ok(ConversationRead {
1947 conversation_id: id.to_string(),
1948 next_after_seq: next,
1949 history_from_seq: a.membership.as_ref().and_then(|m| m.history_from_seq),
1950 messages,
1951 })
1952}
1953
1954#[allow(clippy::type_complexity)]
1956async fn receipts_for(
1957 pool: &PgPool,
1958 message_ids: &[Uuid],
1959 membership_id: Uuid,
1960) -> BusResult<Vec<(Uuid, ReceiptInfo)>> {
1961 if message_ids.is_empty() {
1962 return Ok(Vec::new());
1963 }
1964 let rows: Vec<(
1965 Uuid,
1966 String,
1967 String,
1968 Option<chrono::DateTime<chrono::Utc>>,
1969 Option<chrono::DateTime<chrono::Utc>>,
1970 Option<chrono::DateTime<chrono::Utc>>,
1971 Option<chrono::DateTime<chrono::Utc>>,
1972 Option<chrono::DateTime<chrono::Utc>>,
1973 Option<String>,
1974 )> = sqlx::query_as(
1975 "SELECT r.message_id, ag.name, m.session, r.stored_at, r.delivered_at, r.presented_at,
1976 r.acknowledged_at, r.resolved_at, r.note
1977 FROM message_receipts r
1978 JOIN conversation_memberships m ON m.id = r.membership_id
1979 JOIN agents ag ON ag.id = m.agent_id
1980 WHERE r.message_id = ANY($1) AND r.membership_id = $2",
1981 )
1982 .bind(message_ids)
1983 .bind(membership_id)
1984 .fetch_all(pool)
1985 .await?;
1986 Ok(rows
1987 .into_iter()
1988 .map(
1989 |(message_id, agent, session, stored, delivered, presented, acked, resolved, note)| {
1990 (
1991 message_id,
1992 ReceiptInfo {
1993 address: address_of(&agent, &session),
1994 session: (!session.is_empty()).then_some(session),
1995 agent,
1996 stored_at: ts_opt(stored),
1997 delivered_at: ts_opt(delivered),
1998 presented_at: ts_opt(presented),
1999 acknowledged_at: ts_opt(acked),
2000 resolved_at: ts_opt(resolved),
2001 note,
2002 },
2003 )
2004 },
2005 )
2006 .collect())
2007}
2008
2009async fn receipt_of(
2010 pool: &PgPool,
2011 message_id: Uuid,
2012 membership_id: Uuid,
2013) -> BusResult<Option<ReceiptInfo>> {
2014 let row: Option<(
2015 String,
2016 String,
2017 Option<chrono::DateTime<chrono::Utc>>,
2018 Option<chrono::DateTime<chrono::Utc>>,
2019 Option<chrono::DateTime<chrono::Utc>>,
2020 Option<chrono::DateTime<chrono::Utc>>,
2021 Option<chrono::DateTime<chrono::Utc>>,
2022 Option<String>,
2023 )> = sqlx::query_as(
2024 "SELECT ag.name, m.session, r.stored_at, r.delivered_at, r.presented_at,
2025 r.acknowledged_at, r.resolved_at, r.note
2026 FROM message_receipts r
2027 JOIN conversation_memberships m ON m.id = r.membership_id
2028 JOIN agents ag ON ag.id = m.agent_id
2029 WHERE r.message_id = $1 AND r.membership_id = $2",
2030 )
2031 .bind(message_id)
2032 .bind(membership_id)
2033 .fetch_optional(pool)
2034 .await?;
2035 Ok(row.map(
2036 |(agent, session, stored, delivered, presented, acked, resolved, note)| ReceiptInfo {
2037 address: address_of(&agent, &session),
2038 session: (!session.is_empty()).then_some(session),
2039 agent,
2040 stored_at: ts_opt(stored),
2041 delivered_at: ts_opt(delivered),
2042 presented_at: ts_opt(presented),
2043 acknowledged_at: ts_opt(acked),
2044 resolved_at: ts_opt(resolved),
2045 note,
2046 },
2047 ))
2048}
2049
2050pub async fn get_message(
2052 pool: &PgPool,
2053 backends: &crate::store::routing::Backends,
2054 auth: &AuthCtx,
2055 message_id: Uuid,
2056) -> BusResult<ConversationMessage> {
2057 require_capability(pool, auth).await?;
2058 #[allow(clippy::type_complexity)]
2059 let row: Option<(
2060 Uuid,
2061 i64,
2062 String,
2063 String,
2064 String,
2065 Option<Uuid>,
2066 serde_json::Value,
2067 chrono::DateTime<chrono::Utc>,
2068 String,
2069 Option<String>,
2070 Option<chrono::DateTime<chrono::Utc>>,
2071 Option<String>,
2072 String,
2073 )> = sqlx::query_as(
2074 "SELECT m.conversation_id, m.seq, ag.name, m.sender_session, m.body, m.reply_to,
2075 m.metadata, m.created_at, m.publication_state, m.canonical_locator,
2076 m.tombstoned_at, m.tombstone_reason, m.backend
2077 FROM conversation_messages m
2078 JOIN agents ag ON ag.id = m.sender_agent
2079 WHERE m.id = $1 AND m.deleted_at IS NULL",
2080 )
2081 .bind(message_id)
2082 .fetch_optional(pool)
2083 .await?;
2084 let Some((
2085 conversation_id,
2086 seq,
2087 from,
2088 from_session,
2089 body,
2090 reply_to,
2091 metadata,
2092 created_at,
2093 state,
2094 locator,
2095 tombstoned,
2096 tombstone_reason,
2097 message_backend,
2098 )) = row
2099 else {
2100 return Err(BusError::not_found("no such message"));
2101 };
2102 let a = readable(pool, auth, conversation_id).await?;
2105 require_accepted(&a)?;
2106 if let Some(m) = &a.membership
2107 && let Some(floor) = m.history_from_seq
2108 && seq <= floor
2109 {
2110 return Err(BusError::Forbidden(
2111 "this message is before the point your membership starts".to_owned(),
2112 ));
2113 }
2114 let my_receipt = match &a.membership {
2115 Some(m) => receipt_of(pool, message_id, m.id).await?,
2116 None => None,
2117 };
2118 let publication = state.clone();
2121 let (body, publication, unavailable) = match resolve_body(
2122 pool,
2123 backends,
2124 auth.team_id,
2125 message_id,
2126 BodyRow {
2127 body,
2128 state,
2129 locator,
2130 backend: message_backend,
2131 tombstoned: tombstoned.is_some(),
2132 tombstone_reason,
2133 },
2134 )
2135 .await?
2136 {
2137 BodyState::Present(body) => (body, publication, None),
2138 BodyState::Missing { publication, why } => {
2139 (String::new(), publication.to_owned(), Some(why.to_string()))
2140 }
2141 BodyState::Unreachable(why) => (String::new(), publication, Some(why.to_string())),
2142 };
2143 Ok(ConversationMessage {
2144 message_id: message_id.to_string(),
2145 seq,
2146 from_address: address_of(&from, &from_session),
2147 from,
2148 body,
2149 reply_to: reply_to.map(|r| r.to_string()),
2150 metadata,
2151 created_at: ts(created_at),
2152 my_receipt,
2153 publication,
2154 unavailable,
2155 })
2156}
2157
2158pub async fn ack(
2161 pool: &PgPool,
2162 auth: &AuthCtx,
2163 message_id: Uuid,
2164 resolved: bool,
2165 note: Option<String>,
2166) -> BusResult<ReceiptInfo> {
2167 require_capability(pool, auth).await?;
2168 let (conversation_id,): (Uuid,) =
2169 sqlx::query_as("SELECT conversation_id FROM conversation_messages WHERE id = $1")
2170 .bind(message_id)
2171 .fetch_optional(pool)
2172 .await?
2173 .ok_or_else(|| BusError::not_found("no such message"))?;
2174 let a = readable(pool, auth, conversation_id).await?;
2175 let Some(membership) = a.membership.as_ref().filter(|m| m.state == "active") else {
2176 return Err(BusError::Forbidden(
2177 "only an active member of this conversation can acknowledge its messages".to_owned(),
2178 ));
2179 };
2180 let note = match note {
2181 Some(n) => Some(crate::store::check_text("note", &n, 2000)?),
2182 None => None,
2183 };
2184
2185 let mut tx = pool.begin().await?;
2186 crate::store::sessions::guard(&mut tx, auth).await?;
2187 require_project_open(&mut tx, auth, conversation_id).await?;
2188 let updated: Option<(Uuid,)> = sqlx::query_as(
2195 "UPDATE message_receipts r
2196 SET acknowledged_at = COALESCE(acknowledged_at, now()),
2197 resolved_at = CASE WHEN $3 THEN COALESCE(resolved_at, now()) ELSE resolved_at END,
2198 note = COALESCE($4, note)
2199 FROM conversation_memberships m
2200 WHERE r.message_id = $1 AND r.membership_id = $2
2201 AND m.id = r.membership_id AND m.state = 'active'
2202 RETURNING r.membership_id",
2203 )
2204 .bind(message_id)
2205 .bind(membership.id)
2206 .bind(resolved)
2207 .bind(note.as_deref())
2208 .fetch_optional(&mut *tx)
2209 .await?;
2210 if updated.is_none() {
2211 return Err(BusError::Forbidden(
2212 "this message was not addressed to your window, so there is nothing for you to \
2213 acknowledge. You can read it, which is not the same observation."
2214 .to_owned(),
2215 ));
2216 }
2217 audit(
2218 &mut tx,
2219 auth,
2220 Some(conversation_id),
2221 "message.ack",
2222 Some(message_id),
2223 serde_json::json!({ "resolved": resolved }),
2224 )
2225 .await?;
2226 crate::store::inbox::enqueue_receipt_reference(
2231 &mut tx,
2232 message_id,
2233 membership.id,
2234 if resolved { "resolved" } else { "acknowledged" },
2235 )
2236 .await?;
2237 tx.commit().await?;
2238 receipt_of(pool, message_id, membership.id)
2239 .await?
2240 .ok_or_else(|| BusError::not_found("receipt vanished"))
2241}
2242
2243pub async fn receipts(
2245 pool: &PgPool,
2246 auth: &AuthCtx,
2247 message_id: Uuid,
2248) -> BusResult<MessageReceipts> {
2249 require_capability(pool, auth).await?;
2250 let row: Option<(Uuid, i64)> =
2251 sqlx::query_as("SELECT conversation_id, seq FROM conversation_messages WHERE id = $1")
2252 .bind(message_id)
2253 .fetch_optional(pool)
2254 .await?;
2255 let Some((conversation_id, seq)) = row else {
2256 return Err(BusError::not_found("no such message"));
2257 };
2258 let a = readable(pool, auth, conversation_id).await?;
2259 require_accepted(&a)?;
2260 if let Some(m) = &a.membership
2265 && let Some(floor) = m.history_from_seq
2266 && seq <= floor
2267 {
2268 return Err(BusError::Forbidden(
2269 "this message is before the point your membership starts, so its receipts are not yours to read either"
2270 .to_owned(),
2271 ));
2272 }
2273 let rows: Vec<(
2274 String,
2275 String,
2276 Option<chrono::DateTime<chrono::Utc>>,
2277 Option<chrono::DateTime<chrono::Utc>>,
2278 Option<chrono::DateTime<chrono::Utc>>,
2279 Option<chrono::DateTime<chrono::Utc>>,
2280 Option<chrono::DateTime<chrono::Utc>>,
2281 Option<String>,
2282 )> = sqlx::query_as(
2283 "SELECT ag.name, m.session, r.stored_at, r.delivered_at, r.presented_at,
2284 r.acknowledged_at, r.resolved_at, r.note
2285 FROM message_receipts r
2286 JOIN conversation_memberships m ON m.id = r.membership_id
2287 JOIN agents ag ON ag.id = m.agent_id
2288 WHERE r.message_id = $1
2289 ORDER BY ag.name, m.session",
2290 )
2291 .bind(message_id)
2292 .fetch_all(pool)
2293 .await?;
2294 let receipts: Vec<ReceiptInfo> = rows
2295 .into_iter()
2296 .map(
2297 |(agent, session, stored, delivered, presented, acked, resolved, note)| ReceiptInfo {
2298 address: address_of(&agent, &session),
2299 session: (!session.is_empty()).then_some(session),
2300 agent,
2301 stored_at: ts_opt(stored),
2302 delivered_at: ts_opt(delivered),
2303 presented_at: ts_opt(presented),
2304 acknowledged_at: ts_opt(acked),
2305 resolved_at: ts_opt(resolved),
2306 note,
2307 },
2308 )
2309 .collect();
2310 Ok(MessageReceipts {
2311 message_id: message_id.to_string(),
2312 seq,
2313 acknowledged: receipts
2314 .iter()
2315 .filter(|r| r.acknowledged_at.is_some())
2316 .count(),
2317 resolved: receipts.iter().filter(|r| r.resolved_at.is_some()).count(),
2318 total: receipts.len(),
2319 receipts,
2320 })
2321}
2322
2323pub async fn activity(pool: &PgPool, auth: &AuthCtx) -> BusResult<Vec<ConversationActivity>> {
2325 let rows: Vec<(Uuid, String, i64, i64)> = sqlx::query_as(
2326 "SELECT c.id, c.title, c.last_seq,
2327 (SELECT count(*) FROM message_receipts r
2328 WHERE r.membership_id = m.id AND r.acknowledged_at IS NULL)
2329 FROM conversations c
2330 JOIN conversation_memberships m
2331 ON m.conversation_id = c.id AND m.agent_id = $2 AND m.session = $3
2332 WHERE c.team_id = $1 AND c.archived_at IS NULL AND m.state = 'active'
2333 -- A project thread wakes nobody whose grant is gone.
2334 AND (c.visibility <> 'project' OR EXISTS (
2335 SELECT 1 FROM project_agent_access a
2336 WHERE a.project_id = c.project_id AND a.agent_id = m.agent_id))
2337 ORDER BY c.last_seq DESC",
2338 )
2339 .bind(auth.team_id)
2340 .bind(auth.agent_id)
2341 .bind(&auth.session)
2342 .fetch_all(pool)
2343 .await?;
2344 Ok(rows
2345 .into_iter()
2346 .filter(|(_, _, _, unack)| *unack > 0)
2347 .map(
2348 |(id, title, last_seq, unacknowledged)| ConversationActivity {
2349 conversation_id: id.to_string(),
2350 title,
2351 last_seq,
2352 unacknowledged,
2353 },
2354 )
2355 .collect())
2356}
2357
2358pub async fn transfer_membership(
2369 pool: &PgPool,
2370 auth: &AuthCtx,
2371 id: Uuid,
2372 to: &str,
2373) -> BusResult<TransferResult> {
2374 require_capability(pool, auth).await?;
2375 let a = readable(pool, auth, id).await?;
2376 let Some(mine) = a.membership.as_ref().filter(|m| m.state == "active") else {
2377 return Err(BusError::Forbidden(
2378 "you have no active membership in this conversation to transfer".to_owned(),
2379 ));
2380 };
2381 let (agent, session) = crate::store::messaging::parse_address(to)?;
2382 let agent_id = crate::store::agent_id_by_name(pool, auth.team_id, &agent).await?;
2383 if agent_id != auth.agent_id {
2388 return Err(BusError::Forbidden(format!(
2389 "a membership can only be transferred to another window of the same agent. \
2390 '{to}' is someone else — invite them instead, which gives them their own \
2391 history boundary and their own receipts."
2392 )));
2393 }
2394 let session = session.unwrap_or_default();
2395 if session == auth.session {
2396 return Err(BusError::invalid("that is the window you are calling from"));
2397 }
2398
2399 let mut tx = pool.begin().await?;
2400 crate::store::sessions::guard(&mut tx, auth).await?;
2401 sqlx::query("SELECT id FROM conversations WHERE id = $1 FOR UPDATE")
2407 .bind(id)
2408 .execute(&mut *tx)
2409 .await?;
2410 require_project_open(&mut tx, auth, id).await?;
2411 let source: Option<(String,)> =
2415 sqlx::query_as("SELECT state FROM conversation_memberships WHERE id = $1 FOR UPDATE")
2416 .bind(mine.id)
2417 .fetch_optional(&mut *tx)
2418 .await?;
2419 if source.as_ref().map(|s| s.0.as_str()) != Some("active") {
2420 return Err(BusError::conflict(
2421 "your membership of this conversation is no longer active, so there is nothing to transfer. Nothing was written.",
2422 ));
2423 }
2424 let target: Option<(String,)> = sqlx::query_as(
2431 "SELECT state FROM conversation_memberships
2432 WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
2433 FOR UPDATE",
2434 )
2435 .bind(id)
2436 .bind(auth.agent_id)
2437 .bind(&session)
2438 .fetch_optional(&mut *tx)
2439 .await?;
2440 match target.as_ref().map(|t| t.0.as_str()) {
2441 Some("active") => {
2442 return Err(BusError::conflict(format!(
2443 "'{to}' is already in this conversation with a seat of its own, so there is \
2444 nothing to hand over; nothing was written. Keep using that window, or have \
2445 it leave first if it should take this seat and its history instead."
2446 )));
2447 }
2448 Some("invited") => {
2449 return Err(BusError::conflict(format!(
2450 "'{to}' already holds an invitation to this conversation; nothing was \
2451 written. Accept it from that window, or have it leave first and transfer \
2452 again."
2453 )));
2454 }
2455 Some("removed") => {
2456 return Err(BusError::Forbidden(format!(
2457 "'{to}' was removed from this conversation, and a transfer does not undo a \
2458 removal; nothing was written."
2459 )));
2460 }
2461 _ => {}
2462 }
2463 let moved: Option<(Uuid,)> = sqlx::query_as(
2464 "INSERT INTO conversation_memberships
2465 (conversation_id, agent_id, session, role, state, history_from_seq, invited_by,
2466 transfer_from)
2467 SELECT $1, $2, $3, m.role, 'invited', m.history_from_seq, $2, m.id
2468 FROM conversation_memberships m WHERE m.id = $4
2469 -- Only a seat that was left reaches this: the others were refused
2470 -- above. It is offered again, unbound, so whoever accepts proves
2471 -- it is that window.
2472 ON CONFLICT (conversation_id, agent_id, session) DO UPDATE SET
2473 role = EXCLUDED.role,
2474 state = 'invited',
2475 history_from_seq = EXCLUDED.history_from_seq,
2476 transfer_from = EXCLUDED.transfer_from,
2477 session_id = NULL,
2478 invited_at = now(),
2479 ended_at = NULL
2480 RETURNING id",
2481 )
2482 .bind(id)
2483 .bind(auth.agent_id)
2484 .bind(&session)
2485 .bind(mine.id)
2486 .fetch_optional(&mut *tx)
2487 .await?;
2488 if moved.is_none() {
2489 return Err(BusError::conflict(
2490 "that window's seat could not be offered the transfer; nothing was written",
2491 ));
2492 }
2493 sqlx::query("UPDATE conversation_memberships SET superseded_by = NULL WHERE id = $1")
2495 .bind(mine.id)
2496 .execute(&mut *tx)
2497 .await?;
2498 audit(
2499 &mut tx,
2500 auth,
2501 Some(id),
2502 "member.transfer",
2503 Some(auth.agent_id),
2504 serde_json::json!({ "from": address_of(&auth.agent_name, &auth.session), "to": to }),
2505 )
2506 .await?;
2507 tx.commit().await?;
2508 Ok(TransferResult {
2509 conversation_id: id.to_string(),
2510 from_address: address_of(&auth.agent_name, &auth.session),
2511 to_address: to.to_owned(),
2512 state: "proposed".into(),
2513 })
2514}
2515
2516async fn supersede_predecessor(
2519 tx: &mut sqlx::PgConnection,
2520 auth: &AuthCtx,
2521 conversation: Uuid,
2522 new_membership: Uuid,
2523) -> BusResult<()> {
2524 sqlx::query(
2529 "UPDATE conversation_memberships p
2530 SET state = 'left', ended_at = now(), superseded_by = $3
2531 FROM conversation_memberships n
2532 WHERE n.id = $3 AND n.transfer_from = p.id
2533 AND p.conversation_id = $1 AND p.agent_id = $2 AND p.id <> $3
2534 AND p.state = 'active'
2535 AND p.superseded_by IS NULL",
2536 )
2537 .bind(conversation)
2538 .bind(auth.agent_id)
2539 .bind(new_membership)
2540 .execute(&mut *tx)
2541 .await?;
2542 sqlx::query("UPDATE conversation_memberships SET transfer_from = NULL WHERE id = $1")
2544 .bind(new_membership)
2545 .execute(tx)
2546 .await?;
2547 Ok(())
2548}
2549
2550pub async fn recover_history(
2559 pool: &PgPool,
2560 backends: &crate::store::routing::Backends,
2561 auth: &AuthCtx,
2562 id: Uuid,
2563 after_seq: Option<i64>,
2564 limit: Option<i64>,
2565) -> BusResult<ConversationRead> {
2566 require_capability(pool, auth).await?;
2567 if auth.session_is_authenticated() {
2568 return Err(BusError::Forbidden(
2569 "recovery is an owner-agent operation: call it with your agent token, not with \
2570 a window's session credential."
2571 .to_owned(),
2572 ));
2573 }
2574 let mut tx = pool.begin().await?;
2577 sqlx::query("SELECT id FROM agents WHERE id = $1 FOR NO KEY UPDATE")
2583 .bind(auth.agent_id)
2584 .fetch_one(&mut *tx)
2585 .await?;
2586 let live: Vec<(Uuid,)> = sqlx::query_as(
2590 "SELECT s.id FROM agent_sessions s
2591 WHERE s.agent_id = $1 AND s.revoked_at IS NULL AND s.expires_at > now()
2592 AND NOT EXISTS (SELECT 1 FROM api_tokens t
2593 WHERE t.id = s.parent_token AND t.revoked_at IS NOT NULL)",
2594 )
2595 .bind(auth.agent_id)
2596 .fetch_all(&mut *tx)
2597 .await?;
2598 if !live.is_empty() {
2599 return Err(BusError::conflict(format!(
2600 "{} of your sessions are still live. Recovery is for windows that are gone: \
2601 ask that window to read the thread, or revoke_session it first.",
2602 live.len()
2603 )));
2604 }
2605
2606 let rows: Vec<(
2608 Uuid,
2609 i64,
2610 String,
2611 String,
2612 String,
2613 Option<Uuid>,
2614 serde_json::Value,
2615 chrono::DateTime<chrono::Utc>,
2616 )> = sqlx::query_as(
2617 "SELECT m.id, m.seq, ag.name, m.sender_session, m.body, m.reply_to, m.metadata,
2618 m.created_at
2619 FROM conversation_messages m
2620 JOIN agents ag ON ag.id = m.sender_agent
2621 JOIN conversations c ON c.id = m.conversation_id
2622 WHERE m.conversation_id = $1
2623 AND c.team_id = $2
2624 AND m.seq > $5
2625 AND m.deleted_at IS NULL
2626 AND EXISTS (
2627 SELECT 1 FROM conversation_memberships me
2628 WHERE me.conversation_id = m.conversation_id
2629 AND me.agent_id = $3
2630 AND me.state <> 'removed'
2631 -- An invitation is not membership: a window that was
2632 -- asked and never answered read nothing, and its
2633 -- agent recovers nothing on its behalf. A seat that
2634 -- was active once still counts, however it ended.
2635 AND me.accepted_at IS NOT NULL
2636 AND m.seq > COALESCE(me.history_from_seq, 0))
2637 -- A project thread additionally needs current project access.
2638 AND (c.visibility = 'private' OR EXISTS (
2639 SELECT 1 FROM project_agent_access a
2640 WHERE a.project_id = c.project_id AND a.agent_id = $3))
2641 ORDER BY m.seq
2642 LIMIT $4",
2643 )
2644 .bind(id)
2645 .bind(auth.team_id)
2646 .bind(auth.agent_id)
2647 .bind(limit.unwrap_or(DEFAULT_PAGE).clamp(1, MAX_PAGE))
2648 .bind(after_seq.unwrap_or(0))
2649 .fetch_all(&mut *tx)
2650 .await?;
2651 if rows.is_empty() {
2652 return Err(BusError::not_found(
2653 "nothing to recover here: no non-revoked membership of yours covers this \
2654 conversation",
2655 ));
2656 }
2657 audit(
2658 &mut tx,
2659 auth,
2660 Some(id),
2661 "member.recover",
2662 Some(auth.agent_id),
2663 serde_json::json!({ "messages": rows.len(), "read_only": true }),
2664 )
2665 .await?;
2666 tx.commit().await?;
2667
2668 let next_after_seq = (rows.len() as i64 == limit.unwrap_or(DEFAULT_PAGE).clamp(1, MAX_PAGE))
2671 .then(|| rows.last().map(|r| r.1))
2672 .flatten();
2673 let mut messages = Vec::with_capacity(rows.len());
2677 for (mid, seq, from, from_session, body, reply_to, metadata, created_at) in rows {
2678 #[allow(clippy::type_complexity)]
2679 let state: (
2680 String,
2681 Option<String>,
2682 Option<chrono::DateTime<chrono::Utc>>,
2683 Option<String>,
2684 String,
2685 ) = sqlx::query_as(
2686 "SELECT publication_state, canonical_locator, tombstoned_at, tombstone_reason,
2687 backend
2688 FROM conversation_messages WHERE id = $1",
2689 )
2690 .bind(mid)
2691 .fetch_one(pool)
2692 .await?;
2693 let publication = state.0.clone();
2694 let (body, publication, unavailable) = match resolve_body(
2695 pool,
2696 backends,
2697 auth.team_id,
2698 mid,
2699 BodyRow {
2700 body,
2701 state: state.0,
2702 locator: state.1,
2703 backend: state.4,
2704 tombstoned: state.2.is_some(),
2705 tombstone_reason: state.3,
2706 },
2707 )
2708 .await?
2709 {
2710 BodyState::Present(body) => (body, publication, None),
2711 BodyState::Missing { publication, why } => {
2712 (String::new(), publication.to_owned(), Some(why.to_string()))
2713 }
2714 BodyState::Unreachable(why) => (String::new(), publication, Some(why.to_string())),
2715 };
2716 messages.push(ConversationMessage {
2717 message_id: mid.to_string(),
2718 seq,
2719 from_address: address_of(&from, &from_session),
2720 from,
2721 body,
2722 reply_to: reply_to.map(|r| r.to_string()),
2723 metadata,
2724 created_at: ts(created_at),
2725 my_receipt: None,
2728 publication,
2729 unavailable,
2730 });
2731 }
2732 Ok(ConversationRead {
2733 conversation_id: id.to_string(),
2734 next_after_seq,
2735 history_from_seq: None,
2736 messages,
2737 })
2738}