artifactize_core/agent/session/
gc.rs1use std::{
6 collections::BTreeSet,
7 fs::{self, File},
8 path::Path,
9 time::SystemTime,
10};
11
12use rusqlite::OpenFlags;
13use serde::Serialize;
14
15use super::directory;
16use crate::{limits::AgentSessions, store::DATABASE, types::SessionId};
17
18#[derive(Debug, Default, Clone, Serialize)]
20#[serde(rename_all = "camelCase")]
21pub struct Collection {
22 pub bytes: u64,
24 pub remaining: u64,
26 pub removed: Vec<SessionId>,
28}
29
30#[derive(Debug, Default, Clone, Copy, Serialize)]
32pub struct Usage {
33 pub sessions: usize,
34 pub bytes: u64,
35}
36
37struct Entry {
38 id: SessionId,
39 bytes: u64,
40 written: SystemTime,
41}
42
43fn entries(state: &Path) -> Result<Vec<Entry>, String> {
45 let directory = directory(state);
46 let handle = match crate::platform::open_no_follow(File::options().read(true), &directory) {
47 Ok(handle) => handle,
48 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
49 Err(error) => {
50 return Err(format!(
51 "{}: {error}",
52 crate::platform::path_text(&directory)
53 ));
54 }
55 };
56 let listing = crate::platform::read_dir(&handle).map_err(|e| e.to_string())?;
57 let mut entries = Vec::new();
58 for entry in listing {
59 let entry = entry.map_err(|e| e.to_string())?;
60 let name = entry.file_name();
61 let Some(id) = name.to_str().and_then(|name| name.strip_suffix(".jsonl")) else {
62 continue;
63 };
64 let Ok(id) = id.parse::<SessionId>() else {
65 continue;
67 };
68 if crate::platform::entry_kind(&handle, &name).map_err(|e| e.to_string())?
69 != crate::platform::FileKind::File
70 {
71 continue;
72 }
73 let name = crate::platform::EntryName::new(&name).ok_or("Invalid session entry name.")?;
74 let file = crate::platform::open_entry(&handle, &name).map_err(|e| e.to_string())?;
75 let metadata = file.metadata().map_err(|e| e.to_string())?;
76 if !metadata.is_file() {
77 continue;
78 }
79 entries.push(Entry {
80 id,
81 bytes: metadata.len(),
82 written: metadata.modified().map_err(|e| e.to_string())?,
83 });
84 }
85 Ok(entries)
86}
87
88fn order(entries: &mut [Entry]) {
90 entries.sort_by(|a, b| a.written.cmp(&b.written).then_with(|| a.id.cmp(&b.id)));
91}
92
93pub fn usage(state: &Path) -> Result<Usage, String> {
95 let entries = entries(state)?;
96 Ok(Usage {
97 sessions: entries.len(),
98 bytes: entries.iter().map(|entry| entry.bytes).sum(),
99 })
100}
101
102fn running(state: &Path) -> Result<BTreeSet<SessionId>, String> {
104 crate::store::check_probe_files(state)?;
105 let database = state.join(DATABASE);
106 if !database.try_exists().map_err(|e| e.to_string())? {
107 return Ok(BTreeSet::new());
108 }
109 let read = || -> Result<BTreeSet<SessionId>, rusqlite::Error> {
110 let db =
111 rusqlite::Connection::open_with_flags(&database, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
112 db.busy_timeout(crate::store::SQLITE_BUSY_TIMEOUT)?;
113 let initialized: bool = db.query_row(
114 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='requests')",
115 [],
116 |row| row.get(0),
117 )?;
118 if !initialized {
119 return Ok(BTreeSet::new());
120 }
121 let mut statement = db.prepare(
122 "SELECT json_extract(data,'$.sessionId') FROM requests WHERE status='RUNNING' AND json_extract(data,'$.sessionId') IS NOT NULL",
123 )?;
124 statement
125 .query_map([], |row| row.get::<_, SessionId>(0))?
126 .collect()
127 };
128 read().map_err(|e| format!("Cannot read running Agent reviews: {e}"))
129}
130
131pub fn collect(state: &Path, bounds: AgentSessions, dry_run: bool) -> Result<Collection, String> {
135 let publisher = if dry_run {
136 crate::changes::Publisher::default()
137 } else {
138 crate::changes::Publisher::new(state)
139 };
140 let mut entries = entries(state)?;
141 let bytes = entries.iter().map(|entry| entry.bytes).sum();
142 let mut collection = Collection {
143 bytes,
144 remaining: bytes,
145 removed: Vec::new(),
146 };
147 if bytes <= bounds.max_bytes {
148 return Ok(collection);
149 }
150 let running = running(state)?;
151 order(&mut entries);
152 let directory = directory(state);
153 for entry in entries {
154 if collection.remaining <= bounds.target_bytes {
155 break;
156 }
157 if running.contains(&entry.id) {
158 continue;
159 }
160 if !dry_run {
161 let lock = directory.join(format!("{}.lock", entry.id));
162 let held = match crate::platform::open_no_follow(
164 File::options().read(true).write(true),
165 &lock,
166 ) {
167 Ok(file) => match file.try_lock() {
168 Ok(()) => Some(file),
169 Err(std::fs::TryLockError::WouldBlock) => continue,
170 Err(std::fs::TryLockError::Error(error)) => return Err(error.to_string()),
171 },
172 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
173 Err(error) => return Err(error.to_string()),
174 };
175 match fs::remove_file(directory.join(format!("{}.jsonl", entry.id))) {
176 Ok(()) => {
177 publisher.notify(crate::changes::Change::SessionInvalidated(entry.id.clone()))
178 }
179 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
180 Err(error) => {
181 return Err(format!("Cannot remove Agent session {}: {error}", entry.id));
182 }
183 }
184 if held.is_some() {
185 let _ = fs::remove_file(&lock);
186 }
187 }
188 collection.remaining = collection.remaining.saturating_sub(entry.bytes);
189 collection.removed.push(entry.id);
190 }
191 Ok(collection)
192}
193
194#[cfg(test)]
195mod tests {
196 use std::time::{Duration, UNIX_EPOCH};
197
198 use super::*;
199
200 fn store(sizes: &[(&str, usize)]) -> tempfile::TempDir {
202 let state = crate::test_os::tempdir();
203 let sessions = directory(state.path());
204 fs::create_dir_all(&sessions).unwrap();
205 for (id, bytes) in sizes {
206 let path = sessions.join(format!("{id}.jsonl"));
207 fs::write(&path, vec![b'x'; *bytes]).unwrap();
208 crate::test_os::set_modified(&path, UNIX_EPOCH + Duration::from_secs(1_000));
209 }
210 state
211 }
212
213 #[test]
214 fn ordering_uses_explicit_times_then_identity() {
215 let entry = |id: &str, secs| Entry {
216 id: id.parse().unwrap(),
217 bytes: 1,
218 written: UNIX_EPOCH + Duration::from_secs(secs),
219 };
220 let mut entries = [entry("a", 20), entry("c", 10), entry("b", 10)];
221 order(&mut entries);
222 assert_eq!(entries.map(|entry| entry.id.to_string()), ["b", "c", "a"]);
223 }
224
225 fn bounds(max_bytes: u64, target_bytes: u64) -> AgentSessions {
226 AgentSessions {
227 enabled: true,
228 max_bytes,
229 target_bytes,
230 }
231 }
232
233 #[test]
234 fn collects_oldest_first_down_to_the_target_only_past_the_maximum() {
235 let state = store(&[("a", 100), ("b", 100), ("c", 100), ("d", 100)]);
236 let collection = collect(state.path(), bounds(400, 150), false).unwrap();
238 assert!(collection.removed.is_empty());
239 assert_eq!(usage(state.path()).unwrap().sessions, 4);
240
241 let dry = collect(state.path(), bounds(399, 150), true).unwrap();
242 assert_eq!(
243 dry.removed
244 .iter()
245 .map(ToString::to_string)
246 .collect::<Vec<_>>(),
247 ["a", "b", "c"]
248 );
249 assert_eq!((dry.bytes, dry.remaining), (400, 100));
250 assert_eq!(usage(state.path()).unwrap().sessions, 4);
251
252 let lock = directory(state.path()).join("b.lock");
254 let held = File::create(&lock).unwrap();
255 held.lock().unwrap();
256 let collection = collect(state.path(), bounds(399, 200), false).unwrap();
257 assert_eq!(
258 collection
259 .removed
260 .iter()
261 .map(ToString::to_string)
262 .collect::<Vec<_>>(),
263 ["a", "c"]
264 );
265 assert_eq!(collection.remaining, 200);
266 drop(held);
267 let left: BTreeSet<_> = fs::read_dir(directory(state.path()))
268 .unwrap()
269 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
270 .collect();
271 assert_eq!(
272 left,
273 ["b.jsonl", "b.lock", "d.jsonl"]
274 .map(str::to_owned)
275 .into_iter()
276 .collect()
277 );
278 }
279
280 #[test]
281 fn invalid_session_filenames_are_not_collected() {
282 let state = store(&[("valid", 100), ("..", 100), ("bad name", 100)]);
283 assert_eq!(usage(state.path()).unwrap().sessions, 1);
284 let collection = collect(state.path(), bounds(1, 0), false).unwrap();
285 assert_eq!(
286 collection
287 .removed
288 .iter()
289 .map(ToString::to_string)
290 .collect::<Vec<_>>(),
291 ["valid"]
292 );
293 assert!(directory(state.path()).join("...jsonl").is_file());
294 assert!(directory(state.path()).join("bad name.jsonl").is_file());
295 }
296
297 #[test]
298 fn running_session_ids_are_validated_at_the_sql_edge() {
299 let state = store(&[("active", 100), ("finished", 100)]);
300 let db = rusqlite::Connection::open(state.path().join(DATABASE)).unwrap();
301 db.execute_batch(
302 "CREATE TABLE requests(status TEXT, data TEXT);
303 INSERT INTO requests VALUES('RUNNING','{\"sessionId\":\"active\"}');",
304 )
305 .unwrap();
306 let collection = collect(state.path(), bounds(1, 0), false).unwrap();
307 assert_eq!(
308 collection
309 .removed
310 .iter()
311 .map(ToString::to_string)
312 .collect::<Vec<_>>(),
313 ["finished"]
314 );
315 db.execute_batch("UPDATE requests SET data='{\"sessionId\":\"../invalid\"}';")
316 .unwrap();
317 assert!(running(state.path()).is_err());
318 assert!(directory(state.path()).join("active.jsonl").is_file());
319 }
320
321 #[test]
322 fn a_missing_store_is_empty() {
323 let state = crate::test_os::tempdir();
324 let collection = collect(state.path(), bounds(1, 0), false).unwrap();
325 assert_eq!((collection.bytes, collection.removed.len()), (0, 0));
326 }
327}