vtcode_memory/
retention.rs1use std::path::Path;
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use walkdir::WalkDir;
7
8use crate::error::SessionStoreError;
9use crate::query::SessionSummary;
10use crate::sessions_root;
11
12#[derive(Debug)]
13struct RetentionCandidate {
14 path: std::path::PathBuf,
15 summary: SessionSummary,
16}
17
18#[derive(Debug, Clone, Copy)]
20pub struct RetentionPolicy {
21 pub max_sessions: usize,
23 pub max_age_days: u64,
25}
26
27impl Default for RetentionPolicy {
28 fn default() -> Self {
29 Self { max_sessions: 50, max_age_days: 30 }
30 }
31}
32
33pub fn apply_retention(workspace: &Path, policy: RetentionPolicy) -> Result<usize, SessionStoreError> {
39 apply_retention_preserving(workspace, policy, None)
40}
41
42pub fn apply_retention_preserving(
49 workspace: &Path,
50 policy: RetentionPolicy,
51 preserve_session_id: Option<&str>,
52) -> Result<usize, SessionStoreError> {
53 let root = sessions_root(workspace);
54 let root_metadata = match std::fs::symlink_metadata(&root) {
55 Ok(metadata) => metadata,
56 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
57 Err(error) => return Err(SessionStoreError::io(root.clone(), error)),
58 };
59 if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
60 return Ok(0);
61 }
62 let preserve_path = preserve_session_id.map(|session_id| crate::session_dir(workspace, session_id));
63 let mut sessions = retention_candidates(&root, preserve_path.as_deref())?;
64 let mut removed = 0usize;
65
66 if sessions.len() > policy.max_sessions {
68 sessions.sort_by(|a, b| a.summary.updated_at.cmp(&b.summary.updated_at));
69 let to_remove = sessions.len() - policy.max_sessions;
70 for s in sessions.iter().take(to_remove) {
71 remove_session(&root, &s.path)?;
72 removed += 1;
73 }
74 sessions.drain(..to_remove);
76 }
77
78 let cutoff = age_cutoff(policy.max_age_days);
80 for s in &sessions {
81 if older_than(s.summary.updated_at.as_str(), cutoff) {
82 remove_session(&root, &s.path)?;
83 removed += 1;
84 }
85 }
86
87 Ok(removed)
88}
89
90pub const RETENTION_PIN_FILE: &str = "retention-pin.json";
93
94#[must_use]
96pub fn session_retention_pinned(session_dir: &Path) -> bool {
97 session_dir.join(RETENTION_PIN_FILE).is_file()
98}
99
100pub fn pin_session_retention(session_dir: &Path, reason: &str) -> Result<(), SessionStoreError> {
102 let path = session_dir.join(RETENTION_PIN_FILE);
103 let body = serde_json::json!({
104 "reason": reason,
105 "pinned_at": chrono::Utc::now().to_rfc3339(),
106 });
107 std::fs::write(&path, body.to_string()).map_err(|e| SessionStoreError::io(path, e))
108}
109
110pub fn unpin_session_retention(session_dir: &Path) -> Result<bool, SessionStoreError> {
112 let path = session_dir.join(RETENTION_PIN_FILE);
113 if !path.is_file() {
114 return Ok(false);
115 }
116 std::fs::remove_file(&path).map_err(|e| SessionStoreError::io(path, e))?;
117 Ok(true)
118}
119
120fn retention_candidates(
124 root: &Path,
125 preserve_path: Option<&Path>,
126) -> Result<Vec<RetentionCandidate>, SessionStoreError> {
127 let entries = std::fs::read_dir(root).map_err(|e| SessionStoreError::io(root.to_path_buf(), e))?;
128 let mut candidates = Vec::new();
129 for entry in entries {
130 let entry = entry.map_err(|e| SessionStoreError::io(root.to_path_buf(), e))?;
131 let file_type = entry.file_type().map_err(|e| SessionStoreError::io(entry.path(), e))?;
132 if !file_type.is_dir() || file_type.is_symlink() {
133 continue;
134 }
135 let path = entry.path();
136 if preserve_path.is_some_and(|preserve_path| preserve_path == path) {
137 continue;
138 }
139 if session_retention_pinned(&path) {
141 continue;
142 }
143 let manifest_path = path.join("manifest.json");
144 let Ok(bytes) = std::fs::read(&manifest_path) else {
145 continue;
146 };
147 let Ok(summary) = serde_json::from_slice::<SessionSummary>(&bytes) else {
148 continue;
149 };
150 if summary.status == "active" {
151 continue;
152 }
153 candidates.push(RetentionCandidate { path, summary });
154 }
155 Ok(candidates)
156}
157
158pub fn gc_legacy(workspace: &Path) -> Result<u64, SessionStoreError> {
165 let vt = workspace.join(".vtcode");
166 let mut freed = 0u64;
167 for name in ["history", "logs"] {
168 let dir = vt.join(name);
169 if dir.exists() {
170 freed += dir_size(&dir);
171 std::fs::remove_dir_all(&dir).map_err(|e| SessionStoreError::io(dir.clone(), e))?;
172 }
173 }
174 Ok(freed)
175}
176
177fn remove_session(root: &Path, dir: &Path) -> Result<(), SessionStoreError> {
178 if dir.parent() != Some(root) {
181 return Ok(());
182 }
183 let metadata = match std::fs::symlink_metadata(dir) {
184 Ok(metadata) => metadata,
185 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
186 Err(error) => return Err(SessionStoreError::io(dir.to_path_buf(), error)),
187 };
188 if metadata.file_type().is_symlink() || !metadata.is_dir() {
189 return Ok(());
190 }
191 std::fs::remove_dir_all(dir).map_err(|e| SessionStoreError::io(dir.to_path_buf(), e))?;
192 Ok(())
193}
194
195fn dir_size(dir: &Path) -> u64 {
196 WalkDir::new(dir)
197 .into_iter()
198 .filter_map(Result::ok)
199 .filter(|e| e.file_type().is_file())
200 .filter_map(|e| e.metadata().ok())
201 .map(|m| m.len())
202 .sum()
203}
204
205fn age_cutoff(max_age_days: u64) -> SystemTime {
206 let seconds = max_age_days.saturating_mul(24 * 3600);
207 SystemTime::now() - Duration::from_secs(seconds)
208}
209
210fn older_than(rfc3339: &str, cutoff: SystemTime) -> bool {
211 let Ok(dt) = chrono::DateTime::parse_from_rfc3339(rfc3339) else {
212 return false;
213 };
214 let cutoff_secs = cutoff
215 .duration_since(UNIX_EPOCH)
216 .map(|d| d.as_secs() as i64)
217 .unwrap_or(i64::MAX);
218 dt.timestamp() < cutoff_secs
219}