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 mark_abandoned_active_sessions(workspace, policy.max_age_days, preserve_session_id)?;
66 let preserve_path = preserve_session_id.map(|session_id| crate::session_dir(workspace, session_id));
67 let sessions = retention_candidates(&root, preserve_path.as_deref())?;
68 let mut removed = 0usize;
69
70 let (empty, mut sessions): (Vec<_>, Vec<_>) = sessions
74 .into_iter()
75 .partition(|s| s.summary.turn_count == 0 && s.summary.status != "active");
76 for s in &empty {
77 remove_session(&root, &s.path)?;
78 removed += 1;
79 }
80
81 if sessions.len() > policy.max_sessions {
83 sessions.sort_by(|a, b| a.summary.updated_at.cmp(&b.summary.updated_at));
84 let to_remove = sessions.len() - policy.max_sessions;
85 for s in sessions.iter().take(to_remove) {
86 remove_session(&root, &s.path)?;
87 removed += 1;
88 }
89 sessions.drain(..to_remove);
91 }
92
93 let cutoff = age_cutoff(policy.max_age_days);
95 for s in &sessions {
96 if older_than(s.summary.updated_at.as_str(), cutoff) {
97 remove_session(&root, &s.path)?;
98 removed += 1;
99 }
100 }
101
102 Ok(removed)
103}
104
105pub const RETENTION_PIN_FILE: &str = "retention-pin.json";
108
109pub fn evict_zero_turn_completed_store(workspace: &Path, session_id: &str) -> Result<bool, SessionStoreError> {
117 let root = sessions_root(workspace);
118 let dir = crate::session_dir(workspace, session_id);
119 if dir.parent() != Some(&root) {
120 return Ok(false);
121 }
122 if session_retention_pinned(&dir) || session_dir_is_live(&dir) {
123 return Ok(false);
124 }
125 let manifest_path = dir.join("manifest.json");
126 let Ok(bytes) = std::fs::read(&manifest_path) else {
127 return Ok(false);
128 };
129 let Ok(summary) = serde_json::from_slice::<SessionSummary>(&bytes) else {
130 return Ok(false);
131 };
132 if summary.turn_count > 0 || summary.status == "active" {
133 return Ok(false);
134 }
135 remove_session(&root, &dir)?;
136 Ok(true)
137}
138
139#[must_use]
141pub fn session_retention_pinned(session_dir: &Path) -> bool {
142 session_dir.join(RETENTION_PIN_FILE).is_file()
143}
144
145pub(crate) fn session_dir_is_live(session_dir: &Path) -> bool {
154 use crate::event_log::SESSION_LOCK_FILE;
155
156 let lock_path = session_dir.join(SESSION_LOCK_FILE);
157 let file = match std::fs::OpenOptions::new().read(true).write(true).open(&lock_path) {
158 Ok(file) => file,
159 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
160 Err(_) => return true,
161 };
162 file.try_lock()
163 .map_err(std::io::Error::from)
164 .is_err_and(|error| error.kind() == std::io::ErrorKind::WouldBlock)
165}
166
167#[must_use]
174pub fn retention_pinned_session_ids(workspace: &Path) -> Vec<String> {
175 let Ok(entries) = std::fs::read_dir(sessions_root(workspace)) else {
176 return Vec::new();
177 };
178 entries
179 .flatten()
180 .filter(|entry| entry.file_type().map(|file_type| file_type.is_dir()).unwrap_or(false))
181 .filter(|entry| session_retention_pinned(&entry.path()))
182 .filter_map(|entry| entry.file_name().into_string().ok())
183 .collect()
184}
185
186pub fn pin_session_retention(session_dir: &Path, reason: &str) -> Result<(), SessionStoreError> {
188 let path = session_dir.join(RETENTION_PIN_FILE);
189 let body = serde_json::json!({
190 "reason": reason,
191 "pinned_at": chrono::Utc::now().to_rfc3339(),
192 });
193 std::fs::write(&path, body.to_string()).map_err(|e| SessionStoreError::io(path, e))
194}
195
196pub fn unpin_session_retention(session_dir: &Path) -> Result<bool, SessionStoreError> {
198 let path = session_dir.join(RETENTION_PIN_FILE);
199 if !path.is_file() {
200 return Ok(false);
201 }
202 std::fs::remove_file(&path).map_err(|e| SessionStoreError::io(path, e))?;
203 Ok(true)
204}
205
206fn retention_candidates(
210 root: &Path,
211 preserve_path: Option<&Path>,
212) -> Result<Vec<RetentionCandidate>, SessionStoreError> {
213 let entries = std::fs::read_dir(root).map_err(|e| SessionStoreError::io(root.to_path_buf(), e))?;
214 let mut candidates = Vec::new();
215 for entry in entries {
216 let entry = entry.map_err(|e| SessionStoreError::io(root.to_path_buf(), e))?;
217 let file_type = entry.file_type().map_err(|e| SessionStoreError::io(entry.path(), e))?;
218 if !file_type.is_dir() || file_type.is_symlink() {
219 continue;
220 }
221 let path = entry.path();
222 if preserve_path.is_some_and(|preserve_path| preserve_path == path) {
223 continue;
224 }
225 if session_retention_pinned(&path) {
227 continue;
228 }
229 if session_dir_is_live(&path) {
233 continue;
234 }
235 let manifest_path = path.join("manifest.json");
236 let Ok(bytes) = std::fs::read(&manifest_path) else {
237 continue;
238 };
239 let Ok(summary) = serde_json::from_slice::<SessionSummary>(&bytes) else {
240 continue;
241 };
242 if summary.status == "active" {
243 continue;
244 }
245 candidates.push(RetentionCandidate { path, summary });
246 }
247 Ok(candidates)
248}
249
250pub fn mark_abandoned_active_sessions(
259 workspace: &Path,
260 max_age_days: u64,
261 preserve_session_id: Option<&str>,
262) -> Result<usize, SessionStoreError> {
263 if max_age_days == 0 {
264 return Ok(0);
265 }
266 let root = sessions_root(workspace);
267 let preserve_path = preserve_session_id.map(|session_id| crate::session_dir(workspace, session_id));
268 let Ok(entries) = std::fs::read_dir(&root) else {
269 return Ok(0);
270 };
271 let cutoff = age_cutoff(max_age_days);
272 let mut marked = 0usize;
273 for entry in entries.flatten() {
274 let path = entry.path();
275 let file_type = match entry.file_type() {
276 Ok(file_type) => file_type,
277 Err(_) => continue,
278 };
279 if !file_type.is_dir() || file_type.is_symlink() {
282 continue;
283 }
284 if preserve_path.as_deref() == Some(path.as_path()) {
285 continue;
286 }
287 if session_retention_pinned(&path) {
288 continue;
289 }
290 if session_dir_is_live(&path) {
294 continue;
295 }
296 let manifest_path = path.join("manifest.json");
297 let Ok(bytes) = std::fs::read(&manifest_path) else {
298 continue;
299 };
300 let Ok(mut summary) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
301 continue;
302 };
303 let is_active = summary.get("status").and_then(serde_json::Value::as_str) == Some("active");
304 if !is_active {
305 continue;
306 }
307 let updated_at = summary
308 .get("updated_at")
309 .and_then(serde_json::Value::as_str)
310 .unwrap_or_default();
311 if !older_than(updated_at, cutoff) {
312 continue;
313 }
314 if let Some(object) = summary.as_object_mut() {
315 object.insert("status".to_string(), serde_json::Value::String("completed".to_string()));
316 } else {
317 continue;
318 }
319 let body = serde_json::to_vec_pretty(&summary)
320 .map_err(|error| SessionStoreError::io(manifest_path.clone(), std::io::Error::other(error)))?;
321 vtcode_commons::VtCodePaths::write_private_file_atomic(&manifest_path, &body)
326 .map_err(|error| SessionStoreError::io(manifest_path.clone(), std::io::Error::other(error)))?;
327 marked += 1;
328 }
329 Ok(marked)
330}
331
332pub fn gc_legacy(workspace: &Path) -> Result<u64, SessionStoreError> {
339 let vt = workspace.join(".vtcode");
340 let mut freed = 0u64;
341 for name in ["history", "logs"] {
342 let dir = vt.join(name);
343 if dir.exists() {
344 freed += dir_size(&dir);
345 std::fs::remove_dir_all(&dir).map_err(|e| SessionStoreError::io(dir.clone(), e))?;
346 }
347 }
348 Ok(freed)
349}
350
351fn remove_session(root: &Path, dir: &Path) -> Result<(), SessionStoreError> {
352 if dir.parent() != Some(root) {
355 return Ok(());
356 }
357 let metadata = match std::fs::symlink_metadata(dir) {
358 Ok(metadata) => metadata,
359 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
360 Err(error) => return Err(SessionStoreError::io(dir.to_path_buf(), error)),
361 };
362 if metadata.file_type().is_symlink() || !metadata.is_dir() {
363 return Ok(());
364 }
365 std::fs::remove_dir_all(dir).map_err(|e| SessionStoreError::io(dir.to_path_buf(), e))?;
366 Ok(())
367}
368
369fn dir_size(dir: &Path) -> u64 {
370 WalkDir::new(dir)
371 .into_iter()
372 .filter_map(Result::ok)
373 .filter(|e| e.file_type().is_file())
374 .filter_map(|e| e.metadata().ok())
375 .map(|m| m.len())
376 .sum()
377}
378
379fn age_cutoff(max_age_days: u64) -> SystemTime {
380 let seconds = max_age_days.saturating_mul(24 * 3600);
381 SystemTime::now() - Duration::from_secs(seconds)
382}
383
384fn older_than(rfc3339: &str, cutoff: SystemTime) -> bool {
385 let Ok(dt) = chrono::DateTime::parse_from_rfc3339(rfc3339) else {
386 return false;
387 };
388 let cutoff_secs = cutoff
389 .duration_since(UNIX_EPOCH)
390 .map(|d| d.as_secs() as i64)
391 .unwrap_or(i64::MAX);
392 dt.timestamp() < cutoff_secs
393}