Skip to main content

agent_exec/
delete.rs

1//! Implementation of the `delete` sub-command.
2//!
3//! Supports two modes:
4//!   - `delete <JOB_ID>`: remove one explicit job directory (non-running only).
5//!   - `delete --all [--dry-run]`: remove all terminal jobs whose persisted
6//!     `meta.json.cwd` matches the caller's current working directory.
7//!
8//! `--dry-run` may be combined with either mode to report actions without
9//! removing any directories.
10
11use anyhow::{Result, anyhow};
12use tracing::debug;
13
14use crate::jobstore::{InvalidJobState, JobDir, resolve_root};
15use crate::run::resolve_effective_cwd;
16use crate::schema::{DeleteData, DeleteJobResult, JobStatus, Response};
17
18#[cfg(unix)]
19fn pid_is_alive(pid: u32) -> bool {
20    let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
21    if ret == 0 {
22        return true;
23    }
24
25    let err = std::io::Error::last_os_error();
26    matches!(err.raw_os_error(), Some(libc::EPERM))
27}
28
29#[cfg(windows)]
30fn pid_is_alive(pid: u32) -> bool {
31    use windows::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
32    use windows::Win32::System::Threading::{
33        GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
34    };
35
36    let handle = match unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } {
37        Ok(handle) => handle,
38        Err(_) => return false,
39    };
40
41    let mut exit_code = 0u32;
42    let ok = unsafe { GetExitCodeProcess(handle, &mut exit_code) }.is_ok();
43    unsafe {
44        let _ = CloseHandle(handle);
45    }
46    ok && exit_code == STILL_ACTIVE.0
47}
48
49#[cfg(not(any(unix, windows)))]
50fn pid_is_alive(_pid: u32) -> bool {
51    false
52}
53
54/// Options for the `delete` sub-command.
55#[derive(Debug)]
56pub struct DeleteOpts<'a> {
57    pub root: Option<&'a str>,
58    /// When `Some`, delete a single job by ID.  Mutually exclusive with `all`.
59    pub job_id: Option<&'a str>,
60    /// When true, delete all terminal jobs scoped to the caller's cwd.
61    pub all: bool,
62    /// When true, report candidates without removing any directories.
63    pub dry_run: bool,
64}
65
66/// Execute `delete`: dispatch to single-job or bulk mode.
67pub fn execute(opts: DeleteOpts) -> Result<()> {
68    let root = resolve_root(opts.root);
69    let root_str = root.display().to_string();
70
71    if let Some(job_id) = opts.job_id {
72        delete_single(&root, &root_str, job_id, opts.dry_run)
73    } else {
74        delete_all(&root, &root_str, opts.dry_run)
75    }
76}
77
78/// Delete a single explicit job by ID or unambiguous prefix.
79///
80/// Rejects running jobs with `InvalidJobState`.  Returns `JobNotFound` when
81/// the directory does not exist, and `AmbiguousJobId` when the prefix matches
82/// multiple jobs.
83fn delete_single(
84    root: &std::path::Path,
85    root_str: &str,
86    job_id: &str,
87    dry_run: bool,
88) -> Result<()> {
89    // Use JobDir::open for prefix-based resolution (exact match fast path included).
90    // Returns AmbiguousJobId if the prefix matches multiple jobs.
91    let job_dir = JobDir::open(root, job_id)?;
92    let job_path = job_dir.path;
93    // Use the resolved canonical ID in all output (never the user-supplied prefix).
94    let resolved_id = job_dir.job_id;
95
96    // Read state to determine whether the job is running.
97    let state_path = job_path.join("state.json");
98    let state_opt: Option<crate::schema::JobState> = std::fs::read(&state_path)
99        .ok()
100        .and_then(|b| serde_json::from_slice(&b).ok());
101
102    let state_str = match &state_opt {
103        Some(s) => s.status().as_str().to_string(),
104        None => "unknown".to_string(),
105    };
106
107    // Reject running jobs.
108    if state_opt
109        .as_ref()
110        .map(|s| *s.status() == JobStatus::Running)
111        .unwrap_or(false)
112    {
113        return Err(anyhow::Error::new(InvalidJobState(format!(
114            "cannot delete job {job_id}: job is currently running"
115        ))));
116    }
117
118    let action = if dry_run {
119        debug!(job_id, "delete: dry-run would delete job");
120        "would_delete"
121    } else {
122        std::fs::remove_dir_all(&job_path).map_err(|e| {
123            anyhow!(
124                "failed to delete job directory {}: {}",
125                job_path.display(),
126                e
127            )
128        })?;
129        debug!(job_id, "delete: deleted job");
130        "deleted"
131    };
132
133    Response::new(
134        "delete",
135        DeleteData {
136            root: root_str.to_string(),
137            dry_run,
138            deleted: if action == "deleted" { 1 } else { 0 },
139            skipped: 0,
140            jobs: vec![DeleteJobResult {
141                job_id: resolved_id,
142                state: state_str,
143                action: action.to_string(),
144                reason: "explicit_delete".to_string(),
145            }],
146        },
147    )
148    .print();
149
150    Ok(())
151}
152
153/// Delete all terminal jobs whose persisted `meta.json.cwd` matches the
154/// caller's current working directory.  Running and created jobs are skipped.
155fn delete_all(root: &std::path::Path, root_str: &str, dry_run: bool) -> Result<()> {
156    let current_cwd = resolve_effective_cwd(None);
157
158    debug!(
159        root = %root_str,
160        cwd = %current_cwd,
161        dry_run,
162        "delete --all: starting"
163    );
164
165    // If root does not exist there is nothing to do.
166    if !root.exists() {
167        debug!(root = %root_str, "delete --all: root does not exist; nothing to delete");
168        Response::new(
169            "delete",
170            DeleteData {
171                root: root_str.to_string(),
172                dry_run,
173                deleted: 0,
174                skipped: 0,
175                jobs: vec![],
176            },
177        )
178        .print();
179        return Ok(());
180    }
181
182    let read_dir = std::fs::read_dir(root)
183        .map_err(|e| anyhow!("failed to read root directory {}: {}", root_str, e))?;
184
185    let mut job_results: Vec<DeleteJobResult> = Vec::new();
186    let mut deleted_count: u64 = 0;
187    let mut skipped_count: u64 = 0;
188
189    for entry in read_dir {
190        let entry = match entry {
191            Ok(e) => e,
192            Err(e) => {
193                debug!(error = %e, "delete --all: failed to read directory entry; skipping");
194                skipped_count += 1;
195                continue;
196            }
197        };
198
199        let path = entry.path();
200        if !path.is_dir() {
201            continue;
202        }
203
204        let job_id = match path.file_name().and_then(|n| n.to_str()) {
205            Some(n) => n.to_string(),
206            None => {
207                debug!(path = %path.display(), "delete --all: cannot get dir name; skipping");
208                skipped_count += 1;
209                continue;
210            }
211        };
212
213        // Read meta.json to check cwd.
214        let meta_path = path.join("meta.json");
215        let meta: Option<crate::schema::JobMeta> = std::fs::read(&meta_path)
216            .ok()
217            .and_then(|b| serde_json::from_slice(&b).ok());
218
219        // Filter by cwd: only jobs whose persisted cwd matches the caller's cwd.
220        match meta.as_ref().and_then(|m| m.cwd.as_deref()) {
221            Some(job_cwd) if job_cwd == current_cwd => {
222                // cwd matches; proceed to state check
223            }
224            _ => {
225                debug!(
226                    job_id = %job_id,
227                    job_cwd = ?meta.as_ref().and_then(|m| m.cwd.as_deref()),
228                    current_cwd = %current_cwd,
229                    "delete --all: skipping job (cwd mismatch or absent)"
230                );
231                // Not counted in skipped — just out of scope.
232                continue;
233            }
234        }
235
236        // Read state.json to determine eligibility.
237        let state_path = path.join("state.json");
238        let state_opt: Option<crate::schema::JobState> = std::fs::read(&state_path)
239            .ok()
240            .and_then(|b| serde_json::from_slice(&b).ok());
241
242        let (state_str, status) = match &state_opt {
243            Some(s) => (s.status().as_str().to_string(), Some(s.status().clone())),
244            None => {
245                debug!(job_id = %job_id, "delete --all: state.json missing or unreadable; skipping");
246                skipped_count += 1;
247                job_results.push(DeleteJobResult {
248                    job_id,
249                    state: "unknown".to_string(),
250                    action: "skipped".to_string(),
251                    reason: "state_unreadable".to_string(),
252                });
253                continue;
254            }
255        };
256
257        // Only terminal states are eligible for bulk deletion; skip created and running.
258        let is_terminal = matches!(
259            status.as_ref(),
260            Some(JobStatus::Exited) | Some(JobStatus::Killed) | Some(JobStatus::Failed)
261        );
262
263        if !is_terminal {
264            let reason = match status.as_ref() {
265                Some(JobStatus::Running) => "running",
266                Some(JobStatus::Created) => "created",
267                _ => "non_terminal",
268            };
269            debug!(job_id = %job_id, state = %state_str, "delete --all: non-terminal job; skipping");
270            skipped_count += 1;
271            job_results.push(DeleteJobResult {
272                job_id,
273                state: state_str,
274                action: "skipped".to_string(),
275                reason: reason.to_string(),
276            });
277            continue;
278        }
279
280        if state_opt
281            .as_ref()
282            .and_then(|s| s.pid)
283            .is_some_and(pid_is_alive)
284        {
285            debug!(job_id = %job_id, state = %state_str, "delete --all: live pid for terminal state; skipping");
286            skipped_count += 1;
287            job_results.push(DeleteJobResult {
288                job_id,
289                state: state_str,
290                action: "skipped".to_string(),
291                reason: "pid_alive".to_string(),
292            });
293            continue;
294        }
295
296        // Eligible terminal job: delete or dry-run.
297        let action = if dry_run {
298            debug!(job_id = %job_id, "delete --all: dry-run would delete");
299            "would_delete"
300        } else {
301            match std::fs::remove_dir_all(&path) {
302                Ok(()) => {
303                    debug!(job_id = %job_id, "delete --all: deleted");
304                    deleted_count += 1;
305                    "deleted"
306                }
307                Err(e) => {
308                    debug!(job_id = %job_id, error = %e, "delete --all: failed to delete; skipping");
309                    skipped_count += 1;
310                    job_results.push(DeleteJobResult {
311                        job_id,
312                        state: state_str,
313                        action: "skipped".to_string(),
314                        reason: format!("delete_failed: {e}"),
315                    });
316                    continue;
317                }
318            }
319        };
320
321        job_results.push(DeleteJobResult {
322            job_id,
323            state: state_str,
324            action: action.to_string(),
325            reason: "terminal_in_cwd".to_string(),
326        });
327    }
328
329    debug!(
330        deleted = deleted_count,
331        skipped = skipped_count,
332        "delete --all: complete"
333    );
334
335    Response::new(
336        "delete",
337        DeleteData {
338            root: root_str.to_string(),
339            dry_run,
340            deleted: deleted_count,
341            skipped: skipped_count,
342            jobs: job_results,
343        },
344    )
345    .print();
346
347    Ok(())
348}