1use std::fs;
25use std::io;
26use std::path::{Path, PathBuf};
27
28use serde::Serialize;
29
30#[derive(Debug)]
32pub enum RetentionError {
33 Io(io::Error),
34 UnreadableChain(PathBuf),
37 BudgetUnmet {
42 budget: u64,
43 remaining: u64,
44 protected_chain_heads: usize,
45 notes_kept: bool,
47 protected_bytes: u64,
50 nothing_deleted: bool,
53 },
54}
55
56impl std::fmt::Display for RetentionError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 RetentionError::Io(e) => write!(f, "retention io error: {e}"),
60 RetentionError::UnreadableChain(path) => write!(
61 f,
62 "unreadable named-chain file {} — refusing every destructive session \
63 operation while chain protection is unknown",
64 path.display()
65 ),
66 RetentionError::BudgetUnmet {
67 budget,
68 remaining,
69 protected_chain_heads,
70 notes_kept,
71 protected_bytes,
72 nothing_deleted,
73 } => {
74 if *nothing_deleted {
75 write!(
76 f,
77 "disk budget unmet: nothing was deleted, because the budget of \
78 {budget} bytes is below the {protected_bytes} bytes this sweep \
79 may not delete ({protected_chain_heads} protected chain head(s)"
80 )?;
81 if *notes_kept {
82 write!(f, " and every saved note")?;
83 }
84 write!(f, "); {remaining} bytes are in use")?;
85 } else {
86 write!(
87 f,
88 "disk budget unmet: {remaining} bytes remain against a budget of \
89 {budget} after evicting every unprotected artifact \
90 ({protected_chain_heads} protected chain head(s) retained)"
91 )?;
92 }
93 if *notes_kept {
94 write!(
95 f,
96 "; no note was deleted: the budget evicts notes only with \
97 --include-notes"
98 )?;
99 }
100 Ok(())
101 }
102 }
103 }
104}
105
106impl std::error::Error for RetentionError {}
107
108impl From<io::Error> for RetentionError {
109 fn from(e: io::Error) -> Self {
110 RetentionError::Io(e)
111 }
112}
113
114#[derive(Debug, Clone)]
117pub struct RetentionRoots {
118 pub config_dir: PathBuf,
120 pub base_dir: PathBuf,
122 pub cache_dir: PathBuf,
124}
125
126impl RetentionRoots {
127 pub fn resolve() -> Self {
129 let cache_dir = std::env::var("HOME")
130 .map(|home| PathBuf::from(home).join(".cache/synaps"))
131 .unwrap_or_else(|_| crate::config::base_dir().join("cache"));
132 Self {
133 config_dir: crate::config::get_active_config_dir(),
134 base_dir: crate::config::base_dir(),
135 cache_dir,
136 }
137 }
138
139 fn sessions_dir(&self) -> PathBuf {
140 self.config_dir.join("sessions")
141 }
142 fn chains_dir(&self) -> PathBuf {
143 self.config_dir.join("chains")
144 }
145 fn memory_dir(&self) -> PathBuf {
146 self.base_dir.join("memory")
147 }
148 fn spill_dir(&self) -> PathBuf {
149 crate::core::tool_spill::spill_root(&self.base_dir)
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
155#[serde(rename_all = "snake_case")]
156pub enum RetentionDomain {
157 Sessions,
158 Memory,
159 Traces,
160 Logs,
161}
162
163#[derive(Debug, Clone, Copy, Default)]
165pub struct RetentionPolicy {
166 pub max_age_days: Option<u32>,
169 pub max_disk_bytes: Option<u64>,
171 pub include_notes: bool,
177}
178
179const DAY_MS: u64 = 86_400_000;
180
181#[cfg(unix)]
183const NOTES_LOCK_SUFFIX: &str = ".jsonl.lock";
184
185#[derive(Debug, Clone, Serialize)]
186pub struct DomainReport {
187 pub domain: RetentionDomain,
188 pub files: usize,
189 pub bytes: u64,
190}
191
192#[derive(Debug, Clone, Serialize)]
193pub struct RetentionReport {
194 pub domains: Vec<DomainReport>,
195 pub total_bytes: u64,
196}
197
198#[derive(Debug, Clone, Copy, Default, Serialize)]
199pub struct SweepOutcome {
200 pub deleted_files: usize,
201 pub freed_bytes: u64,
202 pub protected_chain_heads: usize,
203 pub memory_records_dropped: usize,
204}
205
206#[derive(Debug, Clone, Copy, Serialize)]
207pub struct ExportSummary {
208 pub files: usize,
209 pub bytes: u64,
210}
211
212fn list_files(dir: &Path) -> Vec<PathBuf> {
213 let mut out = Vec::new();
214 if let Ok(entries) = fs::read_dir(dir) {
215 for entry in entries.flatten() {
216 if entry.path().is_file() {
217 out.push(entry.path());
218 }
219 }
220 }
221 out.sort();
222 out
223}
224
225fn log_files(config_dir: &Path) -> Vec<PathBuf> {
226 let mut out: Vec<PathBuf> = list_files(config_dir)
227 .into_iter()
228 .filter(|p| {
229 p.file_name()
230 .and_then(|n| n.to_str())
231 .is_some_and(|n| n.starts_with("synaps.log"))
232 })
233 .collect();
234 out.sort();
235 out
236}
237
238fn chain_heads(roots: &RetentionRoots) -> Result<Vec<String>, RetentionError> {
242 let mut heads = Vec::new();
243 for path in list_files(&roots.chains_dir()) {
244 let raw =
245 fs::read_to_string(&path).map_err(|_| RetentionError::UnreadableChain(path.clone()))?;
246 let value: serde_json::Value = serde_json::from_str(&raw)
247 .map_err(|_| RetentionError::UnreadableChain(path.clone()))?;
248 match value["head"].as_str() {
249 Some(head) => heads.push(head.to_string()),
250 None => return Err(RetentionError::UnreadableChain(path.clone())),
251 }
252 }
253 Ok(heads)
254}
255
256fn session_age_ms(path: &Path) -> Option<u64> {
258 if let Ok(raw) = fs::read_to_string(path) {
259 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) {
260 if let Some(updated) = value["updated_at"].as_str() {
261 if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(updated) {
262 return Some(ts.timestamp_millis().max(0) as u64);
263 }
264 }
265 }
266 }
267 mtime_ms(path)
268}
269
270fn mtime_ms(path: &Path) -> Option<u64> {
271 fs::metadata(path)
272 .and_then(|m| m.modified())
273 .ok()
274 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
275 .map(|d| d.as_millis() as u64)
276}
277
278pub fn inspect(roots: &RetentionRoots) -> io::Result<RetentionReport> {
280 let sessions = list_files(&roots.sessions_dir());
281 let memory: Vec<PathBuf> = list_files(&roots.memory_dir())
282 .into_iter()
283 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("jsonl"))
284 .collect();
285 let traces = cache_files(&roots.cache_dir);
286 let logs = log_files(&roots.config_dir);
287
288 let sum = |files: &[PathBuf]| -> u64 {
289 files
290 .iter()
291 .filter_map(|p| fs::metadata(p).ok().map(|m| m.len()))
292 .sum()
293 };
294
295 let domains = vec![
296 DomainReport {
297 domain: RetentionDomain::Sessions,
298 files: sessions.len(),
299 bytes: sum(&sessions),
300 },
301 DomainReport {
302 domain: RetentionDomain::Memory,
303 files: memory.len(),
304 bytes: sum(&memory),
305 },
306 DomainReport {
307 domain: RetentionDomain::Traces,
308 files: traces.len(),
309 bytes: sum(&traces),
310 },
311 DomainReport {
312 domain: RetentionDomain::Logs,
313 files: logs.len(),
314 bytes: sum(&logs),
315 },
316 ];
317 let total_bytes = domains.iter().map(|d| d.bytes).sum();
318 Ok(RetentionReport {
319 domains,
320 total_bytes,
321 })
322}
323
324fn protected_bytes(
333 roots: &RetentionRoots,
334 protected: &[String],
335 policy: &RetentionPolicy,
336 now_ms: u64,
337) -> io::Result<u64> {
338 let mut total = 0u64;
339 for path in list_files(&roots.sessions_dir()) {
340 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
341 if protected.iter().any(|id| id == stem) {
342 total += fs::metadata(&path)?.len();
343 }
344 }
345 if !policy.include_notes {
346 for path in list_files(&roots.memory_dir()) {
347 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
348 continue;
349 }
350 let raw = fs::read(&path)?;
351 let (dropped, kept) = compacted_lines(&raw, now_ms, None);
352 total += if dropped == 0 {
353 raw.len() as u64 } else {
355 kept.iter().map(|line| line.len() as u64 + 1).sum()
356 };
357 }
358 }
359 Ok(total)
360}
361
362pub fn sweep_at(
368 roots: &RetentionRoots,
369 policy: &RetentionPolicy,
370 now_ms: u64,
371) -> Result<SweepOutcome, RetentionError> {
372 let mut outcome = SweepOutcome::default();
373 let protected: Vec<String> = chain_heads(roots)?;
376 let mut may_not_delete = 0;
380 if let Some(budget) = policy.max_disk_bytes {
381 may_not_delete = protected_bytes(roots, &protected, policy, now_ms)?;
382 if budget < may_not_delete {
383 return Err(RetentionError::BudgetUnmet {
384 budget,
385 remaining: inspect(roots)?.total_bytes,
386 protected_chain_heads: protected.len(),
387 notes_kept: !policy.include_notes,
388 protected_bytes: may_not_delete,
389 nothing_deleted: true,
390 });
391 }
392 }
393 let age_cutoff = policy
394 .max_age_days
395 .map(|days| now_ms.saturating_sub(days as u64 * DAY_MS));
396
397 for path in list_files(&roots.sessions_dir()) {
401 if path.extension().and_then(|e| e.to_str()) != Some("journal")
402 || path.with_extension("json").exists()
403 {
404 continue;
405 }
406 let id = path
407 .file_stem()
408 .and_then(|s| s.to_str())
409 .unwrap_or_default()
410 .to_string();
411 if protected.contains(&id) {
412 outcome.protected_chain_heads += 1;
413 continue;
414 }
415 delete_file(&path, &mut outcome)?;
416 }
417
418 if let Some(cutoff) = age_cutoff {
421 for path in list_files(&roots.sessions_dir()) {
422 if is_paired_journal(&path) {
425 continue;
426 }
427 let id = path
428 .file_stem()
429 .and_then(|s| s.to_str())
430 .unwrap_or_default()
431 .to_string();
432 if protected.contains(&id) {
433 outcome.protected_chain_heads += 1;
434 continue;
435 }
436 let Some(age_ref) = session_age_ms(&path) else {
437 continue; };
439 if age_ref < cutoff {
440 delete_session_artifact(&path, &mut outcome)?;
441 remove_session_spill(roots, &id, &mut outcome)?;
443 }
444 }
445
446 for path in cache_files(&roots.cache_dir)
449 .into_iter()
450 .chain(log_files(&roots.config_dir))
451 {
452 if mtime_ms(&path).is_some_and(|m| m < cutoff) {
453 delete_file(&path, &mut outcome)?;
454 }
455 }
456
457 let age = std::time::Duration::from_millis(now_ms.saturating_sub(cutoff));
460 let now = std::time::UNIX_EPOCH + std::time::Duration::from_millis(now_ms);
461 let swept = crate::core::tool_spill::sweep_stale_spill(&roots.spill_dir(), age, now);
462 outcome.deleted_files += swept.files;
463 outcome.freed_bytes += swept.bytes;
464 }
465
466 let notes_cutoff = age_cutoff.filter(|_| policy.include_notes);
471 for path in list_files(&roots.memory_dir()) {
472 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
473 continue;
474 }
475 let dropped = compact_memory_file(&path, now_ms, notes_cutoff)?;
476 outcome.memory_records_dropped += dropped;
477 }
478
479 if let Some(budget) = policy.max_disk_bytes {
485 let total_now = |roots: &RetentionRoots| -> u64 {
486 inspect(roots).map(|r| r.total_bytes).unwrap_or(u64::MAX)
487 };
488
489 if total_now(roots) > budget {
491 let mut candidates: Vec<(u64, PathBuf, bool)> = Vec::new();
492 for path in list_files(&roots.sessions_dir()) {
493 if is_paired_journal(&path) {
495 continue;
496 }
497 let id = path
498 .file_stem()
499 .and_then(|s| s.to_str())
500 .unwrap_or_default()
501 .to_string();
502 let age = session_age_ms(&path).unwrap_or(u64::MAX);
503 candidates.push((age, path, protected.contains(&id)));
504 }
505 for path in cache_files(&roots.cache_dir)
506 .into_iter()
507 .chain(log_files(&roots.config_dir))
508 {
509 let age = mtime_ms(&path).unwrap_or(u64::MAX);
510 candidates.push((age, path, false));
511 }
512 candidates.sort_by_key(|item| item.0);
513 for (_, path, is_protected) in candidates {
514 if total_now(roots) <= budget {
515 break;
516 }
517 if is_protected {
518 outcome.protected_chain_heads += 1;
519 continue;
520 }
521 delete_file(&path, &mut outcome).map_err(RetentionError::Io)?;
522 }
523 }
524
525 if policy.include_notes && total_now(roots) > budget {
528 let over = total_now(roots) - budget;
529 let dropped = evict_oldest_memory_records(roots, over)?;
530 outcome.memory_records_dropped += dropped;
531 }
532
533 let remaining = total_now(roots);
535 if remaining > budget {
536 return Err(RetentionError::BudgetUnmet {
537 budget,
538 remaining,
539 protected_chain_heads: protected.len(),
540 notes_kept: !policy.include_notes,
541 protected_bytes: may_not_delete,
542 nothing_deleted: false,
543 });
544 }
545 }
546
547 Ok(outcome)
548}
549
550fn evict_oldest_memory_records(
555 roots: &RetentionRoots,
556 bytes_to_free: u64,
557) -> Result<usize, RetentionError> {
558 let mut live: Vec<(u64, PathBuf, String, usize)> = Vec::new();
560 for path in list_files(&roots.memory_dir()) {
561 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
562 continue;
563 }
564 let raw = fs::read(&path).map_err(RetentionError::Io)?;
565 let mut tombstoned: std::collections::HashSet<String> = std::collections::HashSet::new();
566 let mut records: Vec<(u64, String, usize)> = Vec::new();
567 for line in notes_lines(&raw).flatten() {
568 let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
569 continue;
570 };
571 if let Some(id) = value["tombstone"].as_str() {
572 tombstoned.insert(id.to_string());
573 continue;
574 }
575 if let Some(id) = value["id"].as_str() {
576 records.push((
577 value["timestamp_ms"].as_u64().unwrap_or(0),
578 id.to_string(),
579 line.len() + 1,
580 ));
581 }
582 }
583 for (ts, id, len) in records {
584 if !tombstoned.contains(&id) {
585 live.push((ts, path.clone(), id, len));
586 }
587 }
588 }
589 live.sort_by_key(|item| item.0);
590
591 let mut freed: u64 = 0;
592 let mut victims: std::collections::HashMap<PathBuf, std::collections::HashSet<String>> =
593 std::collections::HashMap::new();
594 let mut dropped = 0usize;
595 for (_, path, id, len) in live {
596 if freed >= bytes_to_free {
597 break;
598 }
599 victims.entry(path).or_default().insert(id);
600 freed += len as u64;
601 dropped += 1;
602 }
603
604 for (path, ids) in victims {
605 let _lock = lock_notes(&path)?;
608 let raw = fs::read(&path).map_err(RetentionError::Io)?;
609 rewrite_read_done();
610 let kept: Vec<&str> = notes_lines(&raw)
611 .flatten()
614 .filter(|line| {
615 let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
616 return false; };
618 if let Some(id) = value["id"].as_str() {
619 return !ids.contains(id);
620 }
621 true
624 })
625 .collect();
626 let mut body = kept.join("\n");
627 if !body.is_empty() {
628 body.push('\n');
629 }
630 crate::core::private_fs::write_atomic_private(&path, body.as_bytes())
631 .map_err(io::Error::from)
632 .map_err(RetentionError::Io)?;
633 }
634 Ok(dropped)
635}
636
637fn notes_lines(raw: &[u8]) -> impl Iterator<Item = Option<&str>> {
644 let empty = raw.is_empty();
645 let raw = raw.strip_suffix(b"\n").unwrap_or(raw);
646 raw.split(|byte| *byte == b'\n')
647 .filter(move |_| !empty)
648 .map(|line| {
649 let line = line.strip_suffix(b"\r").unwrap_or(line);
650 std::str::from_utf8(line).ok()
651 })
652}
653
654fn lock_notes(path: &Path) -> io::Result<crate::core::lock_file::ExclusiveLock> {
658 crate::memory::store::lock_notes_file(path)
659}
660
661#[cfg(test)]
662thread_local! {
663 static AFTER_REWRITE_READ: std::cell::RefCell<Option<Box<dyn FnMut()>>> =
666 const { std::cell::RefCell::new(None) };
667}
668
669#[cfg(test)]
670fn rewrite_read_done() {
671 let hook = AFTER_REWRITE_READ.with(|hook| hook.borrow_mut().take());
672 if let Some(mut hook) = hook {
673 hook();
674 }
675}
676
677#[cfg(not(test))]
678fn rewrite_read_done() {}
679
680fn walk_regular_files(root: &Path) -> Vec<PathBuf> {
684 fn recurse(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
685 if depth > 32 {
686 return;
687 }
688 if let Ok(entries) = fs::read_dir(dir) {
689 for entry in entries.flatten() {
690 let Ok(file_type) = entry.file_type() else {
691 continue;
692 };
693 if file_type.is_symlink() {
694 continue; }
696 let path = entry.path();
697 if file_type.is_dir() {
698 recurse(&path, depth + 1, out);
699 } else if file_type.is_file() {
700 out.push(path);
701 }
702 }
703 }
704 }
705 let mut out = Vec::new();
706 recurse(root, 0, &mut out);
707 out.sort();
708 out
709}
710
711fn cache_files(cache_dir: &Path) -> Vec<PathBuf> {
713 walk_regular_files(cache_dir)
714}
715
716pub fn sweep(
718 roots: &RetentionRoots,
719 policy: &RetentionPolicy,
720) -> Result<SweepOutcome, RetentionError> {
721 sweep_at(roots, policy, crate::epoch_millis())
722}
723
724fn delete_file(path: &Path, outcome: &mut SweepOutcome) -> io::Result<()> {
725 let len = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
726 fs::remove_file(path)?;
727 outcome.deleted_files += 1;
728 outcome.freed_bytes += len;
729 Ok(())
730}
731
732const PAIRED_SESSION_EXTENSIONS: [&str; 2] = ["journal", "turn"];
736
737fn is_paired_journal(path: &Path) -> bool {
741 path.extension()
742 .and_then(|e| e.to_str())
743 .is_some_and(|e| PAIRED_SESSION_EXTENSIONS.contains(&e))
744 && path.with_extension("json").exists()
745}
746
747fn remove_session_spill(
749 roots: &RetentionRoots,
750 id: &str,
751 outcome: &mut SweepOutcome,
752) -> io::Result<()> {
753 let freed = crate::core::tool_spill::remove_session_spill(&roots.spill_dir(), id)?;
754 outcome.deleted_files += freed.files;
755 outcome.freed_bytes += freed.bytes;
756 Ok(())
757}
758
759fn delete_session_artifact(path: &Path, outcome: &mut SweepOutcome) -> io::Result<()> {
763 delete_file(path, outcome)?;
764 if path.extension().and_then(|e| e.to_str()) == Some("json") {
765 for ext in PAIRED_SESSION_EXTENSIONS {
766 let paired = path.with_extension(ext);
767 if paired.exists() {
768 delete_file(&paired, outcome)?;
769 }
770 }
771 }
772 Ok(())
773}
774
775fn compact_memory_file(
786 path: &Path,
787 now_ms: u64,
788 global_cutoff_ms: Option<u64>,
789) -> io::Result<usize> {
790 let _lock = lock_notes(path)?;
793 let raw = fs::read(path)?;
794 rewrite_read_done();
795 let (dropped, kept) = compacted_lines(&raw, now_ms, global_cutoff_ms);
796 if dropped > 0 {
797 let mut body = kept.join("\n");
798 if !body.is_empty() {
799 body.push('\n');
800 }
801 crate::core::private_fs::write_atomic_private(path, body.as_bytes())
802 .map_err(io::Error::from)?;
803 }
804 Ok(dropped)
805}
806
807fn compacted_lines(
811 raw: &[u8],
812 now_ms: u64,
813 global_cutoff_ms: Option<u64>,
814) -> (usize, Vec<String>) {
815 enum Line {
818 Tombstone(String),
819 Record(Option<String>, u64, Option<u64>, String),
820 }
821 let mut tombstoned: std::collections::HashSet<String> = std::collections::HashSet::new();
822 let mut lines: Vec<Line> = Vec::new();
823 let mut dropped = 0usize;
824
825 for line in notes_lines(raw) {
826 let Some(line) = line else {
827 dropped += 1; continue;
829 };
830 let trimmed = line.trim();
831 if trimmed.is_empty() {
832 continue;
833 }
834 let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
835 dropped += 1; continue;
837 };
838 if let Some(id) = value["tombstone"].as_str() {
839 tombstoned.insert(id.to_string());
840 lines.push(Line::Tombstone(trimmed.to_string()));
841 continue;
842 }
843 let ts = value["timestamp_ms"].as_u64().unwrap_or(0);
844 let per_record_span_ms = value["retention"]["max_age_days"]
845 .as_u64()
846 .map(|days| days * DAY_MS);
847 lines.push(Line::Record(
848 value["id"].as_str().map(String::from),
849 ts,
850 per_record_span_ms,
851 trimmed.to_string(),
852 ));
853 }
854
855 let mut kept: Vec<String> = Vec::new();
856 for line in lines {
857 let (id, ts, per_record_span_ms, line) = match line {
858 Line::Tombstone(line) => {
863 kept.push(line);
864 continue;
865 }
866 Line::Record(id, ts, span, line) => (id, ts, span, line),
867 };
868 if id.as_ref().is_some_and(|id| tombstoned.contains(id)) {
869 dropped += 1;
870 continue;
871 }
872 if per_record_span_ms.is_some_and(|span| ts.saturating_add(span) < now_ms) {
873 dropped += 1;
874 continue;
875 }
876 if global_cutoff_ms.is_some_and(|cutoff| ts < cutoff) {
877 dropped += 1;
878 continue;
879 }
880 kept.push(line);
881 }
882 (dropped, kept)
883}
884
885#[cfg(unix)]
893pub fn export(roots: &RetentionRoots, dest: &Path) -> io::Result<ExportSummary> {
894 use crate::private_fs::ConfinedDir;
895 let mut summary = ExportSummary { files: 0, bytes: 0 };
896 let root = ConfinedDir::create_root(dest)?;
897
898 let flat = [
903 (roots.sessions_dir(), "sessions"),
904 (roots.memory_dir(), "memory"),
905 ];
906 for (src, sub) in flat {
907 let Ok(src_root) = ConfinedDir::open_root(&src) else {
908 continue; };
910 let out = root.child_dir(sub)?;
911 for entry in src_root.entries()? {
912 if !entry.is_file {
913 continue; }
915 if sub == "memory" && entry.name.ends_with(NOTES_LOCK_SUFFIX) {
916 continue; }
918 let components = [entry.name.clone()];
919 match src_root.open_file(&components) {
920 Ok(source) => {
921 let bytes = copy_opened_into_confined(source, &out, &entry.name)?;
922 summary.bytes += bytes;
923 summary.files += 1;
924 }
925 Err(_) => continue,
928 }
929 }
930 }
931
932 if let Ok(cache_root) = ConfinedDir::open_root(&roots.cache_dir) {
934 let traces_root = root.child_dir("traces")?;
935 export_tree_from_handles(&cache_root, &traces_root, &mut summary, 0)?;
936 }
937
938 if let Ok(logs_root) = ConfinedDir::open_root(&roots.config_dir) {
941 let logs_out = root.child_dir("logs")?;
942 for entry in logs_root.entries()? {
943 if !entry.is_file || !entry.name.starts_with("synaps.log") {
944 continue;
945 }
946 match logs_root.open_file(std::slice::from_ref(&entry.name)) {
947 Ok(source) => {
948 let bytes = copy_opened_into_confined(source, &logs_out, &entry.name)?;
949 summary.bytes += bytes;
950 summary.files += 1;
951 }
952 Err(_) => continue,
953 }
954 }
955 }
956 Ok(summary)
957}
958
959#[cfg(unix)]
962fn copy_opened_into_confined(
963 mut source: fs::File,
964 dest_dir: &crate::private_fs::ConfinedDir,
965 name: &str,
966) -> io::Result<u64> {
967 use std::io::{Read, Write};
968 if !source.metadata()?.is_file() {
969 return Err(io::Error::other("confined export source is not regular"));
970 }
971 let mut out = dest_dir.create_file(name)?;
972 let mut buf = [0u8; 64 * 1024];
973 let mut written = 0u64;
974 loop {
975 let n = source.read(&mut buf)?;
976 if n == 0 {
977 break;
978 }
979 out.write_all(&buf[..n])?;
980 written += n as u64;
981 }
982 Ok(written)
983}
984
985#[cfg(unix)]
989fn export_tree_from_handles(
990 source: &crate::private_fs::ConfinedDir,
991 destination: &crate::private_fs::ConfinedDir,
992 summary: &mut ExportSummary,
993 depth: usize,
994) -> io::Result<()> {
995 if depth > 32 {
996 return Err(io::Error::other("confined export source depth exceeds 32"));
997 }
998 for entry in source.entries()? {
999 if entry.is_file {
1000 match source.open_file(std::slice::from_ref(&entry.name)) {
1001 Ok(opened) => {
1002 let bytes = copy_opened_into_confined(opened, destination, &entry.name)?;
1003 summary.bytes += bytes;
1004 summary.files += 1;
1005 }
1006 Err(_) => continue,
1007 }
1008 } else if entry.is_dir {
1009 let source_child = match source.open_dirs(std::slice::from_ref(&entry.name)) {
1010 Ok(child) => child,
1011 Err(_) => continue,
1012 };
1013 let dest_child = destination.child_dir(&entry.name)?;
1014 export_tree_from_handles(&source_child, &dest_child, summary, depth + 1)?;
1015 }
1016 }
1017 Ok(())
1018}
1019
1020#[cfg(not(unix))]
1022pub fn export(_roots: &RetentionRoots, _dest: &Path) -> io::Result<ExportSummary> {
1023 Err(io::Error::other(
1024 "confined export requires a unix platform (directory-handle-relative creation)",
1025 ))
1026}
1027
1028pub fn forget(roots: &RetentionRoots, domain: RetentionDomain, id: &str) -> io::Result<()> {
1032 match domain {
1033 RetentionDomain::Sessions => {
1034 let file = sanitize_file_id(id)?;
1036 let heads = chain_heads(roots).map_err(|e| io::Error::other(e.to_string()))?;
1037 if heads.contains(&id.to_string()) {
1038 return Err(io::Error::other(format!(
1039 "session {id} is the head of a named chain — delete or repoint the \
1040 chain first (retention never leaves chains dangling)"
1041 )));
1042 }
1043 let path = roots
1044 .sessions_dir()
1045 .join(format!("{}.json", file.display()));
1046 fs::remove_file(path)?;
1047 for ext in PAIRED_SESSION_EXTENSIONS {
1050 let paired = roots
1051 .sessions_dir()
1052 .join(format!("{}.{ext}", file.display()));
1053 match fs::remove_file(paired) {
1054 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
1055 other => other?,
1056 }
1057 }
1058 crate::core::tool_spill::remove_session_spill(&roots.spill_dir(), id)?;
1060 Ok(())
1061 }
1062 RetentionDomain::Traces => {
1063 #[cfg(unix)]
1067 {
1068 let mut components = crate::private_fs::validated_relative_components(id)
1069 .map_err(|_| io::Error::other(format!("invalid artifact id: {id:?}")))?;
1070 let name = components
1071 .pop()
1072 .ok_or_else(|| io::Error::other(format!("invalid artifact id: {id:?}")))?;
1073 let root = crate::private_fs::ConfinedDir::open_root(&roots.cache_dir)?;
1074 let dir = root.open_dirs(&components)?;
1075 dir.remove_file(&name)
1076 }
1077 #[cfg(not(unix))]
1078 {
1079 fs::remove_file(roots.cache_dir.join(sanitize_file_id(id)?))
1080 }
1081 }
1082 RetentionDomain::Logs => {
1083 let file = sanitize_file_id(id)?;
1084 if !id.starts_with("synaps.log") {
1085 return Err(io::Error::other(
1086 "invalid log id: must start with synaps.log",
1087 ));
1088 }
1089 fs::remove_file(roots.config_dir.join(file))
1090 }
1091 RetentionDomain::Memory => {
1092 let (namespace, record_id) = id.split_once(':').ok_or_else(|| {
1093 io::Error::other("invalid memory id: use \"<namespace>:<record-id>\"")
1094 })?;
1095 let namespace = sanitize_file_id(namespace)?;
1096 if !crate::memory::store::valid_import_id(record_id) {
1099 return Err(io::Error::other(format!(
1100 "invalid memory record id: {record_id:?}"
1101 )));
1102 }
1103 let path = roots
1104 .memory_dir()
1105 .join(format!("{}.jsonl", namespace.display()));
1106 fs::metadata(&path)?;
1108 let _lock = lock_notes(&path)?;
1110 let raw = fs::read(&path)?;
1111 rewrite_read_done();
1112
1113 let mut live = false;
1117 let mut tombstoned = false;
1118 for line in notes_lines(&raw).flatten() {
1119 let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1120 continue;
1121 };
1122 if value["tombstone"].as_str() == Some(record_id) {
1123 tombstoned = true;
1124 } else if value["id"].as_str() == Some(record_id) {
1125 live = true;
1126 }
1127 }
1128 if !live || tombstoned {
1129 return Err(io::Error::other(format!(
1130 "record {record_id} not present as a live record in namespace {}",
1131 namespace.display()
1132 )));
1133 }
1134 let line = serde_json::json!({
1135 "tombstone": record_id,
1136 "timestamp_ms": crate::epoch_millis(),
1137 });
1138 let mut body = raw;
1141 if !body.ends_with(b"\n") && !body.is_empty() {
1142 body.push(b'\n');
1143 }
1144 body.extend_from_slice(line.to_string().as_bytes());
1145 body.push(b'\n');
1146 crate::core::private_fs::write_atomic_private(&path, &body)
1147 .map_err(io::Error::from)
1148 }
1149 }
1150}
1151
1152fn sanitize_file_id(id: &str) -> io::Result<PathBuf> {
1154 if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") {
1155 return Err(io::Error::other(format!("invalid artifact id: {id:?}")));
1156 }
1157 Ok(PathBuf::from(id))
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162 use super::*;
1163 use crate::memory::store::{self, MemoryRecord};
1164 use std::sync::atomic::{AtomicBool, Ordering};
1165 use std::sync::{Arc, Mutex};
1166 use std::thread::JoinHandle;
1167 use std::time::Duration;
1168 use tempfile::TempDir;
1169
1170 fn roots(tmp: &TempDir) -> RetentionRoots {
1171 RetentionRoots {
1172 config_dir: tmp.path().join("config"),
1173 base_dir: tmp.path().join("base"),
1174 cache_dir: tmp.path().join("cache"),
1175 }
1176 }
1177
1178 fn note(ns: &str, id: &str, timestamp_ms: u64) -> MemoryRecord {
1179 let mut record = store::new_record(ns, format!("content of {id}"), vec![], None);
1180 record.id = Some(id.to_string());
1181 record.timestamp_ms = timestamp_ms;
1182 record
1183 }
1184
1185 fn save(roots: &RetentionRoots, ns: &str, id: &str, timestamp_ms: u64) {
1186 store::append_to(&roots.base_dir, ¬e(ns, id, timestamp_ms)).unwrap();
1187 }
1188
1189 fn notes_file(roots: &RetentionRoots, ns: &str) -> PathBuf {
1190 roots.memory_dir().join(format!("{ns}.jsonl"))
1191 }
1192
1193 fn ids(path: &Path) -> Vec<String> {
1195 fs::read_to_string(path)
1196 .unwrap()
1197 .lines()
1198 .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
1199 .filter_map(|value| value["id"].as_str().map(String::from))
1200 .collect()
1201 }
1202
1203 const AGE: RetentionPolicy = RetentionPolicy {
1205 max_age_days: Some(1),
1206 max_disk_bytes: None,
1207 include_notes: true,
1208 };
1209
1210 fn save_during_next_rewrite(
1215 roots: &RetentionRoots,
1216 ns: &'static str,
1217 id: &'static str,
1218 ) -> Arc<Mutex<Option<JoinHandle<()>>>> {
1219 let slot = Arc::new(Mutex::new(None));
1220 let (left, roots, path) = (slot.clone(), roots.clone(), notes_file(roots, ns));
1221 AFTER_REWRITE_READ.with(|hook| {
1222 *hook.borrow_mut() = Some(Box::new(move || {
1223 let saver = {
1224 let roots = roots.clone();
1225 std::thread::spawn(move || save(&roots, ns, id, crate::epoch_millis()))
1226 };
1227 std::thread::sleep(Duration::from_millis(150));
1228 assert!(!saver.is_finished(), "the save waits for the rewrite");
1229 assert!(!ids(&path).contains(&id.to_string()));
1230 *left.lock().unwrap() = Some(saver);
1231 }));
1232 });
1233 slot
1234 }
1235
1236 fn join_save(slot: &Arc<Mutex<Option<JoinHandle<()>>>>) {
1237 let saver = slot.lock().unwrap().take();
1238 saver.expect("the rewriter read the file").join().unwrap();
1239 }
1240
1241 #[test]
1246 fn a_save_waits_for_a_rewrite_and_its_note_is_in_the_file_after_it() {
1247 let now = crate::epoch_millis();
1248
1249 let tmp = TempDir::new().unwrap();
1251 let roots = roots(&tmp);
1252 save(&roots, "ns", "mem-old", 1);
1253 save(&roots, "ns", "mem-new", now);
1254 let late = save_during_next_rewrite(&roots, "ns", "mem-late");
1255 let outcome = sweep_at(&roots, &AGE, now).unwrap();
1256 join_save(&late);
1257 assert_eq!(outcome.memory_records_dropped, 1);
1258 assert_eq!(ids(¬es_file(&roots, "ns")), ["mem-new", "mem-late"]);
1259
1260 let tmp = TempDir::new().unwrap();
1262 let roots = self::roots(&tmp);
1263 save(&roots, "ns", "mem-old", 1);
1264 save(&roots, "ns", "mem-new", 2);
1265 let late = save_during_next_rewrite(&roots, "ns", "mem-late");
1266 assert_eq!(evict_oldest_memory_records(&roots, 1).unwrap(), 1);
1267 join_save(&late);
1268 assert_eq!(ids(¬es_file(&roots, "ns")), ["mem-new", "mem-late"]);
1269
1270 let tmp = TempDir::new().unwrap();
1272 let roots = self::roots(&tmp);
1273 save(&roots, "ns", "mem-a", now);
1274 let late = save_during_next_rewrite(&roots, "ns", "mem-late");
1275 forget(&roots, RetentionDomain::Memory, "ns:mem-a").unwrap();
1276 join_save(&late);
1277 let path = notes_file(&roots, "ns");
1278 assert_eq!(ids(&path), ["mem-a", "mem-late"]);
1279 assert!(fs::read_to_string(&path)
1280 .unwrap()
1281 .contains(r#""tombstone":"mem-a""#));
1282 }
1283
1284 #[test]
1288 fn saves_beside_repeated_rewrites_lose_no_note() {
1289 const SAVERS: usize = 4;
1290 const SAVES: usize = 40;
1291 let tmp = TempDir::new().unwrap();
1292 let roots = roots(&tmp);
1293 save(&roots, "ns", "mem-first", crate::epoch_millis());
1294 let done = Arc::new(AtomicBool::new(false));
1295
1296 let rewriter = {
1297 let (roots, done) = (roots.clone(), done.clone());
1298 std::thread::spawn(move || {
1299 let mut rewrites = 0usize;
1300 let mut round = 0usize;
1301 while !done.load(Ordering::Acquire) || round < 3 {
1302 let now = crate::epoch_millis();
1303 let victim = format!("mem-victim-{round}");
1304 save(&roots, "ns", &victim, now);
1305 save(&roots, "ns", &format!("mem-aged-{round}"), 1);
1306 forget(&roots, RetentionDomain::Memory, &format!("ns:{victim}")).unwrap();
1307 let outcome = sweep_at(&roots, &AGE, now).unwrap();
1308 assert_eq!(outcome.memory_records_dropped, 2, "round {round}");
1309 rewrites += 2;
1310 round += 1;
1311 }
1312 rewrites
1313 })
1314 };
1315 let savers: Vec<_> = (0..SAVERS)
1316 .map(|saver| {
1317 let roots = roots.clone();
1318 std::thread::spawn(move || {
1319 for n in 0..SAVES {
1320 save(&roots, "ns", &format!("mem-{saver}-{n}"), crate::epoch_millis());
1321 std::thread::sleep(Duration::from_millis(1));
1322 }
1323 })
1324 })
1325 .collect();
1326 for saver in savers {
1327 saver.join().unwrap();
1328 }
1329 done.store(true, Ordering::Release);
1330 assert!(rewriter.join().unwrap() >= 6);
1331
1332 let mut found = ids(¬es_file(&roots, "ns"));
1333 found.sort();
1334 let mut expected: Vec<String> = (0..SAVERS)
1335 .flat_map(|saver| (0..SAVES).map(move |n| format!("mem-{saver}-{n}")))
1336 .collect();
1337 expected.push("mem-first".into());
1338 expected.sort();
1339 assert_eq!(found, expected);
1340 }
1341
1342 #[test]
1345 fn a_rewrite_that_cannot_get_the_lock_fails_and_rewrites_nothing() {
1346 let tmp = TempDir::new().unwrap();
1347 let roots = roots(&tmp);
1348 let now = crate::epoch_millis();
1349 save(&roots, "ns", "mem-old", 1);
1350 save(&roots, "ns", "mem-new", now);
1351 let path = notes_file(&roots, "ns");
1352 let before = fs::read(&path).unwrap();
1353
1354 let held = store::lock_notes_file(&path).unwrap();
1355 store::set_notes_lock_wait(Some(Duration::from_millis(60)));
1356 let swept = sweep_at(&roots, &AGE, now).unwrap_err();
1357 let evicted = evict_oldest_memory_records(&roots, 1).unwrap_err();
1358 let forgotten = forget(&roots, RetentionDomain::Memory, "ns:mem-new").unwrap_err();
1359 store::set_notes_lock_wait(None);
1360 for message in [swept.to_string(), evicted.to_string(), forgotten.to_string()] {
1361 assert!(message.contains("ns.jsonl is locked by another writer"), "{message}");
1362 }
1363 assert_eq!(fs::read(&path).unwrap(), before);
1364 assert!(!path.with_extension("jsonl.tmp").exists());
1365
1366 drop(held);
1368 assert_eq!(sweep_at(&roots, &AGE, now).unwrap().memory_records_dropped, 1);
1369 forget(&roots, RetentionDomain::Memory, "ns:mem-new").unwrap();
1370 }
1371
1372 #[test]
1376 fn a_tombstone_outlives_every_rewrite() {
1377 use crate::memory::store::{
1378 import_line_in, ImportOutcome, ImportRun, ImportTarget, MemoryProvenance,
1379 MemoryRetention, MemorySensitivity, NewMemoryRecord, ProjectScope,
1380 };
1381 let tmp = TempDir::new().unwrap();
1382 let roots = roots(&tmp);
1383 let project = tmp.path().join("project");
1384 fs::create_dir_all(&project).unwrap();
1385 let scope = ProjectScope::for_root(&project).unwrap();
1386 let ns = scope.namespace();
1387 let path = notes_file(&roots, &ns);
1388 let stored = |content: &str| {
1389 let new = NewMemoryRecord {
1390 content: content.to_string(),
1391 tags: vec![],
1392 provenance: MemoryProvenance {
1393 source: "model".into(),
1394 session: None,
1395 },
1396 sensitivity: MemorySensitivity::Normal,
1397 retention: MemoryRetention::Standard,
1398 };
1399 let record = store::store_record_in(&roots.base_dir, &scope, new).unwrap();
1400 std::thread::sleep(Duration::from_millis(2)); record.id.unwrap()
1402 };
1403 let inode = || {
1404 #[cfg(unix)]
1405 {
1406 std::os::unix::fs::MetadataExt::ino(&fs::metadata(&path).unwrap())
1407 }
1408 #[cfg(not(unix))]
1409 {
1410 0u64
1411 }
1412 };
1413
1414 let forgotten = stored("forgotten");
1415 let exported = fs::read_to_string(&path).unwrap().trim().to_string();
1416 let (second, third, fourth) = (stored("second"), stored("third"), stored("fourth"));
1417 store::forget_in(&roots.base_dir, &scope, &forgotten).unwrap();
1418 let tombstone = fs::read_to_string(&path)
1419 .unwrap()
1420 .lines()
1421 .last()
1422 .unwrap()
1423 .to_string();
1424 assert!(tombstone.contains(&format!(r#""tombstone":"{forgotten}""#)));
1425
1426 let check = |live: &[&String]| {
1429 let text = fs::read_to_string(&path).unwrap();
1430 assert_eq!(text.lines().filter(|line| *line == tombstone).count(), 1);
1431 let live: Vec<String> = live.iter().map(|id| id.to_string()).collect();
1432 assert_eq!(ids(&path), live);
1433 let imported = import_line_in(
1434 &roots.base_dir,
1435 &exported,
1436 ImportTarget::AsRecorded,
1437 &mut ImportRun::new(false),
1438 )
1439 .unwrap();
1440 assert_eq!(imported.outcome.name(), ImportOutcome::KeptForgotten.name());
1441 assert_eq!(fs::read_to_string(&path).unwrap(), text, "nothing imported");
1442 };
1443
1444 #[cfg(unix)]
1447 let before = inode();
1448 let now = crate::epoch_millis();
1449 let none = RetentionPolicy::default();
1450 assert_eq!(sweep_at(&roots, &none, now).unwrap().memory_records_dropped, 1);
1451 #[cfg(unix)]
1452 assert_ne!(inode(), before, "the sweep rewrote the file");
1453 check(&[&second, &third, &fourth]);
1454
1455 let before = inode();
1458 assert_eq!(sweep_at(&roots, &none, now).unwrap().memory_records_dropped, 0);
1459 assert_eq!(inode(), before, "a sweep with nothing to drop does not rewrite");
1460 check(&[&second, &third, &fourth]);
1461
1462 assert_eq!(evict_oldest_memory_records(&roots, 1).unwrap(), 1);
1464 #[cfg(unix)]
1465 assert_ne!(inode(), before, "the eviction rewrote the file");
1466 check(&[&third, &fourth]);
1467
1468 store::forget_in(&roots.base_dir, &scope, &third).unwrap();
1471 check(&[&third, &fourth]);
1472 #[cfg(unix)]
1473 let before = inode();
1474 forget(&roots, RetentionDomain::Memory, &format!("{ns}:{fourth}")).unwrap();
1475 #[cfg(unix)]
1476 assert_ne!(inode(), before, "the forget rewrote the file");
1477 check(&[&third, &fourth]);
1478 let text = fs::read_to_string(&path).unwrap();
1479 for id in [&forgotten, &third, &fourth] {
1480 assert!(text.contains(&format!(r#""tombstone":"{id}""#)), "{id}");
1481 }
1482
1483 assert_eq!(sweep_at(&roots, &none, now).unwrap().memory_records_dropped, 2);
1486 check(&[]);
1487 let text = fs::read_to_string(&path).unwrap();
1488 assert_eq!(text.lines().count(), 3);
1489 assert_eq!(text.matches(r#""tombstone":"#).count(), 3);
1490 }
1491
1492 #[test]
1498 fn a_line_that_is_not_utf8_does_not_stop_a_rewriter() {
1499 let torn = b"{\"namespace\":\"ns\",\"content\":\"caf\xc3";
1500 let file_with_fragment = |roots: &RetentionRoots| {
1501 save(roots, "ns", "mem-a", 10);
1502 let path = notes_file(roots, "ns");
1503 let mut bytes = fs::read(&path).unwrap();
1504 bytes.extend_from_slice(torn);
1505 fs::write(&path, bytes).unwrap();
1506 save(roots, "ns", "mem-b", 20);
1508 save(roots, "ns", "mem-c", crate::epoch_millis());
1509 assert!(fs::read_to_string(&path).is_err(), "the file is not UTF-8");
1510 path
1511 };
1512 let has_fragment = |path: &Path| {
1513 let bytes = fs::read(path).unwrap();
1514 bytes.windows(torn.len()).any(|window| window == torn)
1515 };
1516
1517 let tmp = TempDir::new().unwrap();
1519 let roots = roots(&tmp);
1520 let path = file_with_fragment(&roots);
1521 let before = fs::read(&path).unwrap();
1522 forget(&roots, RetentionDomain::Memory, "ns:mem-b").unwrap();
1523 let after = fs::read(&path).unwrap();
1524 assert!(after.starts_with(&before));
1525 assert!(has_fragment(&path));
1526 let added = std::str::from_utf8(&after[before.len()..]).unwrap();
1527 assert!(added.contains(r#""tombstone":"mem-b""#) && added.ends_with('\n'));
1528 assert_eq!(added.lines().count(), 1);
1529
1530 let outcome = sweep_at(&roots, &RetentionPolicy::default(), 30).unwrap();
1533 assert_eq!(outcome.memory_records_dropped, 2);
1534 assert!(!has_fragment(&path));
1535 assert_eq!(ids(&path), ["mem-a", "mem-c"]);
1536 assert!(fs::read_to_string(&path).unwrap().contains(r#""tombstone":"mem-b""#));
1537
1538 let tmp = TempDir::new().unwrap();
1541 let roots = self::roots(&tmp);
1542 let path = file_with_fragment(&roots);
1543 assert_eq!(evict_oldest_memory_records(&roots, 1).unwrap(), 1);
1544 assert!(!has_fragment(&path));
1545 assert_eq!(ids(&path), ["mem-b", "mem-c"]);
1546
1547 let lines: Vec<_> = notes_lines(b"a\r\n\n\xff\nb").collect();
1549 assert_eq!(lines, [Some("a"), Some(""), None, Some("b")]);
1550 assert_eq!(notes_lines(b"").count(), 0);
1551 assert_eq!(notes_lines(b"a\n").collect::<Vec<_>>(), [Some("a")]);
1552 assert_eq!(notes_lines(b"\n").collect::<Vec<_>>(), [Some("")]);
1553 }
1554
1555 #[test]
1560 fn an_age_sweep_keeps_saved_notes_unless_it_is_told_to_include_them() {
1561 use crate::memory::store::MemoryRetention;
1562 let tmp = TempDir::new().unwrap();
1563 let roots = roots(&tmp);
1564 let now = 1_000 * DAY_MS;
1565 let with_class = |id: &str, timestamp_ms: u64, class: MemoryRetention| {
1566 let mut record = note("ns", id, timestamp_ms);
1567 record.retention = Some(class);
1568 store::append_to(&roots.base_dir, &record).unwrap();
1569 };
1570 with_class("mem-standard-old", DAY_MS, MemoryRetention::Standard);
1571 save(&roots, "ns", "mem-classless-old", 2 * DAY_MS);
1572 with_class("mem-expired", 900 * DAY_MS, MemoryRetention::MaxAgeDays(30));
1573 with_class("mem-not-expired", 990 * DAY_MS, MemoryRetention::MaxAgeDays(30));
1574 with_class("mem-standard-new", 999 * DAY_MS, MemoryRetention::Standard);
1575 let path = notes_file(&roots, "ns");
1576 let age = |include_notes| RetentionPolicy {
1577 max_age_days: Some(5),
1578 max_disk_bytes: None,
1579 include_notes,
1580 };
1581
1582 let outcome = sweep_at(&roots, &age(false), now).unwrap();
1583 assert_eq!(outcome.memory_records_dropped, 1, "the note past its own expiry");
1584 assert_eq!(
1585 ids(&path),
1586 ["mem-standard-old", "mem-classless-old", "mem-not-expired", "mem-standard-new"]
1587 );
1588 assert_eq!(sweep_at(&roots, &age(false), now).unwrap().memory_records_dropped, 0);
1590
1591 let outcome = sweep_at(&roots, &age(true), now).unwrap();
1594 assert_eq!(outcome.memory_records_dropped, 3);
1595 assert_eq!(ids(&path), ["mem-standard-new"]);
1596
1597 let none = RetentionPolicy {
1599 include_notes: true,
1600 ..Default::default()
1601 };
1602 assert_eq!(sweep_at(&roots, &none, now).unwrap().memory_records_dropped, 0);
1603
1604 with_class("mem-standard-newer", now, MemoryRetention::Standard);
1606 let total = inspect(&roots).unwrap().total_bytes;
1607 let budget = |include_notes| RetentionPolicy {
1608 max_disk_bytes: Some(total - 1),
1609 include_notes,
1610 ..Default::default()
1611 };
1612 assert!(matches!(
1613 sweep_at(&roots, &budget(false), now),
1614 Err(RetentionError::BudgetUnmet { notes_kept: true, .. })
1615 ));
1616 assert_eq!(ids(&path), ["mem-standard-new", "mem-standard-newer"]);
1617 assert_eq!(sweep_at(&roots, &budget(true), now).unwrap().memory_records_dropped, 1);
1618 assert_eq!(ids(&path), ["mem-standard-newer"]);
1619 }
1620
1621 #[test]
1626 fn a_note_with_an_imported_id_can_be_forgotten_from_the_command_line() {
1627 let tmp = TempDir::new().unwrap();
1628 let roots = roots(&tmp);
1629 for id in ["note:2024/a", "note:2024/ab", "mem-x"] {
1630 save(&roots, "ns", id, 5);
1631 }
1632 let path = notes_file(&roots, "ns");
1633 forget(&roots, RetentionDomain::Memory, "ns:note:2024/a").unwrap();
1634 let text = fs::read_to_string(&path).unwrap();
1635 assert_eq!(text.matches("tombstone").count(), 1);
1636 assert!(text.contains(r#""tombstone":"note:2024/a""#));
1637 let again = forget(&roots, RetentionDomain::Memory, "ns:note:2024/a").unwrap_err();
1638 assert!(again.to_string().contains("not present as a live record"), "{again}");
1639 let part = forget(&roots, RetentionDomain::Memory, "ns:note:2024").unwrap_err();
1640 assert!(part.to_string().contains("not present as a live record"), "{part}");
1641
1642 let before = fs::read(&path).unwrap();
1643 let long = format!("ns:{}", "i".repeat(129));
1644 for bad in ["ns:", "ns:has space", "ns:tab\there", long.as_str()] {
1645 let error = forget(&roots, RetentionDomain::Memory, bad).unwrap_err();
1646 assert!(error.to_string().starts_with("invalid memory record id"), "{error}");
1647 }
1648 assert_eq!(fs::read(&path).unwrap(), before);
1649 }
1650
1651 #[test]
1655 fn the_lock_file_is_not_a_notes_file() {
1656 let tmp = TempDir::new().unwrap();
1657 let roots = roots(&tmp);
1658 let now = crate::epoch_millis();
1659 save(&roots, "ns", "mem-old", 1);
1660 save(&roots, "ns", "mem-new", now);
1661 let path = notes_file(&roots, "ns");
1662 let lock = roots.memory_dir().join("ns.jsonl.lock");
1663 assert_eq!(store::notes_lock_path(&path), lock);
1664 assert!(lock.is_file());
1665
1666 assert_eq!(store::list_namespaces_in(&roots.base_dir).unwrap(), ["ns"]);
1667 let counted = |roots: &RetentionRoots| {
1668 let report = inspect(roots).unwrap();
1669 let memory = report
1670 .domains
1671 .iter()
1672 .find(|d| d.domain == RetentionDomain::Memory)
1673 .unwrap();
1674 (memory.files, memory.bytes)
1675 };
1676 assert_eq!(counted(&roots), (1, fs::metadata(&path).unwrap().len()));
1677
1678 assert_eq!(sweep_at(&roots, &AGE, now).unwrap().memory_records_dropped, 1);
1680 assert_eq!(evict_oldest_memory_records(&roots, 1).unwrap(), 1);
1681 assert!(lock.is_file(), "the lock file is never removed or renamed");
1682 assert_eq!(fs::metadata(&lock).unwrap().len(), 0);
1683 assert_eq!(counted(&roots), (1, 0));
1684 assert_eq!(store::list_namespaces_in(&roots.base_dir).unwrap(), ["ns"]);
1685
1686 #[cfg(unix)]
1687 {
1688 use std::os::unix::fs::PermissionsExt;
1689 assert_eq!(fs::metadata(&lock).unwrap().permissions().mode() & 0o777, 0o600);
1690 let dest = tmp.path().join("export");
1691 let summary = export(&roots, &dest).unwrap();
1692 assert_eq!(summary.files, 1);
1693 let exported: Vec<_> = fs::read_dir(dest.join("memory"))
1694 .unwrap()
1695 .map(|entry| entry.unwrap().file_name())
1696 .collect();
1697 assert_eq!(exported, ["ns.jsonl"]);
1698 }
1699 }
1700}