use super::manager::{Session, SessionInternalDiagnostic, SessionManager};
use super::metadata::{
metadata_path_for_session, push_session_diagnostic, report_session_diagnostic,
session_from_entry, session_metadata_for_listing,
};
use super::read::validate_session_id;
use super::store::{remove_primary, validate_path_file};
use crate::persistence::CrossProcessFileLock;
use std::{
fs,
path::Path,
time::{Duration, SystemTime},
};
pub(crate) const PRUNE_SESSIONS_DEFAULT_DAYS: u64 = 30;
pub(crate) const PRUNE_SESSIONS_USAGE: &str =
"usage: /prune-sessions [days]; days must be a positive integer";
const MAX_PRUNE_SUMMARY_CHARS: usize = 4096;
const MAX_PRUNE_FAILURES: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PruneDeletionFailure {
pub(crate) session_id: String,
pub(crate) category: &'static str,
pub(crate) detail: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PruneSessionsReport {
pub(crate) retention_days: u64,
pub(crate) deleted_ids: Vec<String>,
pub(crate) skipped_active_id: Option<String>,
pub(crate) failed_ids: Vec<String>,
pub(crate) failures: Vec<PruneDeletionFailure>,
pub(crate) omitted_failures: usize,
pub(crate) diagnostics: Vec<SessionInternalDiagnostic>,
}
impl PruneSessionsReport {
pub(crate) fn summary(&self) -> String {
let mut parts = vec![format!(
"pruned {} sessions older than {} days",
self.deleted_ids.len(),
self.retention_days
)];
if self.skipped_active_id.is_some() {
parts.push("skipped active session".to_string());
}
if !self.failed_ids.is_empty() {
let mut failures = self
.failures
.iter()
.map(|failure| {
format!(
"{} ({}: {})",
failure.session_id, failure.category, failure.detail
)
})
.collect::<Vec<_>>();
if self.omitted_failures > 0 {
failures.push(format!(
"omitted {} additional failures",
self.omitted_failures
));
}
parts.push(format!(
"failed to delete {} sessions: {}",
self.failed_ids.len(),
failures.join(", ")
));
}
let summary = parts.join("; ");
if summary.chars().count() <= MAX_PRUNE_SUMMARY_CHARS {
return summary;
}
let mut bounded = summary
.chars()
.take(MAX_PRUNE_SUMMARY_CHARS - 32)
.collect::<String>();
bounded.push_str("; output truncated");
bounded
}
}
pub(crate) fn parse_prune_sessions_days(arg: Option<&str>) -> Result<u64, &'static str> {
let tokens = arg
.unwrap_or("")
.split_whitespace()
.filter(|token| !token.is_empty())
.collect::<Vec<_>>();
match tokens.as_slice() {
[] => Ok(PRUNE_SESSIONS_DEFAULT_DAYS),
[token] if token.chars().all(|ch| ch.is_ascii_digit()) && !token.is_empty() => {
let days = token.parse::<u64>().map_err(|_| PRUNE_SESSIONS_USAGE)?;
if days == 0 || days.checked_mul(86_400).is_none() {
return Err(PRUNE_SESSIONS_USAGE);
}
Ok(days)
}
_ => Err(PRUNE_SESSIONS_USAGE),
}
}
fn prune_delete_error_category(kind: std::io::ErrorKind) -> (&'static str, &'static str) {
match kind {
std::io::ErrorKind::PermissionDenied => ("permission_denied", "permission denied"),
std::io::ErrorKind::NotFound => ("not_found", "session file was not found"),
std::io::ErrorKind::IsADirectory
| std::io::ErrorKind::NotADirectory
| std::io::ErrorKind::InvalidInput => ("wrong_type", "session path has wrong type"),
_ => ("filesystem", "filesystem deletion failed"),
}
}
fn record_prune_failure(
report: &mut PruneSessionsReport,
session_id: String,
category: &'static str,
detail: &'static str,
) {
report.failed_ids.push(session_id.clone());
if report.failures.len() < MAX_PRUNE_FAILURES {
report.failures.push(PruneDeletionFailure {
session_id,
category,
detail,
});
}
}
#[derive(Debug, Clone)]
struct PruneCandidate {
session: Session,
activity_time: SystemTime,
}
struct PruneCandidatesReport {
candidates: Vec<PruneCandidate>,
diagnostics: Vec<SessionInternalDiagnostic>,
failed_ids: Vec<String>,
failures: Vec<PruneDeletionFailure>,
}
fn validated_history_path(root: &Path, id: &str) -> Option<std::path::PathBuf> {
validate_session_id(id.to_string()).ok()?;
let root_metadata = fs::symlink_metadata(root).ok()?;
if root_metadata.file_type().is_symlink() || !root_metadata.file_type().is_dir() {
return None;
}
let history_parent = root.join(".history");
let history_metadata = fs::symlink_metadata(&history_parent).ok()?;
if history_metadata.file_type().is_symlink() || !history_metadata.file_type().is_dir() {
return None;
}
let history = history_parent.join(id);
let metadata = fs::symlink_metadata(&history).ok()?;
if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
return None;
}
if !history.starts_with(root) {
return None;
}
Some(history)
}
impl SessionManager {
pub(crate) fn prune_sessions(
&self,
retention_days: u64,
active_session_id: Option<&str>,
) -> anyhow::Result<PruneSessionsReport> {
self.prune_sessions_with(
SystemTime::now(),
retention_days,
active_session_id,
|path| {
let id = path
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix(".jsonl"))
.unwrap_or_default();
remove_primary(&self.root, id)
},
)
}
fn prune_sessions_with(
&self,
now: SystemTime,
retention_days: u64,
active_session_id: Option<&str>,
mut remove_file: impl FnMut(&Path) -> std::io::Result<()>,
) -> anyhow::Result<PruneSessionsReport> {
let retention_secs = retention_days
.checked_mul(86_400)
.ok_or_else(|| anyhow::anyhow!(PRUNE_SESSIONS_USAGE))?;
let cutoff = now
.checked_sub(Duration::from_secs(retention_secs))
.unwrap_or(SystemTime::UNIX_EPOCH);
let mut report = PruneSessionsReport {
retention_days,
deleted_ids: Vec::new(),
skipped_active_id: None,
failed_ids: Vec::new(),
failures: Vec::new(),
omitted_failures: 0,
diagnostics: Vec::new(),
};
let candidates_report = self.prune_candidates()?;
report.diagnostics = candidates_report.diagnostics;
report.failed_ids.extend(candidates_report.failed_ids);
report.failures = candidates_report.failures;
report.omitted_failures = report
.failed_ids
.len()
.saturating_sub(report.failures.len());
for candidate in candidates_report.candidates {
let id = candidate.session.id().to_string();
if candidate.activity_time >= cutoff {
continue;
}
if active_session_id == Some(&id) {
report.skipped_active_id = Some(id);
continue;
}
let _lock_guard = match CrossProcessFileLock::acquire(candidate.session.path()) {
Ok(guard) => guard,
Err(_) => {
record_prune_failure(
&mut report,
id,
"lock",
"could not lock session for deletion",
);
continue;
}
};
if validate_path_file(&self.root, &id, candidate.session.path()).is_err() {
record_prune_failure(
&mut report,
id,
"changed",
"session changed before deletion",
);
continue;
}
let current_metadata = match session_metadata_for_listing(&candidate.session) {
Ok(metadata) => metadata,
Err(_) => {
record_prune_failure(
&mut report,
id,
"metadata",
"session metadata could not be rechecked",
);
continue;
}
};
if current_metadata.activity_time(&candidate.session) >= cutoff {
continue;
}
match remove_file(candidate.session.path()) {
Ok(()) => {
if let Some(history) = validated_history_path(&self.root, &id)
&& let Err(error) = fs::remove_dir_all(history)
{
push_session_diagnostic(
&mut report.diagnostics,
Some(id.clone()),
format!("failed to prune archived session history: {error}"),
);
}
if let Some(parent) = self.root.parent() {
let store =
crate::checkpoints::CheckpointStore::new(parent.join("checkpoints"));
if store.prune_session(&id).is_err() {
push_session_diagnostic(
&mut report.diagnostics,
Some(id.clone()),
"failed to prune checkpoint storage for deleted session"
.to_string(),
);
}
}
report.deleted_ids.push(id);
}
Err(error) => {
let (category, detail) = prune_delete_error_category(error.kind());
record_prune_failure(&mut report, id, category, detail);
}
}
}
report.omitted_failures = report
.failed_ids
.len()
.saturating_sub(report.failures.len());
Ok(report)
}
fn prune_candidates(&self) -> anyhow::Result<PruneCandidatesReport> {
if !self.root.exists() {
return Ok(PruneCandidatesReport {
candidates: Vec::new(),
diagnostics: Vec::new(),
failed_ids: Vec::new(),
failures: Vec::new(),
});
}
let mut candidates = Vec::new();
let mut diagnostics = Vec::new();
let mut failed_ids = Vec::new();
let mut failures = Vec::new();
let mut entries = fs::read_dir(&self.root)?.collect::<Result<Vec<_>, _>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let Ok(file_type) = entry.file_type() else {
push_session_diagnostic(
&mut diagnostics,
None,
"failed to inspect session file type".to_string(),
);
continue;
};
if !file_type.is_file() {
continue;
}
let path = entry.path();
let Some((id, path)) = session_from_entry(path) else {
continue;
};
let id = match validate_session_id(id) {
Ok(id) => id,
Err(error) => {
push_session_diagnostic(
&mut diagnostics,
None,
format!("ignored invalid session file: {error}"),
);
continue;
}
};
let expected_path = self.path_for_valid_id(&id)?;
if path != expected_path {
push_session_diagnostic(
&mut diagnostics,
Some(id),
"ignored session file with unexpected path".to_string(),
);
continue;
}
let session = Session { id, path };
let metadata = match session_metadata_for_listing(&session) {
Ok(metadata) => metadata,
Err(error) => {
report_session_diagnostic(
super::metadata::SessionDiagnosticOperation::Listing,
&metadata_path_for_session(&session),
&error,
);
failed_ids.push(session.id.clone());
if failures.len() < MAX_PRUNE_FAILURES {
failures.push(PruneDeletionFailure {
session_id: session.id.clone(),
category: "metadata",
detail: "session metadata could not be loaded",
});
}
continue;
}
};
candidates.push(PruneCandidate {
activity_time: metadata.activity_time(&session),
session,
});
}
Ok(PruneCandidatesReport {
candidates,
diagnostics,
failed_ids,
failures,
})
}
#[cfg(test)]
fn prune_sessions_for_test(
&self,
now: SystemTime,
retention_days: u64,
active_session_id: Option<&str>,
remove_file: impl FnMut(&Path) -> std::io::Result<()>,
) -> anyhow::Result<PruneSessionsReport> {
self.prune_sessions_with(now, retention_days, active_session_id, remove_file)
}
}
#[cfg(test)]
mod tests {
use super::super::event::SessionEvent;
use super::*;
use chrono::{TimeZone, Utc};
use serde_json::json;
use tempfile::TempDir;
#[test]
fn prune_sessions_parser_accepts_default_and_positive_integer_only() {
assert_eq!(parse_prune_sessions_days(None).unwrap(), 30);
assert_eq!(parse_prune_sessions_days(Some(" ")).unwrap(), 30);
assert_eq!(parse_prune_sessions_days(Some("7")).unwrap(), 7);
assert_eq!(parse_prune_sessions_days(Some(" 07 ")).unwrap(), 7);
for invalid in [
"0",
"-1",
"+7",
"1.5",
"seven",
"7 now",
"18446744073709551616",
] {
assert_eq!(
parse_prune_sessions_days(Some(invalid)),
Err(PRUNE_SESSIONS_USAGE)
);
}
}
#[test]
fn prune_sessions_deletes_only_old_inactive_top_level_jsonl() {
let temp = TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
fs::create_dir_all(temp.path().join("sessions/nested")).unwrap();
crate::sessions::store::secure_test_session_root(temp.path().join("sessions").as_path());
let now = SystemTime::from(Utc.with_ymd_and_hms(2026, 1, 31, 0, 0, 0).unwrap());
let old = manager.open("old-session").unwrap();
let active = manager.open("active-session").unwrap();
let new = manager.open("new-session").unwrap();
let invalid = temp.path().join("sessions/bad.name.jsonl");
let wrong_ext = temp.path().join("sessions/old-note.txt");
let nested = temp.path().join("sessions/nested/nested-session.jsonl");
append_event_at(&old, temp.path(), 2025, 12, 1);
append_event_at(&active, temp.path(), 2025, 12, 1);
append_event_at(&new, temp.path(), 2026, 1, 15);
fs::write(&invalid, "{}").unwrap();
fs::write(&wrong_ext, "keep").unwrap();
fs::write(&nested, "keep").unwrap();
let report = manager
.prune_sessions_for_test(now, 30, Some(active.id()), |path| fs::remove_file(path))
.unwrap();
assert_eq!(report.deleted_ids, vec!["old-session"]);
assert_eq!(report.skipped_active_id.as_deref(), Some("active-session"));
assert!(report.failed_ids.is_empty());
assert_eq!(report.diagnostics.len(), 1);
assert!(report.diagnostics[0].message.contains("invalid session"));
assert!(!old.path().exists());
assert!(active.path().exists());
assert!(new.path().exists());
assert!(invalid.exists());
assert!(wrong_ext.exists());
assert!(nested.exists());
assert_eq!(
report.summary(),
"pruned 1 sessions older than 30 days; skipped active session"
);
}
#[test]
fn prune_sessions_retains_equal_cutoff_and_uses_latest_valid_event() {
let temp = TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let now = SystemTime::from(Utc.with_ymd_and_hms(2026, 1, 31, 0, 0, 0).unwrap());
let equal = manager.open("equal-cutoff").unwrap();
let malformed_then_recent = manager.open("malformed-recent").unwrap();
append_event_at(&equal, temp.path(), 2026, 1, 1);
let old = event_at(&malformed_then_recent, temp.path(), 2025, 12, 1);
let recent = event_at(&malformed_then_recent, temp.path(), 2026, 1, 30);
fs::create_dir_all(malformed_then_recent.path().parent().unwrap()).unwrap();
fs::write(
malformed_then_recent.path(),
format!(
"{}\nnot json\n{}\n",
serde_json::to_string(&old).unwrap(),
serde_json::to_string(&recent).unwrap()
),
)
.unwrap();
crate::sessions::store::secure_test_session_root(
malformed_then_recent.path().parent().unwrap(),
);
let report = manager
.prune_sessions_for_test(now, 30, None, |path| fs::remove_file(path))
.unwrap();
assert!(report.deleted_ids.is_empty(), "{report:?}");
assert!(equal.path().exists());
assert!(malformed_then_recent.path().exists());
}
#[test]
fn prune_sessions_reports_partial_delete_failures_and_continues() {
let temp = TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let now = SystemTime::from(Utc.with_ymd_and_hms(2026, 1, 31, 0, 0, 0).unwrap());
let fail = manager.open("fail-session").unwrap();
let delete = manager.open("delete-session").unwrap();
append_event_at(&fail, temp.path(), 2025, 12, 1);
append_event_at(&delete, temp.path(), 2025, 12, 1);
let report = manager
.prune_sessions_for_test(now, 30, None, |path| {
if path.file_stem().and_then(|stem| stem.to_str()) == Some("fail-session") {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"nope",
))
} else {
fs::remove_file(path)
}
})
.unwrap();
assert_eq!(report.failures[0].category, "permission_denied");
assert_eq!(report.failures[0].detail, "permission denied");
assert_eq!(report.deleted_ids, vec!["delete-session"]);
assert_eq!(report.failed_ids, vec!["fail-session"]);
assert!(fail.path().exists());
assert!(!delete.path().exists());
assert_eq!(
report.summary(),
"pruned 1 sessions older than 30 days; failed to delete 1 sessions: fail-session (permission_denied: permission denied)"
);
}
#[test]
fn prune_sessions_holds_cross_process_lock_while_deleting_jsonl() {
let temp = TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let now = SystemTime::from(Utc.with_ymd_and_hms(2026, 1, 31, 0, 0, 0).unwrap());
let old = manager.open("locked-delete").unwrap();
append_event_at(&old, temp.path(), 2025, 12, 1);
let lock_path = lock_path_for_session(&old);
let report = manager
.prune_sessions_for_test(now, 30, None, |path| {
assert_eq!(path, old.path());
assert!(lock_path.exists());
fs::remove_file(path)
})
.unwrap();
assert_eq!(report.deleted_ids, vec!["locked-delete"]);
assert!(report.failed_ids.is_empty());
assert!(!old.path().exists());
}
#[test]
fn prune_sessions_rechecks_activity_under_lock_during_append() {
let temp = TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let now = SystemTime::from(Utc.with_ymd_and_hms(2026, 1, 31, 0, 0, 0).unwrap());
for iteration in 0..16 {
let session = manager.open(format!("append-race-{iteration}")).unwrap();
append_event_at(&session, temp.path(), 2025, 12, 1);
let append_session = session.clone();
let cwd = temp.path().to_path_buf();
let handle = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_micros(50 * iteration));
append_session
.append(&event_at(&append_session, &cwd, 2026, 1, 30))
.unwrap();
});
let report = manager
.prune_sessions_for_test(now, 30, None, |path| fs::remove_file(path))
.unwrap();
handle.join().unwrap();
assert!(
session.path().exists(),
"iteration {iteration}: JSONL missing"
);
let events = session.read_events_tolerant().unwrap().events;
let has_recent = events.iter().any(|event| {
event.timestamp == Utc.with_ymd_and_hms(2026, 1, 30, 0, 0, 0).unwrap()
});
assert!(has_recent, "iteration {iteration}: recent event lost");
if !report.deleted_ids.contains(&session.id().to_string()) {
let has_old = events.iter().any(|event| {
event.timestamp == Utc.with_ymd_and_hms(2025, 12, 1, 0, 0, 0).unwrap()
});
assert!(
has_old,
"iteration {iteration}: session skipped by prune but old event missing"
);
}
}
}
fn lock_path_for_session(session: &Session) -> std::path::PathBuf {
let file_name = session.path().file_name().unwrap().to_string_lossy();
session.path().with_file_name(format!(".{file_name}.lock"))
}
fn append_event_at(session: &Session, cwd: &Path, year: i32, month: u32, day: u32) {
session
.append(&event_at(session, cwd, year, month, day))
.unwrap();
}
fn event_at(session: &Session, cwd: &Path, year: i32, month: u32, day: u32) -> SessionEvent {
let mut event = SessionEvent::new(
"diagnostic",
session.id().to_string(),
cwd.to_path_buf(),
json!({}),
);
event.timestamp = Utc.with_ymd_and_hms(year, month, day, 0, 0, 0).unwrap();
event
}
}