use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use walkdir::WalkDir;
use crate::error::SessionStoreError;
use crate::query::SessionSummary;
use crate::sessions_root;
#[derive(Debug)]
struct RetentionCandidate {
path: std::path::PathBuf,
summary: SessionSummary,
}
#[derive(Debug, Clone, Copy)]
pub struct RetentionPolicy {
pub max_sessions: usize,
pub max_age_days: u64,
}
impl Default for RetentionPolicy {
fn default() -> Self {
Self { max_sessions: 50, max_age_days: 30 }
}
}
pub fn apply_retention(workspace: &Path, policy: RetentionPolicy) -> Result<usize, SessionStoreError> {
apply_retention_preserving(workspace, policy, None)
}
pub fn apply_retention_preserving(
workspace: &Path,
policy: RetentionPolicy,
preserve_session_id: Option<&str>,
) -> Result<usize, SessionStoreError> {
let root = sessions_root(workspace);
let root_metadata = match std::fs::symlink_metadata(&root) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(error) => return Err(SessionStoreError::io(root.clone(), error)),
};
if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
return Ok(0);
}
mark_abandoned_active_sessions(workspace, policy.max_age_days, preserve_session_id)?;
let preserve_path = preserve_session_id.map(|session_id| crate::session_dir(workspace, session_id));
let mut sessions = retention_candidates(&root, preserve_path.as_deref())?;
let mut removed = 0usize;
if sessions.len() > policy.max_sessions {
sessions.sort_by(|a, b| a.summary.updated_at.cmp(&b.summary.updated_at));
let to_remove = sessions.len() - policy.max_sessions;
for s in sessions.iter().take(to_remove) {
remove_session(&root, &s.path)?;
removed += 1;
}
sessions.drain(..to_remove);
}
let cutoff = age_cutoff(policy.max_age_days);
for s in &sessions {
if older_than(s.summary.updated_at.as_str(), cutoff) {
remove_session(&root, &s.path)?;
removed += 1;
}
}
Ok(removed)
}
pub const RETENTION_PIN_FILE: &str = "retention-pin.json";
#[must_use]
pub fn session_retention_pinned(session_dir: &Path) -> bool {
session_dir.join(RETENTION_PIN_FILE).is_file()
}
pub(crate) fn session_dir_is_live(session_dir: &Path) -> bool {
use crate::event_log::SESSION_LOCK_FILE;
let lock_path = session_dir.join(SESSION_LOCK_FILE);
let file = match std::fs::OpenOptions::new().read(true).write(true).open(&lock_path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
Err(_) => return true,
};
file.try_lock()
.map_err(std::io::Error::from)
.is_err_and(|error| error.kind() == std::io::ErrorKind::WouldBlock)
}
#[must_use]
pub fn retention_pinned_session_ids(workspace: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(sessions_root(workspace)) else {
return Vec::new();
};
entries
.flatten()
.filter(|entry| entry.file_type().map(|file_type| file_type.is_dir()).unwrap_or(false))
.filter(|entry| session_retention_pinned(&entry.path()))
.filter_map(|entry| entry.file_name().into_string().ok())
.collect()
}
pub fn pin_session_retention(session_dir: &Path, reason: &str) -> Result<(), SessionStoreError> {
let path = session_dir.join(RETENTION_PIN_FILE);
let body = serde_json::json!({
"reason": reason,
"pinned_at": chrono::Utc::now().to_rfc3339(),
});
std::fs::write(&path, body.to_string()).map_err(|e| SessionStoreError::io(path, e))
}
pub fn unpin_session_retention(session_dir: &Path) -> Result<bool, SessionStoreError> {
let path = session_dir.join(RETENTION_PIN_FILE);
if !path.is_file() {
return Ok(false);
}
std::fs::remove_file(&path).map_err(|e| SessionStoreError::io(path, e))?;
Ok(true)
}
fn retention_candidates(
root: &Path,
preserve_path: Option<&Path>,
) -> Result<Vec<RetentionCandidate>, SessionStoreError> {
let entries = std::fs::read_dir(root).map_err(|e| SessionStoreError::io(root.to_path_buf(), e))?;
let mut candidates = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| SessionStoreError::io(root.to_path_buf(), e))?;
let file_type = entry.file_type().map_err(|e| SessionStoreError::io(entry.path(), e))?;
if !file_type.is_dir() || file_type.is_symlink() {
continue;
}
let path = entry.path();
if preserve_path.is_some_and(|preserve_path| preserve_path == path) {
continue;
}
if session_retention_pinned(&path) {
continue;
}
if session_dir_is_live(&path) {
continue;
}
let manifest_path = path.join("manifest.json");
let Ok(bytes) = std::fs::read(&manifest_path) else {
continue;
};
let Ok(summary) = serde_json::from_slice::<SessionSummary>(&bytes) else {
continue;
};
if summary.status == "active" {
continue;
}
candidates.push(RetentionCandidate { path, summary });
}
Ok(candidates)
}
pub fn mark_abandoned_active_sessions(
workspace: &Path,
max_age_days: u64,
preserve_session_id: Option<&str>,
) -> Result<usize, SessionStoreError> {
if max_age_days == 0 {
return Ok(0);
}
let root = sessions_root(workspace);
let preserve_path = preserve_session_id.map(|session_id| crate::session_dir(workspace, session_id));
let Ok(entries) = std::fs::read_dir(&root) else {
return Ok(0);
};
let cutoff = age_cutoff(max_age_days);
let mut marked = 0usize;
for entry in entries.flatten() {
let path = entry.path();
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(_) => continue,
};
if !file_type.is_dir() || file_type.is_symlink() {
continue;
}
if preserve_path.as_deref() == Some(path.as_path()) {
continue;
}
if session_retention_pinned(&path) {
continue;
}
if session_dir_is_live(&path) {
continue;
}
let manifest_path = path.join("manifest.json");
let Ok(bytes) = std::fs::read(&manifest_path) else {
continue;
};
let Ok(mut summary) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
continue;
};
let is_active = summary.get("status").and_then(serde_json::Value::as_str) == Some("active");
if !is_active {
continue;
}
let updated_at = summary
.get("updated_at")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if !older_than(updated_at, cutoff) {
continue;
}
if let Some(object) = summary.as_object_mut() {
object.insert("status".to_string(), serde_json::Value::String("completed".to_string()));
} else {
continue;
}
let body = serde_json::to_vec_pretty(&summary)
.map_err(|error| SessionStoreError::io(manifest_path.clone(), std::io::Error::other(error)))?;
vtcode_commons::VtCodePaths::write_private_file_atomic(&manifest_path, &body)
.map_err(|error| SessionStoreError::io(manifest_path.clone(), std::io::Error::other(error)))?;
marked += 1;
}
Ok(marked)
}
pub fn gc_legacy(workspace: &Path) -> Result<u64, SessionStoreError> {
let vt = workspace.join(".vtcode");
let mut freed = 0u64;
for name in ["history", "logs"] {
let dir = vt.join(name);
if dir.exists() {
freed += dir_size(&dir);
std::fs::remove_dir_all(&dir).map_err(|e| SessionStoreError::io(dir.clone(), e))?;
}
}
Ok(freed)
}
fn remove_session(root: &Path, dir: &Path) -> Result<(), SessionStoreError> {
if dir.parent() != Some(root) {
return Ok(());
}
let metadata = match std::fs::symlink_metadata(dir) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(SessionStoreError::io(dir.to_path_buf(), error)),
};
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Ok(());
}
std::fs::remove_dir_all(dir).map_err(|e| SessionStoreError::io(dir.to_path_buf(), e))?;
Ok(())
}
fn dir_size(dir: &Path) -> u64 {
WalkDir::new(dir)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.filter_map(|e| e.metadata().ok())
.map(|m| m.len())
.sum()
}
fn age_cutoff(max_age_days: u64) -> SystemTime {
let seconds = max_age_days.saturating_mul(24 * 3600);
SystemTime::now() - Duration::from_secs(seconds)
}
fn older_than(rfc3339: &str, cutoff: SystemTime) -> bool {
let Ok(dt) = chrono::DateTime::parse_from_rfc3339(rfc3339) else {
return false;
};
let cutoff_secs = cutoff
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(i64::MAX);
dt.timestamp() < cutoff_secs
}