1use 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 let written = metadata.modified().map_err(|e| e.to_string())?;
80 #[cfg(test)]
82 let written = tests::written(state, &id).unwrap_or(written);
83 entries.push(Entry {
84 id,
85 bytes: metadata.len(),
86 written,
87 });
88 }
89 Ok(entries)
90}
91
92fn order(entries: &mut [Entry]) {
94 entries.sort_by(|a, b| a.written.cmp(&b.written).then_with(|| a.id.cmp(&b.id)));
95}
96
97pub fn usage(state: &Path) -> Result<Usage, String> {
99 let entries = entries(state)?;
100 Ok(Usage {
101 sessions: entries.len(),
102 bytes: entries.iter().map(|entry| entry.bytes).sum(),
103 })
104}
105
106fn running(state: &Path) -> Result<BTreeSet<SessionId>, String> {
108 crate::store::check_probe_files(state)?;
109 let database = state.join(DATABASE);
110 if !database.try_exists().map_err(|e| e.to_string())? {
111 return Ok(BTreeSet::new());
112 }
113 let read = || -> Result<BTreeSet<SessionId>, rusqlite::Error> {
114 let db =
115 rusqlite::Connection::open_with_flags(&database, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
116 db.busy_timeout(crate::store::SQLITE_BUSY_TIMEOUT)?;
117 let initialized: bool = db.query_row(
118 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='requests')",
119 [],
120 |row| row.get(0),
121 )?;
122 if !initialized {
123 return Ok(BTreeSet::new());
124 }
125 let mut statement = db.prepare(
126 "SELECT json_extract(data,'$.sessionId') FROM requests WHERE status='RUNNING' AND json_extract(data,'$.sessionId') IS NOT NULL",
127 )?;
128 statement
129 .query_map([], |row| row.get::<_, SessionId>(0))?
130 .collect()
131 };
132 read().map_err(|e| format!("Cannot read running Agent reviews: {e}"))
133}
134
135pub fn collect(state: &Path, bounds: AgentSessions, dry_run: bool) -> Result<Collection, String> {
139 let publisher = if dry_run {
140 crate::changes::Publisher::default()
141 } else {
142 crate::changes::Publisher::new(state)
143 };
144 let mut entries = entries(state)?;
145 let bytes = entries.iter().map(|entry| entry.bytes).sum();
146 let mut collection = Collection {
147 bytes,
148 remaining: bytes,
149 removed: Vec::new(),
150 };
151 if bytes <= bounds.max_bytes {
152 return Ok(collection);
153 }
154 let running = running(state)?;
155 order(&mut entries);
156 let directory = directory(state);
157 for entry in entries {
158 if collection.remaining <= bounds.target_bytes {
159 break;
160 }
161 if running.contains(&entry.id) {
162 continue;
163 }
164 if !dry_run {
165 let lock = directory.join(format!("{}.lock", entry.id));
166 let held = match crate::platform::open_no_follow(
168 File::options().read(true).write(true),
169 &lock,
170 ) {
171 Ok(file) => match file.try_lock() {
172 Ok(()) => Some(file),
173 Err(std::fs::TryLockError::WouldBlock) => continue,
174 Err(std::fs::TryLockError::Error(error)) => return Err(error.to_string()),
175 },
176 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
177 Err(error) => return Err(error.to_string()),
178 };
179 match fs::remove_file(directory.join(format!("{}.jsonl", entry.id))) {
180 Ok(()) => {
181 publisher.notify(crate::changes::Change::SessionInvalidated(entry.id.clone()))
182 }
183 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
184 Err(error) => {
185 return Err(format!("Cannot remove Agent session {}: {error}", entry.id));
186 }
187 }
188 if held.is_some() {
189 let _ = fs::remove_file(&lock);
190 }
191 }
192 collection.remaining = collection.remaining.saturating_sub(entry.bytes);
193 collection.removed.push(entry.id);
194 }
195 Ok(collection)
196}
197
198#[cfg(test)]
199mod tests {
200 use std::{
201 cell::RefCell,
202 collections::BTreeMap,
203 path::PathBuf,
204 time::{Duration, UNIX_EPOCH},
205 };
206
207 use super::*;
208
209 thread_local! {
210 static WRITTEN: RefCell<BTreeMap<PathBuf, BTreeMap<String, SystemTime>>> =
213 const { RefCell::new(BTreeMap::new()) };
214 }
215
216 pub(super) fn written(state: &Path, id: &SessionId) -> Option<SystemTime> {
217 WRITTEN.with(|times| times.borrow().get(state)?.get(id.as_str()).copied())
218 }
219
220 struct Store {
222 root: tempfile::TempDir,
223 }
224 impl Store {
225 fn path(&self) -> &Path {
226 self.root.path()
227 }
228
229 fn set_written(&self, id: &str, seconds: u64) {
230 WRITTEN.with(|times| {
231 let mut times = times.borrow_mut();
232 let written = times
233 .get_mut(self.path())
234 .expect("owned state's metadata")
235 .get_mut(id)
236 .expect("declared session");
237 *written = UNIX_EPOCH + Duration::from_secs(seconds);
238 });
239 }
240 }
241 impl Drop for Store {
242 fn drop(&mut self) {
243 WRITTEN.with(|times| {
244 times.borrow_mut().remove(self.path());
245 });
246 }
247 }
248
249 fn store(sizes: &[(&str, usize)]) -> Store {
251 let state = Store {
252 root: crate::test_os::tempdir(),
253 };
254 let sessions = directory(state.path());
255 fs::create_dir_all(&sessions).unwrap();
256 for (id, bytes) in sizes {
257 fs::write(sessions.join(format!("{id}.jsonl")), vec![b'x'; *bytes]).unwrap();
258 }
259 WRITTEN.with(|times| {
260 times.borrow_mut().insert(
261 state.path().to_path_buf(),
262 sizes
263 .iter()
264 .map(|(id, _)| ((*id).to_owned(), UNIX_EPOCH))
265 .collect(),
266 );
267 });
268 state
269 }
270
271 #[test]
272 fn ordering_uses_explicit_times_then_identity() {
273 let state = store(&[("a", 1), ("c", 1), ("b", 1)]);
274 for (id, seconds) in [("a", 20), ("c", 10), ("b", 10)] {
275 state.set_written(id, seconds);
276 }
277 let collection = collect(state.path(), bounds(1, 0), false).unwrap();
278 assert_eq!(
279 collection
280 .removed
281 .iter()
282 .map(ToString::to_string)
283 .collect::<Vec<_>>(),
284 ["b", "c", "a"]
285 );
286 assert_eq!(usage(state.path()).unwrap().sessions, 0);
287 }
288
289 fn bounds(max_bytes: u64, target_bytes: u64) -> AgentSessions {
290 AgentSessions {
291 enabled: true,
292 max_bytes,
293 target_bytes,
294 }
295 }
296
297 #[test]
298 fn collects_oldest_first_down_to_the_target_only_past_the_maximum() {
299 let state = store(&[("a", 100), ("b", 100), ("c", 100), ("d", 100)]);
300 let collection = collect(state.path(), bounds(400, 150), false).unwrap();
302 assert!(collection.removed.is_empty());
303 assert_eq!(usage(state.path()).unwrap().sessions, 4);
304
305 let dry = collect(state.path(), bounds(399, 150), true).unwrap();
306 assert_eq!(
307 dry.removed
308 .iter()
309 .map(ToString::to_string)
310 .collect::<Vec<_>>(),
311 ["a", "b", "c"]
312 );
313 assert_eq!((dry.bytes, dry.remaining), (400, 100));
314 assert_eq!(usage(state.path()).unwrap().sessions, 4);
315
316 let lock = directory(state.path()).join("b.lock");
318 let held = File::create(&lock).unwrap();
319 held.lock().unwrap();
320 let collection = collect(state.path(), bounds(399, 200), false).unwrap();
321 assert_eq!(
322 collection
323 .removed
324 .iter()
325 .map(ToString::to_string)
326 .collect::<Vec<_>>(),
327 ["a", "c"]
328 );
329 assert_eq!(collection.remaining, 200);
330 drop(held);
331 let left: BTreeSet<_> = fs::read_dir(directory(state.path()))
332 .unwrap()
333 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
334 .collect();
335 assert_eq!(
336 left,
337 ["b.jsonl", "b.lock", "d.jsonl"]
338 .map(str::to_owned)
339 .into_iter()
340 .collect()
341 );
342 }
343
344 #[test]
345 fn invalid_session_filenames_are_not_collected() {
346 let state = store(&[("valid", 100), ("..", 100), ("bad name", 100)]);
347 assert_eq!(usage(state.path()).unwrap().sessions, 1);
348 let collection = collect(state.path(), bounds(1, 0), false).unwrap();
349 assert_eq!(
350 collection
351 .removed
352 .iter()
353 .map(ToString::to_string)
354 .collect::<Vec<_>>(),
355 ["valid"]
356 );
357 assert!(directory(state.path()).join("...jsonl").is_file());
358 assert!(directory(state.path()).join("bad name.jsonl").is_file());
359 }
360
361 #[test]
362 fn running_session_ids_are_validated_at_the_sql_edge() {
363 let state = store(&[("active", 100), ("finished", 100)]);
364 let db = rusqlite::Connection::open(state.path().join(DATABASE)).unwrap();
365 db.execute_batch(
366 "CREATE TABLE requests(status TEXT, data TEXT);
367 INSERT INTO requests VALUES('RUNNING','{\"sessionId\":\"active\"}');",
368 )
369 .unwrap();
370 let collection = collect(state.path(), bounds(1, 0), false).unwrap();
371 assert_eq!(
372 collection
373 .removed
374 .iter()
375 .map(ToString::to_string)
376 .collect::<Vec<_>>(),
377 ["finished"]
378 );
379 db.execute_batch("UPDATE requests SET data='{\"sessionId\":\"../invalid\"}';")
380 .unwrap();
381 assert!(collect(state.path(), bounds(1, 0), false).is_err());
382 assert!(directory(state.path()).join("active.jsonl").is_file());
383 }
384
385 #[test]
386 fn a_missing_store_is_empty() {
387 let state = crate::test_os::tempdir();
388 let collection = collect(state.path(), bounds(1, 0), false).unwrap();
389 assert_eq!((collection.bytes, collection.removed.len()), (0, 0));
390 }
391}