Skip to main content

capsule_runtime/
retention.rs

1//! Retention: what bounds the three stores that grow, and what it costs to bound them.
2//!
3//! Three 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 **formation directory** — `~/.murmur/formations/<frm_…>/` — holds one ended formation's
9//!   peer directories, and with them every peer's sessions. It is the other half of the evidence
10//!   whose first half is the entry member's session in the project, so it is pruned whole, by the
11//!   entry member's `trace.retain`, and only by the launcher of a later formation from the same
12//!   project once every member of that later one is reaped.
13//! * A **conversation record** — `~/.murmur/conversations/<record>/<ctx>/conversation.jsonl` — is
14//!   one unit that grows. Deleting it destroys the conversation an operator wanted kept, so it is
15//!   pruned by truncating its front: the oldest messages go, the recent ones stay, and every
16//!   surviving message keeps the id it has always had.
17//!
18//! Three properties hold across everything below:
19//!
20//! * **No defaults.** Every entry point takes the policy it enforces. An absent policy is an
21//!   absent call: nothing here has a fallback that deletes.
22//! * **No clock of its own.** Every prune function takes `now_ms`, so age is a fixture's to supply
23//!   and a test never sleeps.
24//! * **No trace of its own.** Every prune function returns what it removed and writes nothing;
25//!   the caller holds the [`crate::trace::TraceWriter`] and records the deletion against the
26//!   session that performed it, or reports it as the launcher that performed it.
27//!
28//! Session and formation age is read out of the `ses_` or `frm_` id — a uuid v7 whose first 12
29//! hex characters are a millisecond timestamp — so both policies are computed from one directory
30//! listing with no `stat`. Record age is the mtime of `conversation.jsonl`, because a context id
31//! is not necessarily a uuid and "untouched since" is the useful question for a record that spans
32//! weeks.
33
34use std::path::{Path, PathBuf};
35
36use crate::conversation::{
37    header_line, message_id_timestamp_ms, parse_message_line, read_header, CONVERSATION_ROOT_DIR,
38    RECORD_FILE_NAME,
39};
40
41use crate::formation::FormationId;
42use crate::formation_launch::FormationMarker;
43
44pub use crate::conversation::{RecordHeader, TruncationMarker};
45
46/// A session directory that was removed, and why.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct PrunedSession {
49    /// The `ses_…` directory name, as it appeared under the sessions root.
50    pub name: String,
51    /// [`crate::trace::RETENTION_REASON_MAX_SESSIONS`] or
52    /// [`crate::trace::RETENTION_REASON_MAX_AGE`].
53    pub reason: &'static str,
54}
55
56/// A formation directory that was removed, and why.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct PrunedFormation {
59    /// The formation whose directory went.
60    pub formation_id: FormationId,
61    /// The directory that was removed, whole: `<formations dir>/<frm_id>`.
62    pub path: PathBuf,
63    /// [`crate::trace::RETENTION_REASON_MAX_AGE`] or
64    /// [`crate::trace::RETENTION_REASON_MAX_SESSIONS`].
65    pub reason: &'static str,
66}
67
68/// A context directory that was removed, and why.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct PrunedRecord {
71    /// The context id, which is the directory name under the record root.
72    pub context_id: String,
73    /// The directory that was removed, whole.
74    pub path: PathBuf,
75    /// Always [`crate::trace::RETENTION_REASON_MAX_AGE`]: a record is never removed on a count.
76    pub reason: &'static str,
77}
78
79/// What one truncation dropped and what it left.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct TruncationOutcome {
82    /// Messages this truncation removed from the front. `0` means the file was left untouched.
83    pub dropped: u64,
84    /// Messages left in the record.
85    pub kept: u64,
86    /// The `msg_` id of the oldest surviving message, empty when nothing survives with an id.
87    pub oldest_surviving_id: String,
88    /// The `msg_` id of the newest dropped message, empty when nothing dropped carried one.
89    pub last_dropped_id: String,
90}
91
92/// Where one message id turned up, and in what state.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum MessageStatus {
95    /// The id is a line in the record. `position` is 1-based, oldest first.
96    Present { position: u64, total: u64 },
97    /// The id is not a line, the header carries a truncation marker, and the id was minted at or
98    /// before the newest dropped message. A reference an artifact stored is dangling because the
99    /// record was trimmed — not because the id was never real.
100    Truncated {
101        dropped: u64,
102        oldest_surviving_id: String,
103    },
104    /// Anything else: no such id, in a record that has dropped nothing that old.
105    Unknown,
106}
107
108/// One record a message id was looked for in, and what that record had to say about it.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct MessageLocation {
111    pub record: String,
112    pub context_id: String,
113    pub path: PathBuf,
114    pub status: MessageStatus,
115}
116
117/// One context's record, as `mur conversation ls` reports it.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct RecordSummary {
120    /// Record store: the directory under `~/.murmur/conversations/`.
121    pub record: String,
122    /// Context id: the directory under the record store.
123    pub context_id: String,
124    pub path: PathBuf,
125    /// Message lines. The header line is not one and is never counted.
126    pub messages: u64,
127    /// Size of `conversation.jsonl` on disk.
128    pub bytes: u64,
129    /// Last write to `conversation.jsonl`, in milliseconds since the epoch. `None` when the
130    /// host's mtime is unreadable or predates the epoch.
131    pub last_touched_ms: Option<u64>,
132    /// The capsule the header line names, and `None` for a record written before the header
133    /// existed. An unowned record is never pruned automatically.
134    pub capsule: Option<String>,
135    /// What this record has already dropped, or `None` if it has never been truncated.
136    pub truncation: Option<TruncationMarker>,
137}
138
139/// A context directory `mur conversation rm` removed.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct RemovedRecord {
142    pub record: String,
143    pub context_id: String,
144    /// The directory that was removed, whole.
145    pub path: PathBuf,
146    /// Messages it held.
147    pub messages: u64,
148}
149
150// ── Session pruning ──────────────────────────────────────────────────────────
151
152/// The millisecond timestamp inside a `ses_` id, or `None` when the name is not one.
153///
154/// `ses_` ids are uuid v7 in simple form, so their first 12 hex characters are the mint time in
155/// milliseconds. This is the whole of the session age policy: no `stat`, no walk, and a lexical
156/// sort of the names is a chronological sort of the sessions.
157pub fn session_id_timestamp_ms(name: &str) -> Option<u64> {
158    uuid_v7_timestamp_ms(name, "ses_")
159}
160
161/// The millisecond timestamp in the first 12 hex characters after `prefix`, the mint time of a
162/// uuid v7 in simple form. `None` when `id` does not start with `prefix` or they are not hex.
163pub(crate) fn uuid_v7_timestamp_ms(id: &str, prefix: &str) -> Option<u64> {
164    let hex = id.strip_prefix(prefix)?;
165    u64::from_str_radix(hex.get(..12)?, 16).ok()
166}
167
168/// Remove the sessions of one records group that `policy` does not keep: each pruned session's
169/// records directory in `records_group` and, when `session_workdirs` is given, its session
170/// workdir directory of the same name there, so a session goes whole.
171///
172/// The decision is made on `records_group`'s `ses_*` listing alone: a session whose workdir half
173/// is already gone still goes, and a workdir directory with no records directory is never a
174/// candidate. Both removals go by descriptor through their roots, with no link followed.
175///
176/// `current_session_id` is a hard floor: no directory whose name sorts at or after it is ever a
177/// candidate, whatever the policy says. That is what keeps the running session's own trace, and
178/// what keeps a sibling capsule launched a moment ago from having its live session deleted —
179/// uuid v7 ordering makes "minted after mine" a property of the name, so no lock file is needed.
180///
181/// Both keys are ANDed: a session survives only if it is inside every limit declared. The count
182/// is over every entry present, the current session included, so `max_sessions: 3` leaves three
183/// directories and one of them is the running one. Reasons are attributed age-first, because
184/// "too old" is the more specific statement about a directory that fails both.
185///
186/// A records directory that cannot be removed is skipped and left out of the returned list, and
187/// its workdir half is kept with it: retention never fails a launch, and a trace event must name
188/// only what actually went.
189pub fn prune_sessions(
190    records_group: &crate::workdir_fs::WorkdirRoot,
191    session_workdirs: Option<&crate::workdir_fs::WorkdirRoot>,
192    current_session_id: &str,
193    policy: &murmur_artifact::TraceRetainConfig,
194    now_ms: u64,
195) -> Vec<PrunedSession> {
196    if policy.max_sessions.is_none() && policy.max_age_secs.is_none() {
197        return Vec::new();
198    }
199
200    let mut entries: Vec<String> = match records_group.list_dir("") {
201        Ok(Some(listed)) => listed
202            .into_iter()
203            .filter(|(_, kind)| *kind == crate::workdir_fs::EntryKind::Directory)
204            .map(|(name, _)| name)
205            .filter(|name| session_id_timestamp_ms(name).is_some())
206            .collect(),
207        Ok(None) | Err(_) => return Vec::new(),
208    };
209    // Newest first, which for uuid v7 names is both the lexical and the chronological order.
210    entries.sort_unstable_by(|a, b| b.cmp(a));
211
212    let age_floor_ms = policy
213        .max_age_secs
214        .map(|secs| now_ms.saturating_sub(secs.saturating_mul(1000)));
215
216    let mut pruned = Vec::new();
217    for (rank, name) in entries.iter().enumerate() {
218        // The floor. `>=` rather than `>` so the current session is excluded by the same rule
219        // that excludes everything minted after it.
220        if name.as_str() >= current_session_id {
221            continue;
222        }
223        let over_age = age_floor_ms.is_some_and(|floor| {
224            session_id_timestamp_ms(name).is_some_and(|minted| minted < floor)
225        });
226        let over_count = policy.max_sessions.is_some_and(|max| rank >= max as usize);
227        if !over_age && !over_count {
228            continue;
229        }
230        let reason = if over_age {
231            crate::trace::RETENTION_REASON_MAX_AGE
232        } else {
233            crate::trace::RETENTION_REASON_MAX_SESSIONS
234        };
235        if records_group.remove_dir_all(name).is_ok() {
236            if let Some(workdirs) = session_workdirs {
237                let _ = workdirs.remove_dir_all(name);
238            }
239            pruned.push(PrunedSession {
240                name: name.clone(),
241                reason,
242            });
243        }
244    }
245    pruned
246}
247
248// ── Formation pruning ────────────────────────────────────────────────────────
249
250/// Remove the formation directories under `formations_dir` that belong to `project_dir` and that
251/// `policy` — the entry member's `trace.retain` — does not keep.
252///
253/// A directory is this project's when it is a real directory, not a symlink, its name is a
254/// formation id, and its [`FormationMarker`] parses and names `project_dir`, both sides
255/// canonicalized. Only those are ranked. A directory with no marker or an unreadable one is
256/// unowned, and one whose marker names another project is foreign: neither is ranked, and neither
257/// is ever removed. Every directory made before markers existed is unowned.
258///
259/// The policy reads as it does for sessions:
260///
261/// * `max_age_secs` is measured from the formation id's mint time, with no `stat`.
262/// * `max_sessions` counts this project's directories present, newest first, `current` and any
263///   minted after it included.
264/// * Both keys are ANDed, and reasons are attributed age-first.
265///
266/// `current` is a hard floor: no directory whose id sorts at or after it is ever removed. A
267/// directory `is_live` answers `true` for is skipped and keeps its rank, so a formation still
268/// running is never removed and still counts against `max_sessions`.
269///
270/// A removed formation's records subtree, the records group of its directory and every member's
271/// group beneath it, goes with it. A missing `formations_dir` removes nothing. A directory that
272/// cannot be removed is left out of the returned list, which is newest first.
273pub fn prune_formations(
274    formations_dir: &Path,
275    current: &FormationId,
276    project_dir: &Path,
277    policy: &murmur_artifact::TraceRetainConfig,
278    now_ms: u64,
279    is_live: impl Fn(&FormationId, &FormationMarker) -> bool,
280) -> Vec<PrunedFormation> {
281    if policy.max_sessions.is_none() && policy.max_age_secs.is_none() {
282        return Vec::new();
283    }
284    let Ok(entries) = std::fs::read_dir(formations_dir) else {
285        return Vec::new();
286    };
287    let project_dir = crate::formation_launch::canonical_project_dir(project_dir);
288    let mut owned: Vec<(FormationId, FormationMarker)> = entries
289        .filter_map(Result::ok)
290        .filter_map(|entry| {
291            let id = FormationId::parse(entry.file_name().to_str()?).ok()?;
292            let path = entry.path();
293            if !std::fs::symlink_metadata(&path).ok()?.is_dir() {
294                return None;
295            }
296            let marker = FormationMarker::read(&path)?;
297            (crate::formation_launch::canonical_project_dir(&marker.project_dir) == project_dir)
298                .then_some((id, marker))
299        })
300        .collect();
301    // Newest first, which for uuid v7 ids is both the lexical and the chronological order.
302    owned.sort_unstable_by(|(a, _), (b, _)| b.as_str().cmp(a.as_str()));
303
304    let age_floor_ms = policy
305        .max_age_secs
306        .map(|secs| now_ms.saturating_sub(secs.saturating_mul(1000)));
307
308    let mut pruned = Vec::new();
309    for (rank, (id, marker)) in owned.iter().enumerate() {
310        if id.as_str() >= current.as_str() {
311            continue;
312        }
313        let over_age = age_floor_ms.is_some_and(|floor| id.minted_at_ms() < floor);
314        let over_count = policy.max_sessions.is_some_and(|max| rank >= max as usize);
315        if !over_age && !over_count {
316            continue;
317        }
318        if is_live(id, marker) {
319            continue;
320        }
321        let reason = if over_age {
322            crate::trace::RETENTION_REASON_MAX_AGE
323        } else {
324            crate::trace::RETENTION_REASON_MAX_SESSIONS
325        };
326        let path = formations_dir.join(id.as_str());
327        // Every member's records group mirrors a directory beneath this one, so the formation's
328        // records subtree goes with it. Resolved before the removal, while the path still
329        // canonicalizes to what the members were launched in.
330        let records = crate::records_dir::records_group(&path).ok();
331        if std::fs::remove_dir_all(&path).is_ok() {
332            if let Some(records) = records {
333                let _ = std::fs::remove_dir_all(records);
334            }
335            pruned.push(PrunedFormation {
336                formation_id: id.clone(),
337                path,
338                reason,
339            });
340        }
341    }
342    pruned
343}
344
345// ── Record pruning ───────────────────────────────────────────────────────────
346
347/// Remove the context directories under `record_root` that `policy.max_age_secs` does not keep.
348///
349/// Three things are never touched:
350///
351/// * **The context this launch is using** (`current_context_id`) — retention must not delete the
352///   conversation it is about to continue.
353/// * **A record no header line owns.** Every record written before the header existed is
354///   unowned, and stays unowned until the capsule that writes it adopts it. `mur conversation rm`
355///   is what reaches an abandoned one.
356/// * **A record whose header names another capsule.** Two capsules pointed at one
357///   `context.record_store` is a deliberate operator act; one silently pruning the other's
358///   history is not.
359///
360/// `policy.max_messages` is not enforced here: it truncates the record this launch opens, at the
361/// point it is opened, which is O(one record) instead of O(every conversation) — see
362/// [`truncate_record`].
363pub fn prune_records(
364    record_root: &Path,
365    capsule_name: &str,
366    current_context_id: Option<&str>,
367    policy: &murmur_artifact::ContextRetainConfig,
368    now_ms: u64,
369) -> Vec<PrunedRecord> {
370    let Some(max_age_secs) = policy.max_age_secs else {
371        return Vec::new();
372    };
373    let floor_ms = now_ms.saturating_sub(max_age_secs.saturating_mul(1000));
374
375    let mut pruned = Vec::new();
376    for (context_id, dir) in context_dirs(record_root) {
377        if Some(context_id.as_str()) == current_context_id {
378            continue;
379        }
380        let file = dir.join(RECORD_FILE_NAME);
381        match read_header(&file) {
382            Some(header) if header.capsule == capsule_name => {}
383            _ => continue,
384        }
385        let Some(touched_ms) = last_touched_ms(&file) else {
386            continue;
387        };
388        if touched_ms >= floor_ms {
389            continue;
390        }
391        if std::fs::remove_dir_all(&dir).is_ok() {
392            pruned.push(PrunedRecord {
393                context_id,
394                path: dir,
395                reason: crate::trace::RETENTION_REASON_MAX_AGE,
396            });
397        }
398    }
399    pruned
400}
401
402/// Drop everything before the newest `keep` messages of `path`, atomically.
403///
404/// The file is rewritten, not edited in place: the kept tail plus a header line recording the
405/// drop is staged in the record's own directory, fsynced, and renamed over the original. Same
406/// directory means same filesystem, which is what makes the rename atomic — a crash at any point
407/// leaves the original whole and the temp file orphaned, never a half-written record.
408///
409/// Every surviving message keeps the exact `id` bytes its line carried; truncation drops
410/// messages and never renumbers them.
411///
412/// `capsule` is used only when the record has no header yet: an existing header's `capsule` is
413/// preserved, so truncating never transfers ownership of a record between capsules.
414///
415/// A record already at or under `keep` is left untouched and reported as `dropped: 0`.
416pub fn truncate_record(path: &Path, keep: u32, capsule: &str) -> Result<TruncationOutcome, String> {
417    match stage_truncation(path, keep, capsule, now_ms())? {
418        None => Ok(TruncationOutcome {
419            dropped: 0,
420            kept: count_messages(path),
421            oldest_surviving_id: String::new(),
422            last_dropped_id: String::new(),
423        }),
424        Some((staged, outcome)) => {
425            staged.commit()?;
426            Ok(outcome)
427        }
428    }
429}
430
431/// The half of [`truncate_record`] that touches nothing the reader can see: the rewritten record
432/// is on disk under a temporary name in the record's own directory, and the original is still the
433/// original until [`StagedRewrite::commit`] renames over it.
434///
435/// `None` means there was nothing to drop.
436pub(crate) fn stage_truncation(
437    path: &Path,
438    keep: u32,
439    capsule: &str,
440    at_ms: u64,
441) -> Result<Option<(StagedRewrite, TruncationOutcome)>, String> {
442    let raw = match std::fs::read_to_string(path) {
443        Ok(raw) => raw,
444        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
445        Err(err) => return Err(format!("{}: {err}", path.display())),
446    };
447
448    let existing = read_header(path);
449    let lines: Vec<&str> = raw.lines().collect();
450    // Every line that is a message, by its index in the file. Non-message lines — the header,
451    // and a torn final line — are not messages to any reader and are not counted here either.
452    let message_indices: Vec<usize> = lines
453        .iter()
454        .enumerate()
455        .filter(|(_, line)| parse_message_line(line).is_some())
456        .map(|(index, _)| index)
457        .collect();
458
459    let total = message_indices.len();
460    let keep = keep as usize;
461    if total <= keep {
462        return Ok(None);
463    }
464    let dropped = total - keep;
465    let first_kept_index = message_indices[dropped];
466
467    let id_of = |index: usize| -> String {
468        parse_message_line(lines[index])
469            .as_ref()
470            .and_then(|message| crate::agent::message_id(message).map(str::to_string))
471            .unwrap_or_default()
472    };
473    let last_dropped_id = id_of(message_indices[dropped - 1]);
474    let oldest_surviving_id = message_indices
475        .get(dropped)
476        .map(|i| id_of(*i))
477        .unwrap_or_default();
478
479    let header = RecordHeader {
480        kind: crate::conversation::RECORD_HEADER_TYPE.to_string(),
481        capsule: existing
482            .as_ref()
483            .map(|h| h.capsule.clone())
484            .unwrap_or_else(|| capsule.to_string()),
485        created_ms: existing.as_ref().map(|h| h.created_ms).unwrap_or(at_ms),
486        truncated: Some(TruncationMarker {
487            // Cumulative over the record's life: an operator reading `dropped` wants to know how
488            // much of this conversation is gone, not how much the last truncation took.
489            dropped: existing
490                .as_ref()
491                .and_then(|h| h.truncated.as_ref())
492                .map(|marker| marker.dropped)
493                .unwrap_or(0)
494                + dropped as u64,
495            oldest_surviving_id: oldest_surviving_id.clone(),
496            last_dropped_id: last_dropped_id.clone(),
497            at_ms,
498        }),
499    };
500
501    let mut contents = header_line(&header)?;
502    for line in &lines[first_kept_index..] {
503        contents.push_str(line);
504        contents.push('\n');
505    }
506
507    let staged = StagedRewrite::stage_with_mode(
508        path,
509        contents.as_bytes(),
510        Some(crate::conversation::RECORD_FILE_MODE),
511    )?;
512    Ok(Some((
513        staged,
514        TruncationOutcome {
515            dropped: dropped as u64,
516            kept: keep as u64,
517            oldest_surviving_id,
518            last_dropped_id,
519        },
520    )))
521}
522
523/// A rewrite that exists on disk but has not replaced anything yet.
524///
525/// Staged as `<record dir>/.conversation.jsonl.<pid>.<nonce>.tmp` — in the record's own
526/// directory, so the rename that commits it is within one filesystem and therefore atomic, and
527/// under a name no reader mistakes for a record.
528pub(crate) struct StagedRewrite {
529    target: PathBuf,
530    temp: PathBuf,
531    committed: bool,
532}
533
534impl StagedRewrite {
535    /// Stage `contents` for `target`, with the staged file's mode set explicitly for a target whose
536    /// permissions are part of its contract. `None` leaves the mode to the process umask.
537    pub(crate) fn stage_with_mode(
538        target: &Path,
539        contents: &[u8],
540        mode: Option<u32>,
541    ) -> Result<Self, String> {
542        use std::io::Write;
543        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
544
545        let dir = target
546            .parent()
547            .ok_or_else(|| format!("{} has no parent directory", target.display()))?;
548        let name = target
549            .file_name()
550            .map(|name| name.to_string_lossy().into_owned())
551            .unwrap_or_else(|| "record".to_string());
552        let temp = dir.join(format!(
553            ".{name}.{}.{}.tmp",
554            std::process::id(),
555            uuid::Uuid::now_v7().simple()
556        ));
557        let mut options = std::fs::OpenOptions::new();
558        options.write(true).create(true).truncate(true);
559        if let Some(mode) = mode {
560            options.mode(mode);
561        }
562        let mut file = options
563            .open(&temp)
564            .map_err(|err| format!("{}: {err}", temp.display()))?;
565        // The fsync is the point: a rename is only atomic with respect to a file whose bytes are
566        // already durable. Without it a crash can leave the new name pointing at a short file.
567        file.write_all(contents)
568            .and_then(|()| file.sync_all())
569            .map_err(|err| format!("{}: {err}", temp.display()))?;
570        // Re-asserted after the write: `mode` on the open only applies when this call created the
571        // file, and a mode that is part of the target's contract must hold for a staged file an
572        // earlier run left behind too.
573        if let Some(mode) = mode {
574            std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(mode))
575                .map_err(|err| format!("{}: {err}", temp.display()))?;
576        }
577        Ok(Self {
578            target: target.to_path_buf(),
579            temp,
580            committed: false,
581        })
582    }
583
584    #[cfg(test)]
585    pub(crate) fn temp_path(&self) -> &Path {
586        &self.temp
587    }
588
589    pub(crate) fn commit(mut self) -> Result<(), String> {
590        std::fs::rename(&self.temp, &self.target)
591            .map_err(|err| format!("{}: {err}", self.target.display()))?;
592        self.committed = true;
593        Ok(())
594    }
595}
596
597impl Drop for StagedRewrite {
598    fn drop(&mut self) {
599        if !self.committed {
600            let _ = std::fs::remove_file(&self.temp);
601        }
602    }
603}
604
605// ── The record store, as the CLI reads it ────────────────────────────────────
606
607/// Every conversation record on this host, record store by record store.
608///
609/// Ordered by record then context id, so two runs of `mur conversation ls` print the same table.
610/// A host with no `~/.murmur/conversations/` has no records, which is an empty list rather than
611/// an error.
612pub fn list_records() -> Result<Vec<RecordSummary>, String> {
613    let root = conversations_root()?;
614    let mut summaries = Vec::new();
615    for record in child_dirs(&root) {
616        let store = root.join(&record);
617        for (context_id, dir) in context_dirs(&store) {
618            let path = dir.join(RECORD_FILE_NAME);
619            if !path.exists() {
620                continue;
621            }
622            let header = read_header(&path);
623            summaries.push(RecordSummary {
624                record: record.clone(),
625                context_id,
626                messages: count_messages(&path),
627                bytes: std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0),
628                last_touched_ms: last_touched_ms(&path),
629                capsule: header.as_ref().map(|h| h.capsule.clone()),
630                truncation: header.and_then(|h| h.truncated),
631                path,
632            });
633        }
634    }
635    summaries.sort_by(|a, b| {
636        a.record
637            .cmp(&b.record)
638            .then_with(|| a.context_id.cmp(&b.context_id))
639    });
640    Ok(summaries)
641}
642
643/// Remove one context directory whole, and report what it held.
644///
645/// The only way to reclaim a record no capsule owns — one written before the header line
646/// existed, whose capsule has since been retired. Automatic pruning never touches those.
647pub fn remove_record(record: &str, context_id: &str) -> Result<RemovedRecord, String> {
648    let dir = conversations_root()?.join(record).join(context_id);
649    if !dir.is_dir() {
650        return Err(format!("{} is not a context directory", dir.display()));
651    }
652    let messages = count_messages(&dir.join(RECORD_FILE_NAME));
653    std::fs::remove_dir_all(&dir).map_err(|err| format!("{}: {err}", dir.display()))?;
654    Ok(RemovedRecord {
655        record: record.to_string(),
656        context_id: context_id.to_string(),
657        path: dir,
658        messages,
659    })
660}
661
662/// Every record that has something to say about `message_id`.
663///
664/// A record answers [`MessageStatus::Present`] when the id is one of its lines, and
665/// [`MessageStatus::Truncated`] when it is not but the record has dropped a message at least as
666/// new as this one — the id's own uuid v7 timestamp against the marker's `last_dropped_id`.
667/// Records with nothing to say are left out, so an empty list is
668/// [`MessageStatus::Unknown`] everywhere.
669///
670/// That classification is the whole reason the marker exists: an artifact that stored
671/// `source_id: msg_X` and now finds nothing must be told the conversation was trimmed, not that
672/// its reference was never real.
673pub fn locate_message(message_id: &str) -> Result<Vec<MessageLocation>, String> {
674    let minted_ms = message_id_timestamp_ms(message_id);
675    let mut found = Vec::new();
676    for summary in list_records()? {
677        let raw = std::fs::read_to_string(&summary.path).unwrap_or_default();
678        let mut position = 0u64;
679        let mut at = None;
680        for line in raw.lines() {
681            let Some(message) = parse_message_line(line) else {
682                continue;
683            };
684            position += 1;
685            if crate::agent::message_id(&message) == Some(message_id) {
686                at = Some(position);
687            }
688        }
689        let status = match (at, summary.truncation.as_ref()) {
690            (Some(position), _) => MessageStatus::Present {
691                position,
692                total: position.max(summary.messages),
693            },
694            (None, Some(marker)) => {
695                let dropped_at = message_id_timestamp_ms(&marker.last_dropped_id);
696                match (minted_ms, dropped_at) {
697                    (Some(minted), Some(last)) if minted <= last => MessageStatus::Truncated {
698                        dropped: marker.dropped,
699                        oldest_surviving_id: marker.oldest_surviving_id.clone(),
700                    },
701                    _ => MessageStatus::Unknown,
702                }
703            }
704            (None, None) => MessageStatus::Unknown,
705        };
706        if status != MessageStatus::Unknown {
707            found.push(MessageLocation {
708                record: summary.record,
709                context_id: summary.context_id,
710                path: summary.path,
711                status,
712            });
713        }
714    }
715    Ok(found)
716}
717
718// ── Shared helpers ───────────────────────────────────────────────────────────
719
720/// `~/.murmur/conversations`, resolved and not created.
721pub fn conversations_root() -> Result<PathBuf, String> {
722    Ok(crate::state_store::murmur_home_dir()?.join(CONVERSATION_ROOT_DIR))
723}
724
725/// Milliseconds since the epoch, for the callers that have no fixture to take a clock from.
726pub fn now_ms() -> u64 {
727    std::time::SystemTime::now()
728        .duration_since(std::time::UNIX_EPOCH)
729        .map(|elapsed| elapsed.as_millis() as u64)
730        .unwrap_or(0)
731}
732
733/// Immediate subdirectory names of `dir`, sorted, skipping dotted entries.
734fn child_dirs(dir: &Path) -> Vec<String> {
735    let mut names: Vec<String> = match std::fs::read_dir(dir) {
736        Ok(entries) => entries
737            .filter_map(Result::ok)
738            .filter(|entry| entry.path().is_dir())
739            .map(|entry| entry.file_name().to_string_lossy().into_owned())
740            .filter(|name| !name.starts_with('.'))
741            .collect(),
742        Err(_) => Vec::new(),
743    };
744    names.sort();
745    names
746}
747
748/// Every context directory under one record store, as `(context id, path)`.
749fn context_dirs(record_root: &Path) -> Vec<(String, PathBuf)> {
750    child_dirs(record_root)
751        .into_iter()
752        .map(|name| {
753            let path = record_root.join(&name);
754            (name, path)
755        })
756        .collect()
757}
758
759/// Message lines in `path`. The header line is not a message and is never counted, which is what
760/// keeps it out of the `total` every reader of a record reports.
761fn count_messages(path: &Path) -> u64 {
762    std::fs::read_to_string(path)
763        .map(|raw| {
764            raw.lines()
765                .filter(|line| parse_message_line(line).is_some())
766                .count() as u64
767        })
768        .unwrap_or(0)
769}
770
771/// Last write to `path`, in milliseconds since the epoch.
772fn last_touched_ms(path: &Path) -> Option<u64> {
773    std::fs::metadata(path)
774        .and_then(|metadata| metadata.modified())
775        .ok()?
776        .duration_since(std::time::UNIX_EPOCH)
777        .ok()
778        .map(|elapsed| elapsed.as_millis() as u64)
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784    use crate::conversation::parse_header;
785    use murmur_artifact::{ContextRetainConfig, TraceRetainConfig};
786    use serde_json::json;
787
788    /// A `ses_` name whose uuid-v7 timestamp is exactly `ms`, and whose tail distinguishes it
789    /// from any sibling minted in the same millisecond.
790    fn ses(ms: u64, tail: u64) -> String {
791        format!("ses_{ms:012x}{tail:020x}")
792    }
793
794    /// A `msg_` id whose uuid-v7 timestamp is exactly `ms`.
795    fn msg(ms: u64, tail: u64) -> String {
796        format!("msg_{ms:012x}{tail:020x}")
797    }
798
799    fn message_line(id: &str, text: &str) -> String {
800        json!({"role": "user", "content": [{"type": "text", "text": text}], "id": id}).to_string()
801    }
802
803    fn make_sessions(root: &Path, names: &[String]) {
804        for name in names {
805            let dir = root.join(name);
806            std::fs::create_dir_all(dir.join("blobs")).unwrap();
807            std::fs::write(dir.join("trace.jsonl"), "{}\n").unwrap();
808            std::fs::write(dir.join("blobs").join("deadbeef"), b"body").unwrap();
809        }
810    }
811
812    fn names(root: &Path) -> Vec<String> {
813        let mut found: Vec<String> = std::fs::read_dir(root)
814            .unwrap()
815            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
816            .collect();
817        found.sort();
818        found
819    }
820
821    const NOW: u64 = 1_756_400_000_000;
822
823    /// the count is over every entry present, the running session included,
824    /// so `max_sessions: 3` leaves three directories and one of them is the current one. What went
825    /// went whole — no orphaned `trace.jsonl`, no orphaned `blobs/`.
826    #[test]
827    fn max_sessions_leaves_exactly_the_newest_n_including_the_current() {
828        let root = tempfile::tempdir().unwrap();
829        let all: Vec<String> = (1..=6).map(|n| ses(NOW - (6 - n) * 1000, n)).collect();
830        make_sessions(root.path(), &all);
831
832        let pruned = prune_sessions(
833            &crate::workdir_fs::WorkdirRoot::open(root.path()).unwrap(),
834            None,
835            &all[5],
836            &TraceRetainConfig {
837                max_sessions: Some(3),
838                max_age_secs: None,
839            },
840            NOW,
841        );
842
843        assert_eq!(
844            names(root.path()),
845            vec![all[3].clone(), all[4].clone(), all[5].clone()]
846        );
847        let mut removed: Vec<String> = pruned.iter().map(|p| p.name.clone()).collect();
848        removed.sort();
849        assert_eq!(
850            removed,
851            vec![all[0].clone(), all[1].clone(), all[2].clone()]
852        );
853        assert!(pruned
854            .iter()
855            .all(|p| p.reason == crate::trace::RETENTION_REASON_MAX_SESSIONS));
856        for name in &all[..3] {
857            assert!(!root.path().join(name).exists(), "{name} went whole");
858        }
859    }
860
861    /// the decision comes out of the id. Every directory here is created in
862    /// the same instant, so every mtime is equally fresh; the two whose ids encode two hours ago
863    /// are the two that go.
864    #[test]
865    fn max_age_reads_the_id_and_never_the_filesystem() {
866        let root = tempfile::tempdir().unwrap();
867        let old_a = ses(NOW - 7_200_000, 1);
868        let old_b = ses(NOW - 7_100_000, 2);
869        let recent = ses(NOW - 60_000, 3);
870        let current = ses(NOW, 4);
871        make_sessions(
872            root.path(),
873            &[
874                old_a.clone(),
875                old_b.clone(),
876                recent.clone(),
877                current.clone(),
878            ],
879        );
880
881        let pruned = prune_sessions(
882            &crate::workdir_fs::WorkdirRoot::open(root.path()).unwrap(),
883            None,
884            &current,
885            &TraceRetainConfig {
886                max_sessions: None,
887                max_age_secs: Some(900),
888            },
889            NOW,
890        );
891
892        assert_eq!(pruned.len(), 2, "{pruned:?}");
893        assert!(pruned
894            .iter()
895            .all(|p| p.reason == crate::trace::RETENTION_REASON_MAX_AGE));
896        assert_eq!(names(root.path()), vec![recent, current]);
897    }
898
899    /// the two keys are ANDed. An old-id directory never survives on rank, and a recent-id
900    /// directory outside the count does not survive on age.
901    #[test]
902    fn both_trace_keys_keep_only_sessions_inside_both() {
903        let root = tempfile::tempdir().unwrap();
904        let old = [ses(NOW - 7_200_000, 1), ses(NOW - 7_100_000, 2)];
905        let recent = [
906            ses(NOW - 300_000, 3),
907            ses(NOW - 200_000, 4),
908            ses(NOW - 100_000, 5),
909        ];
910        let current = ses(NOW, 6);
911        let mut all = old.to_vec();
912        all.extend(recent.iter().cloned());
913        all.push(current.clone());
914        make_sessions(root.path(), &all);
915
916        let pruned = prune_sessions(
917            &crate::workdir_fs::WorkdirRoot::open(root.path()).unwrap(),
918            None,
919            &current,
920            &TraceRetainConfig {
921                max_sessions: Some(2),
922                max_age_secs: Some(900),
923            },
924            NOW,
925        );
926
927        assert_eq!(names(root.path()), vec![recent[2].clone(), current.clone()]);
928        assert_eq!(pruned.len(), 4);
929        for session in &pruned {
930            let expected = if old.contains(&session.name) {
931                crate::trace::RETENTION_REASON_MAX_AGE
932            } else {
933                crate::trace::RETENTION_REASON_MAX_SESSIONS
934            };
935            assert_eq!(session.reason, expected, "{session:?}");
936        }
937    }
938
939    /// the floor. Nothing at or after the current session's own id is ever a candidate, which
940    /// is also what keeps a sibling capsule's concurrently running session safe with no lock file.
941    #[test]
942    fn nothing_at_or_after_the_current_session_id_is_a_candidate() {
943        let root = tempfile::tempdir().unwrap();
944        let current = ses(NOW, 5);
945        // A sibling launched a millisecond later, while this session was still staging.
946        let sibling = ses(NOW + 1, 6);
947        let older = ses(NOW - 1000, 4);
948        make_sessions(
949            root.path(),
950            &[current.clone(), sibling.clone(), older.clone()],
951        );
952
953        let pruned = prune_sessions(
954            &crate::workdir_fs::WorkdirRoot::open(root.path()).unwrap(),
955            None,
956            &current,
957            &TraceRetainConfig {
958                max_sessions: Some(1),
959                max_age_secs: Some(1),
960            },
961            NOW,
962        );
963
964        assert_eq!(pruned.len(), 1);
965        assert_eq!(pruned[0].name, older);
966        assert_eq!(names(root.path()), vec![current.clone(), sibling]);
967        assert!(root.path().join(&current).join("trace.jsonl").exists());
968    }
969
970    /// A pruned session goes whole: its records directory and its session workdir directory of
971    /// the same name both go, and a workdir directory with no records directory is never a
972    /// candidate, whatever its name says.
973    #[test]
974    fn a_pruned_session_takes_its_records_and_its_session_workdir() {
975        let records = tempfile::tempdir().unwrap();
976        let workdirs = tempfile::tempdir().unwrap();
977        let all: Vec<String> = (1..=3).map(|n| ses(NOW - (3 - n) * 1000, n)).collect();
978        make_sessions(records.path(), &all);
979        for name in &all {
980            std::fs::create_dir_all(workdirs.path().join(name).join("out")).unwrap();
981        }
982        let workdir_only = ses(NOW - 9_000, 9);
983        std::fs::create_dir_all(workdirs.path().join(&workdir_only)).unwrap();
984
985        let pruned = prune_sessions(
986            &crate::workdir_fs::WorkdirRoot::open(records.path()).unwrap(),
987            Some(&crate::workdir_fs::WorkdirRoot::open(workdirs.path()).unwrap()),
988            &all[2],
989            &TraceRetainConfig {
990                max_sessions: Some(2),
991                max_age_secs: None,
992            },
993            NOW,
994        );
995
996        assert_eq!(pruned.len(), 1);
997        assert_eq!(pruned[0].name, all[0]);
998        assert_eq!(names(records.path()), vec![all[1].clone(), all[2].clone()]);
999        let mut kept = vec![all[1].clone(), all[2].clone(), workdir_only];
1000        kept.sort();
1001        assert_eq!(names(workdirs.path()), kept);
1002    }
1003
1004    /// A link planted where a session workdir directory belongs is refused, not followed: the
1005    /// directory it points at survives the pruning of the session it is named for.
1006    #[test]
1007    fn a_link_in_place_of_a_session_workdir_is_not_followed() {
1008        let records = tempfile::tempdir().unwrap();
1009        let workdirs = tempfile::tempdir().unwrap();
1010        let elsewhere = tempfile::tempdir().unwrap();
1011        std::fs::write(elsewhere.path().join("keep"), b"x").unwrap();
1012        let all: Vec<String> = (1..=2).map(|n| ses(NOW - (2 - n) * 1000, n)).collect();
1013        make_sessions(records.path(), &all);
1014        std::os::unix::fs::symlink(elsewhere.path(), workdirs.path().join(&all[0])).unwrap();
1015
1016        let pruned = prune_sessions(
1017            &crate::workdir_fs::WorkdirRoot::open(records.path()).unwrap(),
1018            Some(&crate::workdir_fs::WorkdirRoot::open(workdirs.path()).unwrap()),
1019            &all[1],
1020            &TraceRetainConfig {
1021                max_sessions: Some(1),
1022                max_age_secs: None,
1023            },
1024            NOW,
1025        );
1026
1027        assert_eq!(pruned.len(), 1);
1028        assert!(elsewhere.path().join("keep").exists());
1029    }
1030
1031    /// A policy with neither key set — which the manifest parser refuses, but which the runtime
1032    /// must still treat as inert rather than as "prune everything".
1033    #[test]
1034    fn an_empty_policy_removes_nothing() {
1035        let root = tempfile::tempdir().unwrap();
1036        let all = [ses(NOW - 9_000_000, 1), ses(NOW, 2)];
1037        make_sessions(root.path(), &all);
1038        assert!(prune_sessions(
1039            &crate::workdir_fs::WorkdirRoot::open(root.path()).unwrap(),
1040            None,
1041            &all[1],
1042            &TraceRetainConfig {
1043                max_sessions: None,
1044                max_age_secs: None
1045            },
1046            NOW
1047        )
1048        .is_empty());
1049        assert_eq!(names(root.path()), all.to_vec());
1050    }
1051
1052    // ── Truncation ───────────────────────────────────────────────────────────
1053
1054    /// A record with `count` messages, owned by `capsule` when `owned`.
1055    fn write_record(dir: &Path, capsule: Option<&str>, count: u64) -> PathBuf {
1056        std::fs::create_dir_all(dir).unwrap();
1057        let path = dir.join(RECORD_FILE_NAME);
1058        let mut contents = String::new();
1059        if let Some(capsule) = capsule {
1060            contents.push_str(
1061                &header_line(&RecordHeader {
1062                    kind: crate::conversation::RECORD_HEADER_TYPE.to_string(),
1063                    capsule: capsule.to_string(),
1064                    created_ms: NOW,
1065                    truncated: None,
1066                })
1067                .unwrap(),
1068            );
1069        }
1070        for i in 1..=count {
1071            contents.push_str(&message_line(
1072                &msg(NOW - (count - i) * 1000, i),
1073                &format!("m{i}"),
1074            ));
1075            contents.push('\n');
1076        }
1077        std::fs::write(&path, contents).unwrap();
1078        path
1079    }
1080
1081    /// the newest N survive with their ids intact, the file is a header plus exactly N
1082    /// message lines, and every line parses.
1083    #[test]
1084    fn max_messages_leaves_the_newest_n_with_their_ids_intact() {
1085        let dir = tempfile::tempdir().unwrap();
1086        let path = write_record(dir.path(), Some("capsule"), 10);
1087        let before: Vec<String> = std::fs::read_to_string(&path)
1088            .unwrap()
1089            .lines()
1090            .filter_map(parse_message_line)
1091            .map(|m| m["id"].as_str().unwrap().to_string())
1092            .collect();
1093
1094        let outcome = truncate_record(&path, 3, "capsule").unwrap();
1095        assert_eq!(outcome.dropped, 7);
1096        assert_eq!(outcome.kept, 3);
1097        assert_eq!(outcome.last_dropped_id, before[6]);
1098        assert_eq!(outcome.oldest_surviving_id, before[7]);
1099
1100        let raw = std::fs::read_to_string(&path).unwrap();
1101        let lines: Vec<&str> = raw.lines().collect();
1102        assert_eq!(
1103            lines.len(),
1104            4,
1105            "a header plus exactly three messages: {lines:?}"
1106        );
1107        for line in &lines {
1108            serde_json::from_str::<serde_json::Value>(line).expect("every line is JSON");
1109        }
1110        let header = parse_header(lines[0]).expect("the first line is the header");
1111        assert_eq!(header.capsule, "capsule");
1112        let marker = header.truncated.expect("the header records the drop");
1113        assert_eq!(marker.dropped, 7);
1114        assert_eq!(marker.last_dropped_id, before[6]);
1115        assert_eq!(marker.oldest_surviving_id, before[7]);
1116
1117        let after: Vec<String> = lines[1..]
1118            .iter()
1119            .map(|line| {
1120                parse_message_line(line).expect("a message with a string role")["id"]
1121                    .as_str()
1122                    .unwrap()
1123                    .to_string()
1124            })
1125            .collect();
1126        assert_eq!(after, before[7..], "surviving ids are the bytes they were");
1127    }
1128
1129    /// The header does not count as a message: `count_messages` — the same rule
1130    /// `crate::conversation::read_record` applies to `total` — is unchanged by it.
1131    #[test]
1132    fn a_header_line_is_not_counted_as_a_message() {
1133        let dir = tempfile::tempdir().unwrap();
1134        let headerless = write_record(&dir.path().join("a"), None, 4);
1135        let owned = write_record(&dir.path().join("b"), Some("capsule"), 4);
1136        assert_eq!(count_messages(&headerless), 4);
1137        assert_eq!(count_messages(&owned), 4);
1138    }
1139
1140    /// Truncating twice accumulates: `dropped` is what this record has lost over its life, not
1141    /// what the last rewrite took.
1142    #[test]
1143    fn a_second_truncation_accumulates_the_dropped_count() {
1144        let dir = tempfile::tempdir().unwrap();
1145        let path = write_record(dir.path(), Some("capsule"), 10);
1146        truncate_record(&path, 6, "capsule").unwrap();
1147        truncate_record(&path, 2, "capsule").unwrap();
1148        let marker = read_header(&path).unwrap().truncated.unwrap();
1149        assert_eq!(marker.dropped, 8);
1150        assert_eq!(count_messages(&path), 2);
1151    }
1152
1153    #[test]
1154    fn truncation_rewrite_keeps_the_record_owner_only() {
1155        use std::os::unix::fs::PermissionsExt;
1156        let dir = tempfile::tempdir().unwrap();
1157        let path = write_record(dir.path(), Some("capsule"), 5);
1158        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1159
1160        truncate_record(&path, 2, "capsule").unwrap();
1161
1162        assert_eq!(count_messages(&path), 2);
1163        assert_eq!(crate::murmur_home::mode_of(&path), 0o600);
1164    }
1165
1166    /// A record already at or under the limit is left untouched, byte for byte.
1167    #[test]
1168    fn a_record_inside_the_limit_is_not_rewritten() {
1169        let dir = tempfile::tempdir().unwrap();
1170        let path = write_record(dir.path(), Some("capsule"), 3);
1171        let before = std::fs::read(&path).unwrap();
1172        let outcome = truncate_record(&path, 3, "capsule").unwrap();
1173        assert_eq!(outcome.dropped, 0);
1174        assert_eq!(std::fs::read(&path).unwrap(), before);
1175    }
1176
1177    /// a crash mid-truncate leaves the original intact. The staged rewrite is a temp file in
1178    /// the record's own directory — same filesystem, so the rename that commits it is atomic —
1179    /// and until that rename the original parses whole and holds its original message count.
1180    #[test]
1181    fn a_staged_truncation_leaves_the_original_intact_until_the_rename() {
1182        let dir = tempfile::tempdir().unwrap();
1183        let path = write_record(dir.path(), Some("capsule"), 10);
1184        let before = std::fs::read(&path).unwrap();
1185
1186        let (staged, outcome) = stage_truncation(&path, 3, "capsule", NOW).unwrap().unwrap();
1187        assert_eq!(outcome.dropped, 7);
1188
1189        let temp = staged.temp_path().to_path_buf();
1190        assert_eq!(
1191            temp.parent(),
1192            path.parent(),
1193            "the temp file is in the record's own directory, so the rename is atomic"
1194        );
1195        assert_ne!(temp.file_name().unwrap(), RECORD_FILE_NAME);
1196
1197        assert_eq!(
1198            std::fs::read(&path).unwrap(),
1199            before,
1200            "the original is byte-identical before the rename"
1201        );
1202        assert_eq!(count_messages(&path), 10);
1203        for line in std::fs::read_to_string(&path).unwrap().lines() {
1204            serde_json::from_str::<serde_json::Value>(line).expect("the original parses whole");
1205        }
1206
1207        // Dropping without committing is the crash: the original stands and no debris is left.
1208        drop(staged);
1209        assert!(!temp.exists());
1210        assert_eq!(std::fs::read(&path).unwrap(), before);
1211        assert_eq!(count_messages(&path), 10);
1212    }
1213
1214    // ── Record pruning ───────────────────────────────────────────────────────
1215
1216    /// automatic record pruning touches only a record whose header names this
1217    /// capsule. A record another capsule owns and a record no header owns both survive, however
1218    /// old they are — and the current launch's own context is never removed.
1219    #[test]
1220    fn record_pruning_skips_another_capsules_records_and_every_unowned_one() {
1221        let root = tempfile::tempdir().unwrap();
1222        write_record(&root.path().join("mine"), Some("mine"), 3);
1223        write_record(&root.path().join("theirs"), Some("theirs"), 3);
1224        write_record(&root.path().join("unowned"), None, 3);
1225        write_record(&root.path().join("current"), Some("mine"), 3);
1226
1227        // Far enough in the future that every mtime is outside a one-second window.
1228        let far_future = now_ms() + 3_600_000;
1229        let pruned = prune_records(
1230            root.path(),
1231            "mine",
1232            Some("current"),
1233            &ContextRetainConfig {
1234                max_messages: None,
1235                max_age_secs: Some(1),
1236            },
1237            far_future,
1238        );
1239
1240        assert_eq!(pruned.len(), 1, "{pruned:?}");
1241        assert_eq!(pruned[0].context_id, "mine");
1242        assert_eq!(pruned[0].reason, crate::trace::RETENTION_REASON_MAX_AGE);
1243        assert_eq!(names(root.path()), vec!["current", "theirs", "unowned"]);
1244    }
1245
1246    /// a record inside the window stays. Age is last write, so a record written a moment ago
1247    /// survives a 90-day policy however long ago its conversation started.
1248    #[test]
1249    fn a_record_written_inside_the_window_is_kept() {
1250        let root = tempfile::tempdir().unwrap();
1251        write_record(&root.path().join("fresh"), Some("mine"), 3);
1252        let pruned = prune_records(
1253            root.path(),
1254            "mine",
1255            None,
1256            &ContextRetainConfig {
1257                max_messages: None,
1258                max_age_secs: Some(90 * 86_400),
1259            },
1260            now_ms(),
1261        );
1262        assert!(pruned.is_empty());
1263        assert_eq!(names(root.path()), vec!["fresh"]);
1264    }
1265
1266    /// `max_messages` alone never removes a context directory: it is enforced on the record the
1267    /// launch opens, not by the age sweep.
1268    #[test]
1269    fn max_messages_alone_removes_no_record() {
1270        let root = tempfile::tempdir().unwrap();
1271        write_record(&root.path().join("ctx"), Some("mine"), 100);
1272        assert!(prune_records(
1273            root.path(),
1274            "mine",
1275            None,
1276            &ContextRetainConfig {
1277                max_messages: Some(1),
1278                max_age_secs: None
1279            },
1280            now_ms() + 3_600_000,
1281        )
1282        .is_empty());
1283        assert_eq!(names(root.path()), vec!["ctx"]);
1284    }
1285
1286    // ── Formation pruning ────────────────────────────────────────────────────
1287
1288    /// A formation id whose uuid-v7 timestamp is exactly `ms`.
1289    fn frm(ms: u64, tail: u64) -> FormationId {
1290        FormationId::parse(&format!("frm_{ms:012x}{tail:020x}")).unwrap()
1291    }
1292
1293    fn retain(max_sessions: Option<u32>, max_age_secs: Option<u64>) -> TraceRetainConfig {
1294        TraceRetainConfig {
1295            max_sessions,
1296            max_age_secs,
1297        }
1298    }
1299
1300    /// `<root>/<id>/worker/.murmur/ses_…/trace.jsonl`, with `marker` as `formation.json` when
1301    /// given.
1302    fn make_formation(root: &Path, id: &FormationId, marker: Option<&str>) -> PathBuf {
1303        let dir = root.join(id.as_str());
1304        let session = dir.join("worker/.murmur").join(ses(NOW, 1));
1305        std::fs::create_dir_all(&session).unwrap();
1306        std::fs::write(session.join("trace.jsonl"), "{}\n").unwrap();
1307        if let Some(marker) = marker {
1308            std::fs::write(
1309                dir.join(crate::formation_launch::FORMATION_MARKER_FILE),
1310                marker,
1311            )
1312            .unwrap();
1313        }
1314        dir
1315    }
1316
1317    fn marker_naming(project: &Path) -> String {
1318        serde_json::to_string(&FormationMarker {
1319            project_dir: project.to_path_buf(),
1320            launcher_pid: 1,
1321            launcher_start: String::new(),
1322        })
1323        .unwrap()
1324    }
1325
1326    /// Owned formations of `project` under `root`, one per id.
1327    fn make_owned(root: &Path, project: &Path, ids: &[&FormationId]) {
1328        for id in ids {
1329            make_formation(root, id, Some(&marker_naming(project)));
1330        }
1331    }
1332
1333    fn removed(pruned: &[PrunedFormation]) -> Vec<(FormationId, &'static str)> {
1334        pruned
1335            .iter()
1336            .map(|p| (p.formation_id.clone(), p.reason))
1337            .collect()
1338    }
1339
1340    const DAY_MS: u64 = 86_400_000;
1341
1342    /// The current formation and every one minted after it stay, under both keys at their
1343    /// tightest and every directory past both.
1344    #[test]
1345    fn prune_formations_never_removes_the_current_or_a_later_formation() {
1346        let root = tempfile::tempdir().unwrap();
1347        let project = tempfile::tempdir().unwrap();
1348        let old = frm(NOW - 3 * DAY_MS, 1);
1349        let current = frm(NOW - 2 * DAY_MS, 2);
1350        let later = frm(NOW - 2 * DAY_MS + 1000, 3);
1351        make_owned(root.path(), project.path(), &[&old, &current, &later]);
1352
1353        let pruned = prune_formations(
1354            root.path(),
1355            &current,
1356            project.path(),
1357            &retain(Some(1), Some(1)),
1358            NOW,
1359            |_, _| false,
1360        );
1361
1362        assert_eq!(
1363            removed(&pruned),
1364            [(old.clone(), crate::trace::RETENTION_REASON_MAX_AGE)]
1365        );
1366        assert_eq!(pruned[0].path, root.path().join(old.as_str()));
1367        assert_eq!(
1368            names(root.path()),
1369            vec![current.as_str().to_string(), later.as_str().to_string()]
1370        );
1371    }
1372
1373    /// `max_sessions: 3` leaves the three newest of this project's directories, the current one
1374    /// and one minted after it among them, and what went is listed newest first.
1375    #[test]
1376    fn prune_formations_keeps_the_newest_n_with_the_current_counted() {
1377        let root = tempfile::tempdir().unwrap();
1378        let project = tempfile::tempdir().unwrap();
1379        let all: Vec<FormationId> = (1..=5).map(|n| frm(NOW - (5 - n) * 1000, n)).collect();
1380        make_owned(root.path(), project.path(), &all.iter().collect::<Vec<_>>());
1381
1382        let pruned = prune_formations(
1383            root.path(),
1384            &all[3],
1385            project.path(),
1386            &retain(Some(3), None),
1387            NOW,
1388            |_, _| false,
1389        );
1390
1391        let reason = crate::trace::RETENTION_REASON_MAX_SESSIONS;
1392        assert_eq!(
1393            removed(&pruned),
1394            [(all[1].clone(), reason), (all[0].clone(), reason)]
1395        );
1396        assert_eq!(
1397            names(root.path()),
1398            all[2..]
1399                .iter()
1400                .map(|id| id.as_str().to_string())
1401                .collect::<Vec<_>>()
1402        );
1403    }
1404
1405    /// Age comes out of the id: every directory here is made in the same instant, and the two
1406    /// whose ids encode two hours ago are the two that go.
1407    #[test]
1408    fn prune_formations_reads_age_from_the_id() {
1409        let root = tempfile::tempdir().unwrap();
1410        let project = tempfile::tempdir().unwrap();
1411        let old_a = frm(NOW - 7_200_000, 1);
1412        let old_b = frm(NOW - 7_100_000, 2);
1413        let recent = frm(NOW - 60_000, 3);
1414        let current = frm(NOW, 4);
1415        make_owned(
1416            root.path(),
1417            project.path(),
1418            &[&old_a, &old_b, &recent, &current],
1419        );
1420
1421        let pruned = prune_formations(
1422            root.path(),
1423            &current,
1424            project.path(),
1425            &retain(None, Some(900)),
1426            NOW,
1427            |_, _| false,
1428        );
1429
1430        let reason = crate::trace::RETENTION_REASON_MAX_AGE;
1431        assert_eq!(removed(&pruned), [(old_b, reason), (old_a, reason)]);
1432        assert_eq!(
1433            names(root.path()),
1434            vec![recent.as_str().to_string(), current.as_str().to_string()]
1435        );
1436    }
1437
1438    /// A directory past both limits is reported as too old; one past the count alone, as over the
1439    /// count.
1440    #[test]
1441    fn prune_formations_attributes_reasons_age_first() {
1442        let root = tempfile::tempdir().unwrap();
1443        let project = tempfile::tempdir().unwrap();
1444        let old = frm(NOW - 7_200_000, 1);
1445        let recent = frm(NOW - 60_000, 2);
1446        let current = frm(NOW, 3);
1447        make_owned(root.path(), project.path(), &[&old, &recent, &current]);
1448
1449        let pruned = prune_formations(
1450            root.path(),
1451            &current,
1452            project.path(),
1453            &retain(Some(1), Some(900)),
1454            NOW,
1455            |_, _| false,
1456        );
1457
1458        assert_eq!(
1459            removed(&pruned),
1460            [
1461                (recent, crate::trace::RETENTION_REASON_MAX_SESSIONS),
1462                (old, crate::trace::RETENTION_REASON_MAX_AGE),
1463            ]
1464        );
1465    }
1466
1467    /// A live formation is never removed, and still counts: the one ranked behind it goes under
1468    /// `max_sessions: 2` although only two directories are not live.
1469    #[test]
1470    fn prune_formations_skips_a_live_directory_and_keeps_its_rank() {
1471        let root = tempfile::tempdir().unwrap();
1472        let project = tempfile::tempdir().unwrap();
1473        let older = frm(NOW - 3000, 1);
1474        let live = frm(NOW - 2000, 2);
1475        let current = frm(NOW, 3);
1476        make_owned(root.path(), project.path(), &[&older, &live, &current]);
1477
1478        let asked = std::cell::RefCell::new(Vec::new());
1479        let pruned = prune_formations(
1480            root.path(),
1481            &current,
1482            project.path(),
1483            &retain(Some(1), None),
1484            NOW,
1485            |id, marker| {
1486                assert_eq!(marker.launcher_pid, 1);
1487                asked.borrow_mut().push(id.clone());
1488                *id == live
1489            },
1490        );
1491
1492        assert_eq!(
1493            removed(&pruned),
1494            [(older.clone(), crate::trace::RETENTION_REASON_MAX_SESSIONS)]
1495        );
1496        assert_eq!(*asked.borrow(), [live.clone(), older]);
1497        assert_eq!(
1498            names(root.path()),
1499            vec![live.as_str().to_string(), current.as_str().to_string()]
1500        );
1501
1502        // Ranked behind the live one, a directory inside the count is kept.
1503        let kept = frm(NOW - 2500, 4);
1504        make_owned(root.path(), project.path(), &[&kept]);
1505        let pruned = prune_formations(
1506            root.path(),
1507            &current,
1508            project.path(),
1509            &retain(Some(3), None),
1510            NOW,
1511            |id, _| *id == live,
1512        );
1513        assert!(pruned.is_empty(), "{pruned:?}");
1514        let pruned = prune_formations(
1515            root.path(),
1516            &current,
1517            project.path(),
1518            &retain(Some(2), None),
1519            NOW,
1520            |id, _| *id == live,
1521        );
1522        assert_eq!(
1523            removed(&pruned),
1524            [(kept, crate::trace::RETENTION_REASON_MAX_SESSIONS)]
1525        );
1526    }
1527
1528    /// No marker, a marker that does not parse, and a marker naming another project: none is
1529    /// removed, and none takes a rank from this project's own directories.
1530    #[test]
1531    fn prune_formations_never_ranks_or_removes_unowned_or_foreign_directories() {
1532        let root = tempfile::tempdir().unwrap();
1533        let project = tempfile::tempdir().unwrap();
1534        let other = tempfile::tempdir().unwrap();
1535        let owned_old = frm(NOW - 3 * DAY_MS, 1);
1536        let owned_kept = frm(NOW - 2 * DAY_MS, 2);
1537        let unmarked = frm(NOW - 2 * DAY_MS + 1, 3);
1538        let garbled = frm(NOW - 2 * DAY_MS + 2, 4);
1539        let foreign = frm(NOW - 2 * DAY_MS + 3, 5);
1540        let current = frm(NOW, 6);
1541        make_owned(
1542            root.path(),
1543            project.path(),
1544            &[&owned_old, &owned_kept, &current],
1545        );
1546        make_formation(root.path(), &unmarked, None);
1547        make_formation(root.path(), &garbled, Some("not json {"));
1548        make_formation(root.path(), &foreign, Some(&marker_naming(other.path())));
1549
1550        let pruned = prune_formations(
1551            root.path(),
1552            &current,
1553            project.path(),
1554            &retain(Some(2), None),
1555            NOW,
1556            |_, _| false,
1557        );
1558        assert_eq!(
1559            removed(&pruned),
1560            [(owned_old, crate::trace::RETENTION_REASON_MAX_SESSIONS)]
1561        );
1562
1563        let pruned = prune_formations(
1564            root.path(),
1565            &current,
1566            project.path(),
1567            &retain(Some(1), Some(DAY_MS / 1000)),
1568            NOW,
1569            |_, _| false,
1570        );
1571        assert_eq!(
1572            removed(&pruned),
1573            [(owned_kept, crate::trace::RETENTION_REASON_MAX_AGE)]
1574        );
1575        for id in [&unmarked, &garbled, &foreign, &current] {
1576            assert!(root.path().join(id.as_str()).is_dir(), "{id}");
1577        }
1578    }
1579
1580    /// A symlink, a regular file, and a directory whose name is not a formation id are never
1581    /// candidates, marker or not; the symlink's target is left alone too.
1582    #[test]
1583    fn prune_formations_ignores_a_symlink_a_regular_file_and_a_name_that_is_not_an_id() {
1584        let root = tempfile::tempdir().unwrap();
1585        let elsewhere = tempfile::tempdir().unwrap();
1586        let project = tempfile::tempdir().unwrap();
1587        let marker = marker_naming(project.path());
1588        let target = make_formation(elsewhere.path(), &frm(NOW - 3 * DAY_MS, 1), Some(&marker));
1589        let linked = frm(NOW - 3 * DAY_MS, 2);
1590        std::os::unix::fs::symlink(&target, root.path().join(linked.as_str())).unwrap();
1591        std::fs::write(root.path().join(frm(NOW - 3 * DAY_MS, 3).as_str()), &marker).unwrap();
1592        for name in ["not-a-formation", "frm_ABC", "frm_0123"] {
1593            std::fs::create_dir(root.path().join(name)).unwrap();
1594            std::fs::write(
1595                root.path()
1596                    .join(name)
1597                    .join(crate::formation_launch::FORMATION_MARKER_FILE),
1598                &marker,
1599            )
1600            .unwrap();
1601        }
1602        let before = names(root.path());
1603
1604        let pruned = prune_formations(
1605            root.path(),
1606            &frm(NOW, 9),
1607            project.path(),
1608            &retain(Some(1), Some(1)),
1609            NOW,
1610            |_, _| false,
1611        );
1612
1613        assert!(pruned.is_empty(), "{pruned:?}");
1614        assert_eq!(names(root.path()), before);
1615        assert!(target.join("worker/.murmur").is_dir());
1616    }
1617
1618    /// A policy with neither key is no policy, and a missing formations directory holds nothing.
1619    #[test]
1620    fn prune_formations_with_neither_key_or_no_directory_removes_nothing() {
1621        let root = tempfile::tempdir().unwrap();
1622        let project = tempfile::tempdir().unwrap();
1623        let old = frm(NOW - 3 * DAY_MS, 1);
1624        make_owned(root.path(), project.path(), &[&old]);
1625
1626        let pruned = prune_formations(
1627            root.path(),
1628            &frm(NOW, 2),
1629            project.path(),
1630            &retain(None, None),
1631            NOW,
1632            |_, _| panic!("nothing is a candidate without a policy"),
1633        );
1634        assert!(pruned.is_empty());
1635        assert!(root.path().join(old.as_str()).is_dir());
1636
1637        assert!(prune_formations(
1638            &root.path().join("missing"),
1639            &frm(NOW, 2),
1640            project.path(),
1641            &retain(Some(1), Some(1)),
1642            NOW,
1643            |_, _| false,
1644        )
1645        .is_empty());
1646    }
1647
1648    /// The project directory is compared canonicalized: a launch through a symlink to the project
1649    /// owns what a launch through its real path marked.
1650    #[test]
1651    fn prune_formations_compares_project_directories_canonically() {
1652        let root = tempfile::tempdir().unwrap();
1653        let project = tempfile::tempdir().unwrap();
1654        let links = tempfile::tempdir().unwrap();
1655        let link = links.path().join("project");
1656        std::os::unix::fs::symlink(project.path(), &link).unwrap();
1657        let old = frm(NOW - 3 * DAY_MS, 1);
1658        make_owned(
1659            root.path(),
1660            &std::fs::canonicalize(project.path()).unwrap(),
1661            &[&old],
1662        );
1663
1664        let pruned = prune_formations(
1665            root.path(),
1666            &frm(NOW, 2),
1667            &link,
1668            &retain(None, Some(1)),
1669            NOW,
1670            |_, _| false,
1671        );
1672        assert_eq!(
1673            removed(&pruned),
1674            [(old, crate::trace::RETENTION_REASON_MAX_AGE)]
1675        );
1676    }
1677
1678    /// A directory that cannot be removed whole is not reported as removed.
1679    #[test]
1680    fn prune_formations_leaves_a_directory_it_could_not_remove_out_of_the_result() {
1681        use std::os::unix::fs::PermissionsExt;
1682
1683        let root = tempfile::tempdir().unwrap();
1684        let project = tempfile::tempdir().unwrap();
1685        let stuck = frm(NOW - 3 * DAY_MS, 1);
1686        let gone = frm(NOW - 3 * DAY_MS + 1, 2);
1687        make_owned(root.path(), project.path(), &[&stuck, &gone]);
1688        let stuck_dir = root.path().join(stuck.as_str());
1689        std::fs::set_permissions(&stuck_dir, std::fs::Permissions::from_mode(0o500)).unwrap();
1690        // Root removes it regardless, so there is nothing to observe.
1691        if std::fs::write(stuck_dir.join("probe"), b"").is_ok() {
1692            std::fs::set_permissions(&stuck_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
1693            return;
1694        }
1695
1696        let pruned = prune_formations(
1697            root.path(),
1698            &frm(NOW, 3),
1699            project.path(),
1700            &retain(None, Some(1)),
1701            NOW,
1702            |_, _| false,
1703        );
1704        std::fs::set_permissions(&stuck_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
1705
1706        assert_eq!(
1707            removed(&pruned),
1708            [(gone, crate::trace::RETENTION_REASON_MAX_AGE)]
1709        );
1710        assert!(stuck_dir.is_dir());
1711    }
1712}