1use objectiveai_sdk::agent::completions::message::{
11 File, ImageUrl, InputAudio, RichContent, RichContentPart, VideoUrl,
12};
13use objectiveai_sdk::cli::command::agents::queue::read::pending::{
14 QueuePart, QueuePartType, ResponseItem,
15};
16use objectiveai_sdk::client_objectiveai_mcp::server_response::{
17 ReadMessageQueueResult, ReadMessageQueueRow,
18};
19use sqlx::{PgConnection, Postgres, Row as _, Transaction};
20
21use super::{Error, Pool};
22
23#[derive(Debug, Clone)]
25pub enum ContentRow {
26 Text(String),
27 Image(ImageUrl),
28 Audio(InputAudio),
29 Video(VideoUrl),
30 File(File),
31}
32
33pub fn content_row_to_part(row: ContentRow) -> RichContentPart {
35 match row {
36 ContentRow::Text(text) => RichContentPart::Text { text },
37 ContentRow::Image(image_url) => RichContentPart::ImageUrl { image_url },
38 ContentRow::Audio(input_audio) => RichContentPart::InputAudio { input_audio },
39 ContentRow::Video(video_url) => RichContentPart::VideoUrl { video_url },
40 ContentRow::File(file) => RichContentPart::File { file },
41 }
42}
43
44#[derive(Debug, Clone)]
47pub struct DrainedMessage {
48 pub agent_instance_hierarchy: Option<String>,
49 pub agent_tag: Option<String>,
50 pub key: Option<String>,
51 pub enqueued_at: i64,
52 pub content: RichContent,
53}
54
55#[derive(Debug, Clone)]
57pub enum DeliveryTarget {
58 Hierarchy { agent_instance_hierarchy: String },
61 GroupedTag { agent_tag: String },
65}
66
67fn now_seconds() -> i64 {
68 use std::time::{SystemTime, UNIX_EPOCH};
69 SystemTime::now()
70 .duration_since(UNIX_EPOCH)
71 .map(|d| d.as_secs() as i64)
72 .unwrap_or(0)
73}
74
75pub async fn read_content(
83 pool: &Pool,
84 id: i64,
85) -> Result<Option<ContentRow>, Error> {
86 let mut conn = pool.acquire().await?;
87 read_content_on_conn(&mut conn, id).await
88}
89
90async fn read_content_on_conn(
96 conn: &mut PgConnection,
97 id: i64,
98) -> Result<Option<ContentRow>, Error> {
99 let row = sqlx::query("SELECT kind FROM objectiveai.message_queue_contents WHERE id = $1")
100 .bind(id)
101 .fetch_optional(&mut *conn)
102 .await?;
103 let Some(row) = row else { return Ok(None) };
104 let kind: String = row.try_get(0)?;
105 let result = match kind.as_str() {
106 "text" => {
107 let r = sqlx::query("SELECT text FROM objectiveai.message_queue_texts WHERE id = $1")
108 .bind(id)
109 .fetch_one(&mut *conn)
110 .await?;
111 ContentRow::Text(r.try_get(0)?)
112 }
113 "image" => {
114 let r = sqlx::query("SELECT url, detail FROM objectiveai.message_queue_images WHERE id = $1")
115 .bind(id)
116 .fetch_one(&mut *conn)
117 .await?;
118 let url: String = r.try_get(0)?;
119 let detail_str: Option<String> = r.try_get(1)?;
120 let detail = match detail_str {
121 Some(s) => serde_json::from_value(serde_json::Value::String(s))?,
122 None => None,
123 };
124 ContentRow::Image(ImageUrl { url, detail })
125 }
126 "audio" => {
127 let r = sqlx::query("SELECT data, format FROM objectiveai.message_queue_audios WHERE id = $1")
128 .bind(id)
129 .fetch_one(&mut *conn)
130 .await?;
131 ContentRow::Audio(InputAudio {
132 data: r.try_get(0)?,
133 format: r.try_get(1)?,
134 })
135 }
136 "video" => {
137 let r = sqlx::query("SELECT url FROM objectiveai.message_queue_videos WHERE id = $1")
138 .bind(id)
139 .fetch_one(&mut *conn)
140 .await?;
141 ContentRow::Video(VideoUrl { url: r.try_get(0)? })
142 }
143 "file" => {
144 let r = sqlx::query(
145 "SELECT file_data, file_id, filename, file_url \
146 FROM objectiveai.message_queue_files WHERE id = $1",
147 )
148 .bind(id)
149 .fetch_one(&mut *conn)
150 .await?;
151 ContentRow::File(File {
152 file_data: r.try_get(0)?,
153 file_id: r.try_get(1)?,
154 filename: r.try_get(2)?,
155 file_url: r.try_get(3)?,
156 })
157 }
158 other => {
159 return Err(Error::InvalidData(format!(
160 "unknown message_queue_contents.kind: {other}"
161 )));
162 }
163 };
164 Ok(Some(result))
165}
166
167pub async fn enqueue_with_content(
178 pool: &Pool,
179 agent_instance_hierarchy: Option<String>,
180 agent_tag: Option<String>,
181 sender_agent_instance_hierarchy: &str,
182 key: Option<String>,
183 content: RichContent,
184) -> Result<i64, Error> {
185 let mut tx = pool.begin().await?;
186 let message_queue_id = enqueue_with_content_in_tx(
187 &mut tx,
188 agent_instance_hierarchy.as_deref(),
189 agent_tag.as_deref(),
190 sender_agent_instance_hierarchy,
191 key.as_deref(),
192 now_seconds(),
193 content,
194 )
195 .await?;
196 tx.commit().await?;
197 Ok(message_queue_id)
198}
199
200async fn enqueue_with_content_in_tx(
205 tx: &mut Transaction<'_, Postgres>,
206 agent_instance_hierarchy: Option<&str>,
207 agent_tag: Option<&str>,
208 sender_agent_instance_hierarchy: &str,
209 key: Option<&str>,
210 enqueued_at: i64,
211 content: RichContent,
212) -> Result<i64, Error> {
213 if let Some(key_value) = key {
214 sqlx::query(
221 "UPDATE objectiveai.message_queue SET active = FALSE \
222 WHERE active = TRUE \
223 AND key = $3 \
224 AND ( \
225 (agent_instance_hierarchy IS NOT NULL \
226 AND $1::text IS NOT NULL \
227 AND agent_instance_hierarchy = $1) \
228 OR \
229 (agent_tag IS NOT NULL \
230 AND $2::text IS NOT NULL \
231 AND agent_tag = $2) \
232 )",
233 )
234 .bind(agent_instance_hierarchy)
235 .bind(agent_tag)
236 .bind(key_value)
237 .execute(&mut **tx)
238 .await?;
239 }
240 let message_queue_id: i64 = sqlx::query_scalar(
241 "INSERT INTO objectiveai.message_queue \
242 (agent_instance_hierarchy, agent_tag, \
243 sender_agent_instance_hierarchy, enqueued_at, key) \
244 VALUES ($1, $2, $3, $4, $5) \
245 RETURNING id",
246 )
247 .bind(agent_instance_hierarchy)
248 .bind(agent_tag)
249 .bind(sender_agent_instance_hierarchy)
250 .bind(enqueued_at)
251 .bind(key)
252 .fetch_one(&mut **tx)
253 .await?;
254 walk_rich(tx, message_queue_id, content).await?;
255 Ok(message_queue_id)
256}
257
258async fn walk_rich(
264 tx: &mut Transaction<'_, Postgres>,
265 message_queue_id: i64,
266 content: RichContent,
267) -> Result<(), Error> {
268 match content {
269 RichContent::Text(text) => {
270 insert_content_text(tx, message_queue_id, &text).await?;
271 }
272 RichContent::Parts(parts) => {
273 for part in parts {
274 insert_content_part(tx, message_queue_id, part).await?;
275 }
276 }
277 }
278 Ok(())
279}
280
281async fn insert_content_part(
282 tx: &mut Transaction<'_, Postgres>,
283 message_queue_id: i64,
284 part: RichContentPart,
285) -> Result<i64, Error> {
286 match part {
287 RichContentPart::Text { text } => insert_content_text(tx, message_queue_id, &text).await,
288 RichContentPart::ImageUrl { image_url } => {
289 insert_content_image(tx, message_queue_id, &image_url).await
290 }
291 RichContentPart::InputAudio { input_audio } => {
292 insert_content_audio(tx, message_queue_id, &input_audio).await
293 }
294 RichContentPart::InputVideo { video_url }
295 | RichContentPart::VideoUrl { video_url } => {
296 insert_content_video(tx, message_queue_id, &video_url).await
297 }
298 RichContentPart::File { file } => insert_content_file(tx, message_queue_id, &file).await,
299 }
300}
301
302async fn mint_content_id(
303 tx: &mut Transaction<'_, Postgres>,
304 message_queue_id: i64,
305 kind: &str,
306) -> Result<i64, Error> {
307 let id: i64 = sqlx::query_scalar(
308 "INSERT INTO objectiveai.message_queue_contents (message_queue_id, kind) VALUES ($1, $2) RETURNING id",
309 )
310 .bind(message_queue_id)
311 .bind(kind)
312 .fetch_one(&mut **tx)
313 .await?;
314 Ok(id)
315}
316
317async fn insert_content_text(
318 tx: &mut Transaction<'_, Postgres>,
319 message_queue_id: i64,
320 text: &str,
321) -> Result<i64, Error> {
322 let id = mint_content_id(tx, message_queue_id, "text").await?;
323 sqlx::query("INSERT INTO objectiveai.message_queue_texts (id, text) VALUES ($1, $2)")
324 .bind(id)
325 .bind(text)
326 .execute(&mut **tx)
327 .await?;
328 Ok(id)
329}
330
331async fn insert_content_image(
332 tx: &mut Transaction<'_, Postgres>,
333 message_queue_id: i64,
334 image: &ImageUrl,
335) -> Result<i64, Error> {
336 let id = mint_content_id(tx, message_queue_id, "image").await?;
337 let detail = image
338 .detail
339 .as_ref()
340 .map(|d| serde_json::to_value(d).map(|v| v.as_str().map(str::to_string)))
341 .transpose()?
342 .flatten();
343 sqlx::query("INSERT INTO objectiveai.message_queue_images (id, url, detail) VALUES ($1, $2, $3)")
344 .bind(id)
345 .bind(&image.url)
346 .bind(detail)
347 .execute(&mut **tx)
348 .await?;
349 Ok(id)
350}
351
352async fn insert_content_audio(
353 tx: &mut Transaction<'_, Postgres>,
354 message_queue_id: i64,
355 audio: &InputAudio,
356) -> Result<i64, Error> {
357 let id = mint_content_id(tx, message_queue_id, "audio").await?;
358 sqlx::query("INSERT INTO objectiveai.message_queue_audios (id, data, format) VALUES ($1, $2, $3)")
359 .bind(id)
360 .bind(&audio.data)
361 .bind(&audio.format)
362 .execute(&mut **tx)
363 .await?;
364 Ok(id)
365}
366
367async fn insert_content_video(
368 tx: &mut Transaction<'_, Postgres>,
369 message_queue_id: i64,
370 video: &VideoUrl,
371) -> Result<i64, Error> {
372 let id = mint_content_id(tx, message_queue_id, "video").await?;
373 sqlx::query("INSERT INTO objectiveai.message_queue_videos (id, url) VALUES ($1, $2)")
374 .bind(id)
375 .bind(&video.url)
376 .execute(&mut **tx)
377 .await?;
378 Ok(id)
379}
380
381async fn insert_content_file(
382 tx: &mut Transaction<'_, Postgres>,
383 message_queue_id: i64,
384 file: &File,
385) -> Result<i64, Error> {
386 let id = mint_content_id(tx, message_queue_id, "file").await?;
387 sqlx::query(
388 "INSERT INTO objectiveai.message_queue_files (id, file_data, file_id, filename, file_url) \
389 VALUES ($1, $2, $3, $4, $5)",
390 )
391 .bind(id)
392 .bind(&file.file_data)
393 .bind(&file.file_id)
394 .bind(&file.filename)
395 .bind(&file.file_url)
396 .execute(&mut **tx)
397 .await?;
398 Ok(id)
399}
400
401#[derive(Debug, Clone)]
416pub enum ResolvedTarget {
417 Hierarchy(String),
418 Tag(String),
419}
420
421pub async fn list_pending_for_targets(
429 pool: &Pool,
430 targets: &[ResolvedTarget],
431 after_id: Option<i64>,
432 limit: Option<i64>,
433) -> Result<Vec<ResponseItem>, Error> {
434 if targets.is_empty() {
435 return Ok(Vec::new());
436 }
437 let mut hier_targets: Vec<String> = Vec::new();
438 let mut tag_targets: Vec<String> = Vec::new();
439 for t in targets {
440 match t {
441 ResolvedTarget::Hierarchy(h) => hier_targets.push(h.clone()),
442 ResolvedTarget::Tag(t) => tag_targets.push(t.clone()),
443 }
444 }
445 let rows = sqlx::query(
446 "SELECT p.id AS message_queue_id, \
447 p.agent_instance_hierarchy AS agent_instance_hierarchy, \
448 p.agent_tag AS agent_tag, \
449 p.sender_agent_instance_hierarchy AS sender_agent_instance_hierarchy, \
450 p.enqueued_at AS timestamp_queued, \
451 p.key AS key, \
452 c.id AS content_id, \
453 c.kind AS content_kind \
454 FROM objectiveai.message_queue p \
455 JOIN objectiveai.message_queue_contents c ON c.message_queue_id = p.id \
456 WHERE p.active = TRUE \
457 AND ( \
458 ( $1::text[] IS NOT NULL \
459 AND p.agent_instance_hierarchy = ANY($1) ) \
460 OR ( $2::text[] IS NOT NULL \
461 AND p.agent_tag = ANY($2) ) \
462 ) \
463 AND c.id > COALESCE($3, 0) \
464 ORDER BY p.id ASC, c.id ASC \
465 LIMIT $4",
466 )
467 .bind(if hier_targets.is_empty() { None } else { Some(&hier_targets) })
468 .bind(if tag_targets.is_empty() { None } else { Some(&tag_targets) })
469 .bind(after_id)
470 .bind(limit)
471 .fetch_all(&**pool)
472 .await?;
473
474 let mut out: Vec<ResponseItem> = Vec::new();
479 let mut cur_parent: Option<i64> = None;
480 let mut cur_aih: Option<String> = None;
481 let mut cur_tag: Option<String> = None;
482 let mut cur_sender: String = String::new();
483 let mut cur_timestamp: i64 = 0;
484 let mut cur_key: Option<String> = None;
485 let mut cur_parts: Vec<QueuePart> = Vec::new();
486
487 let flush = |parent: &mut Option<i64>,
488 aih: &mut Option<String>,
489 tag: &mut Option<String>,
490 sender: &mut String,
491 timestamp: &mut i64,
492 key: &mut Option<String>,
493 parts: &mut Vec<QueuePart>,
494 out: &mut Vec<ResponseItem>| {
495 if parts.is_empty() {
496 return;
497 }
498 let parts = std::mem::take(parts);
499 let sender = std::mem::take(sender);
500 let key = key.take();
501 let timestamp_queued = *timestamp;
502 let delete_id = parent.take().unwrap_or_default();
503 if let Some(h) = aih.take() {
504 out.push(ResponseItem::AgentInstanceHierarchy {
505 delete_id,
506 agent_instance_hierarchy: h,
507 sender_agent_instance_hierarchy: sender,
508 timestamp_queued,
509 key,
510 parts,
511 });
512 } else if let Some(t) = tag.take() {
513 out.push(ResponseItem::Tag {
514 delete_id,
515 agent_tag: t,
516 sender_agent_instance_hierarchy: sender,
517 timestamp_queued,
518 key,
519 parts,
520 });
521 }
522 *timestamp = 0;
523 };
524
525 for row in rows {
526 let parent_id: i64 = row.try_get("message_queue_id")?;
527 if cur_parent != Some(parent_id) {
528 flush(
529 &mut cur_parent, &mut cur_aih, &mut cur_tag,
530 &mut cur_sender, &mut cur_timestamp, &mut cur_key,
531 &mut cur_parts, &mut out,
532 );
533 cur_parent = Some(parent_id);
534 cur_aih = row.try_get("agent_instance_hierarchy")?;
535 cur_tag = row.try_get("agent_tag")?;
536 cur_sender = row.try_get("sender_agent_instance_hierarchy")?;
537 cur_timestamp = row.try_get("timestamp_queued")?;
538 cur_key = row.try_get("key")?;
539 }
540 let content_id: i64 = row.try_get("content_id")?;
541 let kind: String = row.try_get("content_kind")?;
542 let r#type = match kind.as_str() {
543 "text" => QueuePartType::Text,
544 "image" => QueuePartType::Image,
545 "audio" => QueuePartType::Audio,
546 "video" => QueuePartType::Video,
547 "file" => QueuePartType::File,
548 other => return Err(Error::InvalidData(format!(
549 "unknown message_queue_contents.kind: {other}"
550 ))),
551 };
552 cur_parts.push(QueuePart { id: content_id, r#type });
553 }
554 flush(
555 &mut cur_parent, &mut cur_aih, &mut cur_tag,
556 &mut cur_sender, &mut cur_timestamp, &mut cur_key,
557 &mut cur_parts, &mut out,
558 );
559 Ok(out)
560}
561
562struct DrainedRow {
568 message_queue_id: i64,
569 agent_instance_hierarchy: Option<String>,
570 agent_tag: Option<String>,
571 key: Option<String>,
572 enqueued_at: i64,
573}
574
575fn rows_to_drained(rows: Vec<sqlx::postgres::PgRow>) -> Result<Vec<DrainedRow>, Error> {
576 let mut out = Vec::with_capacity(rows.len());
577 for row in rows {
578 out.push(DrainedRow {
579 message_queue_id: row.try_get(0)?,
580 agent_instance_hierarchy: row.try_get(1)?,
581 agent_tag: row.try_get(2)?,
582 key: row.try_get(3)?,
583 enqueued_at: row.try_get(4)?,
584 });
585 }
586 Ok(out)
587}
588
589async fn reconstruct_and_delete(
595 tx: &mut Transaction<'_, Postgres>,
596 rows: Vec<DrainedRow>,
597) -> Result<Vec<DrainedMessage>, Error> {
598 let mut out = Vec::with_capacity(rows.len());
599 for row in rows {
600 let content = reconstruct_rich_content(tx, row.message_queue_id).await?;
601 sqlx::query(
602 "UPDATE objectiveai.message_queue SET active = FALSE \
603 WHERE id = $1 AND active = TRUE",
604 )
605 .bind(row.message_queue_id)
606 .execute(&mut **tx)
607 .await?;
608 out.push(DrainedMessage {
609 agent_instance_hierarchy: row.agent_instance_hierarchy,
610 agent_tag: row.agent_tag,
611 key: row.key,
612 enqueued_at: row.enqueued_at,
613 content,
614 });
615 }
616 Ok(out)
617}
618
619async fn reconstruct_rich_content(
625 tx: &mut Transaction<'_, Postgres>,
626 message_queue_id: i64,
627) -> Result<RichContent, Error> {
628 let (_, parts) = fetch_content_parts_for_queue_id(tx, message_queue_id).await?;
629 if parts.len() == 1 {
630 if let RichContentPart::Text { text } = &parts[0] {
631 return Ok(RichContent::Text(text.clone()));
632 }
633 }
634 Ok(RichContent::Parts(parts))
635}
636
637#[derive(Debug, Clone)]
646pub enum DeleteOutcome {
647 Deleted(DrainedMessage),
649 NotFound,
651 Unauthorized {
654 sender_agent_instance_hierarchy: String,
655 },
656}
657
658fn sender_under_caller(caller: &str, sender: &str) -> bool {
664 sender == caller || sender.starts_with(&format!("{caller}/"))
665}
666
667pub async fn delete_by_id(
678 pool: &Pool,
679 id: i64,
680 caller_agent_instance_hierarchy: &str,
681) -> Result<DeleteOutcome, Error> {
682 let mut tx = pool.begin().await?;
683 let row = sqlx::query(
684 "SELECT p.id, \
685 p.agent_instance_hierarchy, \
686 p.agent_tag, \
687 p.key, \
688 p.enqueued_at, \
689 p.sender_agent_instance_hierarchy \
690 FROM objectiveai.message_queue p \
691 WHERE p.id = $1 AND p.active = TRUE",
692 )
693 .bind(id)
694 .fetch_optional(&mut *tx)
695 .await?;
696
697 let Some(row) = row else {
698 tx.commit().await?;
699 return Ok(DeleteOutcome::NotFound);
700 };
701
702 let sender: String = row.try_get(5)?;
703 if !sender_under_caller(caller_agent_instance_hierarchy, &sender) {
704 return Ok(DeleteOutcome::Unauthorized {
707 sender_agent_instance_hierarchy: sender,
708 });
709 }
710
711 let drained_row = DrainedRow {
712 message_queue_id: row.try_get(0)?,
713 agent_instance_hierarchy: row.try_get(1)?,
714 agent_tag: row.try_get(2)?,
715 key: row.try_get(3)?,
716 enqueued_at: row.try_get(4)?,
717 };
718 let mut items = reconstruct_and_delete(&mut tx, vec![drained_row]).await?;
719 tx.commit().await?;
720 match items.pop() {
721 Some(message) => Ok(DeleteOutcome::Deleted(message)),
722 None => Ok(DeleteOutcome::NotFound),
723 }
724}
725
726pub async fn read_pending_and_upgrade_tag(
747 pool: &Pool,
748 agent_tag: Option<&str>,
749 target_hierarchy: &str,
750) -> Result<ReadMessageQueueResult, Error> {
751 let mut tx = pool.begin().await?;
752 if let Some(tag) = agent_tag {
753 let now = now_seconds();
754 sqlx::query(
755 "UPDATE objectiveai.tags \
756 SET agent_instance_hierarchy = $2, \
757 tag_group = NULL, \
758 updated_at = $3 \
759 WHERE tag_group = ( \
760 SELECT tag_group FROM objectiveai.tags \
761 WHERE name = $1 AND tag_group IS NOT NULL \
762 )",
763 )
764 .bind(tag)
765 .bind(target_hierarchy)
766 .bind(now)
767 .execute(&mut *tx)
768 .await?;
769 }
770 let rows = sqlx::query(
771 "SELECT p.id, \
772 p.agent_instance_hierarchy, \
773 p.agent_tag, \
774 p.key, \
775 p.enqueued_at \
776 FROM objectiveai.message_queue p \
777 WHERE p.active = TRUE \
778 AND ( \
779 p.agent_instance_hierarchy = $1 \
780 OR ( \
781 p.agent_tag IS NOT NULL \
782 AND EXISTS ( \
783 SELECT 1 FROM objectiveai.tags t \
784 WHERE t.name = p.agent_tag \
785 AND t.agent_instance_hierarchy = $1 \
786 ) \
787 ) ) \
788 ORDER BY p.id ASC",
789 )
790 .bind(target_hierarchy)
791 .fetch_all(&mut *tx)
792 .await?;
793
794 let drained = rows_to_drained(rows)?;
801 let mut out_rows: Vec<ReadMessageQueueRow> = Vec::with_capacity(drained.len());
802 for row in drained {
803 let (content_ids, parts) =
804 fetch_content_parts_for_queue_id(&mut tx, row.message_queue_id).await?;
805 let rich_content = if parts.len() == 1
806 && matches!(parts.first(), Some(RichContentPart::Text { .. }))
807 {
808 let Some(RichContentPart::Text { text }) = parts.into_iter().next()
809 else {
810 unreachable!("matched single Text part above")
811 };
812 RichContent::Text(text)
813 } else {
814 RichContent::Parts(parts)
815 };
816 out_rows.push(ReadMessageQueueRow {
817 content_ids,
818 rich_content,
819 });
820 }
821 tx.commit().await?;
824 Ok(ReadMessageQueueResult { rows: out_rows })
825}
826
827async fn fetch_content_parts_for_queue_id(
838 tx: &mut Transaction<'_, Postgres>,
839 message_queue_id: i64,
840) -> Result<(Vec<i64>, Vec<RichContentPart>), Error> {
841 let id_rows = sqlx::query(
842 "SELECT id FROM objectiveai.message_queue_contents \
843 WHERE message_queue_id = $1 \
844 ORDER BY id ASC",
845 )
846 .bind(message_queue_id)
847 .fetch_all(&mut **tx)
848 .await?;
849 let mut out_ids = Vec::with_capacity(id_rows.len());
850 let mut out_parts = Vec::with_capacity(id_rows.len());
851 for r in id_rows {
852 let id: i64 = r.try_get("id")?;
853 let row = read_content_on_conn(&mut **tx, id).await?.ok_or_else(|| {
854 Error::InvalidData(format!(
855 "message_queue_contents id {id} disappeared mid-tx"
856 ))
857 })?;
858 out_ids.push(id);
859 out_parts.push(content_row_to_part(row));
860 }
861 Ok((out_ids, out_parts))
862}
863
864pub async fn list_delivery_targets(
880 pool: &Pool,
881 parent: &str,
882) -> Result<Vec<DeliveryTarget>, Error> {
883 let pattern = format!("{parent}/%");
884 let hier_rows = sqlx::query(
885 "SELECT DISTINCT \
886 COALESCE(t.agent_instance_hierarchy, p.agent_instance_hierarchy) AS hier \
887 FROM objectiveai.message_queue p \
888 LEFT JOIN objectiveai.tags t \
889 ON p.agent_tag = t.name \
890 AND t.agent_instance_hierarchy IS NOT NULL \
891 WHERE p.active = TRUE AND ( \
892 /* Direct row: target hierarchy in subtree (inclusive). */ \
893 ( \
894 p.agent_instance_hierarchy IS NOT NULL \
895 AND ( \
896 p.agent_instance_hierarchy = $1 \
897 OR p.agent_instance_hierarchy LIKE $2 \
898 ) \
899 ) \
900 OR \
901 /* Tag row resolves through a BOUND tag in the subtree. */ \
902 ( \
903 p.agent_tag IS NOT NULL \
904 AND t.agent_instance_hierarchy IS NOT NULL \
905 AND ( \
906 t.agent_instance_hierarchy = $1 \
907 OR t.agent_instance_hierarchy LIKE $2 \
908 ) \
909 ) ) \
910 ORDER BY hier",
911 )
912 .bind(parent)
913 .bind(&pattern)
914 .fetch_all(&**pool)
915 .await?;
916 let tag_rows = sqlx::query(
917 "SELECT DISTINCT p.agent_tag \
918 FROM objectiveai.message_queue p \
919 JOIN objectiveai.tags t \
920 ON p.agent_tag = t.name \
921 AND t.tag_group IS NOT NULL \
922 JOIN objectiveai.tag_groups g \
923 ON g.id = t.tag_group \
924 WHERE p.active = TRUE AND ( \
925 g.parent_agent_instance_hierarchy = $1 \
926 OR g.parent_agent_instance_hierarchy LIKE $2 \
927 ) \
928 ORDER BY p.agent_tag",
929 )
930 .bind(parent)
931 .bind(&pattern)
932 .fetch_all(&**pool)
933 .await?;
934 let mut out = Vec::with_capacity(hier_rows.len() + tag_rows.len());
935 for row in hier_rows {
936 out.push(DeliveryTarget::Hierarchy {
937 agent_instance_hierarchy: row.try_get(0)?,
938 });
939 }
940 for row in tag_rows {
941 out.push(DeliveryTarget::GroupedTag {
942 agent_tag: row.try_get(0)?,
943 });
944 }
945 Ok(out)
946}
947
948pub async fn check_any_pending(
966 pool: &Pool,
967 target_hierarchy: &str,
968) -> Result<bool, Error> {
969 let row = sqlx::query(
970 "SELECT EXISTS ( \
971 SELECT 1 FROM objectiveai.message_queue p \
972 WHERE p.active = TRUE \
973 AND ( \
974 p.agent_instance_hierarchy = $1 \
975 OR ( \
976 p.agent_tag IS NOT NULL \
977 AND EXISTS ( \
978 SELECT 1 FROM objectiveai.tags t \
979 WHERE t.name = p.agent_tag \
980 AND t.agent_instance_hierarchy = $1 \
981 ) \
982 ) ) \
983 )",
984 )
985 .bind(target_hierarchy)
986 .fetch_one(&**pool)
987 .await?;
988 let pending: bool = row.try_get(0)?;
989 Ok(pending)
990}
991
992pub async fn subscribe_delivered(pool: &Pool, id: i64) -> Result<(), Error> {
1007 use sqlx::postgres::PgListener;
1008
1009 let mut listener = PgListener::connect_with(&**pool).await?;
1010 listener.listen("message_queue_inactive").await?;
1011
1012 let still_active: bool = sqlx::query_scalar(
1019 "SELECT EXISTS(SELECT 1 FROM objectiveai.message_queue \
1020 WHERE id = $1 AND active = TRUE)",
1021 )
1022 .bind(id)
1023 .fetch_one(&**pool)
1024 .await?;
1025 if !still_active {
1026 return Ok(());
1027 }
1028
1029 let target = id.to_string();
1030 loop {
1031 let notification = listener.recv().await?;
1032 if notification.payload() == target {
1033 return Ok(());
1034 }
1035 }
1037}