1use 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#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct PrunedSession {
49 pub name: String,
51 pub reason: &'static str,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct PrunedFormation {
59 pub formation_id: FormationId,
61 pub path: PathBuf,
63 pub reason: &'static str,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct PrunedRecord {
71 pub context_id: String,
73 pub path: PathBuf,
75 pub reason: &'static str,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct TruncationOutcome {
82 pub dropped: u64,
84 pub kept: u64,
86 pub oldest_surviving_id: String,
88 pub last_dropped_id: String,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum MessageStatus {
95 Present { position: u64, total: u64 },
97 Truncated {
101 dropped: u64,
102 oldest_surviving_id: String,
103 },
104 Unknown,
106}
107
108#[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#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct RecordSummary {
120 pub record: String,
122 pub context_id: String,
124 pub path: PathBuf,
125 pub messages: u64,
127 pub bytes: u64,
129 pub last_touched_ms: Option<u64>,
132 pub capsule: Option<String>,
135 pub truncation: Option<TruncationMarker>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct RemovedRecord {
142 pub record: String,
143 pub context_id: String,
144 pub path: PathBuf,
146 pub messages: u64,
148}
149
150pub fn session_id_timestamp_ms(name: &str) -> Option<u64> {
158 uuid_v7_timestamp_ms(name, "ses_")
159}
160
161pub(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
168pub 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 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 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
248pub 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 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 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
345pub 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
402pub 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
431pub(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 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 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
523pub(crate) struct StagedRewrite {
529 target: PathBuf,
530 temp: PathBuf,
531 committed: bool,
532}
533
534impl StagedRewrite {
535 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 file.write_all(contents)
568 .and_then(|()| file.sync_all())
569 .map_err(|err| format!("{}: {err}", temp.display()))?;
570 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
605pub 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
643pub 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
662pub 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
718pub fn conversations_root() -> Result<PathBuf, String> {
722 Ok(crate::state_store::murmur_home_dir()?.join(CONVERSATION_ROOT_DIR))
723}
724
725pub 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
733fn 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
748fn 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
759fn 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
771fn 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 fn ses(ms: u64, tail: u64) -> String {
791 format!("ses_{ms:012x}{tail:020x}")
792 }
793
794 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 #[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 #[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 ¤t,
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 #[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 ¤t,
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 #[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 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 ¤t,
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(¤t).join("trace.jsonl").exists());
968 }
969
970 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 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 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 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 #[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, ¤t, &later]);
1352
1353 let pruned = prune_formations(
1354 root.path(),
1355 ¤t,
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 #[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 #[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, ¤t],
1419 );
1420
1421 let pruned = prune_formations(
1422 root.path(),
1423 ¤t,
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 #[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, ¤t]);
1448
1449 let pruned = prune_formations(
1450 root.path(),
1451 ¤t,
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 #[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, ¤t]);
1477
1478 let asked = std::cell::RefCell::new(Vec::new());
1479 let pruned = prune_formations(
1480 root.path(),
1481 ¤t,
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 let kept = frm(NOW - 2500, 4);
1504 make_owned(root.path(), project.path(), &[&kept]);
1505 let pruned = prune_formations(
1506 root.path(),
1507 ¤t,
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 ¤t,
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 #[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, ¤t],
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 ¤t,
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 ¤t,
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, ¤t] {
1576 assert!(root.path().join(id.as_str()).is_dir(), "{id}");
1577 }
1578 }
1579
1580 #[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 #[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 #[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 #[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 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}