agentmux_srv/backend/storage/
agent_native_memory.rs1use rusqlite::params;
16
17use super::error::StoreError;
18use super::store::Store;
19
20#[derive(Debug, Clone, Default, PartialEq)]
21pub struct NativeMemoryMirrorRow {
22 pub filename: String,
23 pub content: String,
24 pub metadata_type: Option<String>,
25 pub size_bytes: i64,
26 pub updated_at: i64,
27 pub last_seen_path: String,
28 pub last_seen_mtime_ms: i64,
38}
39
40use agentmux_common::time::now_ms;
41
42#[derive(Debug, Clone)]
47pub struct NativeMemoryMirrorRowWithAgent {
48 pub agent_id: String,
49 pub filename: String,
50 pub content: String,
51}
52
53impl Store {
54 pub fn agent_native_memory_list_all_rows(&self) -> Result<Vec<NativeMemoryMirrorRowWithAgent>, StoreError> {
60 let conn = self.conn.lock().unwrap();
61 let mut stmt = conn.prepare(
62 "SELECT agent_id, filename, content FROM db_agent_native_memory",
63 )?;
64 let rows = stmt
65 .query_map(params![], |row| {
66 Ok(NativeMemoryMirrorRowWithAgent {
67 agent_id: row.get(0)?,
68 filename: row.get(1)?,
69 content: row.get(2)?,
70 })
71 })?
72 .collect::<Result<Vec<_>, _>>()?;
73 Ok(rows)
74 }
75
76 pub fn agent_native_memory_list_meta(
80 &self,
81 agent_id: &str,
82 ) -> Result<Vec<NativeMemoryMirrorRow>, StoreError> {
83 let conn = self.conn.lock().unwrap();
84 let mut stmt = conn.prepare(
85 "SELECT filename, '', metadata_type, size_bytes, updated_at, last_seen_path, last_seen_mtime_ms
86 FROM db_agent_native_memory WHERE agent_id = ?1",
87 )?;
88 let rows = stmt
89 .query_map(params![agent_id], |row| {
90 let metadata_type: String = row.get(2)?;
91 Ok(NativeMemoryMirrorRow {
92 filename: row.get(0)?,
93 content: row.get(1)?,
94 metadata_type: if metadata_type.is_empty() { None } else { Some(metadata_type) },
95 size_bytes: row.get(3)?,
96 updated_at: row.get(4)?,
97 last_seen_path: row.get(5)?,
98 last_seen_mtime_ms: row.get(6)?,
99 })
100 })?
101 .collect::<Result<Vec<_>, _>>()?;
102 Ok(rows)
103 }
104
105 pub fn agent_native_memory_read(
109 &self,
110 agent_id: &str,
111 filename: &str,
112 ) -> Result<Option<String>, StoreError> {
113 let conn = self.conn.lock().unwrap();
114 let mut stmt = conn.prepare(
115 "SELECT content FROM db_agent_native_memory WHERE agent_id = ?1 AND filename = ?2",
116 )?;
117 match stmt.query_row(params![agent_id, filename], |row| row.get::<_, String>(0)) {
118 Ok(content) => Ok(Some(content)),
119 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
120 Err(e) => Err(e.into()),
121 }
122 }
123
124 pub fn agent_native_memory_upsert(
144 &self,
145 agent_id: &str,
146 filename: &str,
147 content: &str,
148 metadata_type: Option<&str>,
149 last_seen_path: &str,
150 size_bytes: i64,
151 mtime_ms: i64,
152 ) -> Result<(), StoreError> {
153 let conn = self.conn.lock().unwrap();
154 conn.execute(
155 "INSERT INTO db_agent_native_memory
156 (agent_id, filename, content, metadata_type, size_bytes, updated_at, last_seen_path, last_seen_mtime_ms)
157 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
158 ON CONFLICT(agent_id, filename) DO UPDATE SET
159 content = excluded.content,
160 metadata_type = excluded.metadata_type,
161 size_bytes = excluded.size_bytes,
162 updated_at = excluded.updated_at,
163 last_seen_path = excluded.last_seen_path,
164 last_seen_mtime_ms = excluded.last_seen_mtime_ms",
165 params![
166 agent_id,
167 filename,
168 content,
169 metadata_type.unwrap_or(""),
170 size_bytes,
171 now_ms(),
172 last_seen_path,
173 mtime_ms,
174 ],
175 )?;
176 Ok(())
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 fn shared_store() -> Store {
185 Store::open_shared(std::path::Path::new(":memory:")).unwrap()
186 }
187
188 #[test]
189 fn upsert_then_read_round_trips() {
190 let store = shared_store();
191 store
192 .agent_native_memory_upsert("agent-1", "MEMORY.md", "# hello", Some("user"), "/tmp/memory/MEMORY.md", 7, 1000)
193 .unwrap();
194
195 let content = store.agent_native_memory_read("agent-1", "MEMORY.md").unwrap();
196 assert_eq!(content, Some("# hello".to_string()));
197 }
198
199 #[test]
200 fn read_returns_none_for_an_unmirrored_file() {
201 let store = shared_store();
202 assert_eq!(store.agent_native_memory_read("agent-1", "MEMORY.md").unwrap(), None);
203 }
204
205 #[test]
206 fn upsert_is_idempotent_and_updates_in_place() {
207 let store = shared_store();
208 store
209 .agent_native_memory_upsert("agent-1", "MEMORY.md", "v1", None, "/a", 2, 1000)
210 .unwrap();
211 store
212 .agent_native_memory_upsert("agent-1", "MEMORY.md", "v2", Some("user"), "/b", 2, 2000)
213 .unwrap();
214
215 let content = store.agent_native_memory_read("agent-1", "MEMORY.md").unwrap();
216 assert_eq!(content, Some("v2".to_string()));
217
218 let meta = store.agent_native_memory_list_meta("agent-1").unwrap();
219 assert_eq!(meta.len(), 1);
220 assert_eq!(meta[0].metadata_type, Some("user".to_string()));
221 assert_eq!(meta[0].last_seen_path, "/b");
222 assert_eq!(meta[0].size_bytes, 2);
223 assert_eq!(meta[0].last_seen_mtime_ms, 2000);
224 }
225
226 #[test]
227 fn upsert_stores_the_caller_supplied_size_not_the_content_length() {
228 let store = shared_store();
236 store
237 .agent_native_memory_upsert("agent-1", "MEMORY.md", "truncated...", None, "/a", 50_000_000, 1000)
238 .unwrap();
239
240 let meta = store.agent_native_memory_list_meta("agent-1").unwrap();
241 assert_eq!(meta[0].size_bytes, 50_000_000, "size_bytes must be the caller-supplied real size, not content.len()");
242 }
243
244 #[test]
245 fn list_meta_scopes_to_the_requested_agent_only() {
246 let store = shared_store();
247 store.agent_native_memory_upsert("agent-1", "a.md", "x", None, "/a", 1, 1000).unwrap();
248 store.agent_native_memory_upsert("agent-2", "b.md", "y", None, "/b", 1, 1000).unwrap();
249
250 let meta = store.agent_native_memory_list_meta("agent-1").unwrap();
251 assert_eq!(meta.len(), 1);
252 assert_eq!(meta[0].filename, "a.md");
253 }
254
255 #[test]
256 fn list_meta_omits_content_by_design() {
257 let store = shared_store();
261 store.agent_native_memory_upsert("agent-1", "a.md", "big content", None, "/a", 11, 1000).unwrap();
262 let meta = store.agent_native_memory_list_meta("agent-1").unwrap();
263 assert_eq!(meta[0].content, "");
264 }
265
266 #[test]
267 fn a_file_deleted_from_the_live_fs_after_being_mirrored_once_stays_visible() {
268 let store = shared_store();
272 store.agent_native_memory_upsert("agent-1", "MEMORY.md", "content", None, "/gone", 7, 1000).unwrap();
273
274 let meta = store.agent_native_memory_list_meta("agent-1").unwrap();
275 assert_eq!(meta.len(), 1);
276 let content = store.agent_native_memory_read("agent-1", "MEMORY.md").unwrap();
277 assert_eq!(content, Some("content".to_string()));
278 }
279}