Skip to main content

agent_core/core/
retention.rs

1//! Task 34 — unified retention (spec §9.7): one sweep across sessions
2//! (including compaction parents), memory records, traces, and logs, with max-age and disk budgets and headless
3//! inspect/export/forget operations.
4//!
5//! Invariants:
6//!
7//! - CHAIN INTEGRITY: a session that is the head of a named chain is never
8//!   deleted by any sweep or forget — retention cannot leave a named chain
9//!   pointing at a deleted session.
10//! - Sessions age by their EMBEDDED `updated_at` (mtime fallback);
11//!   traces/logs age by file mtime. Memory records age by their embedded
12//!   timestamp against their OWN retention class; the policy's global age
13//!   reaches them only with `include_notes`.
14//! - Memory files are compacted by atomic rewrite (expired + tombstoned
15//!   records physically removed) rather than whole-file deletion.
16//! - The disk budget deletes oldest-first across whole-file artifacts
17//!   (sessions/traces/logs), skipping protected chain heads. It
18//!   evicts notes only with `include_notes`; without it an unmet budget is
19//!   `BudgetUnmet` with every note untouched.
20//! - A budget below what the sweep may not delete (protected chain heads,
21//!   and notes without `include_notes`) can never be met: the sweep answers
22//!   `BudgetUnmet` before its first deletion, with nothing deleted.
23
24use std::fs;
25use std::io;
26use std::path::{Path, PathBuf};
27
28use serde::Serialize;
29
30/// Typed retention failure (CP-13 fix1 I4/M2).
31#[derive(Debug)]
32pub enum RetentionError {
33    Io(io::Error),
34    /// A named-chain file could not be read or parsed: its head is UNKNOWN,
35    /// so every destructive session operation fails closed.
36    UnreadableChain(PathBuf),
37    /// The disk budget could not be met (typed instead of a silent
38    /// overshoot): either it is below what the sweep may not delete, and
39    /// then nothing was deleted (`nothing_deleted`), or it was still unmet
40    /// after evicting every unprotected artifact.
41    BudgetUnmet {
42        budget: u64,
43        remaining: u64,
44        protected_chain_heads: usize,
45        /// The policy did not say `include_notes`, so no note was evicted.
46        notes_kept: bool,
47        /// What this sweep may not delete, added up before its first
48        /// deletion (the `protected_bytes` function).
49        protected_bytes: u64,
50        /// The budget is below `protected_bytes`: the sweep stopped before
51        /// its first deletion, and every file is as it was.
52        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/// Filesystem roots one retention pass operates over. Production callers
115/// use [`RetentionRoots::resolve`]; tests inject temp roots.
116#[derive(Debug, Clone)]
117pub struct RetentionRoots {
118    /// Active config dir: sessions/, chains/, synaps.log* live here.
119    pub config_dir: PathBuf,
120    /// Base dir: memory/ and spill/ (tool-output spill files) live here.
121    pub base_dir: PathBuf,
122    /// Cache dir: trace/telemetry JSONL files live here.
123    pub cache_dir: PathBuf,
124}
125
126impl RetentionRoots {
127    /// Resolve the production roots.
128    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/// Artifact domains under unified retention.
154#[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/// Unified policy: maximum artifact age and total disk budget.
164#[derive(Debug, Clone, Copy, Default)]
165pub struct RetentionPolicy {
166    /// Sessions, traces, logs and spill folders older than this are
167    /// deleted. Notes are not, unless `include_notes` is set.
168    pub max_age_days: Option<u32>,
169    /// Total disk budget. Notes are evicted for it only with `include_notes`.
170    pub max_disk_bytes: Option<u64>,
171    /// Let the sweep delete saved notes: apply `max_age_days` to notes, and
172    /// let `max_disk_bytes` evict notes oldest first. Off by default: a note
173    /// is something a person or the model chose to keep. A note with an
174    /// expiry of its own (`max_age_days` in its retention class) expires by
175    /// that on every sweep, whatever this says.
176    pub include_notes: bool,
177}
178
179const DAY_MS: u64 = 86_400_000;
180
181/// The end of a notes lock file's name (`memory/<ns>.jsonl.lock`).
182#[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
238/// Session ids protected by named chains (heads must never dangle). Any
239/// unreadable or unparseable chain file fails CLOSED (CP-13 fix1 M2): with
240/// an unknown head, no destructive session operation may proceed.
241fn 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
256/// A session's age reference: embedded `updated_at`, mtime fallback.
257fn 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
278/// Headless inspect: per-domain file counts and byte totals.
279pub 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
324/// What a sweep with `policy` may not delete, in bytes as [`inspect`] counts
325/// them: every file in `sessions/` that belongs to a protected chain head
326/// (its snapshot, journal and turn draft), and, without `include_notes`,
327/// what the notes files hold once this sweep has compacted them (forgotten
328/// notes, notes past their own expiry and torn lines go on every sweep and
329/// are not counted). With `include_notes` notes count as nothing, though
330/// their tombstone lines stay: the sum is never more than what must remain,
331/// so a budget below it cannot be met. Reads only.
332fn 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 // not rewritten: the file as it is
354            } else {
355                kept.iter().map(|line| line.len() as u64 + 1).sum()
356            };
357        }
358    }
359    Ok(total)
360}
361
362/// Unified sweep at an explicit clock (tests inject `now_ms`; production
363/// uses [`sweep`]). Applies max-age per domain, compacts memory files, then
364/// enforces the disk budget oldest-first — never touching chain heads. A
365/// budget below what the sweep may not delete ([`protected_bytes`]) is
366/// refused before anything is deleted, the age pass included.
367pub fn sweep_at(
368    roots: &RetentionRoots,
369    policy: &RetentionPolicy,
370    now_ms: u64,
371) -> Result<SweepOutcome, RetentionError> {
372    let mut outcome = SweepOutcome::default();
373    // Chain protection resolves FIRST and fails closed — nothing is
374    // deleted while any chain head is unknown.
375    let protected: Vec<String> = chain_heads(roots)?;
376    // A budget that cannot be met is refused HERE, before the first
377    // deletion: evicting every unprotected session, trace and log on the
378    // way to an error would destroy them for nothing.
379    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    // ── Orphan journals (Task 35): a `<id>.journal` whose snapshot is gone
398    // is unusable regardless of age — swept on every pass, unless a chain
399    // head claims the id (fail-safe keep). ──
400    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    // ── Age pass: sessions (embedded timestamps; compaction parents are
419    // ordinary sessions here) ──
420    if let Some(cutoff) = age_cutoff {
421        for path in list_files(&roots.sessions_dir()) {
422            // Paired journals age WITH their snapshot (Task 35) — never as
423            // independent candidates.
424            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; // unknown age: fail safe, keep
438            };
439            if age_ref < cutoff {
440                delete_session_artifact(&path, &mut outcome)?;
441                // Its tool-output spill files die with it.
442                remove_session_spill(roots, &id, &mut outcome)?;
443            }
444        }
445
446        // ── Traces + logs: mtime-aged whole files (traces recursive,
447        // symlink-confined) ──
448        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        // ── Tool-output spill dirs past the same cutoff (worker dirs have
458        // no session file, so this is what ages them out) ──
459        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    // ── Memory: atomic rewrite dropping tombstoned records, records past
467    // their OWN retention class, and (only when the policy says
468    // `include_notes`) records past the global cutoff. Runs on every sweep
469    // so per-record retention holds even without a global max age. ──
470    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    // ── Disk budget (CP-13 fix1 I4): staged eviction until the final
480    // total actually fits, or a typed unmet error. Stage order minimizes
481    // data loss: (1) whole-file artifacts oldest-first (never protected
482    // chain heads), (2) memory records oldest-first via atomic rewrite,
483    // only with `include_notes`. ──
484    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        // Stage 1: whole-file artifacts, oldest-first, chain heads immune.
490        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                // Paired journals evict WITH their snapshot (Task 35).
494                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        // Stage 2: memory records, globally oldest-first. Only when asked:
526        // without `include_notes` the budget never deletes a note.
527        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        // Final enforcement: fit or typed error.
534        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
550/// Evict the globally oldest live memory records until at least
551/// `bytes_to_free` of line bytes are removed (atomic per-file rewrites).
552/// Returns the number of records dropped. Tombstone lines are kept: they are
553/// about 70 bytes each and the budget cannot take them back.
554fn evict_oldest_memory_records(
555    roots: &RetentionRoots,
556    bytes_to_free: u64,
557) -> Result<usize, RetentionError> {
558    // Collect (ts, file, id, line_len) for every live record.
559    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        // Held from this read through the rename. The pass above looked
606        // without it: this one decides by id on what it reads here.
607        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            // A line that is not UTF-8 goes the way of any line that does
612            // not parse.
613            .flatten()
614            .filter(|line| {
615                let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
616                    return false; // garbage compacts away
617                };
618                if let Some(id) = value["id"].as_str() {
619                    return !ids.contains(id);
620                }
621                // Every tombstone line stays, whatever id it names: it is
622                // what keeps a forgotten note from being imported again.
623                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
637/// The lines of a notes file, read as bytes: `None` for a line that is not
638/// UTF-8. A tail torn inside a multi-byte character stays in the file as
639/// such a line (a later save fences it off with a newline, nothing removes
640/// it), and `read_to_string` would fail on the whole file because of it, on
641/// every run. Like `str::lines`, without the line ends and without an empty
642/// piece after the last newline.
643fn 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
654/// The lock every appender and rewriter of a notes file holds
655/// (`memory::store::lock_notes_file`). An error when it is not had in time:
656/// the caller does not rewrite.
657fn 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    /// Run by this thread's rewriters once they have read the file, lock
664    /// held, before they write: where a test puts a save in their way.
665    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
680/// Recursively list REGULAR files under a confined root. Symlinks (files
681/// or directories) are never followed — `DirEntry::file_type` does not
682/// follow, so symlinked escapes are simply skipped. Bounded depth.
683fn 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; // confined: never follow
695                }
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
711/// Cache files under the confined cache root (recursive, symlink-free).
712fn cache_files(cache_dir: &Path) -> Vec<PathBuf> {
713    walk_regular_files(cache_dir)
714}
715
716/// Production sweep at the current clock.
717pub 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
732/// Files that belong to a session snapshot `<id>.json` and live and die
733/// with it: the Task 35 journal and the in-flight turn draft
734/// (`session_draft`, `<id>.turn`).
735const PAIRED_SESSION_EXTENSIONS: [&str; 2] = ["journal", "turn"];
736
737/// True when `path` is a journal or turn draft whose snapshot sibling still
738/// exists — such a file is handled WITH its `.json` artifact, never as an
739/// independent retention candidate.
740fn 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
747/// Remove session `id`'s tool-output spill dir (`<base>/spill/<id>/`).
748fn 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
759/// Delete a session artifact: the file plus, for `.json` snapshots, any
760/// sibling journal and turn draft (Task 35 — a session and its journal die
761/// together, so a sweep can never orphan journal state).
762fn 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
775/// Rewrite one memory JSONL file, dropping tombstoned records, records past
776/// their own retention class (`ts + max_age_days < now`), and — when a
777/// global cutoff is set — records older than it. Atomic rewrite; returns
778/// the number of records physically removed. Tombstone lines are kept, in
779/// place, on every rewrite.
780///
781/// A line that does not parse is dropped and counted too, and so is a line
782/// that is not UTF-8 (a tail torn inside a character). The file's lock is
783/// held here, so such a line is never a save caught half written: it is a
784/// fragment a writer left behind (a torn tail that a later save fenced off).
785fn compact_memory_file(
786    path: &Path,
787    now_ms: u64,
788    global_cutoff_ms: Option<u64>,
789) -> io::Result<usize> {
790    // Held from the read through the rename, so no save lands in the file
791    // this replaces, and no line is read half written.
792    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
807/// What [`compact_memory_file`] makes of a notes file's bytes: how many
808/// lines it drops, and the lines it keeps, in order. The one rule of what a
809/// compaction keeps, for the rewrite and for [`protected_bytes`].
810fn compacted_lines(
811    raw: &[u8],
812    now_ms: u64,
813    global_cutoff_ms: Option<u64>,
814) -> (usize, Vec<String>) {
815    /// A line of the file: a tombstone, or a record with what decides
816    /// whether it stays.
817    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; // not UTF-8: a torn line, dropped like one
828            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; // torn/garbage line — compacted away
836            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            // A tombstone line is never dropped and never counted: it stays
859            // where it was, so a forgotten note stays forgotten when its
860            // line is gone (an import checks for the tombstone), and a file
861            // with nothing else to drop is not rewritten.
862            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/// Headless export: copy sessions, memory, traces (recursive), and logs
886/// to `dest` with no symlink surface, no check/use races, and private
887/// modes throughout (CP-13 fix2): every destination directory and file is
888/// created RELATIVE to held directory handles beneath the trusted export
889/// root — a planted or concurrently swapped ancestor symlink fails the
890/// export closed instead of being written through. Unix-only; other
891/// platforms fail closed.
892#[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    // Flat domains: discovery AND authoritative opens are relative to the
899    // same held trusted source-root handle. There is no full-path source
900    // reopen after discovery. A planted/swap-raced source entry is refused
901    // by open_file; a destination collision remains a hard error.
902    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; // absent domain — nothing to export
909        };
910        let out = root.child_dir(sub)?;
911        for entry in src_root.entries()? {
912            if !entry.is_file {
913                continue; // source symlink/non-regular entries are omitted
914            }
915            if sub == "memory" && entry.name.ends_with(NOTES_LOCK_SUFFIX) {
916                continue; // a notes file's lock, not data
917            }
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                // Entry changed after enumeration (including a swap to a
926                // symlink): refuse/skip it, never follow it.
927                Err(_) => continue,
928            }
929        }
930    }
931
932    // Traces: recursively walk entirely through source directory handles.
933    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    // Logs: enumerate the config root through its handle, filter the
939    // handle-returned names, and open relative to the same handle.
940    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/// Copy an ALREADY-CONFINED opened regular source handle into a destination
960/// created relative to a held destination directory handle.
961#[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/// Recursive source traversal using held source and destination directory
986/// handles only. Symlink entries are never opened; a directory swapped after
987/// enumeration fails child `open_dirs` and is skipped. Bounded depth.
988#[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/// Non-unix: confined export is unavailable — fail closed.
1021#[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
1028/// Headless forget: delete one artifact by domain and id. Session chain
1029/// heads fail closed; memory ids use `"<namespace>:<record-id>"` and
1030/// tombstone through the store.
1031pub fn forget(roots: &RetentionRoots, domain: RetentionDomain, id: &str) -> io::Result<()> {
1032    match domain {
1033        RetentionDomain::Sessions => {
1034            // Sanitize BEFORE any path construction (CP-13 fix1 I5).
1035            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            // Pair-delete the opt-in journal (Task 35) and any turn draft —
1048            // forgetting a session must not leave replayable state behind.
1049            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            // And its tool-output spill files.
1059            crate::core::tool_spill::remove_session_spill(&roots.spill_dir(), id)?;
1060            Ok(())
1061        }
1062        RetentionDomain::Traces => {
1063            // Nested relative addressing with validated components and
1064            // dir-handle confinement (CP-13 fix2 moderate): symlinked
1065            // ancestors inside the cache are refused at open time.
1066            #[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            // The store's own check of an id, not the shape of the ids it
1097            // makes: an imported note's id need not start with `mem-`.
1098            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            // No file, no lock file: fail before one is created.
1107            fs::metadata(&path)?;
1108            // Held from the read through the rename.
1109            let _lock = lock_notes(&path)?;
1110            let raw = fs::read(&path)?;
1111            rewrite_read_done();
1112
1113            // EXACT live-id match (CP-13 fix1 M4): parse every line;
1114            // substring/prefix probes and already-tombstoned ids fail
1115            // closed.
1116            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            // Every byte that was read is written back, a line that is not
1139            // UTF-8 included: a forget removes nothing.
1140            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
1152/// Reject path-traversal in file-name ids.
1153fn 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, &note(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    /// The ids of the note lines in a file, in order (tombstones left out).
1194    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    /// An age sweep that takes notes too.
1204    const AGE: RetentionPolicy = RetentionPolicy {
1205        max_age_days: Some(1),
1206        max_disk_bytes: None,
1207        include_notes: true,
1208    };
1209
1210    /// Put a save of note `id` in the way of this thread's next rewrite: it
1211    /// starts once the rewriter has read the file and before it writes. The
1212    /// rewriter is held there for a moment, and the save must still be
1213    /// waiting. Returns where the save's thread is left.
1214    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    /// Each of the three rewriters holds the lock from its read through its
1242    /// rename: a save that arrives in between waits, and its note is in the
1243    /// file the rewrite left. Without the lock the save would go into the
1244    /// file the rename replaces, and be lost after it returned Ok.
1245    #[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        // The sweep.
1250        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(&notes_file(&roots, "ns")), ["mem-new", "mem-late"]);
1259
1260        // Eviction.
1261        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(&notes_file(&roots, "ns")), ["mem-new", "mem-late"]);
1269
1270        // `retention forget memory`.
1271        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    /// Savers on four threads against a thread that rewrites the same file
1285    /// over and over (a forget, then a sweep that has something to drop):
1286    /// every save that returned Ok is in the file at the end, once.
1287    #[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(&notes_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    /// A rewriter that cannot get the lock in time fails and leaves the file
1343    /// as it was.
1344    #[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        // Released, each of them goes through.
1367        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    /// A forgotten note stays forgotten across sweep, eviction and forget:
1373    /// each of the three rewrites the file, the tombstone line is still in
1374    /// it, and an import of the note's own line is turned away by it.
1375    #[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)); // distinct times
1401            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        // What the import's check (`import_note`, "tombstoned" in the file)
1427        // says about the forgotten note's own line, and what is in the file.
1428        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        // The sweep: it drops the forgotten note's line, so it rewrites.
1445        // (Inode checks are unix-only: elsewhere `inode()` is always 0.)
1446        #[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        // A second sweep: tombstones and live notes only, nothing to drop,
1456        // so the file is left as it is.
1457        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        // Eviction of the oldest live note.
1463        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        // The store's forget (an append), then `retention forget memory`
1469        // (a rewrite): the file now has three tombstones.
1470        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        // And the sweep after that drops both forgotten notes and keeps all
1484        // three tombstones.
1485        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    /// A notes file with a line that is not UTF-8 (a tail torn inside a
1493    /// character, fenced off by the next save) is still read by the sweep,
1494    /// the eviction and `retention forget memory`. The forget keeps the
1495    /// line's bytes; the two that compact drop it as they drop any line
1496    /// that does not parse, and the sweep counts it.
1497    #[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            // The next saves fence the fragment off on a line of its own.
1507            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        // `retention forget memory`: the note is forgotten, no byte is lost.
1518        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        // The sweep: it reads the file, drops the forgotten note and the
1531        // fragment, counts both, and leaves a file that is UTF-8.
1532        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        // Eviction: both passes read the file; the rewrite drops the oldest
1539        // note and the fragment.
1540        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        // Lines as `str::lines` gives them, but for the one that is not text.
1548        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    /// An age sweep is for sessions, traces and logs. A `Standard` note
1556    /// survives it however old it is; a note with an expiry of its own goes
1557    /// when that is past; `include_notes` makes the age apply to notes; and
1558    /// the disk budget evicts notes only with `include_notes`.
1559    #[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        // Again: nothing to drop.
1589        assert_eq!(sweep_at(&roots, &age(false), now).unwrap().memory_records_dropped, 0);
1590
1591        // `include_notes`: every note older than the age goes, whatever its
1592        // class (mem-not-expired is 10 days old, inside its own 30).
1593        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        // `include_notes` without an age is no cutoff.
1598        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        // The disk budget evicts notes, oldest first, only with `include_notes`.
1605        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    /// `retention forget memory` takes any id the store would take, not only
1622    /// one that starts with `mem-`: an imported note keeps the id it came
1623    /// with. The match is still exact, and an id the store would not take is
1624    /// still turned away before the file is read.
1625    #[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    /// The lock file sits in `memory/` beside the notes file and is not one:
1652    /// it is not listed as a namespace, not counted, not swept and not
1653    /// exported, and a sweep leaves it where it is.
1654    #[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        // A sweep that rewrites the notes file, and an eviction after it.
1679        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}