Skip to main content

agentmux_srv/backend/storage/
agent_native_memory.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Durable, location-consistent mirror of each agent's native (Claude Code)
5//! memory files. See db_agent_native_memory in migrations.rs
6//! (SHARED_STORE_SCHEMA_VERSION v6 / OBJECT_SCHEMA_VERSION v14) and
7//! docs/specs/SPEC_NATIVE_MEMORY_DURABLE_SYNC_2026_08_07.md for the full
8//! design.
9//!
10//! Keyed by (agent_id, filename), where agent_id is the stable
11//! `AgentDefinition.id` — NOT any live filesystem path, which is
12//! channel-relative by design and therefore not the same across
13//! channels/instances for the same logical agent.
14
15use 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    /// The live file's own on-disk mtime (ms epoch) as of the last upsert —
29    /// NOT `updated_at` (this row's own write time). Callers use this
30    /// together with `size_bytes` to detect whether a live file has
31    /// actually changed since it was last mirrored, without re-reading its
32    /// full content: size alone under-detects a same-byte-length edit
33    /// (reagent P1 on PR #2459 — a same-size edit made only on channel A,
34    /// never re-read there via `read_file`, would otherwise serve stale
35    /// content to channel B forever, since B has no live copy of its own to
36    /// self-correct with).
37    pub last_seen_mtime_ms: i64,
38}
39
40use agentmux_common::time::now_ms;
41
42/// One `db_agent_native_memory` row, with its `agent_id` — the shape
43/// `agent_native_memory_list_all_rows` returns for the (not agent-scoped)
44/// full-table scan a one-time migration needs; every other reader in this
45/// module is scoped to a single `agent_id` and doesn't need this.
46#[derive(Debug, Clone)]
47pub struct NativeMemoryMirrorRowWithAgent {
48    pub agent_id: String,
49    pub filename: String,
50    pub content: String,
51}
52
53impl Store {
54    /// List every `db_agent_native_memory` row across every agent, content
55    /// included — used only by the one-time version-history backfill
56    /// migration (`migrations::m0023_native_memory_versions_backfill`).
57    /// Not for any live RPC path (those are always scoped to one
58    /// `agent_id`, and this loads full content for every row at once).
59    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    /// List every mirrored file for `agent_id` (no content — each row's
77    /// `content` is empty, so a list view never loads full file bodies;
78    /// `agent_native_memory_read` fetches one file's body).
79    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    /// Read one mirrored file's content by `(agent_id, filename)`. Used as
106    /// the read-path fallback when the file is missing from the live FS
107    /// (e.g. a different channel wrote it, or the live folder was wiped).
108    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    /// Upsert a file's current live-FS content into the mirror. Called from
125    /// every `agent:memory:list`/`read_file`/`write_file` RPC on every file
126    /// it touches — cheap (one local SQLite write already on the request),
127    /// and is what makes native memory durable across a channel switch.
128    ///
129    /// `size_bytes` is the file's REAL on-disk size, not necessarily
130    /// `content.len()` — `content` may be truncated at the RPC layer's
131    /// `MAX_MEMORY_FILE_BYTES` cap for a file Claude Code wrote directly
132    /// (bypassing `write_file`'s own size guard). Storing the real size (not
133    /// the capped one) is what lets `agent:memory:list`'s change-detection
134    /// via `(size_bytes, last_seen_mtime_ms)` still work correctly for an
135    /// oversized file — comparing against a capped mirror size would never
136    /// match the live (larger) size, forcing a full re-read + rewrite on
137    /// every single list() call forever (reagent P1 on PR #2459, fourth pass).
138    ///
139    /// `mtime_ms` is the live file's own on-disk modified time (0 if
140    /// unavailable, e.g. a `write_file` call that hasn't re-stat'd the file
141    /// it just wrote) — stored as `last_seen_mtime_ms` for the same
142    /// change-detection purpose.
143    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        // reagent P1 on PR #2459 (fourth pass): for a file exceeding the RPC
229        // layer's MAX_MEMORY_FILE_BYTES cap, `content` is truncated but the
230        // real on-disk size is larger — callers pass that real size
231        // explicitly so a later comparison against the live file's actual
232        // size still matches. Deriving size_bytes from content.len() instead
233        // would make an oversized file's mirror row NEVER match the live
234        // size, forcing a full re-read on every single list() call forever.
235        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        // list_meta is the list-view path — it must not carry full file
258        // bodies (that's what agent_native_memory_read is for), so a large
259        // mirrored file doesn't get loaded into memory just to render a list.
260        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        // Simulates SPEC_NATIVE_MEMORY_DURABLE_SYNC_2026_08_07.md §5(c): the
269        // mirror is the only remaining copy once the live FS file is gone —
270        // list/read must still return it from here.
271        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}