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}