Skip to main content

capsule_runtime/
retention.rs

1//! Retention: what bounds the two stores that grow, and what it costs to bound them.
2//!
3//! Two stores, two operations, because they are different shapes:
4//!
5//! * A **session directory** — `<workdir>/.murmur/<ses_…>/` — is an independent unit. Nothing
6//!   references it and nothing spans two of them, so it is pruned by being deleted whole, taking
7//!   its `trace.jsonl` and its `blobs/` with it.
8//! * A **conversation record** — `~/.murmur/conversations/<record>/<ctx>/conversation.jsonl` — is
9//!   one unit that grows. Deleting it destroys the conversation an operator wanted kept, so it is
10//!   pruned by truncating its front: the oldest messages go, the recent ones stay, and every
11//!   surviving message keeps the id it has always had.
12//!
13//! Three properties hold across everything below:
14//!
15//! * **No defaults.** Every entry point takes the policy it enforces. An absent policy is an
16//!   absent call: nothing here has a fallback that deletes.
17//! * **No clock of its own.** Both prune functions take `now_ms`, so age is a fixture's to supply
18//!   and a test never sleeps.
19//! * **No trace of its own.** Both prune functions return what they removed and write nothing;
20//!   the caller holds the [`crate::trace::TraceWriter`] and records the deletion against the
21//!   session that performed it.
22//!
23//! Session age is read out of the `ses_` id — a uuid v7 whose first 12 hex characters are a
24//! millisecond timestamp — so the whole session policy is computed from one directory listing
25//! with no `stat`. Record age is the mtime of `conversation.jsonl`, because a context id is not
26//! necessarily a uuid and "untouched since" is the useful question for a record that spans weeks.
27
28use std::path::{Path, PathBuf};
29
30use crate::conversation::{
31    header_line, message_id_timestamp_ms, parse_message_line, read_header, CONVERSATION_ROOT_DIR,
32    RECORD_FILE_NAME,
33};
34
35pub use crate::conversation::{RecordHeader, TruncationMarker};
36
37/// A session directory that was removed, and why.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct PrunedSession {
40    /// The `ses_…` directory name, as it appeared under the sessions root.
41    pub name: String,
42    /// [`crate::trace::RETENTION_REASON_MAX_SESSIONS`] or
43    /// [`crate::trace::RETENTION_REASON_MAX_AGE`].
44    pub reason: &'static str,
45}
46
47/// A context directory that was removed, and why.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct PrunedRecord {
50    /// The context id, which is the directory name under the record root.
51    pub context_id: String,
52    /// The directory that was removed, whole.
53    pub path: PathBuf,
54    /// Always [`crate::trace::RETENTION_REASON_MAX_AGE`]: a record is never removed on a count.
55    pub reason: &'static str,
56}
57
58/// What one truncation dropped and what it left.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct TruncationOutcome {
61    /// Messages this truncation removed from the front. `0` means the file was left untouched.
62    pub dropped: u64,
63    /// Messages left in the record.
64    pub kept: u64,
65    /// The `msg_` id of the oldest surviving message, empty when nothing survives with an id.
66    pub oldest_surviving_id: String,
67    /// The `msg_` id of the newest dropped message, empty when nothing dropped carried one.
68    pub last_dropped_id: String,
69}
70
71/// Where one message id turned up, and in what state.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum MessageStatus {
74    /// The id is a line in the record. `position` is 1-based, oldest first.
75    Present { position: u64, total: u64 },
76    /// The id is not a line, the header carries a truncation marker, and the id was minted at or
77    /// before the newest dropped message. A reference an artifact stored is dangling because the
78    /// record was trimmed — not because the id was never real.
79    Truncated {
80        dropped: u64,
81        oldest_surviving_id: String,
82    },
83    /// Anything else: no such id, in a record that has dropped nothing that old.
84    Unknown,
85}
86
87/// One record a message id was looked for in, and what that record had to say about it.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct MessageLocation {
90    pub record: String,
91    pub context_id: String,
92    pub path: PathBuf,
93    pub status: MessageStatus,
94}
95
96/// One context's record, as `mur conversation ls` reports it.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct RecordSummary {
99    /// Record store: the directory under `~/.murmur/conversations/`.
100    pub record: String,
101    /// Context id: the directory under the record store.
102    pub context_id: String,
103    pub path: PathBuf,
104    /// Message lines. The header line is not one and is never counted.
105    pub messages: u64,
106    /// Size of `conversation.jsonl` on disk.
107    pub bytes: u64,
108    /// Last write to `conversation.jsonl`, in milliseconds since the epoch. `None` when the
109    /// host's mtime is unreadable or predates the epoch.
110    pub last_touched_ms: Option<u64>,
111    /// The capsule the header line names, and `None` for a record written before the header
112    /// existed. An unowned record is never pruned automatically.
113    pub capsule: Option<String>,
114    /// What this record has already dropped, or `None` if it has never been truncated.
115    pub truncation: Option<TruncationMarker>,
116}
117
118/// A context directory `mur conversation rm` removed.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct RemovedRecord {
121    pub record: String,
122    pub context_id: String,
123    /// The directory that was removed, whole.
124    pub path: PathBuf,
125    /// Messages it held.
126    pub messages: u64,
127}
128
129// ── Session pruning ──────────────────────────────────────────────────────────
130
131/// The millisecond timestamp inside a `ses_` id, or `None` when the name is not one.
132///
133/// `ses_` ids are uuid v7 in simple form, so their first 12 hex characters are the mint time in
134/// milliseconds. This is the whole of the session age policy: no `stat`, no walk, and a lexical
135/// sort of the names is a chronological sort of the sessions.
136pub fn session_id_timestamp_ms(name: &str) -> Option<u64> {
137    let hex = name.strip_prefix("ses_")?;
138    if hex.len() < 12 {
139        return None;
140    }
141    u64::from_str_radix(&hex[..12], 16).ok()
142}
143
144/// Remove the session directories under `sessions_root` that `policy` does not keep.
145///
146/// `current_session_id` is a hard floor: no directory whose name sorts at or after it is ever a
147/// candidate, whatever the policy says. That is what keeps the running session's own trace, and
148/// what keeps a sibling capsule launched a moment ago from having its live session deleted —
149/// uuid v7 ordering makes "minted after mine" a property of the name, so no lock file is needed.
150///
151/// Both keys are ANDed: a session survives only if it is inside every limit declared. The count
152/// is over every entry present, the current session included, so `max_sessions: 3` leaves three
153/// directories and one of them is the running one. Reasons are attributed age-first, because
154/// "too old" is the more specific statement about a directory that fails both.
155///
156/// A directory that cannot be removed is skipped and left out of the returned list: retention
157/// never fails a launch, and a trace event must name only what actually went.
158pub fn prune_sessions(
159    sessions_root: &Path,
160    current_session_id: &str,
161    policy: &murmur_artifact::TraceRetainConfig,
162    now_ms: u64,
163) -> Vec<PrunedSession> {
164    if policy.max_sessions.is_none() && policy.max_age_secs.is_none() {
165        return Vec::new();
166    }
167
168    let mut entries: Vec<String> = match std::fs::read_dir(sessions_root) {
169        Ok(dir) => dir
170            .filter_map(Result::ok)
171            .filter(|entry| entry.path().is_dir())
172            .map(|entry| entry.file_name().to_string_lossy().into_owned())
173            .filter(|name| session_id_timestamp_ms(name).is_some())
174            .collect(),
175        Err(_) => return Vec::new(),
176    };
177    // Newest first, which for uuid v7 names is both the lexical and the chronological order.
178    entries.sort_unstable_by(|a, b| b.cmp(a));
179
180    let age_floor_ms = policy
181        .max_age_secs
182        .map(|secs| now_ms.saturating_sub(secs.saturating_mul(1000)));
183
184    let mut pruned = Vec::new();
185    for (rank, name) in entries.iter().enumerate() {
186        // The floor. `>=` rather than `>` so the current session is excluded by the same rule
187        // that excludes everything minted after it.
188        if name.as_str() >= current_session_id {
189            continue;
190        }
191        let over_age = age_floor_ms.is_some_and(|floor| {
192            session_id_timestamp_ms(name).is_some_and(|minted| minted < floor)
193        });
194        let over_count = policy.max_sessions.is_some_and(|max| rank >= max as usize);
195        if !over_age && !over_count {
196            continue;
197        }
198        let reason = if over_age {
199            crate::trace::RETENTION_REASON_MAX_AGE
200        } else {
201            crate::trace::RETENTION_REASON_MAX_SESSIONS
202        };
203        if std::fs::remove_dir_all(sessions_root.join(name)).is_ok() {
204            pruned.push(PrunedSession {
205                name: name.clone(),
206                reason,
207            });
208        }
209    }
210    pruned
211}
212
213// ── Record pruning ───────────────────────────────────────────────────────────
214
215/// Remove the context directories under `record_root` that `policy.max_age_secs` does not keep.
216///
217/// Three things are never touched:
218///
219/// * **The context this launch is using** (`current_context_id`) — retention must not delete the
220///   conversation it is about to continue.
221/// * **A record no header line owns.** Every record written before the header existed is
222///   unowned, and stays unowned until the capsule that writes it adopts it. `mur conversation rm`
223///   is what reaches an abandoned one.
224/// * **A record whose header names another capsule.** Two capsules pointed at one
225///   `context.record_store` is a deliberate operator act; one silently pruning the other's
226///   history is not.
227///
228/// `policy.max_messages` is not enforced here: it truncates the record this launch opens, at the
229/// point it is opened, which is O(one record) instead of O(every conversation) — see
230/// [`truncate_record`].
231pub fn prune_records(
232    record_root: &Path,
233    capsule_name: &str,
234    current_context_id: Option<&str>,
235    policy: &murmur_artifact::ContextRetainConfig,
236    now_ms: u64,
237) -> Vec<PrunedRecord> {
238    let Some(max_age_secs) = policy.max_age_secs else {
239        return Vec::new();
240    };
241    let floor_ms = now_ms.saturating_sub(max_age_secs.saturating_mul(1000));
242
243    let mut pruned = Vec::new();
244    for (context_id, dir) in context_dirs(record_root) {
245        if Some(context_id.as_str()) == current_context_id {
246            continue;
247        }
248        let file = dir.join(RECORD_FILE_NAME);
249        match read_header(&file) {
250            Some(header) if header.capsule == capsule_name => {}
251            _ => continue,
252        }
253        let Some(touched_ms) = last_touched_ms(&file) else {
254            continue;
255        };
256        if touched_ms >= floor_ms {
257            continue;
258        }
259        if std::fs::remove_dir_all(&dir).is_ok() {
260            pruned.push(PrunedRecord {
261                context_id,
262                path: dir,
263                reason: crate::trace::RETENTION_REASON_MAX_AGE,
264            });
265        }
266    }
267    pruned
268}
269
270/// Drop everything before the newest `keep` messages of `path`, atomically.
271///
272/// The file is rewritten, not edited in place: the kept tail plus a header line recording the
273/// drop is staged in the record's own directory, fsynced, and renamed over the original. Same
274/// directory means same filesystem, which is what makes the rename atomic — a crash at any point
275/// leaves the original whole and the temp file orphaned, never a half-written record.
276///
277/// Every surviving message keeps the exact `id` bytes its line carried; truncation drops
278/// messages and never renumbers them.
279///
280/// `capsule` is used only when the record has no header yet: an existing header's `capsule` is
281/// preserved, so truncating never transfers ownership of a record between capsules.
282///
283/// A record already at or under `keep` is left untouched and reported as `dropped: 0`.
284pub fn truncate_record(path: &Path, keep: u32, capsule: &str) -> Result<TruncationOutcome, String> {
285    match stage_truncation(path, keep, capsule, now_ms())? {
286        None => Ok(TruncationOutcome {
287            dropped: 0,
288            kept: count_messages(path),
289            oldest_surviving_id: String::new(),
290            last_dropped_id: String::new(),
291        }),
292        Some((staged, outcome)) => {
293            staged.commit()?;
294            Ok(outcome)
295        }
296    }
297}
298
299/// The half of [`truncate_record`] that touches nothing the reader can see: the rewritten record
300/// is on disk under a temporary name in the record's own directory, and the original is still the
301/// original until [`StagedRewrite::commit`] renames over it.
302///
303/// `None` means there was nothing to drop.
304pub(crate) fn stage_truncation(
305    path: &Path,
306    keep: u32,
307    capsule: &str,
308    at_ms: u64,
309) -> Result<Option<(StagedRewrite, TruncationOutcome)>, String> {
310    let raw = match std::fs::read_to_string(path) {
311        Ok(raw) => raw,
312        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
313        Err(err) => return Err(format!("{}: {err}", path.display())),
314    };
315
316    let existing = read_header(path);
317    let lines: Vec<&str> = raw.lines().collect();
318    // Every line that is a message, by its index in the file. Non-message lines — the header,
319    // and a torn final line — are not messages to any reader and are not counted here either.
320    let message_indices: Vec<usize> = lines
321        .iter()
322        .enumerate()
323        .filter(|(_, line)| parse_message_line(line).is_some())
324        .map(|(index, _)| index)
325        .collect();
326
327    let total = message_indices.len();
328    let keep = keep as usize;
329    if total <= keep {
330        return Ok(None);
331    }
332    let dropped = total - keep;
333    let first_kept_index = message_indices[dropped];
334
335    let id_of = |index: usize| -> String {
336        parse_message_line(lines[index])
337            .as_ref()
338            .and_then(|message| crate::agent::message_id(message).map(str::to_string))
339            .unwrap_or_default()
340    };
341    let last_dropped_id = id_of(message_indices[dropped - 1]);
342    let oldest_surviving_id = message_indices
343        .get(dropped)
344        .map(|i| id_of(*i))
345        .unwrap_or_default();
346
347    let header = RecordHeader {
348        kind: crate::conversation::RECORD_HEADER_TYPE.to_string(),
349        capsule: existing
350            .as_ref()
351            .map(|h| h.capsule.clone())
352            .unwrap_or_else(|| capsule.to_string()),
353        created_ms: existing.as_ref().map(|h| h.created_ms).unwrap_or(at_ms),
354        truncated: Some(TruncationMarker {
355            // Cumulative over the record's life: an operator reading `dropped` wants to know how
356            // much of this conversation is gone, not how much the last truncation took.
357            dropped: existing
358                .as_ref()
359                .and_then(|h| h.truncated.as_ref())
360                .map(|marker| marker.dropped)
361                .unwrap_or(0)
362                + dropped as u64,
363            oldest_surviving_id: oldest_surviving_id.clone(),
364            last_dropped_id: last_dropped_id.clone(),
365            at_ms,
366        }),
367    };
368
369    let mut contents = header_line(&header)?;
370    for line in &lines[first_kept_index..] {
371        contents.push_str(line);
372        contents.push('\n');
373    }
374
375    let staged = StagedRewrite::stage_with_mode(
376        path,
377        contents.as_bytes(),
378        Some(crate::conversation::RECORD_FILE_MODE),
379    )?;
380    Ok(Some((
381        staged,
382        TruncationOutcome {
383            dropped: dropped as u64,
384            kept: keep as u64,
385            oldest_surviving_id,
386            last_dropped_id,
387        },
388    )))
389}
390
391/// A rewrite that exists on disk but has not replaced anything yet.
392///
393/// Staged as `<record dir>/.conversation.jsonl.<pid>.<nonce>.tmp` — in the record's own
394/// directory, so the rename that commits it is within one filesystem and therefore atomic, and
395/// under a name no reader mistakes for a record.
396pub(crate) struct StagedRewrite {
397    target: PathBuf,
398    temp: PathBuf,
399    committed: bool,
400}
401
402impl StagedRewrite {
403    /// Stage `contents` for `target`, with the staged file's mode set explicitly for a target whose
404    /// permissions are part of its contract. `None` leaves the mode to the process umask.
405    pub(crate) fn stage_with_mode(
406        target: &Path,
407        contents: &[u8],
408        mode: Option<u32>,
409    ) -> Result<Self, String> {
410        use std::io::Write;
411        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
412
413        let dir = target
414            .parent()
415            .ok_or_else(|| format!("{} has no parent directory", target.display()))?;
416        let name = target
417            .file_name()
418            .map(|name| name.to_string_lossy().into_owned())
419            .unwrap_or_else(|| "record".to_string());
420        let temp = dir.join(format!(
421            ".{name}.{}.{}.tmp",
422            std::process::id(),
423            uuid::Uuid::now_v7().simple()
424        ));
425        let mut options = std::fs::OpenOptions::new();
426        options.write(true).create(true).truncate(true);
427        if let Some(mode) = mode {
428            options.mode(mode);
429        }
430        let mut file = options
431            .open(&temp)
432            .map_err(|err| format!("{}: {err}", temp.display()))?;
433        // The fsync is the point: a rename is only atomic with respect to a file whose bytes are
434        // already durable. Without it a crash can leave the new name pointing at a short file.
435        file.write_all(contents)
436            .and_then(|()| file.sync_all())
437            .map_err(|err| format!("{}: {err}", temp.display()))?;
438        // Re-asserted after the write: `mode` on the open only applies when this call created the
439        // file, and a mode that is part of the target's contract must hold for a staged file an
440        // earlier run left behind too.
441        if let Some(mode) = mode {
442            std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(mode))
443                .map_err(|err| format!("{}: {err}", temp.display()))?;
444        }
445        Ok(Self {
446            target: target.to_path_buf(),
447            temp,
448            committed: false,
449        })
450    }
451
452    #[cfg(test)]
453    pub(crate) fn temp_path(&self) -> &Path {
454        &self.temp
455    }
456
457    pub(crate) fn commit(mut self) -> Result<(), String> {
458        std::fs::rename(&self.temp, &self.target)
459            .map_err(|err| format!("{}: {err}", self.target.display()))?;
460        self.committed = true;
461        Ok(())
462    }
463}
464
465impl Drop for StagedRewrite {
466    fn drop(&mut self) {
467        if !self.committed {
468            let _ = std::fs::remove_file(&self.temp);
469        }
470    }
471}
472
473// ── The record store, as the CLI reads it ────────────────────────────────────
474
475/// Every conversation record on this host, record store by record store.
476///
477/// Ordered by record then context id, so two runs of `mur conversation ls` print the same table.
478/// A host with no `~/.murmur/conversations/` has no records, which is an empty list rather than
479/// an error.
480pub fn list_records() -> Result<Vec<RecordSummary>, String> {
481    let root = conversations_root()?;
482    let mut summaries = Vec::new();
483    for record in child_dirs(&root) {
484        let store = root.join(&record);
485        for (context_id, dir) in context_dirs(&store) {
486            let path = dir.join(RECORD_FILE_NAME);
487            if !path.exists() {
488                continue;
489            }
490            let header = read_header(&path);
491            summaries.push(RecordSummary {
492                record: record.clone(),
493                context_id,
494                messages: count_messages(&path),
495                bytes: std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0),
496                last_touched_ms: last_touched_ms(&path),
497                capsule: header.as_ref().map(|h| h.capsule.clone()),
498                truncation: header.and_then(|h| h.truncated),
499                path,
500            });
501        }
502    }
503    summaries.sort_by(|a, b| {
504        a.record
505            .cmp(&b.record)
506            .then_with(|| a.context_id.cmp(&b.context_id))
507    });
508    Ok(summaries)
509}
510
511/// Remove one context directory whole, and report what it held.
512///
513/// The only way to reclaim a record no capsule owns — one written before the header line
514/// existed, whose capsule has since been retired. Automatic pruning never touches those.
515pub fn remove_record(record: &str, context_id: &str) -> Result<RemovedRecord, String> {
516    let dir = conversations_root()?.join(record).join(context_id);
517    if !dir.is_dir() {
518        return Err(format!("{} is not a context directory", dir.display()));
519    }
520    let messages = count_messages(&dir.join(RECORD_FILE_NAME));
521    std::fs::remove_dir_all(&dir).map_err(|err| format!("{}: {err}", dir.display()))?;
522    Ok(RemovedRecord {
523        record: record.to_string(),
524        context_id: context_id.to_string(),
525        path: dir,
526        messages,
527    })
528}
529
530/// Every record that has something to say about `message_id`.
531///
532/// A record answers [`MessageStatus::Present`] when the id is one of its lines, and
533/// [`MessageStatus::Truncated`] when it is not but the record has dropped a message at least as
534/// new as this one — the id's own uuid v7 timestamp against the marker's `last_dropped_id`.
535/// Records with nothing to say are left out, so an empty list is
536/// [`MessageStatus::Unknown`] everywhere.
537///
538/// That classification is the whole reason the marker exists: an artifact that stored
539/// `source_id: msg_X` and now finds nothing must be told the conversation was trimmed, not that
540/// its reference was never real.
541pub fn locate_message(message_id: &str) -> Result<Vec<MessageLocation>, String> {
542    let minted_ms = message_id_timestamp_ms(message_id);
543    let mut found = Vec::new();
544    for summary in list_records()? {
545        let raw = std::fs::read_to_string(&summary.path).unwrap_or_default();
546        let mut position = 0u64;
547        let mut at = None;
548        for line in raw.lines() {
549            let Some(message) = parse_message_line(line) else {
550                continue;
551            };
552            position += 1;
553            if crate::agent::message_id(&message) == Some(message_id) {
554                at = Some(position);
555            }
556        }
557        let status = match (at, summary.truncation.as_ref()) {
558            (Some(position), _) => MessageStatus::Present {
559                position,
560                total: position.max(summary.messages),
561            },
562            (None, Some(marker)) => {
563                let dropped_at = message_id_timestamp_ms(&marker.last_dropped_id);
564                match (minted_ms, dropped_at) {
565                    (Some(minted), Some(last)) if minted <= last => MessageStatus::Truncated {
566                        dropped: marker.dropped,
567                        oldest_surviving_id: marker.oldest_surviving_id.clone(),
568                    },
569                    _ => MessageStatus::Unknown,
570                }
571            }
572            (None, None) => MessageStatus::Unknown,
573        };
574        if status != MessageStatus::Unknown {
575            found.push(MessageLocation {
576                record: summary.record,
577                context_id: summary.context_id,
578                path: summary.path,
579                status,
580            });
581        }
582    }
583    Ok(found)
584}
585
586// ── Shared helpers ───────────────────────────────────────────────────────────
587
588/// `~/.murmur/conversations`, resolved and not created.
589pub fn conversations_root() -> Result<PathBuf, String> {
590    Ok(crate::state_store::murmur_home_dir()?.join(CONVERSATION_ROOT_DIR))
591}
592
593/// Milliseconds since the epoch, for the callers that have no fixture to take a clock from.
594pub fn now_ms() -> u64 {
595    std::time::SystemTime::now()
596        .duration_since(std::time::UNIX_EPOCH)
597        .map(|elapsed| elapsed.as_millis() as u64)
598        .unwrap_or(0)
599}
600
601/// Immediate subdirectory names of `dir`, sorted, skipping dotted entries.
602fn child_dirs(dir: &Path) -> Vec<String> {
603    let mut names: Vec<String> = match std::fs::read_dir(dir) {
604        Ok(entries) => entries
605            .filter_map(Result::ok)
606            .filter(|entry| entry.path().is_dir())
607            .map(|entry| entry.file_name().to_string_lossy().into_owned())
608            .filter(|name| !name.starts_with('.'))
609            .collect(),
610        Err(_) => Vec::new(),
611    };
612    names.sort();
613    names
614}
615
616/// Every context directory under one record store, as `(context id, path)`.
617fn context_dirs(record_root: &Path) -> Vec<(String, PathBuf)> {
618    child_dirs(record_root)
619        .into_iter()
620        .map(|name| {
621            let path = record_root.join(&name);
622            (name, path)
623        })
624        .collect()
625}
626
627/// Message lines in `path`. The header line is not a message and is never counted, which is what
628/// keeps it out of the `total` every reader of a record reports.
629fn count_messages(path: &Path) -> u64 {
630    std::fs::read_to_string(path)
631        .map(|raw| {
632            raw.lines()
633                .filter(|line| parse_message_line(line).is_some())
634                .count() as u64
635        })
636        .unwrap_or(0)
637}
638
639/// Last write to `path`, in milliseconds since the epoch.
640fn last_touched_ms(path: &Path) -> Option<u64> {
641    std::fs::metadata(path)
642        .and_then(|metadata| metadata.modified())
643        .ok()?
644        .duration_since(std::time::UNIX_EPOCH)
645        .ok()
646        .map(|elapsed| elapsed.as_millis() as u64)
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use crate::conversation::parse_header;
653    use murmur_artifact::{ContextRetainConfig, TraceRetainConfig};
654    use serde_json::json;
655
656    /// A `ses_` name whose uuid-v7 timestamp is exactly `ms`, and whose tail distinguishes it
657    /// from any sibling minted in the same millisecond.
658    fn ses(ms: u64, tail: u64) -> String {
659        format!("ses_{ms:012x}{tail:020x}")
660    }
661
662    /// A `msg_` id whose uuid-v7 timestamp is exactly `ms`.
663    fn msg(ms: u64, tail: u64) -> String {
664        format!("msg_{ms:012x}{tail:020x}")
665    }
666
667    fn message_line(id: &str, text: &str) -> String {
668        json!({"role": "user", "content": [{"type": "text", "text": text}], "id": id}).to_string()
669    }
670
671    fn make_sessions(root: &Path, names: &[String]) {
672        for name in names {
673            let dir = root.join(name);
674            std::fs::create_dir_all(dir.join("blobs")).unwrap();
675            std::fs::write(dir.join("trace.jsonl"), "{}\n").unwrap();
676            std::fs::write(dir.join("blobs").join("deadbeef"), b"body").unwrap();
677        }
678    }
679
680    fn names(root: &Path) -> Vec<String> {
681        let mut found: Vec<String> = std::fs::read_dir(root)
682            .unwrap()
683            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
684            .collect();
685        found.sort();
686        found
687    }
688
689    const NOW: u64 = 1_756_400_000_000;
690
691    /// the count is over every entry present, the running session included,
692    /// so `max_sessions: 3` leaves three directories and one of them is the current one. What went
693    /// went whole — no orphaned `trace.jsonl`, no orphaned `blobs/`.
694    #[test]
695    fn max_sessions_leaves_exactly_the_newest_n_including_the_current() {
696        let root = tempfile::tempdir().unwrap();
697        let all: Vec<String> = (1..=6).map(|n| ses(NOW - (6 - n) * 1000, n)).collect();
698        make_sessions(root.path(), &all);
699
700        let pruned = prune_sessions(
701            root.path(),
702            &all[5],
703            &TraceRetainConfig {
704                max_sessions: Some(3),
705                max_age_secs: None,
706            },
707            NOW,
708        );
709
710        assert_eq!(
711            names(root.path()),
712            vec![all[3].clone(), all[4].clone(), all[5].clone()]
713        );
714        let mut removed: Vec<String> = pruned.iter().map(|p| p.name.clone()).collect();
715        removed.sort();
716        assert_eq!(
717            removed,
718            vec![all[0].clone(), all[1].clone(), all[2].clone()]
719        );
720        assert!(pruned
721            .iter()
722            .all(|p| p.reason == crate::trace::RETENTION_REASON_MAX_SESSIONS));
723        for name in &all[..3] {
724            assert!(!root.path().join(name).exists(), "{name} went whole");
725        }
726    }
727
728    /// the decision comes out of the id. Every directory here is created in
729    /// the same instant, so every mtime is equally fresh; the two whose ids encode two hours ago
730    /// are the two that go.
731    #[test]
732    fn max_age_reads_the_id_and_never_the_filesystem() {
733        let root = tempfile::tempdir().unwrap();
734        let old_a = ses(NOW - 7_200_000, 1);
735        let old_b = ses(NOW - 7_100_000, 2);
736        let recent = ses(NOW - 60_000, 3);
737        let current = ses(NOW, 4);
738        make_sessions(
739            root.path(),
740            &[
741                old_a.clone(),
742                old_b.clone(),
743                recent.clone(),
744                current.clone(),
745            ],
746        );
747
748        let pruned = prune_sessions(
749            root.path(),
750            &current,
751            &TraceRetainConfig {
752                max_sessions: None,
753                max_age_secs: Some(900),
754            },
755            NOW,
756        );
757
758        assert_eq!(pruned.len(), 2, "{pruned:?}");
759        assert!(pruned
760            .iter()
761            .all(|p| p.reason == crate::trace::RETENTION_REASON_MAX_AGE));
762        assert_eq!(names(root.path()), vec![recent, current]);
763    }
764
765    /// the two keys are ANDed. An old-id directory never survives on rank, and a recent-id
766    /// directory outside the count does not survive on age.
767    #[test]
768    fn both_trace_keys_keep_only_sessions_inside_both() {
769        let root = tempfile::tempdir().unwrap();
770        let old = [ses(NOW - 7_200_000, 1), ses(NOW - 7_100_000, 2)];
771        let recent = [
772            ses(NOW - 300_000, 3),
773            ses(NOW - 200_000, 4),
774            ses(NOW - 100_000, 5),
775        ];
776        let current = ses(NOW, 6);
777        let mut all = old.to_vec();
778        all.extend(recent.iter().cloned());
779        all.push(current.clone());
780        make_sessions(root.path(), &all);
781
782        let pruned = prune_sessions(
783            root.path(),
784            &current,
785            &TraceRetainConfig {
786                max_sessions: Some(2),
787                max_age_secs: Some(900),
788            },
789            NOW,
790        );
791
792        assert_eq!(names(root.path()), vec![recent[2].clone(), current.clone()]);
793        assert_eq!(pruned.len(), 4);
794        for session in &pruned {
795            let expected = if old.contains(&session.name) {
796                crate::trace::RETENTION_REASON_MAX_AGE
797            } else {
798                crate::trace::RETENTION_REASON_MAX_SESSIONS
799            };
800            assert_eq!(session.reason, expected, "{session:?}");
801        }
802    }
803
804    /// the floor. Nothing at or after the current session's own id is ever a candidate, which
805    /// is also what keeps a sibling capsule's concurrently running session safe with no lock file.
806    #[test]
807    fn nothing_at_or_after_the_current_session_id_is_a_candidate() {
808        let root = tempfile::tempdir().unwrap();
809        let current = ses(NOW, 5);
810        // A sibling launched a millisecond later, while this session was still staging.
811        let sibling = ses(NOW + 1, 6);
812        let older = ses(NOW - 1000, 4);
813        make_sessions(
814            root.path(),
815            &[current.clone(), sibling.clone(), older.clone()],
816        );
817
818        let pruned = prune_sessions(
819            root.path(),
820            &current,
821            &TraceRetainConfig {
822                max_sessions: Some(1),
823                max_age_secs: Some(1),
824            },
825            NOW,
826        );
827
828        assert_eq!(pruned.len(), 1);
829        assert_eq!(pruned[0].name, older);
830        assert_eq!(names(root.path()), vec![current.clone(), sibling]);
831        assert!(root.path().join(&current).join("trace.jsonl").exists());
832    }
833
834    /// A policy with neither key set — which the manifest parser refuses, but which the runtime
835    /// must still treat as inert rather than as "prune everything".
836    #[test]
837    fn an_empty_policy_removes_nothing() {
838        let root = tempfile::tempdir().unwrap();
839        let all = [ses(NOW - 9_000_000, 1), ses(NOW, 2)];
840        make_sessions(root.path(), &all);
841        assert!(prune_sessions(
842            root.path(),
843            &all[1],
844            &TraceRetainConfig {
845                max_sessions: None,
846                max_age_secs: None
847            },
848            NOW
849        )
850        .is_empty());
851        assert_eq!(names(root.path()), all.to_vec());
852    }
853
854    // ── Truncation ───────────────────────────────────────────────────────────
855
856    /// A record with `count` messages, owned by `capsule` when `owned`.
857    fn write_record(dir: &Path, capsule: Option<&str>, count: u64) -> PathBuf {
858        std::fs::create_dir_all(dir).unwrap();
859        let path = dir.join(RECORD_FILE_NAME);
860        let mut contents = String::new();
861        if let Some(capsule) = capsule {
862            contents.push_str(
863                &header_line(&RecordHeader {
864                    kind: crate::conversation::RECORD_HEADER_TYPE.to_string(),
865                    capsule: capsule.to_string(),
866                    created_ms: NOW,
867                    truncated: None,
868                })
869                .unwrap(),
870            );
871        }
872        for i in 1..=count {
873            contents.push_str(&message_line(
874                &msg(NOW - (count - i) * 1000, i),
875                &format!("m{i}"),
876            ));
877            contents.push('\n');
878        }
879        std::fs::write(&path, contents).unwrap();
880        path
881    }
882
883    /// the newest N survive with their ids intact, the file is a header plus exactly N
884    /// message lines, and every line parses.
885    #[test]
886    fn max_messages_leaves_the_newest_n_with_their_ids_intact() {
887        let dir = tempfile::tempdir().unwrap();
888        let path = write_record(dir.path(), Some("capsule"), 10);
889        let before: Vec<String> = std::fs::read_to_string(&path)
890            .unwrap()
891            .lines()
892            .filter_map(parse_message_line)
893            .map(|m| m["id"].as_str().unwrap().to_string())
894            .collect();
895
896        let outcome = truncate_record(&path, 3, "capsule").unwrap();
897        assert_eq!(outcome.dropped, 7);
898        assert_eq!(outcome.kept, 3);
899        assert_eq!(outcome.last_dropped_id, before[6]);
900        assert_eq!(outcome.oldest_surviving_id, before[7]);
901
902        let raw = std::fs::read_to_string(&path).unwrap();
903        let lines: Vec<&str> = raw.lines().collect();
904        assert_eq!(
905            lines.len(),
906            4,
907            "a header plus exactly three messages: {lines:?}"
908        );
909        for line in &lines {
910            serde_json::from_str::<serde_json::Value>(line).expect("every line is JSON");
911        }
912        let header = parse_header(lines[0]).expect("the first line is the header");
913        assert_eq!(header.capsule, "capsule");
914        let marker = header.truncated.expect("the header records the drop");
915        assert_eq!(marker.dropped, 7);
916        assert_eq!(marker.last_dropped_id, before[6]);
917        assert_eq!(marker.oldest_surviving_id, before[7]);
918
919        let after: Vec<String> = lines[1..]
920            .iter()
921            .map(|line| {
922                parse_message_line(line).expect("a message with a string role")["id"]
923                    .as_str()
924                    .unwrap()
925                    .to_string()
926            })
927            .collect();
928        assert_eq!(after, before[7..], "surviving ids are the bytes they were");
929    }
930
931    /// The header does not count as a message: `count_messages` — the same rule
932    /// `crate::conversation::read_record` applies to `total` — is unchanged by it.
933    #[test]
934    fn a_header_line_is_not_counted_as_a_message() {
935        let dir = tempfile::tempdir().unwrap();
936        let headerless = write_record(&dir.path().join("a"), None, 4);
937        let owned = write_record(&dir.path().join("b"), Some("capsule"), 4);
938        assert_eq!(count_messages(&headerless), 4);
939        assert_eq!(count_messages(&owned), 4);
940    }
941
942    /// Truncating twice accumulates: `dropped` is what this record has lost over its life, not
943    /// what the last rewrite took.
944    #[test]
945    fn a_second_truncation_accumulates_the_dropped_count() {
946        let dir = tempfile::tempdir().unwrap();
947        let path = write_record(dir.path(), Some("capsule"), 10);
948        truncate_record(&path, 6, "capsule").unwrap();
949        truncate_record(&path, 2, "capsule").unwrap();
950        let marker = read_header(&path).unwrap().truncated.unwrap();
951        assert_eq!(marker.dropped, 8);
952        assert_eq!(count_messages(&path), 2);
953    }
954
955    #[test]
956    fn truncation_rewrite_keeps_the_record_owner_only() {
957        use std::os::unix::fs::PermissionsExt;
958        let dir = tempfile::tempdir().unwrap();
959        let path = write_record(dir.path(), Some("capsule"), 5);
960        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
961
962        truncate_record(&path, 2, "capsule").unwrap();
963
964        assert_eq!(count_messages(&path), 2);
965        assert_eq!(crate::murmur_home::mode_of(&path), 0o600);
966    }
967
968    /// A record already at or under the limit is left untouched, byte for byte.
969    #[test]
970    fn a_record_inside_the_limit_is_not_rewritten() {
971        let dir = tempfile::tempdir().unwrap();
972        let path = write_record(dir.path(), Some("capsule"), 3);
973        let before = std::fs::read(&path).unwrap();
974        let outcome = truncate_record(&path, 3, "capsule").unwrap();
975        assert_eq!(outcome.dropped, 0);
976        assert_eq!(std::fs::read(&path).unwrap(), before);
977    }
978
979    /// a crash mid-truncate leaves the original intact. The staged rewrite is a temp file in
980    /// the record's own directory — same filesystem, so the rename that commits it is atomic —
981    /// and until that rename the original parses whole and holds its original message count.
982    #[test]
983    fn a_staged_truncation_leaves_the_original_intact_until_the_rename() {
984        let dir = tempfile::tempdir().unwrap();
985        let path = write_record(dir.path(), Some("capsule"), 10);
986        let before = std::fs::read(&path).unwrap();
987
988        let (staged, outcome) = stage_truncation(&path, 3, "capsule", NOW).unwrap().unwrap();
989        assert_eq!(outcome.dropped, 7);
990
991        let temp = staged.temp_path().to_path_buf();
992        assert_eq!(
993            temp.parent(),
994            path.parent(),
995            "the temp file is in the record's own directory, so the rename is atomic"
996        );
997        assert_ne!(temp.file_name().unwrap(), RECORD_FILE_NAME);
998
999        assert_eq!(
1000            std::fs::read(&path).unwrap(),
1001            before,
1002            "the original is byte-identical before the rename"
1003        );
1004        assert_eq!(count_messages(&path), 10);
1005        for line in std::fs::read_to_string(&path).unwrap().lines() {
1006            serde_json::from_str::<serde_json::Value>(line).expect("the original parses whole");
1007        }
1008
1009        // Dropping without committing is the crash: the original stands and no debris is left.
1010        drop(staged);
1011        assert!(!temp.exists());
1012        assert_eq!(std::fs::read(&path).unwrap(), before);
1013        assert_eq!(count_messages(&path), 10);
1014    }
1015
1016    // ── Record pruning ───────────────────────────────────────────────────────
1017
1018    /// automatic record pruning touches only a record whose header names this
1019    /// capsule. A record another capsule owns and a record no header owns both survive, however
1020    /// old they are — and the current launch's own context is never removed.
1021    #[test]
1022    fn record_pruning_skips_another_capsules_records_and_every_unowned_one() {
1023        let root = tempfile::tempdir().unwrap();
1024        write_record(&root.path().join("mine"), Some("mine"), 3);
1025        write_record(&root.path().join("theirs"), Some("theirs"), 3);
1026        write_record(&root.path().join("unowned"), None, 3);
1027        write_record(&root.path().join("current"), Some("mine"), 3);
1028
1029        // Far enough in the future that every mtime is outside a one-second window.
1030        let far_future = now_ms() + 3_600_000;
1031        let pruned = prune_records(
1032            root.path(),
1033            "mine",
1034            Some("current"),
1035            &ContextRetainConfig {
1036                max_messages: None,
1037                max_age_secs: Some(1),
1038            },
1039            far_future,
1040        );
1041
1042        assert_eq!(pruned.len(), 1, "{pruned:?}");
1043        assert_eq!(pruned[0].context_id, "mine");
1044        assert_eq!(pruned[0].reason, crate::trace::RETENTION_REASON_MAX_AGE);
1045        assert_eq!(names(root.path()), vec!["current", "theirs", "unowned"]);
1046    }
1047
1048    /// a record inside the window stays. Age is last write, so a record written a moment ago
1049    /// survives a 90-day policy however long ago its conversation started.
1050    #[test]
1051    fn a_record_written_inside_the_window_is_kept() {
1052        let root = tempfile::tempdir().unwrap();
1053        write_record(&root.path().join("fresh"), Some("mine"), 3);
1054        let pruned = prune_records(
1055            root.path(),
1056            "mine",
1057            None,
1058            &ContextRetainConfig {
1059                max_messages: None,
1060                max_age_secs: Some(90 * 86_400),
1061            },
1062            now_ms(),
1063        );
1064        assert!(pruned.is_empty());
1065        assert_eq!(names(root.path()), vec!["fresh"]);
1066    }
1067
1068    /// `max_messages` alone never removes a context directory: it is enforced on the record the
1069    /// launch opens, not by the age sweep.
1070    #[test]
1071    fn max_messages_alone_removes_no_record() {
1072        let root = tempfile::tempdir().unwrap();
1073        write_record(&root.path().join("ctx"), Some("mine"), 100);
1074        assert!(prune_records(
1075            root.path(),
1076            "mine",
1077            None,
1078            &ContextRetainConfig {
1079                max_messages: Some(1),
1080                max_age_secs: None
1081            },
1082            now_ms() + 3_600_000,
1083        )
1084        .is_empty());
1085        assert_eq!(names(root.path()), vec!["ctx"]);
1086    }
1087}