1use rusqlite::params;
20use sha2::{Digest, Sha256};
21
22use super::error::StoreError;
23use super::store::Store;
24
25#[derive(Debug, Clone, PartialEq)]
27pub struct NativeMemoryVersion {
28 pub id: String,
29 pub agent_id: String,
30 pub filename: String,
31 pub content: String,
32 pub content_hash: String,
33 pub parent_version_id: Option<String>,
36 pub source: String,
41 pub source_detail: String,
44 pub session_id: String,
45 pub created_at: i64,
46}
47
48#[derive(Debug, Clone, PartialEq)]
53pub struct NativeMemoryVersionSummary {
54 pub id: String,
55 pub content_hash: String,
56 pub parent_version_id: Option<String>,
57 pub source: String,
58 pub source_detail: String,
59 pub session_id: String,
60 pub created_at: i64,
61}
62
63use agentmux_common::time::now_ms;
64
65pub(crate) fn content_hash(content: &str) -> String {
70 let mut hasher = Sha256::new();
71 hasher.update(content.as_bytes());
72 hex::encode(hasher.finalize())
73}
74
75impl Store {
76 pub fn agent_native_memory_version_insert(
106 &self,
107 agent_id: &str,
108 filename: &str,
109 content: &str,
110 source: &str,
111 source_detail: &str,
112 session_id: &str,
113 ) -> Result<NativeMemoryVersion, StoreError> {
114 let id = uuid::Uuid::new_v4().to_string();
115 let hash = content_hash(content);
116 let created_at = now_ms();
117
118 let mut conn = self.conn.lock().unwrap();
119 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
120 let parent_version_id: Option<String> = {
121 let mut stmt = tx.prepare(
122 "SELECT id FROM db_agent_native_memory_versions
123 WHERE agent_id = ?1 AND filename = ?2
124 ORDER BY created_at DESC, rowid DESC
125 LIMIT 1",
126 )?;
127 match stmt.query_row(params![agent_id, filename], |row| row.get::<_, String>(0)) {
128 Ok(id) => Some(id),
129 Err(rusqlite::Error::QueryReturnedNoRows) => None,
130 Err(e) => return Err(e.into()),
131 }
132 };
133 tx.execute(
134 "INSERT INTO db_agent_native_memory_versions
135 (id, agent_id, filename, content, content_hash, parent_version_id, source, source_detail, session_id, created_at)
136 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
137 params![
138 id,
139 agent_id,
140 filename,
141 content,
142 hash,
143 parent_version_id,
144 source,
145 source_detail,
146 session_id,
147 created_at,
148 ],
149 )?;
150 tx.commit()?;
151
152 Ok(NativeMemoryVersion {
153 id,
154 agent_id: agent_id.to_string(),
155 filename: filename.to_string(),
156 content: content.to_string(),
157 content_hash: hash,
158 parent_version_id,
159 source: source.to_string(),
160 source_detail: source_detail.to_string(),
161 session_id: session_id.to_string(),
162 created_at,
163 })
164 }
165
166 pub fn agent_native_memory_version_insert_if_changed(
193 &self,
194 agent_id: &str,
195 filename: &str,
196 content: &str,
197 source: &str,
198 source_detail: &str,
199 session_id: &str,
200 ) -> Result<Option<NativeMemoryVersion>, StoreError> {
201 let hash = content_hash(content);
202 let mut conn = self.conn.lock().unwrap();
203 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
204
205 let latest: Option<(String, String)> = {
206 let mut stmt = tx.prepare(
207 "SELECT id, content_hash FROM db_agent_native_memory_versions
208 WHERE agent_id = ?1 AND filename = ?2
209 ORDER BY created_at DESC, rowid DESC
210 LIMIT 1",
211 )?;
212 match stmt.query_row(params![agent_id, filename], |row| {
213 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
214 }) {
215 Ok(v) => Some(v),
216 Err(rusqlite::Error::QueryReturnedNoRows) => None,
217 Err(e) => return Err(e.into()),
218 }
219 };
220
221 if let Some((_, latest_hash)) = &latest {
222 if *latest_hash == hash {
223 return Ok(None);
224 }
225 }
226
227 let id = uuid::Uuid::new_v4().to_string();
228 let parent_version_id = latest.map(|(id, _)| id);
229 let created_at = now_ms();
230 tx.execute(
231 "INSERT INTO db_agent_native_memory_versions
232 (id, agent_id, filename, content, content_hash, parent_version_id, source, source_detail, session_id, created_at)
233 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
234 params![
235 id,
236 agent_id,
237 filename,
238 content,
239 hash,
240 parent_version_id,
241 source,
242 source_detail,
243 session_id,
244 created_at,
245 ],
246 )?;
247 tx.commit()?;
248
249 Ok(Some(NativeMemoryVersion {
250 id,
251 agent_id: agent_id.to_string(),
252 filename: filename.to_string(),
253 content: content.to_string(),
254 content_hash: hash,
255 parent_version_id,
256 source: source.to_string(),
257 source_detail: source_detail.to_string(),
258 session_id: session_id.to_string(),
259 created_at,
260 }))
261 }
262
263 pub fn agent_native_memory_version_list(
266 &self,
267 agent_id: &str,
268 filename: &str,
269 ) -> Result<Vec<NativeMemoryVersionSummary>, StoreError> {
270 let conn = self.conn.lock().unwrap();
271 let mut stmt = conn.prepare(
272 "SELECT id, content_hash, parent_version_id, source, source_detail, session_id, created_at
273 FROM db_agent_native_memory_versions
274 WHERE agent_id = ?1 AND filename = ?2
275 ORDER BY created_at DESC, rowid DESC",
276 )?;
277 let rows = stmt
278 .query_map(params![agent_id, filename], |row| {
279 Ok(NativeMemoryVersionSummary {
280 id: row.get(0)?,
281 content_hash: row.get(1)?,
282 parent_version_id: row.get(2)?,
283 source: row.get(3)?,
284 source_detail: row.get(4)?,
285 session_id: row.get(5)?,
286 created_at: row.get(6)?,
287 })
288 })?
289 .collect::<Result<Vec<_>, _>>()?;
290 Ok(rows)
291 }
292
293 pub fn agent_native_memory_version_latest(
299 &self,
300 agent_id: &str,
301 filename: &str,
302 ) -> Result<Option<NativeMemoryVersion>, StoreError> {
303 let conn = self.conn.lock().unwrap();
304 let mut stmt = conn.prepare(
305 "SELECT id, content, content_hash, parent_version_id, source, source_detail, session_id, created_at
306 FROM db_agent_native_memory_versions
307 WHERE agent_id = ?1 AND filename = ?2
308 ORDER BY created_at DESC, rowid DESC
309 LIMIT 1",
310 )?;
311 let agent_id_owned = agent_id.to_string();
312 let filename_owned = filename.to_string();
313 match stmt.query_row(params![agent_id, filename], |row| {
314 Ok(NativeMemoryVersion {
315 id: row.get(0)?,
316 agent_id: agent_id_owned.clone(),
317 filename: filename_owned.clone(),
318 content: row.get(1)?,
319 content_hash: row.get(2)?,
320 parent_version_id: row.get(3)?,
321 source: row.get(4)?,
322 source_detail: row.get(5)?,
323 session_id: row.get(6)?,
324 created_at: row.get(7)?,
325 })
326 }) {
327 Ok(v) => Ok(Some(v)),
328 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
329 Err(e) => Err(e.into()),
330 }
331 }
332
333 pub fn agent_native_memory_versions_for_agent(&self, agent_id: &str) -> Result<Vec<NativeMemoryVersion>, StoreError> {
337 let conn = self.conn.lock().unwrap();
338 let mut stmt = conn.prepare(
339 "SELECT id, filename, content, content_hash, parent_version_id, source, source_detail, session_id, created_at
340 FROM db_agent_native_memory_versions
341 WHERE agent_id = ?1
342 ORDER BY created_at ASC, rowid ASC",
343 )?;
344 let rows = stmt
345 .query_map(params![agent_id], |row| {
346 Ok(NativeMemoryVersion {
347 id: row.get(0)?,
348 agent_id: agent_id.to_string(),
349 filename: row.get(1)?,
350 content: row.get(2)?,
351 content_hash: row.get(3)?,
352 parent_version_id: row.get(4)?,
353 source: row.get(5)?,
354 source_detail: row.get(6)?,
355 session_id: row.get(7)?,
356 created_at: row.get(8)?,
357 })
358 })?
359 .collect::<Result<Vec<_>, _>>()?;
360 Ok(rows)
361 }
362
363 pub fn agent_native_memory_version_list_distinct_files(&self) -> Result<Vec<(String, String)>, StoreError> {
368 let conn = self.conn.lock().unwrap();
369 let mut stmt = conn.prepare("SELECT DISTINCT agent_id, filename FROM db_agent_native_memory_versions")?;
370 let rows = stmt
371 .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))?
372 .collect::<Result<Vec<_>, _>>()?;
373 Ok(rows)
374 }
375
376 pub fn agent_native_memory_version_relabel_rollout_first_sight(
437 &self,
438 ) -> Result<usize, StoreError> {
439 const ROLLOUT_WINDOW_MS: i64 = 60 * 1000;
445 let conn = self.conn.lock().unwrap();
446 let updated = conn.execute(
449 "UPDATE db_agent_native_memory_versions
450 SET source = 'agent_inferred',
451 source_detail = json_object(
452 'relabeled_from', 'external_fs_write',
453 'detected_via', json_extract(source_detail, '$.detected_via'),
454 'reason', 'rollout_first_sight'
455 )
456 WHERE source = 'external_fs_write'
457 AND json_extract(source_detail, '$.detected_via') = 'reconciliation_sweep'
458 AND parent_version_id IS NULL
459 AND created_at >= (
460 SELECT strftime('%s', applied_at) * 1000 FROM db_migrations
461 WHERE id = '0023_native_memory_versions_backfill')
462 AND created_at <= (
463 SELECT strftime('%s', applied_at) * 1000 + ?1 FROM db_migrations
464 WHERE id = '0023_native_memory_versions_backfill')",
465 params![ROLLOUT_WINDOW_MS],
466 )?;
467 Ok(updated)
468 }
469
470 pub fn agent_native_memory_version_prune(
471 &self,
472 agent_id: &str,
473 filename: &str,
474 min_keep: u32,
475 max_age_ms: i64,
476 ) -> Result<usize, StoreError> {
477 let cutoff = now_ms() - max_age_ms;
478 let conn = self.conn.lock().unwrap();
479 let deleted = conn.execute(
480 "DELETE FROM db_agent_native_memory_versions
481 WHERE id IN (
482 SELECT id FROM (
483 SELECT id, created_at,
484 ROW_NUMBER() OVER (ORDER BY created_at DESC, rowid DESC) AS rn
485 FROM db_agent_native_memory_versions
486 WHERE agent_id = ?1 AND filename = ?2
487 )
488 WHERE rn > ?3 AND created_at < ?4
489 )",
490 params![agent_id, filename, min_keep, cutoff],
491 )?;
492 Ok(deleted)
493 }
494
495 pub fn agent_native_memory_version_get(
498 &self,
499 version_id: &str,
500 ) -> Result<Option<NativeMemoryVersion>, StoreError> {
501 let conn = self.conn.lock().unwrap();
502 let mut stmt = conn.prepare(
503 "SELECT id, agent_id, filename, content, content_hash, parent_version_id, source, source_detail, session_id, created_at
504 FROM db_agent_native_memory_versions
505 WHERE id = ?1",
506 )?;
507 match stmt.query_row(params![version_id], |row| {
508 Ok(NativeMemoryVersion {
509 id: row.get(0)?,
510 agent_id: row.get(1)?,
511 filename: row.get(2)?,
512 content: row.get(3)?,
513 content_hash: row.get(4)?,
514 parent_version_id: row.get(5)?,
515 source: row.get(6)?,
516 source_detail: row.get(7)?,
517 session_id: row.get(8)?,
518 created_at: row.get(9)?,
519 })
520 }) {
521 Ok(v) => Ok(Some(v)),
522 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
523 Err(e) => Err(e.into()),
524 }
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 fn shared_store() -> Store {
533 Store::open_shared(std::path::Path::new(":memory:")).unwrap()
534 }
535
536 fn record_m0023(store: &Store, applied_at: &str) {
540 store
541 .conn
542 .lock()
543 .unwrap()
544 .execute(
545 "INSERT INTO db_migrations (id, applied_at, duration_ms, scope)
546 VALUES ('0023_native_memory_versions_backfill', ?1, 0, 'global')",
547 params![applied_at],
548 )
549 .unwrap();
550 }
551
552 fn now_iso() -> String {
555 let secs = agentmux_common::time::now_secs_u64();
556 let conn = rusqlite::Connection::open_in_memory().unwrap();
557 conn.query_row(
558 "SELECT strftime('%Y-%m-%dT%H:%M:%S+00:00', ?1, 'unixepoch')",
559 params![secs as i64],
560 |r| r.get::<_, String>(0),
561 )
562 .unwrap()
563 }
564
565 fn sweep_first_sight(store: &Store, agent: &str, file: &str) {
567 store
568 .agent_native_memory_version_insert(
569 agent, file, "pre-existing content", "external_fs_write",
570 r#"{"detected_via":"reconciliation_sweep"}"#, "",
571 )
572 .unwrap();
573 }
574
575 #[test]
576 fn relabels_the_rollout_first_sight_burst() {
577 let store = shared_store();
580 record_m0023(&store, &now_iso());
581 sweep_first_sight(&store, "agent-1", "MEMORY.md");
582
583 assert_eq!(store.agent_native_memory_version_relabel_rollout_first_sight().unwrap(), 1);
584
585 let v = &store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap()[0];
586 assert_eq!(v.source, "agent_inferred");
587 assert!(v.source_detail.contains("relabeled_from"), "{}", v.source_detail);
589 assert!(v.source_detail.contains("reconciliation_sweep"), "{}", v.source_detail);
590 }
591
592 #[test]
593 fn leaves_a_genuine_external_write_outside_the_window_alone() {
594 let store = shared_store();
599 record_m0023(&store, "2020-01-01T00:00:00+00:00");
600 sweep_first_sight(&store, "agent-1", "MEMORY.md");
601
602 assert_eq!(store.agent_native_memory_version_relabel_rollout_first_sight().unwrap(), 0);
603 assert_eq!(
604 store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap()[0].source,
605 "external_fs_write"
606 );
607 }
608
609 #[test]
610 fn relabels_a_genuinely_new_file_inside_the_window_too() {
611 let store = shared_store();
620 record_m0023(&store, &now_iso());
621 sweep_first_sight(&store, "agent-1", "BRAND-NEW.md");
622
623 assert_eq!(store.agent_native_memory_version_relabel_rollout_first_sight().unwrap(), 1);
624 }
625
626 #[test]
627 fn leaves_an_observed_change_to_a_tracked_file_alone() {
628 let store = shared_store();
632 record_m0023(&store, &now_iso());
633 store
634 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "v1", "human", "{}", "")
635 .unwrap();
636 sweep_first_sight(&store, "agent-1", "MEMORY.md");
637
638 assert_eq!(store.agent_native_memory_version_relabel_rollout_first_sight().unwrap(), 0);
639 }
640
641 #[test]
642 fn leaves_fs_watch_detections_alone() {
643 let store = shared_store();
647 record_m0023(&store, &now_iso());
648 store
649 .agent_native_memory_version_insert(
650 "agent-1", "MEMORY.md", "c", "external_fs_write",
651 r#"{"detected_via":"fs_watch"}"#, "",
652 )
653 .unwrap();
654
655 assert_eq!(store.agent_native_memory_version_relabel_rollout_first_sight().unwrap(), 0);
656 }
657
658 #[test]
659 fn is_a_no_op_when_m0023_never_ran() {
660 let store = shared_store();
663 sweep_first_sight(&store, "agent-1", "MEMORY.md");
664 assert_eq!(store.agent_native_memory_version_relabel_rollout_first_sight().unwrap(), 0);
665 }
666
667 #[test]
668 fn is_idempotent() {
669 let store = shared_store();
670 record_m0023(&store, &now_iso());
671 sweep_first_sight(&store, "agent-1", "MEMORY.md");
672 assert_eq!(store.agent_native_memory_version_relabel_rollout_first_sight().unwrap(), 1);
673 assert_eq!(store.agent_native_memory_version_relabel_rollout_first_sight().unwrap(), 0);
676 }
677
678 #[test]
679 fn first_write_has_no_parent() {
680 let store = shared_store();
681 let v = store
682 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "v1", "human", "{}", "sess-1")
683 .unwrap();
684 assert_eq!(v.parent_version_id, None);
685 assert_eq!(v.content, "v1");
686 assert_eq!(v.content_hash, content_hash("v1"));
687 }
688
689 #[test]
690 fn second_write_chains_onto_the_first() {
691 let store = shared_store();
692 let v1 = store
693 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "v1", "human", "{}", "")
694 .unwrap();
695 let v2 = store
696 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "v2", "agent_inferred", "{}", "")
697 .unwrap();
698 assert_eq!(v2.parent_version_id, Some(v1.id));
699 }
700
701 #[test]
702 fn a_no_op_write_still_records_a_new_version() {
703 let store = shared_store();
707 let v1 = store
708 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "same", "human", "{}", "")
709 .unwrap();
710 let v2 = store
711 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "same", "human", "{}", "")
712 .unwrap();
713 assert_ne!(v1.id, v2.id);
714 assert_eq!(v2.parent_version_id, Some(v1.id));
715 assert_eq!(v1.content_hash, v2.content_hash);
716 }
717
718 #[test]
734 fn concurrent_inserts_from_two_separate_connections_form_a_linear_chain() {
735 let tmp = tempfile::NamedTempFile::new().unwrap();
736 let path = tmp.path().to_path_buf();
737 let store_a = std::sync::Arc::new(Store::open_shared(&path).unwrap());
738 let store_b = std::sync::Arc::new(Store::open_shared(&path).unwrap());
739
740 for round in 0..8 {
741 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
742 let (ba, bb) = (barrier.clone(), barrier.clone());
743 let (sa, sb) = (store_a.clone(), store_b.clone());
744 let filename = format!("MEMORY-{round}.md");
745 let (fa, fb) = (filename.clone(), filename.clone());
746
747 let ha = std::thread::spawn(move || {
748 ba.wait();
749 sa.agent_native_memory_version_insert("agent-1", &fa, "A", "human", "{}", "").unwrap()
750 });
751 let hb = std::thread::spawn(move || {
752 bb.wait();
753 sb.agent_native_memory_version_insert("agent-1", &fb, "B", "human", "{}", "").unwrap()
754 });
755 let va = ha.join().unwrap();
756 let vb = hb.join().unwrap();
757
758 let a_onto_b = va.parent_version_id == Some(vb.id.clone());
762 let b_onto_a = vb.parent_version_id == Some(va.id.clone());
763 assert!(
764 a_onto_b != b_onto_a,
765 "round {round}: exactly one of A/B must chain onto the other — A.parent={:?} B.parent={:?}",
766 va.parent_version_id, vb.parent_version_id,
767 );
768 }
769 }
770
771 #[test]
772 fn list_returns_newest_first() {
773 let store = shared_store();
774 store
775 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "v1", "human", "{}", "")
776 .unwrap();
777 store
778 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "v2", "human", "{}", "")
779 .unwrap();
780 let list = store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap();
781 assert_eq!(list.len(), 2);
782 assert!(list[0].content_hash == content_hash("v2"));
784 assert!(list[1].content_hash == content_hash("v1"));
785 }
786
787 #[test]
788 fn list_scopes_to_the_requested_agent_and_filename_only() {
789 let store = shared_store();
790 store
791 .agent_native_memory_version_insert("agent-1", "a.md", "x", "human", "{}", "")
792 .unwrap();
793 store
794 .agent_native_memory_version_insert("agent-1", "b.md", "y", "human", "{}", "")
795 .unwrap();
796 store
797 .agent_native_memory_version_insert("agent-2", "a.md", "z", "human", "{}", "")
798 .unwrap();
799
800 let list = store.agent_native_memory_version_list("agent-1", "a.md").unwrap();
801 assert_eq!(list.len(), 1);
802 assert_eq!(list[0].content_hash, content_hash("x"));
803 }
804
805 #[test]
806 fn list_omits_content_by_design() {
807 let store = shared_store();
808 store
809 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "some content", "human", "{}", "")
810 .unwrap();
811 let list = store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap();
818 assert_eq!(list.len(), 1);
819 }
820
821 #[test]
822 fn latest_returns_none_when_no_version_exists() {
823 let store = shared_store();
824 assert_eq!(
825 store.agent_native_memory_version_latest("agent-1", "MEMORY.md").unwrap(),
826 None
827 );
828 }
829
830 #[test]
831 fn get_reads_a_specific_version_by_id_with_full_content() {
832 let store = shared_store();
833 let v1 = store
834 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "v1", "human", "{}", "")
835 .unwrap();
836 store
837 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "v2", "human", "{}", "")
838 .unwrap();
839
840 let got = store.agent_native_memory_version_get(&v1.id).unwrap();
842 assert_eq!(got, Some(v1));
843 }
844
845 #[test]
846 fn get_returns_none_for_an_unknown_id() {
847 let store = shared_store();
848 assert_eq!(store.agent_native_memory_version_get("nope").unwrap(), None);
849 }
850
851 #[test]
852 fn source_and_source_detail_round_trip_exactly() {
853 let store = shared_store();
854 let detail = r#"{"FROM":"github-consumer","TIER":"sensitive","TRUST":"network-claimed"}"#;
855 let v = store
856 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "content", "jekt", detail, "sess-42")
857 .unwrap();
858 assert_eq!(v.source, "jekt");
859 assert_eq!(v.source_detail, detail);
860 assert_eq!(v.session_id, "sess-42");
861
862 let list = store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap();
863 assert_eq!(list[0].source, "jekt");
864 assert_eq!(list[0].source_detail, detail);
865 }
866
867 #[test]
868 fn insert_if_changed_inserts_on_first_observation() {
869 let store = shared_store();
870 let v = store
871 .agent_native_memory_version_insert_if_changed("agent-1", "MEMORY.md", "content", "external_fs_write", "{}", "")
872 .unwrap();
873 assert!(v.is_some());
874 }
875
876 #[test]
877 fn insert_if_changed_is_a_no_op_for_identical_content() {
878 let store = shared_store();
879 store
880 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "same", "human", "{}", "")
881 .unwrap();
882 let result = store
883 .agent_native_memory_version_insert_if_changed("agent-1", "MEMORY.md", "same", "external_fs_write", "{}", "")
884 .unwrap();
885 assert_eq!(result, None);
886 assert_eq!(store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap().len(), 1);
887 }
888
889 #[test]
890 fn insert_if_changed_inserts_for_different_content() {
891 let store = shared_store();
892 let v1 = store
893 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "v1", "human", "{}", "")
894 .unwrap();
895 let v2 = store
896 .agent_native_memory_version_insert_if_changed("agent-1", "MEMORY.md", "v2", "external_fs_write", "{}", "")
897 .unwrap()
898 .expect("different content must insert");
899 assert_eq!(v2.parent_version_id, Some(v1.id));
900 }
901
902 #[test]
903 fn insert_if_changed_only_the_first_of_two_racing_identical_calls_inserts() {
904 let store = shared_store();
911 let first = store
912 .agent_native_memory_version_insert_if_changed("agent-1", "MEMORY.md", "drifted content", "external_fs_write", "{}", "")
913 .unwrap();
914 let second = store
915 .agent_native_memory_version_insert_if_changed("agent-1", "MEMORY.md", "drifted content", "external_fs_write", "{}", "")
916 .unwrap();
917 assert!(first.is_some());
918 assert_eq!(second, None, "a second call observing the same already-recorded content must be a no-op");
919 assert_eq!(store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap().len(), 1);
920 }
921
922 const DAY_MS: i64 = 24 * 60 * 60 * 1000;
923
924 fn backdate(store: &Store, version_id: &str, days_ago: i64) {
929 let conn = store.conn.lock().unwrap();
930 conn.execute(
931 "UPDATE db_agent_native_memory_versions SET created_at = ?1 WHERE id = ?2",
932 params![now_ms() - days_ago * DAY_MS, version_id],
933 )
934 .unwrap();
935 }
936
937 #[test]
938 fn prune_leaves_a_file_with_fewer_versions_than_the_floor_untouched() {
939 let store = shared_store();
940 for i in 0..5 {
941 let v = store
942 .agent_native_memory_version_insert("agent-1", "MEMORY.md", &format!("v{i}"), "human", "{}", "")
943 .unwrap();
944 backdate(&store, &v.id, 1000); }
946 let deleted = store.agent_native_memory_version_prune("agent-1", "MEMORY.md", 50, 90 * DAY_MS).unwrap();
947 assert_eq!(deleted, 0);
948 assert_eq!(store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap().len(), 5);
949 }
950
951 #[test]
952 fn prune_keeps_the_min_keep_newest_versions_even_when_all_are_old() {
953 let store = shared_store();
954 for i in 0..8 {
955 let v = store
956 .agent_native_memory_version_insert("agent-1", "MEMORY.md", &format!("v{i}"), "human", "{}", "")
957 .unwrap();
958 backdate(&store, &v.id, 1000);
959 }
960 let deleted = store.agent_native_memory_version_prune("agent-1", "MEMORY.md", 3, 90 * DAY_MS).unwrap();
961 assert_eq!(deleted, 5, "floor of 3 must survive out of 8, even though every version is ancient");
962 let remaining = store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap();
963 assert_eq!(remaining.len(), 3);
964 assert_eq!(remaining[0].content_hash, content_hash("v7"));
966 assert_eq!(remaining[1].content_hash, content_hash("v6"));
967 assert_eq!(remaining[2].content_hash, content_hash("v5"));
968 }
969
970 #[test]
971 fn prune_leaves_a_version_beyond_the_floor_alone_if_it_is_still_young() {
972 let store = shared_store();
973 for i in 0..5 {
976 store
977 .agent_native_memory_version_insert("agent-1", "MEMORY.md", &format!("v{i}"), "human", "{}", "")
978 .unwrap();
979 }
980 let deleted = store.agent_native_memory_version_prune("agent-1", "MEMORY.md", 1, 90 * DAY_MS).unwrap();
981 assert_eq!(deleted, 0, "young versions must survive regardless of the count floor");
982 assert_eq!(store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap().len(), 5);
983 }
984
985 #[test]
986 fn prune_deletes_only_versions_both_beyond_the_floor_and_past_the_age_cutoff() {
987 let store = shared_store();
988 let mut ids = Vec::new();
992 for i in 0..7 {
993 let v = store
994 .agent_native_memory_version_insert("agent-1", "MEMORY.md", &format!("v{i}"), "human", "{}", "")
995 .unwrap();
996 backdate(&store, &v.id, 100); ids.push(v.id);
998 }
999 let deleted = store.agent_native_memory_version_prune("agent-1", "MEMORY.md", 2, 90 * DAY_MS).unwrap();
1000 assert_eq!(deleted, 5);
1001 assert_eq!(store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap().len(), 2);
1002 }
1003
1004 #[test]
1005 fn prune_scopes_to_the_requested_agent_and_filename_only() {
1006 let store = shared_store();
1007 let v_a = store
1008 .agent_native_memory_version_insert("agent-1", "MEMORY.md", "a", "human", "{}", "")
1009 .unwrap();
1010 backdate(&store, &v_a.id, 200);
1011 let v_b = store
1012 .agent_native_memory_version_insert("agent-2", "MEMORY.md", "b", "human", "{}", "")
1013 .unwrap();
1014 backdate(&store, &v_b.id, 200);
1015
1016 let deleted = store.agent_native_memory_version_prune("agent-1", "MEMORY.md", 0, 90 * DAY_MS).unwrap();
1017 assert_eq!(deleted, 1);
1018 assert!(store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap().is_empty());
1019 assert_eq!(store.agent_native_memory_version_list("agent-2", "MEMORY.md").unwrap().len(), 1, "a different agent's version must be untouched");
1020 }
1021
1022 #[test]
1023 fn list_distinct_files_returns_each_agent_filename_pair_once() {
1024 let store = shared_store();
1025 store.agent_native_memory_version_insert("agent-1", "a.md", "v1", "human", "{}", "").unwrap();
1026 store.agent_native_memory_version_insert("agent-1", "a.md", "v2", "human", "{}", "").unwrap();
1027 store.agent_native_memory_version_insert("agent-1", "b.md", "v1", "human", "{}", "").unwrap();
1028 store.agent_native_memory_version_insert("agent-2", "a.md", "v1", "human", "{}", "").unwrap();
1029
1030 let mut files = store.agent_native_memory_version_list_distinct_files().unwrap();
1031 files.sort();
1032 assert_eq!(
1033 files,
1034 vec![
1035 ("agent-1".to_string(), "a.md".to_string()),
1036 ("agent-1".to_string(), "b.md".to_string()),
1037 ("agent-2".to_string(), "a.md".to_string()),
1038 ]
1039 );
1040 }
1041
1042 #[test]
1043 fn list_distinct_files_is_empty_when_no_versions_exist() {
1044 let store = shared_store();
1045 assert!(store.agent_native_memory_version_list_distinct_files().unwrap().is_empty());
1046 }
1047}