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 = 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
153fn 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.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 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 match meta.as_ref().and_then(|m| m.cwd.as_deref()) {
221 Some(job_cwd) if job_cwd == current_cwd => {
222 }
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 continue;
233 }
234 }
235
236 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 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 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}