Skip to main content

objectiveai_cli/db/
message_queue.rs

1//! Deferred-message storage for `agents queue {add, list, read id}`.
2//!
3//! Mirrors the sqlite predecessor's split: a `message_queue` row carries
4//! either an `agent_instance_hierarchy` or an `agent_tag` (CHECK
5//! enforces); the master `message_queue_contents` registry has FK-cascade
6//! chains down to per-kind tables (`message_queue_texts`, `message_queue_images`,
7//! `message_queue_audios`, `message_queue_videos`, `message_queue_files`) — one DELETE on
8//! `message_queue` sweeps every per-kind row through the cascades.
9
10use 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/// One content row — typed payload of a single `message_queue_contents.id`.
24#[derive(Debug, Clone)]
25pub enum ContentRow {
26    Text(String),
27    Image(ImageUrl),
28    Audio(InputAudio),
29    Video(VideoUrl),
30    File(File),
31}
32
33/// Map one [`ContentRow`] to its matching SDK [`RichContentPart`] variant.
34pub 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/// One drained message — carries enough metadata to re-INSERT the
45/// original row.
46#[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/// One addressed delivery target.
56#[derive(Debug, Clone)]
57pub enum DeliveryTarget {
58    /// A resolvable hierarchy: direct queue rows plus rows parked
59    /// against BOUND tags (resolved to the tag's hierarchy).
60    Hierarchy { agent_instance_hierarchy: String },
61    /// An un-upgraded (GROUPED) tag with pending rows — spawning it
62    /// materializes the agent from the group's stored spec and
63    /// upgrades the whole group to BOUND.
64    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
75// ---------------------------------------------------------------------------
76// read_content — single-id resolver
77// ---------------------------------------------------------------------------
78
79/// Look up a single content row by `message_queue_contents.id`. Returns
80/// `None` when the id doesn't exist; a per-kind miss is DB corruption
81/// and surfaces as `Error::InvalidData`.
82pub 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
90/// `read_content` that operates on an externally-held `&mut PgConnection`
91/// (or the `&mut **tx` deref of a `Transaction<'_, Postgres>`). Lets
92/// the drain helpers reconstruct content inside their surrounding
93/// transaction. We re-borrow `&mut *conn` for each query so the
94/// per-kind probe + per-payload fetch don't try to move `conn`.
95async 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
167// ---------------------------------------------------------------------------
168// enqueue_with_content — INSERT message_queue + walk content into per-kind tables
169// ---------------------------------------------------------------------------
170
171/// Atomic enqueue: inserts the `message_queue` row, walks `content` and
172/// extracts every part into a per-kind table referenced by id, then
173/// UPDATEs the `message_queue.content` column with the assembled
174/// [`ResponseContent`] JSON (`One(i64)` for single-part, `Many(Vec<i64>)`
175/// for multi-part). Returns the new `message_queue.id`. Everything runs
176/// inside one transaction — failure rolls every content row back.
177pub 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
200/// Insert one message row + its content rows inside an existing
201/// transaction. `enqueued_at` is parameterised so callers that
202/// preserve the original FIFO timestamp (e.g. batched bulk inserts)
203/// can pass it through unchanged.
204async 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        // Upsert via soft-flip: any prior active row for this
215        // (target, key) pair flips to inactive so the partial
216        // unique index (now `WHERE … AND active = TRUE`) lets the
217        // new INSERT through. The flipped row + its
218        // `message_queue_contents` children survive untouched —
219        // they're invisible to readers via `active = FALSE`.
220        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
258/// Insert each content slot into the matching per-kind table.
259/// All rows link back to `message_queue_id` via the FK on
260/// `message_queue_contents`; reads JOIN, never look at the parent
261/// row for content. No JSON shadow of the slot ids is written
262/// anywhere.
263async 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// ---------------------------------------------------------------------------
402// List — JOIN objectiveai.message_queue ⨝ tags with three-rule predicate (Direct child /
403// BOUND tag / PENDING tag).
404// ---------------------------------------------------------------------------
405
406/// List all queued message_queue visible under `parent`. Three rules:
407/// 1. Direct row: `agent_instance_hierarchy` is a direct child of
408///    `parent` (LIKE `parent/%` AND no further `/`).
409/// 2. BOUND tag (LEFT JOIN matches): tag's bound hierarchy is a direct
410///    child of `parent`.
411/// 3. PENDING tag: tag's stored parent equals `parent` exactly.
412/// One target the CLI handler has already resolved. `Hierarchy`
413/// is a direct AIH; `Tag` is a tag-name post-`tags::lookup`. The
414/// SQL filters by whichever column matches.
415#[derive(Debug, Clone)]
416pub enum ResolvedTarget {
417    Hierarchy(String),
418    Tag(String),
419}
420
421/// Stream every `active = TRUE` `message_queue` row for any of
422/// `targets`, JOINed against `message_queue_contents` for parts.
423/// Grouped per-parent so `parts` reflects the relational
424/// content shape, not a JSON shadow. Pagination is by
425/// `message_queue_contents.id` (the `--after-id` / `--limit`
426/// scope is on parts, not rows — but the row boundary stays
427/// stable per parent).
428pub 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    // Walk the joined rows and group consecutive rows that share
475    // `message_queue_id` into one ResponseItem. Each row is one
476    // content slot; the parent's metadata (sender, timestamp,
477    // key) is identical across all of a parent's rows.
478    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
562// ---------------------------------------------------------------------------
563// Drain — atomically pull rows + reconstruct each as a RichContent.
564// ---------------------------------------------------------------------------
565
566/// Row data the drain SELECT pulls out of `message_queue`.
567struct 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
589/// For each matched row, look up its content rows by
590/// `message_queue_id`, reconstruct a `RichContent`, then
591/// soft-flip the parent row (`active = FALSE`). The parent row
592/// and its `message_queue_contents` children survive untouched
593/// — invisible to readers via `active = FALSE`.
594async 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
619/// Pull every `message_queue_contents` row whose
620/// `message_queue_id` matches and assemble a `RichContent` in
621/// id-ASC order. Single-text-part collapses to
622/// `RichContent::Text` so callers see the exact shape the queue
623/// stored.
624async 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// ---------------------------------------------------------------------------
638// Delete-by-id — `agents queue delete <id>`.
639// ---------------------------------------------------------------------------
640
641/// Outcome of a [`delete_by_id`] attempt. Distinguishes the three
642/// terminal states the handler must surface differently: the row was
643/// soft-deleted, no active row matched the id, or the row exists but
644/// the caller isn't authorized to delete it.
645#[derive(Debug, Clone)]
646pub enum DeleteOutcome {
647    /// Row soft-deleted; carries its reconstructed shape.
648    Deleted(DrainedMessage),
649    /// No `active = TRUE` row matched the id.
650    NotFound,
651    /// Row matched but its sender is neither the caller nor a
652    /// descendant of the caller. The row is left untouched.
653    Unauthorized {
654        sender_agent_instance_hierarchy: String,
655    },
656}
657
658/// True when `sender` is the caller itself or a descendant of the
659/// caller in the AIH tree — i.e. the caller is the sender or one of
660/// its ancestors (a "parent of the sender"). The trailing `/` on the
661/// prefix enforces a path boundary so `root/ab` is not treated as
662/// living under `root/a`.
663fn sender_under_caller(caller: &str, sender: &str) -> bool {
664    sender == caller || sender.starts_with(&format!("{caller}/"))
665}
666
667/// Atomically soft-delete the `message_queue` row with the given
668/// `id` (flips `active = FALSE`) and return its reconstructed
669/// shape — but only when `caller_agent_instance_hierarchy` is the
670/// row's sender or an ancestor of it. The authorization decision
671/// happens inside the transaction, before the flip, so an
672/// unauthorized attempt leaves the row untouched.
673///
674/// The row + its `message_queue_contents` children survive in the
675/// table for audit purposes even on delete — they're invisible to
676/// readers via `active = FALSE`.
677pub 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        // Leave the row untouched; the dropped tx rolls back nothing
705        // (we did no writes) but releases the row lock cleanly.
706        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
726// ---------------------------------------------------------------------------
727// Read-without-delete + clear-by-ids — API-driven split of the drain.
728// ---------------------------------------------------------------------------
729
730/// Non-destructive read of every queue row in scope for an agent,
731/// fused with the tag-group upgrade. Two-rule predicate: direct
732/// hierarchy match OR BOUND-tag match.
733///
734/// When `agent_tag` is `Some(name)`, this is the **sole** site where
735/// the tag-group upgrade fires. The UPDATE runs first inside the
736/// transaction so the subsequent SELECT (still in the same tx) sees
737/// every freshly-bound sibling tag via rule 2 — committing both
738/// effects atomically. When `agent_tag` is `None`, the UPDATE is
739/// skipped and only the SELECT runs.
740///
741/// Upgrade semantics: every `tags` row whose `tag_group` matches
742/// `name`'s group flips to BOUND on `target_hierarchy`. If `name`
743/// is itself BOUND (or absent), `tag_group` is NULL there, the
744/// `WHERE tag_group = (…)` predicate is unknown, and the UPDATE
745/// touches nothing — a tag that's already bound is left alone.
746pub 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    // One `ReadMessageQueueRow` per queue parent — no cross-row
795    // separator splicing. Callers join if they want a unified
796    // shape (the API's startup snapshot does;
797    // `ApiQueueDelegate` keeps rows separate so each tool
798    // response surfaces its own content). Single-text-part
799    // collapse happens per row.
800    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    // Commit so the UPGRADE (if any) sticks. The SELECTs in the
822    // same tx see the upgrade's effects already.
823    tx.commit().await?;
824    Ok(ReadMessageQueueResult { rows: out_rows })
825}
826
827/// Like `reconstruct_rich_content` but returns the consumed
828/// `message_queue_contents.id`s alongside the rich-content parts
829/// so the caller can preserve content-id provenance through the
830/// join + separator path. Kinds aren't returned — the LogWriter
831/// resolves them at write time via SQL CASE.
832/// Look up every `message_queue_contents` row whose
833/// `message_queue_id` matches and return (ids, RichContentParts)
834/// in id ASC order. Replaces the old JSON-shadow path — content
835/// is fetched relationally, never via a denormalized parent
836/// column.
837async 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
864// ---------------------------------------------------------------------------
865// Delivery enumeration — `agents queue deliver` fan-out.
866// ---------------------------------------------------------------------------
867
868/// Enumerate every delivery target with pending queue rows in the
869/// subtree rooted at `parent`:
870///
871/// - **Hierarchies** (subtree-inclusive): direct rows, plus tag rows
872///   resolved through a BOUND tag.
873/// - **Un-upgraded (GROUPED) tags** whose `tag_groups` parent sits in
874///   the subtree (inclusive — the minted hierarchy will be a strict
875///   descendant of that parent).
876///
877/// ABSENT tag rows (no `tags` row at all) are filtered out at the
878/// SQL level — there is nothing to wake or materialize for them.
879pub 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
948// ---------------------------------------------------------------------------
949// Pending-only EXISTS check. Pure read; no tag-group upgrade.
950// ---------------------------------------------------------------------------
951
952/// EXISTS-check: are any queue rows in scope for `target_hierarchy`?
953///
954/// Used by `agents spawn`'s end-of-pass restart logic to
955/// decide whether to fire another pass. The two-rule predicate
956/// matches `read_pending_and_upgrade_tag`'s SELECT exactly — direct
957/// hierarchy hit OR BOUND-tag hit.
958///
959/// **No upgrade side effect.** Tag-group upgrade now happens
960/// exclusively inside `read_pending_and_upgrade_tag`, fired by the
961/// conduit on every read-message-queue request. By the time the
962/// spawn pass ends, the conduit has already promoted every sibling
963/// tag in the group via its own reads, so this pure EXISTS check
964/// suffices for the restart decision.
965pub 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
992// ---------------------------------------------------------------------------
993// Delivery subscription — native postgres LISTEN/NOTIFY.
994// ---------------------------------------------------------------------------
995
996/// Wait until the `message_queue` row identified by `id` has been
997/// consumed — i.e. its `active` column has flipped from TRUE to
998/// FALSE. Resolves `Ok(())` the moment the flip is observed,
999/// regardless of which path performed it.
1000///
1001/// Uses `sqlx::postgres::PgListener` on the
1002/// `message_queue_inactive` channel that the AFTER-UPDATE trigger
1003/// in `db::init` populates. The function attaches the listener
1004/// FIRST, then re-checks whether the row is still active — that's
1005/// what closes the window where a fast flip races our `LISTEN`.
1006pub 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    // Belt-and-suspenders: if the row already flipped to inactive
1013    // (the conduit / LogWriter raced our listen), the LISTEN saw
1014    // nothing and would hang forever. SELECT once after attaching
1015    // — if the row is gone or already inactive, we already
1016    // delivered. After this point the LISTEN is attached so any
1017    // future flip will wake us.
1018    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        // Different row — keep listening.
1036    }
1037}