Skip to main content

agentmux_srv/backend/storage/
content.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent-content blobs — per-agent key/value content storage (e.g.
5//! the "instructions" panel content).
6//!
7//! Extracted from `store.rs` in Phase R.4 of the storage
8//! modularization plan
9//! (`docs/specs/SPEC_STORE_MODULARIZATION_2026_05_27.md`). The
10//! method surface is unchanged — `Store::agent_content_*` still
11//! lives on `Store` via this `impl` block; callers stay on
12//! `storage::store::AgentContent` thanks to the re-export.
13
14use rusqlite::params;
15use serde::{Deserialize, Serialize};
16
17use super::error::StoreError;
18use super::store::Store;
19
20/// A content blob attached to a agent definition (e.g. "instructions").
21#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
22#[ts(export, export_to = "../../../frontend/types/rpc/")]
23pub struct AgentContent {
24    pub agent_id: String,
25    pub content_type: String,
26    pub content: String,
27    #[ts(type = "number")]
28    pub updated_at: i64,
29}
30
31impl Store {
32    pub fn agent_content_get(
33        &self,
34        agent_id: &str,
35        content_type: &str,
36    ) -> Result<Option<AgentContent>, StoreError> {
37        let conn = self.conn.lock().unwrap();
38        let mut stmt = conn.prepare(
39            "SELECT agent_id, content_type, content, updated_at
40             FROM db_agent_content WHERE agent_id=?1 AND content_type=?2",
41        )?;
42        let local = {
43            let result = stmt.query_row(params![agent_id, content_type], |row| {
44                Ok(AgentContent {
45                    agent_id: row.get(0)?,
46                    content_type: row.get(1)?,
47                    content: row.get(2)?,
48                    updated_at: row.get(3)?,
49                })
50            });
51            match result {
52                Ok(content) => Some(content),
53                Err(rusqlite::Error::QueryReturnedNoRows) => None,
54                Err(e) => return Err(StoreError::Sqlite(e)),
55            }
56        };
57        drop(stmt);
58        drop(conn);
59        if local.is_some() {
60            return Ok(local);
61        }
62        // Cross-channel fallback (same gate as agent_content_get_all): only for
63        // an agent absent from local SQLite. Launch reads `env` via this single
64        // path, so without it a cross-channel agent would launch missing its
65        // env vars. (codex P2 on #1385.)
66        if self.agent_def_exists_local(agent_id)? {
67            return Ok(local);
68        }
69        if let Some(reg) = self.shared_def_registry() {
70            if let Ok(Some(rec)) = reg.get(agent_id) {
71                if let Some(c) = rec
72                    .data
73                    .content
74                    .iter()
75                    .find(|c| c.content_type == content_type)
76                {
77                    return Ok(Some(AgentContent {
78                        agent_id: agent_id.to_string(),
79                        content_type: c.content_type.clone(),
80                        content: c.content.clone(),
81                        updated_at: rec.data.updated_at,
82                    }));
83                }
84            }
85        }
86        Ok(local)
87    }
88
89    /// Upsert a content blob for an agent.
90    pub fn agent_content_set(&self, content: &AgentContent) -> Result<(), StoreError> {
91        // Cross-channel agents have no local `db_agents` row; the FK
92        // constraint on `db_agent_content` would reject the INSERT. Route those
93        // writes directly to the global registry instead so content (e.g. ui:zoom)
94        // persists and is readable via the cross-channel fallback in
95        // `agent_content_get`. (#1700 zoom persistence P0.)
96        //
97        // `db_agent_content`'s FK targets `db_agents`, not
98        // `db_agent_definitions`, as of Phase 3c (#3088) — checking the
99        // latter here would misclassify any agent that exists ONLY as a
100        // `db_agents` row (a template launch) as "not local" and silently
101        // reroute its content writes to the global registry instead of the
102        // local table the FK actually requires.
103        let is_local = {
104            let conn = self.conn.lock().unwrap();
105            conn.query_row(
106                "SELECT EXISTS(SELECT 1 FROM db_agents WHERE id = ?1)",
107                params![&content.agent_id],
108                |row| row.get::<_, bool>(0),
109            )
110            .unwrap_or(false)
111        };
112        if !is_local {
113            return self.registry_def_update_content_field(
114                &content.agent_id,
115                &content.content_type,
116                Some(&content.content),
117            );
118        }
119        {
120            let conn = self.conn.lock().unwrap();
121            conn.execute(
122                "INSERT INTO db_agent_content (agent_id, content_type, content, updated_at)
123                 VALUES (?1, ?2, ?3, ?4)
124                 ON CONFLICT(agent_id, content_type) DO UPDATE SET content=?3, updated_at=?4",
125                params![
126                    content.agent_id,
127                    content.content_type,
128                    content.content,
129                    content.updated_at,
130                ],
131            )?;
132        }
133        // conn dropped — re-mirror the definition so the global cross-channel
134        // record carries the updated content, but only if one already
135        // exists (no-op for a seeded template, and — as of the definition
136        // flip, reagent P1 on #3092 — no-op for a bare template-launch row
137        // too, which must never be mirrored for the first time by a
138        // routine content write; see registry_def_refresh_if_mirrored's
139        // doc comment). (P0.2b.)
140        self.registry_def_refresh_if_mirrored(&content.agent_id);
141        Ok(())
142    }
143
144    /// Get all content blobs for an agent.
145    /// LOCAL channel's content blobs only — NO cross-channel fallback. Used
146    /// by the def-registry mirror (which always operates on a local agent, so
147    /// must never read the global record) and by `agent_content_get_all`.
148    pub(super) fn agent_content_get_all_local(
149        &self,
150        agent_id: &str,
151    ) -> Result<Vec<AgentContent>, StoreError> {
152        let conn = self.conn.lock().unwrap();
153        let mut stmt = conn.prepare(
154            "SELECT agent_id, content_type, content, updated_at
155             FROM db_agent_content WHERE agent_id=?1 ORDER BY content_type ASC",
156        )?;
157        let rows = stmt.query_map(params![agent_id], |row| {
158            Ok(AgentContent {
159                agent_id: row.get(0)?,
160                content_type: row.get(1)?,
161                content: row.get(2)?,
162                updated_at: row.get(3)?,
163            })
164        })?;
165        let mut contents = Vec::new();
166        for row in rows {
167            contents.push(row?);
168        }
169        Ok(contents)
170    }
171
172    pub fn agent_content_get_all(
173        &self,
174        agent_id: &str,
175    ) -> Result<Vec<AgentContent>, StoreError> {
176        let local = self.agent_content_get_all_local(agent_id)?;
177        if !local.is_empty() {
178            return Ok(local);
179        }
180        // Local is empty. Fall back to the global record ONLY for a
181        // cross-channel agent (one absent from local SQLite). A locally-known
182        // agent with genuinely empty content must return empty — otherwise
183        // deleting its content would resurrect it from the global record.
184        // (reagent P1 on #1385.)
185        if self.agent_def_exists_local(agent_id)? {
186            return Ok(local);
187        }
188        if let Some(reg) = self.shared_def_registry() {
189            if let Ok(Some(rec)) = reg.get(agent_id) {
190                return Ok(rec
191                    .data
192                    .content
193                    .iter()
194                    .map(|c| AgentContent {
195                        agent_id: agent_id.to_string(),
196                        content_type: c.content_type.clone(),
197                        content: c.content.clone(),
198                        updated_at: rec.data.updated_at,
199                    })
200                    .collect());
201            }
202        }
203        Ok(local)
204    }
205
206    /// Delete a specific content blob. Returns true if a row was deleted.
207    pub fn agent_content_delete(
208        &self,
209        agent_id: &str,
210        content_type: &str,
211    ) -> Result<bool, StoreError> {
212        // Cross-channel agents: remove the field from the global registry
213        // record directly; there is no local SQLite row to delete. (#1700.)
214        // Checks `db_agents` — see `agent_content_set`'s comment above.
215        let is_local = {
216            let conn = self.conn.lock().unwrap();
217            conn.query_row(
218                "SELECT EXISTS(SELECT 1 FROM db_agents WHERE id = ?1)",
219                params![agent_id],
220                |row| row.get::<_, bool>(0),
221            )
222            .unwrap_or(false)
223        };
224        if !is_local {
225            return self
226                .registry_def_update_content_field(agent_id, content_type, None)
227                .map(|_| true)
228                .or_else(|e| {
229                    tracing::debug!(
230                        agent_id, content_type, error = %e,
231                        "[content] cross-channel delete: field absent or registry unavailable"
232                    );
233                    Ok(false)
234                });
235        }
236        let rows = {
237            let conn = self.conn.lock().unwrap();
238            conn.execute(
239                "DELETE FROM db_agent_content WHERE agent_id=?1 AND content_type=?2",
240                params![agent_id, content_type],
241            )?
242        };
243        // conn dropped — re-mirror the definition so the global cross-channel
244        // record drops the just-deleted content key too. Symmetric with
245        // `agent_content_set`: without this a reset-to-default (e.g. ui:zoom)
246        // clears the local row but leaves the stale value in the shared
247        // def-registry, which a cross-channel/other-instance reopen would
248        // resurrect via the `agent_content_get` registry fallback. Only
249        // refreshes an EXISTING mirror — see `agent_content_set`'s call
250        // just above for why. (P0.2b.)
251        self.registry_def_refresh_if_mirrored(agent_id);
252        Ok(rows > 0)
253    }
254}