1use std::path::{Path, PathBuf};
29
30use crate::conversation::{
31 header_line, message_id_timestamp_ms, parse_message_line, read_header, CONVERSATION_ROOT_DIR,
32 RECORD_FILE_NAME,
33};
34
35pub use crate::conversation::{RecordHeader, TruncationMarker};
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct PrunedSession {
40 pub name: String,
42 pub reason: &'static str,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct PrunedRecord {
50 pub context_id: String,
52 pub path: PathBuf,
54 pub reason: &'static str,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct TruncationOutcome {
61 pub dropped: u64,
63 pub kept: u64,
65 pub oldest_surviving_id: String,
67 pub last_dropped_id: String,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum MessageStatus {
74 Present { position: u64, total: u64 },
76 Truncated {
80 dropped: u64,
81 oldest_surviving_id: String,
82 },
83 Unknown,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct MessageLocation {
90 pub record: String,
91 pub context_id: String,
92 pub path: PathBuf,
93 pub status: MessageStatus,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct RecordSummary {
99 pub record: String,
101 pub context_id: String,
103 pub path: PathBuf,
104 pub messages: u64,
106 pub bytes: u64,
108 pub last_touched_ms: Option<u64>,
111 pub capsule: Option<String>,
114 pub truncation: Option<TruncationMarker>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct RemovedRecord {
121 pub record: String,
122 pub context_id: String,
123 pub path: PathBuf,
125 pub messages: u64,
127}
128
129pub fn session_id_timestamp_ms(name: &str) -> Option<u64> {
137 let hex = name.strip_prefix("ses_")?;
138 if hex.len() < 12 {
139 return None;
140 }
141 u64::from_str_radix(&hex[..12], 16).ok()
142}
143
144pub fn prune_sessions(
159 sessions_root: &Path,
160 current_session_id: &str,
161 policy: &murmur_artifact::TraceRetainConfig,
162 now_ms: u64,
163) -> Vec<PrunedSession> {
164 if policy.max_sessions.is_none() && policy.max_age_secs.is_none() {
165 return Vec::new();
166 }
167
168 let mut entries: Vec<String> = match std::fs::read_dir(sessions_root) {
169 Ok(dir) => dir
170 .filter_map(Result::ok)
171 .filter(|entry| entry.path().is_dir())
172 .map(|entry| entry.file_name().to_string_lossy().into_owned())
173 .filter(|name| session_id_timestamp_ms(name).is_some())
174 .collect(),
175 Err(_) => return Vec::new(),
176 };
177 entries.sort_unstable_by(|a, b| b.cmp(a));
179
180 let age_floor_ms = policy
181 .max_age_secs
182 .map(|secs| now_ms.saturating_sub(secs.saturating_mul(1000)));
183
184 let mut pruned = Vec::new();
185 for (rank, name) in entries.iter().enumerate() {
186 if name.as_str() >= current_session_id {
189 continue;
190 }
191 let over_age = age_floor_ms.is_some_and(|floor| {
192 session_id_timestamp_ms(name).is_some_and(|minted| minted < floor)
193 });
194 let over_count = policy.max_sessions.is_some_and(|max| rank >= max as usize);
195 if !over_age && !over_count {
196 continue;
197 }
198 let reason = if over_age {
199 crate::trace::RETENTION_REASON_MAX_AGE
200 } else {
201 crate::trace::RETENTION_REASON_MAX_SESSIONS
202 };
203 if std::fs::remove_dir_all(sessions_root.join(name)).is_ok() {
204 pruned.push(PrunedSession {
205 name: name.clone(),
206 reason,
207 });
208 }
209 }
210 pruned
211}
212
213pub fn prune_records(
232 record_root: &Path,
233 capsule_name: &str,
234 current_context_id: Option<&str>,
235 policy: &murmur_artifact::ContextRetainConfig,
236 now_ms: u64,
237) -> Vec<PrunedRecord> {
238 let Some(max_age_secs) = policy.max_age_secs else {
239 return Vec::new();
240 };
241 let floor_ms = now_ms.saturating_sub(max_age_secs.saturating_mul(1000));
242
243 let mut pruned = Vec::new();
244 for (context_id, dir) in context_dirs(record_root) {
245 if Some(context_id.as_str()) == current_context_id {
246 continue;
247 }
248 let file = dir.join(RECORD_FILE_NAME);
249 match read_header(&file) {
250 Some(header) if header.capsule == capsule_name => {}
251 _ => continue,
252 }
253 let Some(touched_ms) = last_touched_ms(&file) else {
254 continue;
255 };
256 if touched_ms >= floor_ms {
257 continue;
258 }
259 if std::fs::remove_dir_all(&dir).is_ok() {
260 pruned.push(PrunedRecord {
261 context_id,
262 path: dir,
263 reason: crate::trace::RETENTION_REASON_MAX_AGE,
264 });
265 }
266 }
267 pruned
268}
269
270pub fn truncate_record(path: &Path, keep: u32, capsule: &str) -> Result<TruncationOutcome, String> {
285 match stage_truncation(path, keep, capsule, now_ms())? {
286 None => Ok(TruncationOutcome {
287 dropped: 0,
288 kept: count_messages(path),
289 oldest_surviving_id: String::new(),
290 last_dropped_id: String::new(),
291 }),
292 Some((staged, outcome)) => {
293 staged.commit()?;
294 Ok(outcome)
295 }
296 }
297}
298
299pub(crate) fn stage_truncation(
305 path: &Path,
306 keep: u32,
307 capsule: &str,
308 at_ms: u64,
309) -> Result<Option<(StagedRewrite, TruncationOutcome)>, String> {
310 let raw = match std::fs::read_to_string(path) {
311 Ok(raw) => raw,
312 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
313 Err(err) => return Err(format!("{}: {err}", path.display())),
314 };
315
316 let existing = read_header(path);
317 let lines: Vec<&str> = raw.lines().collect();
318 let message_indices: Vec<usize> = lines
321 .iter()
322 .enumerate()
323 .filter(|(_, line)| parse_message_line(line).is_some())
324 .map(|(index, _)| index)
325 .collect();
326
327 let total = message_indices.len();
328 let keep = keep as usize;
329 if total <= keep {
330 return Ok(None);
331 }
332 let dropped = total - keep;
333 let first_kept_index = message_indices[dropped];
334
335 let id_of = |index: usize| -> String {
336 parse_message_line(lines[index])
337 .as_ref()
338 .and_then(|message| crate::agent::message_id(message).map(str::to_string))
339 .unwrap_or_default()
340 };
341 let last_dropped_id = id_of(message_indices[dropped - 1]);
342 let oldest_surviving_id = message_indices
343 .get(dropped)
344 .map(|i| id_of(*i))
345 .unwrap_or_default();
346
347 let header = RecordHeader {
348 kind: crate::conversation::RECORD_HEADER_TYPE.to_string(),
349 capsule: existing
350 .as_ref()
351 .map(|h| h.capsule.clone())
352 .unwrap_or_else(|| capsule.to_string()),
353 created_ms: existing.as_ref().map(|h| h.created_ms).unwrap_or(at_ms),
354 truncated: Some(TruncationMarker {
355 dropped: existing
358 .as_ref()
359 .and_then(|h| h.truncated.as_ref())
360 .map(|marker| marker.dropped)
361 .unwrap_or(0)
362 + dropped as u64,
363 oldest_surviving_id: oldest_surviving_id.clone(),
364 last_dropped_id: last_dropped_id.clone(),
365 at_ms,
366 }),
367 };
368
369 let mut contents = header_line(&header)?;
370 for line in &lines[first_kept_index..] {
371 contents.push_str(line);
372 contents.push('\n');
373 }
374
375 let staged = StagedRewrite::stage_with_mode(
376 path,
377 contents.as_bytes(),
378 Some(crate::conversation::RECORD_FILE_MODE),
379 )?;
380 Ok(Some((
381 staged,
382 TruncationOutcome {
383 dropped: dropped as u64,
384 kept: keep as u64,
385 oldest_surviving_id,
386 last_dropped_id,
387 },
388 )))
389}
390
391pub(crate) struct StagedRewrite {
397 target: PathBuf,
398 temp: PathBuf,
399 committed: bool,
400}
401
402impl StagedRewrite {
403 pub(crate) fn stage_with_mode(
406 target: &Path,
407 contents: &[u8],
408 mode: Option<u32>,
409 ) -> Result<Self, String> {
410 use std::io::Write;
411 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
412
413 let dir = target
414 .parent()
415 .ok_or_else(|| format!("{} has no parent directory", target.display()))?;
416 let name = target
417 .file_name()
418 .map(|name| name.to_string_lossy().into_owned())
419 .unwrap_or_else(|| "record".to_string());
420 let temp = dir.join(format!(
421 ".{name}.{}.{}.tmp",
422 std::process::id(),
423 uuid::Uuid::now_v7().simple()
424 ));
425 let mut options = std::fs::OpenOptions::new();
426 options.write(true).create(true).truncate(true);
427 if let Some(mode) = mode {
428 options.mode(mode);
429 }
430 let mut file = options
431 .open(&temp)
432 .map_err(|err| format!("{}: {err}", temp.display()))?;
433 file.write_all(contents)
436 .and_then(|()| file.sync_all())
437 .map_err(|err| format!("{}: {err}", temp.display()))?;
438 if let Some(mode) = mode {
442 std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(mode))
443 .map_err(|err| format!("{}: {err}", temp.display()))?;
444 }
445 Ok(Self {
446 target: target.to_path_buf(),
447 temp,
448 committed: false,
449 })
450 }
451
452 #[cfg(test)]
453 pub(crate) fn temp_path(&self) -> &Path {
454 &self.temp
455 }
456
457 pub(crate) fn commit(mut self) -> Result<(), String> {
458 std::fs::rename(&self.temp, &self.target)
459 .map_err(|err| format!("{}: {err}", self.target.display()))?;
460 self.committed = true;
461 Ok(())
462 }
463}
464
465impl Drop for StagedRewrite {
466 fn drop(&mut self) {
467 if !self.committed {
468 let _ = std::fs::remove_file(&self.temp);
469 }
470 }
471}
472
473pub fn list_records() -> Result<Vec<RecordSummary>, String> {
481 let root = conversations_root()?;
482 let mut summaries = Vec::new();
483 for record in child_dirs(&root) {
484 let store = root.join(&record);
485 for (context_id, dir) in context_dirs(&store) {
486 let path = dir.join(RECORD_FILE_NAME);
487 if !path.exists() {
488 continue;
489 }
490 let header = read_header(&path);
491 summaries.push(RecordSummary {
492 record: record.clone(),
493 context_id,
494 messages: count_messages(&path),
495 bytes: std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0),
496 last_touched_ms: last_touched_ms(&path),
497 capsule: header.as_ref().map(|h| h.capsule.clone()),
498 truncation: header.and_then(|h| h.truncated),
499 path,
500 });
501 }
502 }
503 summaries.sort_by(|a, b| {
504 a.record
505 .cmp(&b.record)
506 .then_with(|| a.context_id.cmp(&b.context_id))
507 });
508 Ok(summaries)
509}
510
511pub fn remove_record(record: &str, context_id: &str) -> Result<RemovedRecord, String> {
516 let dir = conversations_root()?.join(record).join(context_id);
517 if !dir.is_dir() {
518 return Err(format!("{} is not a context directory", dir.display()));
519 }
520 let messages = count_messages(&dir.join(RECORD_FILE_NAME));
521 std::fs::remove_dir_all(&dir).map_err(|err| format!("{}: {err}", dir.display()))?;
522 Ok(RemovedRecord {
523 record: record.to_string(),
524 context_id: context_id.to_string(),
525 path: dir,
526 messages,
527 })
528}
529
530pub fn locate_message(message_id: &str) -> Result<Vec<MessageLocation>, String> {
542 let minted_ms = message_id_timestamp_ms(message_id);
543 let mut found = Vec::new();
544 for summary in list_records()? {
545 let raw = std::fs::read_to_string(&summary.path).unwrap_or_default();
546 let mut position = 0u64;
547 let mut at = None;
548 for line in raw.lines() {
549 let Some(message) = parse_message_line(line) else {
550 continue;
551 };
552 position += 1;
553 if crate::agent::message_id(&message) == Some(message_id) {
554 at = Some(position);
555 }
556 }
557 let status = match (at, summary.truncation.as_ref()) {
558 (Some(position), _) => MessageStatus::Present {
559 position,
560 total: position.max(summary.messages),
561 },
562 (None, Some(marker)) => {
563 let dropped_at = message_id_timestamp_ms(&marker.last_dropped_id);
564 match (minted_ms, dropped_at) {
565 (Some(minted), Some(last)) if minted <= last => MessageStatus::Truncated {
566 dropped: marker.dropped,
567 oldest_surviving_id: marker.oldest_surviving_id.clone(),
568 },
569 _ => MessageStatus::Unknown,
570 }
571 }
572 (None, None) => MessageStatus::Unknown,
573 };
574 if status != MessageStatus::Unknown {
575 found.push(MessageLocation {
576 record: summary.record,
577 context_id: summary.context_id,
578 path: summary.path,
579 status,
580 });
581 }
582 }
583 Ok(found)
584}
585
586pub fn conversations_root() -> Result<PathBuf, String> {
590 Ok(crate::state_store::murmur_home_dir()?.join(CONVERSATION_ROOT_DIR))
591}
592
593pub fn now_ms() -> u64 {
595 std::time::SystemTime::now()
596 .duration_since(std::time::UNIX_EPOCH)
597 .map(|elapsed| elapsed.as_millis() as u64)
598 .unwrap_or(0)
599}
600
601fn child_dirs(dir: &Path) -> Vec<String> {
603 let mut names: Vec<String> = match std::fs::read_dir(dir) {
604 Ok(entries) => entries
605 .filter_map(Result::ok)
606 .filter(|entry| entry.path().is_dir())
607 .map(|entry| entry.file_name().to_string_lossy().into_owned())
608 .filter(|name| !name.starts_with('.'))
609 .collect(),
610 Err(_) => Vec::new(),
611 };
612 names.sort();
613 names
614}
615
616fn context_dirs(record_root: &Path) -> Vec<(String, PathBuf)> {
618 child_dirs(record_root)
619 .into_iter()
620 .map(|name| {
621 let path = record_root.join(&name);
622 (name, path)
623 })
624 .collect()
625}
626
627fn count_messages(path: &Path) -> u64 {
630 std::fs::read_to_string(path)
631 .map(|raw| {
632 raw.lines()
633 .filter(|line| parse_message_line(line).is_some())
634 .count() as u64
635 })
636 .unwrap_or(0)
637}
638
639fn last_touched_ms(path: &Path) -> Option<u64> {
641 std::fs::metadata(path)
642 .and_then(|metadata| metadata.modified())
643 .ok()?
644 .duration_since(std::time::UNIX_EPOCH)
645 .ok()
646 .map(|elapsed| elapsed.as_millis() as u64)
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652 use crate::conversation::parse_header;
653 use murmur_artifact::{ContextRetainConfig, TraceRetainConfig};
654 use serde_json::json;
655
656 fn ses(ms: u64, tail: u64) -> String {
659 format!("ses_{ms:012x}{tail:020x}")
660 }
661
662 fn msg(ms: u64, tail: u64) -> String {
664 format!("msg_{ms:012x}{tail:020x}")
665 }
666
667 fn message_line(id: &str, text: &str) -> String {
668 json!({"role": "user", "content": [{"type": "text", "text": text}], "id": id}).to_string()
669 }
670
671 fn make_sessions(root: &Path, names: &[String]) {
672 for name in names {
673 let dir = root.join(name);
674 std::fs::create_dir_all(dir.join("blobs")).unwrap();
675 std::fs::write(dir.join("trace.jsonl"), "{}\n").unwrap();
676 std::fs::write(dir.join("blobs").join("deadbeef"), b"body").unwrap();
677 }
678 }
679
680 fn names(root: &Path) -> Vec<String> {
681 let mut found: Vec<String> = std::fs::read_dir(root)
682 .unwrap()
683 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
684 .collect();
685 found.sort();
686 found
687 }
688
689 const NOW: u64 = 1_756_400_000_000;
690
691 #[test]
695 fn max_sessions_leaves_exactly_the_newest_n_including_the_current() {
696 let root = tempfile::tempdir().unwrap();
697 let all: Vec<String> = (1..=6).map(|n| ses(NOW - (6 - n) * 1000, n)).collect();
698 make_sessions(root.path(), &all);
699
700 let pruned = prune_sessions(
701 root.path(),
702 &all[5],
703 &TraceRetainConfig {
704 max_sessions: Some(3),
705 max_age_secs: None,
706 },
707 NOW,
708 );
709
710 assert_eq!(
711 names(root.path()),
712 vec![all[3].clone(), all[4].clone(), all[5].clone()]
713 );
714 let mut removed: Vec<String> = pruned.iter().map(|p| p.name.clone()).collect();
715 removed.sort();
716 assert_eq!(
717 removed,
718 vec![all[0].clone(), all[1].clone(), all[2].clone()]
719 );
720 assert!(pruned
721 .iter()
722 .all(|p| p.reason == crate::trace::RETENTION_REASON_MAX_SESSIONS));
723 for name in &all[..3] {
724 assert!(!root.path().join(name).exists(), "{name} went whole");
725 }
726 }
727
728 #[test]
732 fn max_age_reads_the_id_and_never_the_filesystem() {
733 let root = tempfile::tempdir().unwrap();
734 let old_a = ses(NOW - 7_200_000, 1);
735 let old_b = ses(NOW - 7_100_000, 2);
736 let recent = ses(NOW - 60_000, 3);
737 let current = ses(NOW, 4);
738 make_sessions(
739 root.path(),
740 &[
741 old_a.clone(),
742 old_b.clone(),
743 recent.clone(),
744 current.clone(),
745 ],
746 );
747
748 let pruned = prune_sessions(
749 root.path(),
750 ¤t,
751 &TraceRetainConfig {
752 max_sessions: None,
753 max_age_secs: Some(900),
754 },
755 NOW,
756 );
757
758 assert_eq!(pruned.len(), 2, "{pruned:?}");
759 assert!(pruned
760 .iter()
761 .all(|p| p.reason == crate::trace::RETENTION_REASON_MAX_AGE));
762 assert_eq!(names(root.path()), vec![recent, current]);
763 }
764
765 #[test]
768 fn both_trace_keys_keep_only_sessions_inside_both() {
769 let root = tempfile::tempdir().unwrap();
770 let old = [ses(NOW - 7_200_000, 1), ses(NOW - 7_100_000, 2)];
771 let recent = [
772 ses(NOW - 300_000, 3),
773 ses(NOW - 200_000, 4),
774 ses(NOW - 100_000, 5),
775 ];
776 let current = ses(NOW, 6);
777 let mut all = old.to_vec();
778 all.extend(recent.iter().cloned());
779 all.push(current.clone());
780 make_sessions(root.path(), &all);
781
782 let pruned = prune_sessions(
783 root.path(),
784 ¤t,
785 &TraceRetainConfig {
786 max_sessions: Some(2),
787 max_age_secs: Some(900),
788 },
789 NOW,
790 );
791
792 assert_eq!(names(root.path()), vec![recent[2].clone(), current.clone()]);
793 assert_eq!(pruned.len(), 4);
794 for session in &pruned {
795 let expected = if old.contains(&session.name) {
796 crate::trace::RETENTION_REASON_MAX_AGE
797 } else {
798 crate::trace::RETENTION_REASON_MAX_SESSIONS
799 };
800 assert_eq!(session.reason, expected, "{session:?}");
801 }
802 }
803
804 #[test]
807 fn nothing_at_or_after_the_current_session_id_is_a_candidate() {
808 let root = tempfile::tempdir().unwrap();
809 let current = ses(NOW, 5);
810 let sibling = ses(NOW + 1, 6);
812 let older = ses(NOW - 1000, 4);
813 make_sessions(
814 root.path(),
815 &[current.clone(), sibling.clone(), older.clone()],
816 );
817
818 let pruned = prune_sessions(
819 root.path(),
820 ¤t,
821 &TraceRetainConfig {
822 max_sessions: Some(1),
823 max_age_secs: Some(1),
824 },
825 NOW,
826 );
827
828 assert_eq!(pruned.len(), 1);
829 assert_eq!(pruned[0].name, older);
830 assert_eq!(names(root.path()), vec![current.clone(), sibling]);
831 assert!(root.path().join(¤t).join("trace.jsonl").exists());
832 }
833
834 #[test]
837 fn an_empty_policy_removes_nothing() {
838 let root = tempfile::tempdir().unwrap();
839 let all = [ses(NOW - 9_000_000, 1), ses(NOW, 2)];
840 make_sessions(root.path(), &all);
841 assert!(prune_sessions(
842 root.path(),
843 &all[1],
844 &TraceRetainConfig {
845 max_sessions: None,
846 max_age_secs: None
847 },
848 NOW
849 )
850 .is_empty());
851 assert_eq!(names(root.path()), all.to_vec());
852 }
853
854 fn write_record(dir: &Path, capsule: Option<&str>, count: u64) -> PathBuf {
858 std::fs::create_dir_all(dir).unwrap();
859 let path = dir.join(RECORD_FILE_NAME);
860 let mut contents = String::new();
861 if let Some(capsule) = capsule {
862 contents.push_str(
863 &header_line(&RecordHeader {
864 kind: crate::conversation::RECORD_HEADER_TYPE.to_string(),
865 capsule: capsule.to_string(),
866 created_ms: NOW,
867 truncated: None,
868 })
869 .unwrap(),
870 );
871 }
872 for i in 1..=count {
873 contents.push_str(&message_line(
874 &msg(NOW - (count - i) * 1000, i),
875 &format!("m{i}"),
876 ));
877 contents.push('\n');
878 }
879 std::fs::write(&path, contents).unwrap();
880 path
881 }
882
883 #[test]
886 fn max_messages_leaves_the_newest_n_with_their_ids_intact() {
887 let dir = tempfile::tempdir().unwrap();
888 let path = write_record(dir.path(), Some("capsule"), 10);
889 let before: Vec<String> = std::fs::read_to_string(&path)
890 .unwrap()
891 .lines()
892 .filter_map(parse_message_line)
893 .map(|m| m["id"].as_str().unwrap().to_string())
894 .collect();
895
896 let outcome = truncate_record(&path, 3, "capsule").unwrap();
897 assert_eq!(outcome.dropped, 7);
898 assert_eq!(outcome.kept, 3);
899 assert_eq!(outcome.last_dropped_id, before[6]);
900 assert_eq!(outcome.oldest_surviving_id, before[7]);
901
902 let raw = std::fs::read_to_string(&path).unwrap();
903 let lines: Vec<&str> = raw.lines().collect();
904 assert_eq!(
905 lines.len(),
906 4,
907 "a header plus exactly three messages: {lines:?}"
908 );
909 for line in &lines {
910 serde_json::from_str::<serde_json::Value>(line).expect("every line is JSON");
911 }
912 let header = parse_header(lines[0]).expect("the first line is the header");
913 assert_eq!(header.capsule, "capsule");
914 let marker = header.truncated.expect("the header records the drop");
915 assert_eq!(marker.dropped, 7);
916 assert_eq!(marker.last_dropped_id, before[6]);
917 assert_eq!(marker.oldest_surviving_id, before[7]);
918
919 let after: Vec<String> = lines[1..]
920 .iter()
921 .map(|line| {
922 parse_message_line(line).expect("a message with a string role")["id"]
923 .as_str()
924 .unwrap()
925 .to_string()
926 })
927 .collect();
928 assert_eq!(after, before[7..], "surviving ids are the bytes they were");
929 }
930
931 #[test]
934 fn a_header_line_is_not_counted_as_a_message() {
935 let dir = tempfile::tempdir().unwrap();
936 let headerless = write_record(&dir.path().join("a"), None, 4);
937 let owned = write_record(&dir.path().join("b"), Some("capsule"), 4);
938 assert_eq!(count_messages(&headerless), 4);
939 assert_eq!(count_messages(&owned), 4);
940 }
941
942 #[test]
945 fn a_second_truncation_accumulates_the_dropped_count() {
946 let dir = tempfile::tempdir().unwrap();
947 let path = write_record(dir.path(), Some("capsule"), 10);
948 truncate_record(&path, 6, "capsule").unwrap();
949 truncate_record(&path, 2, "capsule").unwrap();
950 let marker = read_header(&path).unwrap().truncated.unwrap();
951 assert_eq!(marker.dropped, 8);
952 assert_eq!(count_messages(&path), 2);
953 }
954
955 #[test]
956 fn truncation_rewrite_keeps_the_record_owner_only() {
957 use std::os::unix::fs::PermissionsExt;
958 let dir = tempfile::tempdir().unwrap();
959 let path = write_record(dir.path(), Some("capsule"), 5);
960 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
961
962 truncate_record(&path, 2, "capsule").unwrap();
963
964 assert_eq!(count_messages(&path), 2);
965 assert_eq!(crate::murmur_home::mode_of(&path), 0o600);
966 }
967
968 #[test]
970 fn a_record_inside_the_limit_is_not_rewritten() {
971 let dir = tempfile::tempdir().unwrap();
972 let path = write_record(dir.path(), Some("capsule"), 3);
973 let before = std::fs::read(&path).unwrap();
974 let outcome = truncate_record(&path, 3, "capsule").unwrap();
975 assert_eq!(outcome.dropped, 0);
976 assert_eq!(std::fs::read(&path).unwrap(), before);
977 }
978
979 #[test]
983 fn a_staged_truncation_leaves_the_original_intact_until_the_rename() {
984 let dir = tempfile::tempdir().unwrap();
985 let path = write_record(dir.path(), Some("capsule"), 10);
986 let before = std::fs::read(&path).unwrap();
987
988 let (staged, outcome) = stage_truncation(&path, 3, "capsule", NOW).unwrap().unwrap();
989 assert_eq!(outcome.dropped, 7);
990
991 let temp = staged.temp_path().to_path_buf();
992 assert_eq!(
993 temp.parent(),
994 path.parent(),
995 "the temp file is in the record's own directory, so the rename is atomic"
996 );
997 assert_ne!(temp.file_name().unwrap(), RECORD_FILE_NAME);
998
999 assert_eq!(
1000 std::fs::read(&path).unwrap(),
1001 before,
1002 "the original is byte-identical before the rename"
1003 );
1004 assert_eq!(count_messages(&path), 10);
1005 for line in std::fs::read_to_string(&path).unwrap().lines() {
1006 serde_json::from_str::<serde_json::Value>(line).expect("the original parses whole");
1007 }
1008
1009 drop(staged);
1011 assert!(!temp.exists());
1012 assert_eq!(std::fs::read(&path).unwrap(), before);
1013 assert_eq!(count_messages(&path), 10);
1014 }
1015
1016 #[test]
1022 fn record_pruning_skips_another_capsules_records_and_every_unowned_one() {
1023 let root = tempfile::tempdir().unwrap();
1024 write_record(&root.path().join("mine"), Some("mine"), 3);
1025 write_record(&root.path().join("theirs"), Some("theirs"), 3);
1026 write_record(&root.path().join("unowned"), None, 3);
1027 write_record(&root.path().join("current"), Some("mine"), 3);
1028
1029 let far_future = now_ms() + 3_600_000;
1031 let pruned = prune_records(
1032 root.path(),
1033 "mine",
1034 Some("current"),
1035 &ContextRetainConfig {
1036 max_messages: None,
1037 max_age_secs: Some(1),
1038 },
1039 far_future,
1040 );
1041
1042 assert_eq!(pruned.len(), 1, "{pruned:?}");
1043 assert_eq!(pruned[0].context_id, "mine");
1044 assert_eq!(pruned[0].reason, crate::trace::RETENTION_REASON_MAX_AGE);
1045 assert_eq!(names(root.path()), vec!["current", "theirs", "unowned"]);
1046 }
1047
1048 #[test]
1051 fn a_record_written_inside_the_window_is_kept() {
1052 let root = tempfile::tempdir().unwrap();
1053 write_record(&root.path().join("fresh"), Some("mine"), 3);
1054 let pruned = prune_records(
1055 root.path(),
1056 "mine",
1057 None,
1058 &ContextRetainConfig {
1059 max_messages: None,
1060 max_age_secs: Some(90 * 86_400),
1061 },
1062 now_ms(),
1063 );
1064 assert!(pruned.is_empty());
1065 assert_eq!(names(root.path()), vec!["fresh"]);
1066 }
1067
1068 #[test]
1071 fn max_messages_alone_removes_no_record() {
1072 let root = tempfile::tempdir().unwrap();
1073 write_record(&root.path().join("ctx"), Some("mine"), 100);
1074 assert!(prune_records(
1075 root.path(),
1076 "mine",
1077 None,
1078 &ContextRetainConfig {
1079 max_messages: Some(1),
1080 max_age_secs: None
1081 },
1082 now_ms() + 3_600_000,
1083 )
1084 .is_empty());
1085 assert_eq!(names(root.path()), vec!["ctx"]);
1086 }
1087}