Skip to main content

agentmux_srv/migrations/
m0023_native_memory_versions_backfill.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Backfill `db_agent_native_memory_versions` with one `source:
5//! "agent_inferred"` version per existing `db_agent_native_memory` row —
6//! reagent P1 (re-review of PR #2674): the spec this migration completes
7//! (`docs/specs/SPEC_MEMORY_VERSION_CONTROL_AND_ARMORY_AUDIT_2026_08_19.md`
8//! §9 Rollout) explicitly calls for this backfill so history views aren't
9//! empty for pre-existing memory on upgrade. Without it, an existing
10//! user's first post-upgrade overwrite of a memory file records only the
11//! NEW content as version 1 — the prior live content (everything that
12//! file held before this feature shipped) is permanently unrecoverable
13//! via `MemoryRevert`/`MemoryDiff`.
14//!
15//! Global scope: `db_agent_native_memory` (source) and
16//! `db_agent_native_memory_versions` (destination) both live in the
17//! shared store (`ctx.shared_store_path`) — the same store `id_store`
18//! resolves to at runtime, and the only one every native-memory RPC
19//! handler actually reads/writes. The per-channel `objects.db` copies of
20//! both tables exist only as `id_store`'s own pre-`0011_shared_store_backfill`
21//! fallback (see each table's own `CREATE TABLE` doc comment in
22//! `migrations.rs`) — on any install that has already passed that
23//! migration (a precondition of this one even existing, since v6/v8 of
24//! the shared schema postdate it), they're not the live data path, so
25//! this migration doesn't scan them.
26//!
27//! Idempotent: only backfills a `(agent_id, filename)` pair that has NO
28//! existing version yet. This matters for correctness, not just
29//! re-run-safety — a pair that already has one or more versions (a normal
30//! write happened after this feature shipped but before this migration
31//! ran) must NOT get a backfilled version inserted now, because it would
32//! land at the END of that pair's chain (this migration runs after normal
33//! startup writes are already possible) with content that's actually
34//! OLDER than what's already recorded, corrupting the chain's chronology.
35
36use std::sync::Arc;
37
38use crate::backend::storage::store::Store;
39
40use super::{Migration, MigrationContext, MigrationError, MigrationScope};
41
42pub struct M0023NativeMemoryVersionsBackfill;
43
44impl Migration for M0023NativeMemoryVersionsBackfill {
45    fn id(&self) -> &'static str { "0023_native_memory_versions_backfill" }
46    fn scope(&self) -> MigrationScope { MigrationScope::Global }
47    fn description(&self) -> &'static str {
48        "Backfill one agent_inferred version per existing native memory file"
49    }
50
51    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
52        if !ctx.shared_store_path.exists() {
53            return Ok(());
54        }
55        let store = Arc::new(
56            Store::open_shared(&ctx.shared_store_path)
57                .map_err(|e| MigrationError(format!("native_memory_versions_backfill: open shared store: {e}")))?,
58        );
59
60        let rows = store
61            .agent_native_memory_list_all_rows()
62            .map_err(|e| MigrationError(format!("native_memory_versions_backfill: list rows: {e}")))?;
63
64        let mut backfilled = 0usize;
65        let mut skipped_already_versioned = 0usize;
66        for row in rows {
67            let existing = store
68                .agent_native_memory_version_latest(&row.agent_id, &row.filename)
69                .map_err(|e| {
70                    MigrationError(format!(
71                        "native_memory_versions_backfill: check existing for {}/{}: {e}",
72                        row.agent_id, row.filename
73                    ))
74                })?;
75            if existing.is_some() {
76                skipped_already_versioned += 1;
77                continue;
78            }
79            store
80                .agent_native_memory_version_insert(
81                    &row.agent_id,
82                    &row.filename,
83                    &row.content,
84                    "agent_inferred",
85                    "{}",
86                    "",
87                )
88                .map_err(|e| {
89                    MigrationError(format!(
90                        "native_memory_versions_backfill: insert for {}/{}: {e}",
91                        row.agent_id, row.filename
92                    ))
93                })?;
94            backfilled += 1;
95        }
96
97        tracing::info!(
98            backfilled,
99            skipped_already_versioned,
100            "native_memory_versions_backfill: complete"
101        );
102        Ok(())
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn ctx_for(shared_store_path: std::path::PathBuf) -> MigrationContext {
111        MigrationContext {
112            home: std::env::temp_dir(),
113            data_dir: std::env::temp_dir(),
114            shared_store_path,
115            channel_store_path: std::env::temp_dir().join("unused-objects.db"),
116        }
117    }
118
119    #[test]
120    fn backfills_a_version_for_an_existing_mirror_row() {
121        let tmp = tempfile::NamedTempFile::new().unwrap();
122        let store = Store::open_shared(tmp.path()).unwrap();
123        store
124            .agent_native_memory_upsert("agent-1", "MEMORY.md", "pre-upgrade content", None, "/some/path", 20, 1000)
125            .unwrap();
126
127        M0023NativeMemoryVersionsBackfill.up(&ctx_for(tmp.path().to_path_buf())).unwrap();
128
129        let versions = store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap();
130        assert_eq!(versions.len(), 1);
131        assert_eq!(versions[0].source, "agent_inferred");
132
133        let latest = store.agent_native_memory_version_latest("agent-1", "MEMORY.md").unwrap().unwrap();
134        assert_eq!(latest.content, "pre-upgrade content");
135    }
136
137    #[test]
138    fn skips_a_pair_that_already_has_a_version() {
139        let tmp = tempfile::NamedTempFile::new().unwrap();
140        let store = Store::open_shared(tmp.path()).unwrap();
141        store
142            .agent_native_memory_upsert("agent-1", "MEMORY.md", "mirror content", None, "/some/path", 14, 1000)
143            .unwrap();
144        // Simulates a normal write that already happened (post-upgrade,
145        // pre-migration) — this pair must NOT get a backfilled version
146        // appended after it.
147        store
148            .agent_native_memory_version_insert("agent-1", "MEMORY.md", "already-recorded content", "human", "{}", "")
149            .unwrap();
150
151        M0023NativeMemoryVersionsBackfill.up(&ctx_for(tmp.path().to_path_buf())).unwrap();
152
153        let versions = store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap();
154        assert_eq!(versions.len(), 1, "must not append a backfilled version onto an already-versioned pair");
155    }
156
157    #[test]
158    fn is_idempotent_on_a_second_run() {
159        let tmp = tempfile::NamedTempFile::new().unwrap();
160        let store = Store::open_shared(tmp.path()).unwrap();
161        store
162            .agent_native_memory_upsert("agent-1", "MEMORY.md", "content", None, "/some/path", 7, 1000)
163            .unwrap();
164
165        M0023NativeMemoryVersionsBackfill.up(&ctx_for(tmp.path().to_path_buf())).unwrap();
166        M0023NativeMemoryVersionsBackfill.up(&ctx_for(tmp.path().to_path_buf())).unwrap();
167
168        let versions = store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap();
169        assert_eq!(versions.len(), 1, "second run must not duplicate the backfilled version");
170    }
171
172    #[test]
173    fn backfills_every_agent_and_file_independently() {
174        let tmp = tempfile::NamedTempFile::new().unwrap();
175        let store = Store::open_shared(tmp.path()).unwrap();
176        store.agent_native_memory_upsert("agent-1", "MEMORY.md", "a1", None, "/p", 2, 1000).unwrap();
177        store.agent_native_memory_upsert("agent-1", "topic.md", "a2", None, "/p", 2, 1000).unwrap();
178        store.agent_native_memory_upsert("agent-2", "MEMORY.md", "b1", None, "/p", 2, 1000).unwrap();
179
180        M0023NativeMemoryVersionsBackfill.up(&ctx_for(tmp.path().to_path_buf())).unwrap();
181
182        assert_eq!(store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap().len(), 1);
183        assert_eq!(store.agent_native_memory_version_list("agent-1", "topic.md").unwrap().len(), 1);
184        assert_eq!(store.agent_native_memory_version_list("agent-2", "MEMORY.md").unwrap().len(), 1);
185    }
186
187    #[test]
188    fn missing_shared_store_is_a_noop() {
189        let ctx = ctx_for(std::path::Path::new("Z:/does/not/exist/store.db").to_path_buf());
190        M0023NativeMemoryVersionsBackfill.up(&ctx).unwrap();
191    }
192}