1use 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#[derive(Debug)]
56pub struct DeleteOpts<'a> {
57 pub root: Option<&'a str>,
58 pub job_id: Option<&'a str>,
60 pub all: bool,
62 pub dry_run: bool,
64}
65
66pub 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
78fn delete_single(
84 root: &std::path::Path,
85 root_str: &str,
86 job_id: &str,
87 dry_run: bool,
88) -> Result<()> {
89 let job_dir = JobDir::open(root, job_id)?;
92 let job_path = job_dir.path;
93 let resolved_id = job_dir.job_id;
95
96 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 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, reason, failed_count) = if dry_run {
119 debug!(job_id, "delete: dry-run would delete job");
120 ("would_delete", "explicit_delete".to_string(), 0u64)
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 if job_path.exists() {
134 debug!(
135 job_id,
136 "delete: post-delete check found path still present; reporting failure"
137 );
138 return Err(anyhow!(
139 "delete reported success but job directory still exists: {}",
140 job_path.display()
141 ));
142 }
143 debug!(job_id, "delete: deleted job");
144 ("deleted", "explicit_delete".to_string(), 0u64)
145 };
146
147 Response::new(
148 "delete",
149 DeleteData {
150 root: root_str.to_string(),
151 dry_run,
152 cwd_scope: None,
153 deleted: if action == "deleted" { 1 } else { 0 },
154 skipped: 0,
155 out_of_scope: 0,
156 failed: failed_count,
157 jobs: vec![DeleteJobResult {
158 job_id: resolved_id,
159 state: state_str,
160 action: action.to_string(),
161 reason,
162 }],
163 },
164 )
165 .print();
166
167 Ok(())
168}
169
170fn delete_all(root: &std::path::Path, root_str: &str, dry_run: bool) -> Result<()> {
173 let current_cwd = resolve_effective_cwd(None);
174
175 debug!(
176 root = %root_str,
177 cwd = %current_cwd,
178 dry_run,
179 "delete --all: starting"
180 );
181
182 if !root.exists() {
184 debug!(root = %root_str, "delete --all: root does not exist; nothing to delete");
185 Response::new(
186 "delete",
187 DeleteData {
188 root: root_str.to_string(),
189 dry_run,
190 cwd_scope: Some(current_cwd.clone()),
191 deleted: 0,
192 skipped: 0,
193 out_of_scope: 0,
194 failed: 0,
195 jobs: vec![],
196 },
197 )
198 .print();
199 return Ok(());
200 }
201
202 let read_dir = std::fs::read_dir(root)
203 .map_err(|e| anyhow!("failed to read root directory {}: {}", root_str, e))?;
204
205 let mut job_results: Vec<DeleteJobResult> = Vec::new();
206 let mut deleted_count: u64 = 0;
207 let mut skipped_count: u64 = 0;
208 let mut out_of_scope_count: u64 = 0;
209 let mut failed_count: u64 = 0;
210
211 for entry in read_dir {
212 let entry = match entry {
213 Ok(e) => e,
214 Err(e) => {
215 debug!(error = %e, "delete --all: failed to read directory entry; skipping");
216 skipped_count += 1;
217 continue;
218 }
219 };
220
221 let path = entry.path();
222 if !path.is_dir() {
223 continue;
224 }
225
226 let job_id = match path.file_name().and_then(|n| n.to_str()) {
227 Some(n) => n.to_string(),
228 None => {
229 debug!(path = %path.display(), "delete --all: cannot get dir name; skipping");
230 skipped_count += 1;
231 continue;
232 }
233 };
234
235 let meta_path = path.join("meta.json");
237 let meta: Option<crate::schema::JobMeta> = std::fs::read(&meta_path)
238 .ok()
239 .and_then(|b| serde_json::from_slice(&b).ok());
240
241 match meta.as_ref().and_then(|m| m.cwd.as_deref()) {
243 Some(job_cwd) if job_cwd == current_cwd => {
244 }
246 _ => {
247 debug!(
248 job_id = %job_id,
249 job_cwd = ?meta.as_ref().and_then(|m| m.cwd.as_deref()),
250 current_cwd = %current_cwd,
251 "delete --all: skipping job (cwd mismatch or absent)"
252 );
253 out_of_scope_count += 1;
258 continue;
259 }
260 }
261
262 let state_path = path.join("state.json");
264 let state_opt: Option<crate::schema::JobState> = std::fs::read(&state_path)
265 .ok()
266 .and_then(|b| serde_json::from_slice(&b).ok());
267
268 let (state_str, status) = match &state_opt {
269 Some(s) => (s.status().as_str().to_string(), Some(s.status().clone())),
270 None => {
271 debug!(job_id = %job_id, "delete --all: state.json missing or unreadable; skipping");
272 skipped_count += 1;
273 out_of_scope_count += 1;
274 job_results.push(DeleteJobResult {
275 job_id,
276 state: "unknown".to_string(),
277 action: "skipped".to_string(),
278 reason: "state_unreadable".to_string(),
279 });
280 continue;
281 }
282 };
283
284 let is_terminal = matches!(
286 status.as_ref(),
287 Some(JobStatus::Exited) | Some(JobStatus::Killed) | Some(JobStatus::Failed)
288 );
289
290 if !is_terminal {
291 let reason = match status.as_ref() {
292 Some(JobStatus::Running) => "running",
293 Some(JobStatus::Created) => "created",
294 _ => "non_terminal",
295 };
296 debug!(job_id = %job_id, state = %state_str, "delete --all: non-terminal job; skipping");
297 skipped_count += 1;
298 out_of_scope_count += 1;
299 job_results.push(DeleteJobResult {
300 job_id,
301 state: state_str,
302 action: "skipped".to_string(),
303 reason: reason.to_string(),
304 });
305 continue;
306 }
307
308 if state_opt
309 .as_ref()
310 .and_then(|s| s.pid)
311 .is_some_and(pid_is_alive)
312 {
313 debug!(job_id = %job_id, state = %state_str, "delete --all: live pid for terminal state; skipping");
314 skipped_count += 1;
315 out_of_scope_count += 1;
316 job_results.push(DeleteJobResult {
317 job_id,
318 state: state_str,
319 action: "skipped".to_string(),
320 reason: "pid_alive".to_string(),
321 });
322 continue;
323 }
324
325 let action = if dry_run {
327 debug!(job_id = %job_id, "delete --all: dry-run would delete");
328 "would_delete"
329 } else {
330 match std::fs::remove_dir_all(&path) {
331 Ok(()) => {
332 if path.exists() {
336 debug!(
337 job_id = %job_id,
338 "delete --all: post-delete check found path still present; reporting failure"
339 );
340 skipped_count += 1;
341 failed_count += 1;
342 job_results.push(DeleteJobResult {
343 job_id,
344 state: state_str,
345 action: "skipped".to_string(),
346 reason: "post_delete_check_failed".to_string(),
347 });
348 continue;
349 }
350 debug!(job_id = %job_id, "delete --all: deleted");
351 deleted_count += 1;
352 "deleted"
353 }
354 Err(e) => {
355 debug!(job_id = %job_id, error = %e, "delete --all: failed to delete; skipping");
356 skipped_count += 1;
357 failed_count += 1;
358 job_results.push(DeleteJobResult {
359 job_id,
360 state: state_str,
361 action: "skipped".to_string(),
362 reason: format!("delete_failed: {e}"),
363 });
364 continue;
365 }
366 }
367 };
368
369 job_results.push(DeleteJobResult {
370 job_id,
371 state: state_str,
372 action: action.to_string(),
373 reason: "terminal_in_cwd".to_string(),
374 });
375 }
376
377 debug!(
378 deleted = deleted_count,
379 skipped = skipped_count,
380 out_of_scope = out_of_scope_count,
381 failed = failed_count,
382 "delete --all: complete"
383 );
384
385 Response::new(
386 "delete",
387 DeleteData {
388 root: root_str.to_string(),
389 dry_run,
390 cwd_scope: Some(current_cwd),
391 deleted: deleted_count,
392 skipped: skipped_count,
393 out_of_scope: out_of_scope_count,
394 failed: failed_count,
395 jobs: job_results,
396 },
397 )
398 .print();
399
400 Ok(())
401}