1use std::{
2 fs,
3 path::{Path, PathBuf},
4 time::Duration,
5};
6
7use rusqlite::OpenFlags;
8use serde::Serialize;
9use time::OffsetDateTime;
10
11use super::{DATABASE, STATE_SCHEMA_VERSION, receipts::check_files};
12use crate::{platform, process, workspace};
13
14#[derive(Default, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct PruneReport {
17 #[serde(serialize_with = "crate::platform::serialize_paths")]
18 pub removed: Vec<PathBuf>,
19 #[serde(serialize_with = "crate::platform::serialize_paths")]
20 pub would_remove: Vec<PathBuf>,
21 pub skipped_runs: Vec<crate::types::RunId>,
22 pub removed_sessions: Vec<crate::types::SessionId>,
24 pub would_remove_sessions: Vec<crate::types::SessionId>,
26}
27
28const SECONDS_PER_MINUTE: u64 = 60;
32const SECONDS_PER_HOUR: u64 = 60 * SECONDS_PER_MINUTE;
34const SECONDS_PER_DAY: u64 = 24 * SECONDS_PER_HOUR;
36const SECONDS_PER_WEEK: u64 = 7 * SECONDS_PER_DAY;
38
39pub fn parse_duration(value: &str) -> Result<Duration, String> {
40 let error = || "Use a whole-number duration with s, m, h, d or w (for example 7d).".to_owned();
41 let split = value
42 .find(|c: char| !c.is_ascii_digit())
43 .ok_or_else(error)?;
44 let multiplier = match &value[split..] {
45 "s" => 1,
46 "m" => SECONDS_PER_MINUTE,
47 "h" => SECONDS_PER_HOUR,
48 "d" => SECONDS_PER_DAY,
49 "w" => SECONDS_PER_WEEK,
50 _ => return Err(error()),
51 };
52 let seconds = value[..split]
53 .parse::<u64>()
54 .ok()
55 .and_then(|amount| amount.checked_mul(multiplier))
56 .filter(|seconds| *seconds <= i64::MAX as u64)
58 .ok_or_else(error)?;
59 Ok(Duration::from_secs(seconds))
60}
61
62pub fn prune(
64 state: &Path,
65 repo: Option<&Path>,
66 older_than: Option<Duration>,
67 dry_run: bool,
68) -> Result<PruneReport, String> {
69 let state = platform::resolve_system_aliases(state).map_err(|e| e.to_string())?;
73 let state = workspace::canonical_target(&real_path(&state)?).map_err(|e| e.to_string())?;
74 let mut report = PruneReport::default();
75 let mut repositories = Vec::new();
76 if let Some(repo) = repo {
77 repositories.push(workspace::canonical_target(repo).map_err(|e| e.to_string())?);
78 }
79 for repo in &repositories {
80 workspace::outside_workspace(repo, &state).map_err(|e| e.to_string())?;
81 }
82 check_files(&state)?;
83 let bounds = crate::limits::Limits::read(&state)?.agent_sessions();
85 let sessions = crate::agent::session::collect(&state, bounds, dry_run)?;
86 if dry_run {
87 report.would_remove_sessions = sessions.removed.into_iter().collect();
88 } else {
89 report.removed_sessions = sessions.removed.into_iter().collect();
90 }
91 if !state
92 .join(DATABASE)
93 .try_exists()
94 .map_err(|e| e.to_string())?
95 {
96 return Ok(report);
97 }
98 let mut db = rusqlite::Connection::open_with_flags(
99 state.join(DATABASE),
100 OpenFlags::SQLITE_OPEN_READ_ONLY,
101 )
102 .map_err(|e| e.to_string())?;
103 db.busy_timeout(super::SQLITE_BUSY_TIMEOUT)
104 .map_err(|e| e.to_string())?;
105 let transaction = db.transaction().map_err(|e| e.to_string())?;
106 let version: u32 = transaction
107 .pragma_query_value(None, "user_version", |row| row.get(0))
108 .map_err(|e| e.to_string())?;
109 if version != STATE_SCHEMA_VERSION {
110 return Err(super::schema_error(version));
111 }
112 let cutoff = older_than
113 .map(|age| {
114 time::Duration::try_from(age)
115 .ok()
116 .and_then(|age| OffsetDateTime::now_utc().checked_sub(age))
117 .ok_or_else(|| "Prune duration is too large.".to_owned())
118 })
119 .transpose()?;
120 let runs = {
121 let mut statement = transaction
122 .prepare("SELECT id,repo,data,status FROM runs ORDER BY id")
123 .map_err(|e| e.to_string())?;
124 statement
125 .query_map([], |row| {
126 Ok((
127 row.get::<_, crate::types::RunId>(0)?,
128 row.get::<_, String>(1)?,
129 row.get::<_, String>(2)?,
130 row.get::<_, crate::types::RunStatus>(3)?,
131 ))
132 })
133 .map_err(|e| e.to_string())?
134 .collect::<Result<Vec<_>, _>>()
135 .map_err(|e| e.to_string())?
136 };
137 let mut eligible = Vec::new();
138 for (id, repo, data, status) in runs {
139 let repo = workspace::canonical_target(Path::new(&repo)).map_err(|e| e.to_string())?;
140 workspace::outside_workspace(&repo, &state).map_err(|e| e.to_string())?;
141 repositories.push(repo);
142 let Some(run) = super::unreadable::evidence::<super::Run>("run", &data) else {
143 report.skipped_runs.push(id);
144 continue;
145 };
146 let finished = run.completed_at().map(crate::types::Timestamp::time);
147 let requests_terminal: bool = transaction
148 .query_row(
149 "SELECT NOT EXISTS(SELECT 1 FROM requests WHERE run_id=? AND status NOT IN ('GREEN','RED','ERROR','INCOMPLETE','BLOCKED','BUDGET_EXHAUSTED'))",
150 [&id],
151 |row| row.get(0),
152 )
153 .map_err(|e| e.to_string())?;
154 let owners = {
155 let mut statement = transaction
156 .prepare(
157 "SELECT owner_pid,owner_start_time,status FROM executions WHERE json_extract(data,'$.provenance.runId')=?1 OR id IN (SELECT execution_id FROM requests WHERE run_id=?1)",
158 )
159 .map_err(|e| e.to_string())?;
160 statement
161 .query_map([&id], |row| {
162 let pid: u32 = row.get(0)?;
164 let start_time = row.get::<_, i64>(1)? as u64;
165 let owner = std::num::NonZeroU32::new(pid).map(|pid| process::ChildIdentity {
166 pid: process::ProcessId::from(pid),
167 start_time,
168 });
169 Ok((owner, row.get::<_, crate::types::ExecutionStatus>(2)?))
170 })
171 .map_err(|e| e.to_string())?
172 .collect::<Result<Vec<_>, _>>()
173 .map_err(|e| e.to_string())?
174 };
175 let mut owned = false;
176 for (owner, status) in owners {
177 owned |= !matches!(
178 status,
179 crate::types::ExecutionStatus::Green
180 | crate::types::ExecutionStatus::Red
181 | crate::types::ExecutionStatus::Error
182 ) || process::is_alive(owner).map_err(|e| e.to_string())?;
183 }
184 if !matches!(
185 status,
186 crate::types::RunStatus::Green
187 | crate::types::RunStatus::Red
188 | crate::types::RunStatus::Error
189 | crate::types::RunStatus::Incomplete
190 ) || finished.is_none()
191 || cutoff.is_some_and(|cutoff| finished.is_some_and(|date| date > cutoff))
192 || !requests_terminal
193 || owned
194 {
195 report.skipped_runs.push(id);
196 } else {
197 eligible.push(id);
198 }
199 }
200 transaction.commit().map_err(|e| e.to_string())?;
201 drop(db);
202
203 let runs = state.join("runs");
204 if !directory(&runs)? {
205 return Ok(report);
206 }
207 let mut targets = Vec::new();
208 for id in eligible {
209 let path = runs.join(id.as_str());
210 if directory(&path)? {
211 collect(&path, &mut targets)?;
212 }
213 }
214 for path in &targets {
216 validate_tree(path, &runs, &repositories)?;
217 }
218 for path in targets {
219 if dry_run {
220 report.would_remove.push(path);
221 } else {
222 real_path(&path)?;
223 validate_tree(&path, &runs, &repositories)?;
224 fs::remove_dir_all(&path)
225 .map_err(|e| format!("Cannot prune {}: {e}", crate::platform::path_text(&path)))?;
226 report.removed.push(path);
227 }
228 }
229 Ok(report)
230}
231
232fn segment(value: &str) -> bool {
233 !value.is_empty()
234 && value
235 .bytes()
236 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
237}
238
239fn prefixed(name: &str, prefix: &str) -> bool {
240 name.strip_prefix(prefix).is_some_and(segment)
241}
242
243fn collect(parent: &Path, targets: &mut Vec<PathBuf>) -> Result<(), String> {
244 let parent_handle = platform::open_directory(parent).map_err(|e| e.to_string())?;
245 let mut entries = platform::read_dir(&parent_handle)
246 .map_err(|e| e.to_string())?
247 .collect::<Result<Vec<_>, _>>()
248 .map_err(|e| e.to_string())?;
249 entries.sort_by_key(|entry| entry.file_name());
250 for entry in entries {
251 let name = entry.file_name();
252 let Some(name) = name.to_str() else { continue };
253 let path = parent.join(entry.file_name());
254 if matches!(name, "output" | "tmp" | "home" | "cache" | "human-tools")
255 || ["tool-output-", "tool-"]
256 .iter()
257 .any(|prefix| prefixed(name, prefix))
258 {
259 if directory(&path)? {
260 targets.push(path);
261 }
262 } else if ["runtime-", "fingerprint-"]
263 .iter()
264 .any(|prefix| prefixed(name, prefix))
265 && directory(&path)?
266 {
267 collect(&path, targets)?;
268 }
269 }
270 Ok(())
271}
272
273fn directory(path: &Path) -> Result<bool, String> {
274 match platform::path_kind(path) {
275 Ok(platform::FileKind::Directory) => {
276 reject_repository(path)?;
277 Ok(true)
278 }
279 Ok(_) => Err(format!(
280 "Prune requires a real directory, not a symlink or file: {}",
281 crate::platform::path_text(path)
282 )),
283 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
284 Err(error) => Err(error.to_string()),
285 }
286}
287
288fn real_path(path: &Path) -> Result<PathBuf, String> {
289 platform::paths::absolute_without_links(path).map_err(|refusal| match refusal {
290 platform::paths::LinkedPath::ParentTraversal => {
291 "Prune paths must not contain parent traversal.".to_owned()
292 }
293 platform::paths::LinkedPath::Link(path) => {
294 format!("Prune refuses symlinks: {}", platform::path_text(&path))
295 }
296 platform::paths::LinkedPath::Io(error) => error.to_string(),
297 })
298}
299
300fn reject_repository(path: &Path) -> Result<(), String> {
301 if workspace::has_artifact_marker(path).map_err(|error| error.to_string())? {
302 return Err(format!(
303 "Prune refuses repository content: {}",
304 crate::platform::path_text(path)
305 ));
306 }
307 match platform::path_kind(&path.join(".git")) {
308 Ok(_) => Err(format!(
309 "Prune refuses repository content: {}",
310 crate::platform::path_text(path)
311 )),
312 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
313 Err(error) => Err(error.to_string()),
314 }
315}
316
317fn validate_tree(path: &Path, runs: &Path, repositories: &[PathBuf]) -> Result<(), String> {
318 let kind = platform::path_kind(path).map_err(|e| e.to_string())?;
320 if matches!(
321 kind,
322 platform::FileKind::Symlink | platform::FileKind::Other
323 ) {
324 return Err(format!(
325 "Prune refuses symlinks: {}",
326 crate::platform::path_text(path)
327 ));
328 }
329 if !platform::is_within(path, runs)
330 || platform::paths_equal(path, runs)
331 || repositories
332 .iter()
333 .any(|repo| platform::is_within(path, repo) || platform::is_within(repo, path))
334 {
335 return Err(format!(
336 "Prune target is outside Run output or overlaps a repository: {}",
337 crate::platform::path_text(path)
338 ));
339 }
340 if kind == platform::FileKind::Directory {
341 reject_repository(path)?;
342 let directory = platform::open_directory(path).map_err(|e| e.to_string())?;
343 for entry in platform::read_dir(&directory).map_err(|e| e.to_string())? {
344 validate_tree(
345 &path.join(entry.map_err(|e| e.to_string())?.file_name()),
346 runs,
347 repositories,
348 )?;
349 }
350 }
351 Ok(())
352}