Skip to main content

artifactize_core/store/
prune.rs

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    /// Agent sessions the size-bound collection deleted, oldest first.
23    pub removed_sessions: Vec<crate::types::SessionId>,
24    /// With `--dry-run`, the Agent sessions it would delete.
25    pub would_remove_sessions: Vec<crate::types::SessionId>,
26}
27
28// `--older-than` units in seconds, each built from the one below it. A day is a fixed
29// 24 hours because pruning compares elapsed time, not calendar dates.
30/// 60 seconds in a minute.
31const SECONDS_PER_MINUTE: u64 = 60;
32/// 60 minutes in an hour.
33const SECONDS_PER_HOUR: u64 = 60 * SECONDS_PER_MINUTE;
34/// 24 hours in a day.
35const SECONDS_PER_DAY: u64 = 24 * SECONDS_PER_HOUR;
36/// 7 days in a week.
37const 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        // `time::Duration` holds whole seconds as an `i64`; the cutoff converts into it.
57        .filter(|seconds| *seconds <= i64::MAX as u64)
58        .ok_or_else(error)?;
59    Ok(Duration::from_secs(seconds))
60}
61
62/// Prune only known scratch directories. Terminal Runs never reacquire an owner.
63pub fn prune(
64    state: &Path,
65    repo: Option<&Path>,
66    older_than: Option<Duration>,
67    dry_run: bool,
68) -> Result<PruneReport, String> {
69    // Canonical like the repositories it is compared with: on Windows, a state path typed in
70    // another case, or with 8.3 short names, would otherwise slip past `outside_workspace`.
71    // System aliases such as macOS /tmp are OS-owned boundaries, not operator-made links.
72    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    // Saved Agent conversations past their size bound (limits.json agentSessions).
84    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                    // A mirrored remote result has no local owner: a saved pid of 0.
163                    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    // Validate the complete deletion set before removing anything; never follow links.
215    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    // A link is refused first: the identity checks below would see where it leads.
319    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}