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