1use std::fs;
21use std::io;
22use std::path::{Path, PathBuf};
23
24use serde::Serialize;
25
26#[derive(Debug)]
28pub enum RetentionError {
29 Io(io::Error),
30 UnreadableChain(PathBuf),
33 BudgetUnmet {
36 budget: u64,
37 remaining: u64,
38 protected_chain_heads: usize,
39 },
40}
41
42impl std::fmt::Display for RetentionError {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 RetentionError::Io(e) => write!(f, "retention io error: {e}"),
46 RetentionError::UnreadableChain(path) => write!(
47 f,
48 "unreadable named-chain file {} — refusing every destructive session \
49 operation while chain protection is unknown",
50 path.display()
51 ),
52 RetentionError::BudgetUnmet {
53 budget,
54 remaining,
55 protected_chain_heads,
56 } => write!(
57 f,
58 "disk budget unmet: {remaining} bytes remain against a budget of \
59 {budget} after evicting every unprotected artifact \
60 ({protected_chain_heads} protected chain head(s) retained)"
61 ),
62 }
63 }
64}
65
66impl std::error::Error for RetentionError {}
67
68impl From<io::Error> for RetentionError {
69 fn from(e: io::Error) -> Self {
70 RetentionError::Io(e)
71 }
72}
73
74#[derive(Debug, Clone)]
77pub struct RetentionRoots {
78 pub config_dir: PathBuf,
80 pub base_dir: PathBuf,
82 pub cache_dir: PathBuf,
84}
85
86impl RetentionRoots {
87 pub fn resolve() -> Self {
89 let cache_dir = std::env::var("HOME")
90 .map(|home| PathBuf::from(home).join(".cache/synaps"))
91 .unwrap_or_else(|_| crate::config::base_dir().join("cache"));
92 Self {
93 config_dir: crate::config::get_active_config_dir(),
94 base_dir: crate::config::base_dir(),
95 cache_dir,
96 }
97 }
98
99 fn sessions_dir(&self) -> PathBuf {
100 self.config_dir.join("sessions")
101 }
102 fn chains_dir(&self) -> PathBuf {
103 self.config_dir.join("chains")
104 }
105 fn memory_dir(&self) -> PathBuf {
106 self.base_dir.join("memory")
107 }
108 fn index_dir(&self) -> PathBuf {
109 self.memory_dir().join("index")
110 }
111 fn spill_dir(&self) -> PathBuf {
112 crate::core::tool_spill::spill_root(&self.base_dir)
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
118#[serde(rename_all = "snake_case")]
119pub enum RetentionDomain {
120 Sessions,
121 Memory,
122 MemoryIndex,
123 Traces,
124 Logs,
125}
126
127#[derive(Debug, Clone, Copy, Default)]
129pub struct RetentionPolicy {
130 pub max_age_days: Option<u32>,
131 pub max_disk_bytes: Option<u64>,
132}
133
134const DAY_MS: u64 = 86_400_000;
135
136#[derive(Debug, Clone, Serialize)]
137pub struct DomainReport {
138 pub domain: RetentionDomain,
139 pub files: usize,
140 pub bytes: u64,
141}
142
143#[derive(Debug, Clone, Serialize)]
144pub struct RetentionReport {
145 pub domains: Vec<DomainReport>,
146 pub total_bytes: u64,
147}
148
149#[derive(Debug, Clone, Copy, Default, Serialize)]
150pub struct SweepOutcome {
151 pub deleted_files: usize,
152 pub freed_bytes: u64,
153 pub protected_chain_heads: usize,
154 pub memory_records_dropped: usize,
155}
156
157#[derive(Debug, Clone, Copy, Serialize)]
158pub struct ExportSummary {
159 pub files: usize,
160 pub bytes: u64,
161}
162
163fn list_files(dir: &Path) -> Vec<PathBuf> {
164 let mut out = Vec::new();
165 if let Ok(entries) = fs::read_dir(dir) {
166 for entry in entries.flatten() {
167 if entry.path().is_file() {
168 out.push(entry.path());
169 }
170 }
171 }
172 out.sort();
173 out
174}
175
176fn log_files(config_dir: &Path) -> Vec<PathBuf> {
177 let mut out: Vec<PathBuf> = list_files(config_dir)
178 .into_iter()
179 .filter(|p| {
180 p.file_name()
181 .and_then(|n| n.to_str())
182 .is_some_and(|n| n.starts_with("synaps.log"))
183 })
184 .collect();
185 out.sort();
186 out
187}
188
189fn dir_bytes(dir: &Path) -> u64 {
190 let mut total = 0;
191 if let Ok(entries) = fs::read_dir(dir) {
192 for entry in entries.flatten() {
193 let path = entry.path();
194 if path.is_dir() {
195 total += dir_bytes(&path);
196 } else if let Ok(meta) = entry.metadata() {
197 total += meta.len();
198 }
199 }
200 }
201 total
202}
203
204fn chain_heads(roots: &RetentionRoots) -> Result<Vec<String>, RetentionError> {
208 let mut heads = Vec::new();
209 for path in list_files(&roots.chains_dir()) {
210 let raw =
211 fs::read_to_string(&path).map_err(|_| RetentionError::UnreadableChain(path.clone()))?;
212 let value: serde_json::Value = serde_json::from_str(&raw)
213 .map_err(|_| RetentionError::UnreadableChain(path.clone()))?;
214 match value["head"].as_str() {
215 Some(head) => heads.push(head.to_string()),
216 None => return Err(RetentionError::UnreadableChain(path.clone())),
217 }
218 }
219 Ok(heads)
220}
221
222fn session_age_ms(path: &Path) -> Option<u64> {
224 if let Ok(raw) = fs::read_to_string(path) {
225 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) {
226 if let Some(updated) = value["updated_at"].as_str() {
227 if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(updated) {
228 return Some(ts.timestamp_millis().max(0) as u64);
229 }
230 }
231 }
232 }
233 mtime_ms(path)
234}
235
236fn mtime_ms(path: &Path) -> Option<u64> {
237 fs::metadata(path)
238 .and_then(|m| m.modified())
239 .ok()
240 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
241 .map(|d| d.as_millis() as u64)
242}
243
244pub fn inspect(roots: &RetentionRoots) -> io::Result<RetentionReport> {
246 let sessions = list_files(&roots.sessions_dir());
247 let memory: Vec<PathBuf> = list_files(&roots.memory_dir())
248 .into_iter()
249 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("jsonl"))
250 .collect();
251 let traces = cache_files(&roots.cache_dir);
252 let logs = log_files(&roots.config_dir);
253 let index_files = walk_regular_files(&roots.index_dir());
254
255 let sum = |files: &[PathBuf]| -> u64 {
256 files
257 .iter()
258 .filter_map(|p| fs::metadata(p).ok().map(|m| m.len()))
259 .sum()
260 };
261 let index_bytes = dir_bytes(&roots.index_dir());
262
263 let domains = vec![
264 DomainReport {
265 domain: RetentionDomain::Sessions,
266 files: sessions.len(),
267 bytes: sum(&sessions),
268 },
269 DomainReport {
270 domain: RetentionDomain::Memory,
271 files: memory.len(),
272 bytes: sum(&memory),
273 },
274 DomainReport {
275 domain: RetentionDomain::MemoryIndex,
276 files: index_files.len(),
277 bytes: index_bytes,
278 },
279 DomainReport {
280 domain: RetentionDomain::Traces,
281 files: traces.len(),
282 bytes: sum(&traces),
283 },
284 DomainReport {
285 domain: RetentionDomain::Logs,
286 files: logs.len(),
287 bytes: sum(&logs),
288 },
289 ];
290 let total_bytes = domains.iter().map(|d| d.bytes).sum();
291 Ok(RetentionReport {
292 domains,
293 total_bytes,
294 })
295}
296
297pub fn sweep_at(
301 roots: &RetentionRoots,
302 policy: &RetentionPolicy,
303 now_ms: u64,
304) -> Result<SweepOutcome, RetentionError> {
305 let mut outcome = SweepOutcome::default();
306 let protected: Vec<String> = chain_heads(roots)?;
309 let age_cutoff = policy
310 .max_age_days
311 .map(|days| now_ms.saturating_sub(days as u64 * DAY_MS));
312
313 for path in list_files(&roots.sessions_dir()) {
317 if path.extension().and_then(|e| e.to_str()) != Some("journal")
318 || path.with_extension("json").exists()
319 {
320 continue;
321 }
322 let id = path
323 .file_stem()
324 .and_then(|s| s.to_str())
325 .unwrap_or_default()
326 .to_string();
327 if protected.contains(&id) {
328 outcome.protected_chain_heads += 1;
329 continue;
330 }
331 delete_file(&path, &mut outcome)?;
332 }
333
334 if let Some(cutoff) = age_cutoff {
337 for path in list_files(&roots.sessions_dir()) {
338 if is_paired_journal(&path) {
341 continue;
342 }
343 let id = path
344 .file_stem()
345 .and_then(|s| s.to_str())
346 .unwrap_or_default()
347 .to_string();
348 if protected.contains(&id) {
349 outcome.protected_chain_heads += 1;
350 continue;
351 }
352 let Some(age_ref) = session_age_ms(&path) else {
353 continue; };
355 if age_ref < cutoff {
356 delete_session_artifact(&path, &mut outcome)?;
357 remove_session_spill(roots, &id, &mut outcome)?;
359 }
360 }
361
362 for path in cache_files(&roots.cache_dir)
365 .into_iter()
366 .chain(log_files(&roots.config_dir))
367 {
368 if mtime_ms(&path).is_some_and(|m| m < cutoff) {
369 delete_file(&path, &mut outcome)?;
370 }
371 }
372
373 let age = std::time::Duration::from_millis(now_ms.saturating_sub(cutoff));
376 let now = std::time::UNIX_EPOCH + std::time::Duration::from_millis(now_ms);
377 let swept = crate::core::tool_spill::sweep_stale_spill(&roots.spill_dir(), age, now);
378 outcome.deleted_files += swept.files;
379 outcome.freed_bytes += swept.bytes;
380 }
381
382 for path in list_files(&roots.memory_dir()) {
387 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
388 continue;
389 }
390 let dropped = compact_memory_file(&path, now_ms, age_cutoff)?;
391 if dropped > 0 {
392 outcome.memory_records_dropped += dropped;
393 if let Some(ns) = path.file_stem().and_then(|s| s.to_str()) {
396 let index_dir = roots.index_dir().join(ns);
397 if index_dir.exists() {
398 outcome.freed_bytes += dir_bytes(&index_dir);
399 fs::remove_dir_all(&index_dir)?;
400 }
401 }
402 }
403 }
404
405 if let Some(budget) = policy.max_disk_bytes {
411 let total_now = |roots: &RetentionRoots| -> u64 {
412 inspect(roots).map(|r| r.total_bytes).unwrap_or(u64::MAX)
413 };
414
415 if total_now(roots) > budget {
417 if let Ok(entries) = fs::read_dir(roots.index_dir()) {
418 for entry in entries.flatten() {
419 if total_now(roots) <= budget {
420 break;
421 }
422 let dir = entry.path();
423 if dir.is_dir() {
424 outcome.freed_bytes += dir_bytes(&dir);
425 fs::remove_dir_all(&dir).map_err(RetentionError::Io)?;
426 outcome.deleted_files += 1;
427 }
428 }
429 }
430 }
431
432 if total_now(roots) > budget {
434 let mut candidates: Vec<(u64, PathBuf, bool)> = Vec::new();
435 for path in list_files(&roots.sessions_dir()) {
436 if is_paired_journal(&path) {
438 continue;
439 }
440 let id = path
441 .file_stem()
442 .and_then(|s| s.to_str())
443 .unwrap_or_default()
444 .to_string();
445 let age = session_age_ms(&path).unwrap_or(u64::MAX);
446 candidates.push((age, path, protected.contains(&id)));
447 }
448 for path in cache_files(&roots.cache_dir)
449 .into_iter()
450 .chain(log_files(&roots.config_dir))
451 {
452 let age = mtime_ms(&path).unwrap_or(u64::MAX);
453 candidates.push((age, path, false));
454 }
455 candidates.sort_by_key(|item| item.0);
456 for (_, path, is_protected) in candidates {
457 if total_now(roots) <= budget {
458 break;
459 }
460 if is_protected {
461 outcome.protected_chain_heads += 1;
462 continue;
463 }
464 delete_file(&path, &mut outcome).map_err(RetentionError::Io)?;
465 }
466 }
467
468 if total_now(roots) > budget {
470 let over = total_now(roots) - budget;
471 let dropped = evict_oldest_memory_records(roots, over)?;
472 outcome.memory_records_dropped += dropped;
473 }
474
475 let remaining = total_now(roots);
477 if remaining > budget {
478 return Err(RetentionError::BudgetUnmet {
479 budget,
480 remaining,
481 protected_chain_heads: protected.len(),
482 });
483 }
484 }
485
486 Ok(outcome)
487}
488
489fn evict_oldest_memory_records(
493 roots: &RetentionRoots,
494 bytes_to_free: u64,
495) -> Result<usize, RetentionError> {
496 let mut live: Vec<(u64, PathBuf, String, usize)> = Vec::new();
498 for path in list_files(&roots.memory_dir()) {
499 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
500 continue;
501 }
502 let raw = fs::read_to_string(&path).map_err(RetentionError::Io)?;
503 let mut tombstoned: std::collections::HashSet<String> = std::collections::HashSet::new();
504 let mut records: Vec<(u64, String, usize)> = Vec::new();
505 for line in raw.lines() {
506 let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
507 continue;
508 };
509 if let Some(id) = value["tombstone"].as_str() {
510 tombstoned.insert(id.to_string());
511 continue;
512 }
513 if let Some(id) = value["id"].as_str() {
514 records.push((
515 value["timestamp_ms"].as_u64().unwrap_or(0),
516 id.to_string(),
517 line.len() + 1,
518 ));
519 }
520 }
521 for (ts, id, len) in records {
522 if !tombstoned.contains(&id) {
523 live.push((ts, path.clone(), id, len));
524 }
525 }
526 }
527 live.sort_by_key(|item| item.0);
528
529 let mut freed: u64 = 0;
530 let mut victims: std::collections::HashMap<PathBuf, std::collections::HashSet<String>> =
531 std::collections::HashMap::new();
532 let mut dropped = 0usize;
533 for (_, path, id, len) in live {
534 if freed >= bytes_to_free {
535 break;
536 }
537 victims.entry(path).or_default().insert(id);
538 freed += len as u64;
539 dropped += 1;
540 }
541
542 for (path, ids) in victims {
543 let raw = fs::read_to_string(&path).map_err(RetentionError::Io)?;
544 let kept: Vec<&str> = raw
545 .lines()
546 .filter(|line| {
547 let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
548 return false; };
550 if let Some(id) = value["id"].as_str() {
551 return !ids.contains(id);
552 }
553 if let Some(id) = value["tombstone"].as_str() {
556 return !ids.contains(id);
557 }
558 true
559 })
560 .collect();
561 let mut body = kept.join("\n");
562 if !body.is_empty() {
563 body.push('\n');
564 }
565 crate::core::private_fs::write_atomic_private(&path, body.as_bytes())
566 .map_err(io::Error::from)
567 .map_err(RetentionError::Io)?;
568 }
569 Ok(dropped)
570}
571
572fn walk_regular_files(root: &Path) -> Vec<PathBuf> {
576 fn recurse(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
577 if depth > 32 {
578 return;
579 }
580 if let Ok(entries) = fs::read_dir(dir) {
581 for entry in entries.flatten() {
582 let Ok(file_type) = entry.file_type() else {
583 continue;
584 };
585 if file_type.is_symlink() {
586 continue; }
588 let path = entry.path();
589 if file_type.is_dir() {
590 recurse(&path, depth + 1, out);
591 } else if file_type.is_file() {
592 out.push(path);
593 }
594 }
595 }
596 }
597 let mut out = Vec::new();
598 recurse(root, 0, &mut out);
599 out.sort();
600 out
601}
602
603fn cache_files(cache_dir: &Path) -> Vec<PathBuf> {
605 walk_regular_files(cache_dir)
606}
607
608pub fn sweep(
610 roots: &RetentionRoots,
611 policy: &RetentionPolicy,
612) -> Result<SweepOutcome, RetentionError> {
613 sweep_at(roots, policy, crate::epoch_millis())
614}
615
616fn delete_file(path: &Path, outcome: &mut SweepOutcome) -> io::Result<()> {
617 let len = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
618 fs::remove_file(path)?;
619 outcome.deleted_files += 1;
620 outcome.freed_bytes += len;
621 Ok(())
622}
623
624const PAIRED_SESSION_EXTENSIONS: [&str; 2] = ["journal", "turn"];
628
629fn is_paired_journal(path: &Path) -> bool {
633 path.extension()
634 .and_then(|e| e.to_str())
635 .is_some_and(|e| PAIRED_SESSION_EXTENSIONS.contains(&e))
636 && path.with_extension("json").exists()
637}
638
639fn remove_session_spill(
641 roots: &RetentionRoots,
642 id: &str,
643 outcome: &mut SweepOutcome,
644) -> io::Result<()> {
645 let freed = crate::core::tool_spill::remove_session_spill(&roots.spill_dir(), id)?;
646 outcome.deleted_files += freed.files;
647 outcome.freed_bytes += freed.bytes;
648 Ok(())
649}
650
651fn delete_session_artifact(path: &Path, outcome: &mut SweepOutcome) -> io::Result<()> {
655 delete_file(path, outcome)?;
656 if path.extension().and_then(|e| e.to_str()) == Some("json") {
657 for ext in PAIRED_SESSION_EXTENSIONS {
658 let paired = path.with_extension(ext);
659 if paired.exists() {
660 delete_file(&paired, outcome)?;
661 }
662 }
663 }
664 Ok(())
665}
666
667fn compact_memory_file(
672 path: &Path,
673 now_ms: u64,
674 global_cutoff_ms: Option<u64>,
675) -> io::Result<usize> {
676 let raw = fs::read_to_string(path)?;
677 let mut tombstoned: std::collections::HashSet<String> = std::collections::HashSet::new();
678 let mut records: Vec<(Option<String>, u64, Option<u64>, String)> = Vec::new();
679 let mut dropped = 0usize;
680
681 for line in raw.lines() {
682 let trimmed = line.trim();
683 if trimmed.is_empty() {
684 continue;
685 }
686 let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
687 dropped += 1; continue;
689 };
690 if let Some(id) = value["tombstone"].as_str() {
691 tombstoned.insert(id.to_string());
692 continue;
693 }
694 let ts = value["timestamp_ms"].as_u64().unwrap_or(0);
695 let per_record_span_ms = value["retention"]["max_age_days"]
696 .as_u64()
697 .map(|days| days * DAY_MS);
698 records.push((
699 value["id"].as_str().map(String::from),
700 ts,
701 per_record_span_ms,
702 trimmed.to_string(),
703 ));
704 }
705
706 let mut kept: Vec<String> = Vec::new();
707 for (id, ts, per_record_span_ms, line) in records {
708 if id.as_ref().is_some_and(|id| tombstoned.contains(id)) {
709 dropped += 1;
710 continue;
711 }
712 if per_record_span_ms.is_some_and(|span| ts.saturating_add(span) < now_ms) {
713 dropped += 1;
714 continue;
715 }
716 if global_cutoff_ms.is_some_and(|cutoff| ts < cutoff) {
717 dropped += 1;
718 continue;
719 }
720 kept.push(line);
721 }
722
723 if dropped > 0 {
725 let mut body = kept.join("\n");
726 if !body.is_empty() {
727 body.push('\n');
728 }
729 crate::core::private_fs::write_atomic_private(path, body.as_bytes())
730 .map_err(io::Error::from)?;
731 }
732 Ok(dropped)
733}
734
735#[cfg(unix)]
743pub fn export(roots: &RetentionRoots, dest: &Path) -> io::Result<ExportSummary> {
744 use crate::private_fs::ConfinedDir;
745 let mut summary = ExportSummary { files: 0, bytes: 0 };
746 let root = ConfinedDir::create_root(dest)?;
747
748 let flat = [
753 (roots.sessions_dir(), "sessions"),
754 (roots.memory_dir(), "memory"),
755 ];
756 for (src, sub) in flat {
757 let Ok(src_root) = ConfinedDir::open_root(&src) else {
758 continue; };
760 let out = root.child_dir(sub)?;
761 for entry in src_root.entries()? {
762 if !entry.is_file {
763 continue; }
765 let components = [entry.name.clone()];
766 match src_root.open_file(&components) {
767 Ok(source) => {
768 let bytes = copy_opened_into_confined(source, &out, &entry.name)?;
769 summary.bytes += bytes;
770 summary.files += 1;
771 }
772 Err(_) => continue,
775 }
776 }
777 }
778
779 if let Ok(cache_root) = ConfinedDir::open_root(&roots.cache_dir) {
781 let traces_root = root.child_dir("traces")?;
782 export_tree_from_handles(&cache_root, &traces_root, &mut summary, 0)?;
783 }
784
785 if let Ok(logs_root) = ConfinedDir::open_root(&roots.config_dir) {
788 let logs_out = root.child_dir("logs")?;
789 for entry in logs_root.entries()? {
790 if !entry.is_file || !entry.name.starts_with("synaps.log") {
791 continue;
792 }
793 match logs_root.open_file(std::slice::from_ref(&entry.name)) {
794 Ok(source) => {
795 let bytes = copy_opened_into_confined(source, &logs_out, &entry.name)?;
796 summary.bytes += bytes;
797 summary.files += 1;
798 }
799 Err(_) => continue,
800 }
801 }
802 }
803 Ok(summary)
804}
805
806#[cfg(unix)]
809fn copy_opened_into_confined(
810 mut source: fs::File,
811 dest_dir: &crate::private_fs::ConfinedDir,
812 name: &str,
813) -> io::Result<u64> {
814 use std::io::{Read, Write};
815 if !source.metadata()?.is_file() {
816 return Err(io::Error::other("confined export source is not regular"));
817 }
818 let mut out = dest_dir.create_file(name)?;
819 let mut buf = [0u8; 64 * 1024];
820 let mut written = 0u64;
821 loop {
822 let n = source.read(&mut buf)?;
823 if n == 0 {
824 break;
825 }
826 out.write_all(&buf[..n])?;
827 written += n as u64;
828 }
829 Ok(written)
830}
831
832#[cfg(unix)]
836fn export_tree_from_handles(
837 source: &crate::private_fs::ConfinedDir,
838 destination: &crate::private_fs::ConfinedDir,
839 summary: &mut ExportSummary,
840 depth: usize,
841) -> io::Result<()> {
842 if depth > 32 {
843 return Err(io::Error::other("confined export source depth exceeds 32"));
844 }
845 for entry in source.entries()? {
846 if entry.is_file {
847 match source.open_file(std::slice::from_ref(&entry.name)) {
848 Ok(opened) => {
849 let bytes = copy_opened_into_confined(opened, destination, &entry.name)?;
850 summary.bytes += bytes;
851 summary.files += 1;
852 }
853 Err(_) => continue,
854 }
855 } else if entry.is_dir {
856 let source_child = match source.open_dirs(std::slice::from_ref(&entry.name)) {
857 Ok(child) => child,
858 Err(_) => continue,
859 };
860 let dest_child = destination.child_dir(&entry.name)?;
861 export_tree_from_handles(&source_child, &dest_child, summary, depth + 1)?;
862 }
863 }
864 Ok(())
865}
866
867#[cfg(not(unix))]
869pub fn export(_roots: &RetentionRoots, _dest: &Path) -> io::Result<ExportSummary> {
870 Err(io::Error::other(
871 "confined export requires a unix platform (directory-handle-relative creation)",
872 ))
873}
874
875pub fn forget(roots: &RetentionRoots, domain: RetentionDomain, id: &str) -> io::Result<()> {
879 match domain {
880 RetentionDomain::Sessions => {
881 let file = sanitize_file_id(id)?;
883 let heads = chain_heads(roots).map_err(|e| io::Error::other(e.to_string()))?;
884 if heads.contains(&id.to_string()) {
885 return Err(io::Error::other(format!(
886 "session {id} is the head of a named chain — delete or repoint the \
887 chain first (retention never leaves chains dangling)"
888 )));
889 }
890 let path = roots
891 .sessions_dir()
892 .join(format!("{}.json", file.display()));
893 fs::remove_file(path)?;
894 for ext in PAIRED_SESSION_EXTENSIONS {
897 let paired = roots
898 .sessions_dir()
899 .join(format!("{}.{ext}", file.display()));
900 match fs::remove_file(paired) {
901 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
902 other => other?,
903 }
904 }
905 crate::core::tool_spill::remove_session_spill(&roots.spill_dir(), id)?;
907 Ok(())
908 }
909 RetentionDomain::Traces => {
910 #[cfg(unix)]
914 {
915 let mut components = crate::private_fs::validated_relative_components(id)
916 .map_err(|_| io::Error::other(format!("invalid artifact id: {id:?}")))?;
917 let name = components
918 .pop()
919 .ok_or_else(|| io::Error::other(format!("invalid artifact id: {id:?}")))?;
920 let root = crate::private_fs::ConfinedDir::open_root(&roots.cache_dir)?;
921 let dir = root.open_dirs(&components)?;
922 dir.remove_file(&name)
923 }
924 #[cfg(not(unix))]
925 {
926 fs::remove_file(roots.cache_dir.join(sanitize_file_id(id)?))
927 }
928 }
929 RetentionDomain::Logs => {
930 let file = sanitize_file_id(id)?;
931 if !id.starts_with("synaps.log") {
932 return Err(io::Error::other(
933 "invalid log id: must start with synaps.log",
934 ));
935 }
936 fs::remove_file(roots.config_dir.join(file))
937 }
938 RetentionDomain::MemoryIndex => {
939 let dir = roots.index_dir().join(sanitize_file_id(id)?);
940 fs::remove_dir_all(dir)
941 }
942 RetentionDomain::Memory => {
943 let (namespace, record_id) = id.split_once(':').ok_or_else(|| {
944 io::Error::other("invalid memory id: use \"<namespace>:<record-id>\"")
945 })?;
946 let namespace = sanitize_file_id(namespace)?;
947 if record_id.is_empty() || !record_id.starts_with("mem-") {
948 return Err(io::Error::other(format!(
949 "invalid memory record id: {record_id:?}"
950 )));
951 }
952 let path = roots
953 .memory_dir()
954 .join(format!("{}.jsonl", namespace.display()));
955 let raw = fs::read_to_string(&path)?;
956
957 let mut live = false;
961 let mut tombstoned = false;
962 for line in raw.lines() {
963 let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
964 continue;
965 };
966 if value["tombstone"].as_str() == Some(record_id) {
967 tombstoned = true;
968 } else if value["id"].as_str() == Some(record_id) {
969 live = true;
970 }
971 }
972 if !live || tombstoned {
973 return Err(io::Error::other(format!(
974 "record {record_id} not present as a live record in namespace {}",
975 namespace.display()
976 )));
977 }
978 let line = serde_json::json!({
979 "tombstone": record_id,
980 "timestamp_ms": crate::epoch_millis(),
981 });
982 let mut body = raw;
983 if !body.ends_with('\n') && !body.is_empty() {
984 body.push('\n');
985 }
986 body.push_str(&line.to_string());
987 body.push('\n');
988 crate::core::private_fs::write_atomic_private(&path, body.as_bytes())
989 .map_err(io::Error::from)
990 }
991 }
992}
993
994fn sanitize_file_id(id: &str) -> io::Result<PathBuf> {
996 if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") {
997 return Err(io::Error::other(format!("invalid artifact id: {id:?}")));
998 }
999 Ok(PathBuf::from(id))
1000}