1use anyhow::Result;
30use tracing::debug;
31
32use crate::jobstore::resolve_root;
33use crate::jobstore::short_job_id;
34use crate::run::resolve_effective_cwd;
35use crate::schema::{JobStatus, JobSummary, ListData, Response};
36use crate::tag::{matches_all_patterns, validate_filter_pattern};
37
38#[cfg(unix)]
39fn pid_is_alive(pid: u32) -> bool {
40 let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
41 if ret == 0 {
42 return true;
43 }
44
45 let err = std::io::Error::last_os_error();
46 matches!(err.raw_os_error(), Some(libc::EPERM))
47}
48
49#[cfg(windows)]
50fn pid_is_alive(pid: u32) -> bool {
51 use windows::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
52 use windows::Win32::System::Threading::{
53 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
54 };
55
56 let handle = match unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } {
57 Ok(handle) => handle,
58 Err(_) => return false,
59 };
60
61 let mut exit_code = 0u32;
62 let ok = unsafe { GetExitCodeProcess(handle, &mut exit_code) }.is_ok();
63 unsafe {
64 let _ = CloseHandle(handle);
65 }
66 ok && exit_code == STILL_ACTIVE.0
67}
68
69#[cfg(not(any(unix, windows)))]
70fn pid_is_alive(_pid: u32) -> bool {
71 true
72}
73
74fn effective_state(state: &crate::schema::JobState) -> String {
75 if *state.status() != JobStatus::Running {
76 return state.status().as_str().to_string();
77 }
78
79 match state.pid {
80 Some(pid) if pid_is_alive(pid) => "running".to_string(),
81 Some(pid) => {
82 debug!(
83 job_id = %state.job_id(),
84 pid,
85 "list: persisted running job has dead pid; presenting as unknown"
86 );
87 "unknown".to_string()
88 }
89 None => {
90 debug!(
91 job_id = %state.job_id(),
92 "list: persisted running job has no pid; presenting as unknown"
93 );
94 "unknown".to_string()
95 }
96 }
97}
98
99#[derive(Debug)]
101pub struct ListOpts<'a> {
102 pub root: Option<&'a str>,
103 pub limit: u64,
105 pub state: Option<&'a str>,
107 pub cwd: Option<&'a str>,
110 pub all: bool,
113 pub tags: Vec<String>,
115}
116
117pub fn execute(opts: ListOpts) -> Result<()> {
119 let root = resolve_root(opts.root);
120 let root_str = root.display().to_string();
121
122 for pattern in &opts.tags {
124 validate_filter_pattern(pattern).map_err(anyhow::Error::from)?;
125 }
126
127 let cwd_filter: Option<String> = if opts.all {
130 None
132 } else if let Some(cwd_arg) = opts.cwd {
133 Some(resolve_effective_cwd(Some(cwd_arg)))
135 } else {
136 Some(resolve_effective_cwd(None))
138 };
139
140 debug!(
141 cwd_filter = ?cwd_filter,
142 all = opts.all,
143 "list: cwd filter determined"
144 );
145
146 if !root.exists() {
148 debug!(root = %root_str, "root does not exist; returning empty list");
149 let response = Response::new(
150 "list",
151 ListData {
152 root: root_str,
153 jobs: vec![],
154 truncated: false,
155 skipped: 0,
156 },
157 );
158 response.print();
159 return Ok(());
160 }
161
162 let read_dir = std::fs::read_dir(&root)
164 .map_err(|e| anyhow::anyhow!("failed to read root directory {}: {}", root_str, e))?;
165
166 let mut jobs: Vec<JobSummary> = Vec::new();
167 let mut skipped: u64 = 0;
168
169 for entry in read_dir {
170 let entry = match entry {
171 Ok(e) => e,
172 Err(e) => {
173 debug!(error = %e, "failed to read directory entry; skipping");
174 skipped += 1;
175 continue;
176 }
177 };
178
179 let path = entry.path();
180 if !path.is_dir() {
181 continue;
183 }
184
185 let meta_path = path.join("meta.json");
187 let meta_bytes = match std::fs::read(&meta_path) {
188 Ok(b) => b,
189 Err(_) => {
190 debug!(path = %path.display(), "meta.json missing or unreadable; skipping");
191 skipped += 1;
192 continue;
193 }
194 };
195 let meta: crate::schema::JobMeta = match serde_json::from_slice(&meta_bytes) {
196 Ok(m) => m,
197 Err(e) => {
198 debug!(path = %path.display(), error = %e, "meta.json parse error; skipping");
199 skipped += 1;
200 continue;
201 }
202 };
203
204 if let Some(ref filter_cwd) = cwd_filter {
206 match meta.cwd.as_deref() {
207 Some(job_cwd) if job_cwd == filter_cwd => {
208 }
210 _ => {
211 debug!(
213 path = %path.display(),
214 job_cwd = ?meta.cwd,
215 filter_cwd = %filter_cwd,
216 "list: skipping job (cwd mismatch)"
217 );
218 continue;
219 }
220 }
221 }
222
223 if !opts.tags.is_empty() && !matches_all_patterns(&meta.tags, &opts.tags) {
225 debug!(
226 path = %path.display(),
227 job_tags = ?meta.tags,
228 patterns = ?opts.tags,
229 "list: skipping job (tag mismatch)"
230 );
231 continue;
232 }
233
234 let state_opt: Option<crate::schema::JobState> = {
236 let state_path = path.join("state.json");
237 match std::fs::read(&state_path) {
238 Ok(b) => serde_json::from_slice(&b).ok(),
239 Err(_) => None,
240 }
241 };
242
243 let (state_str, exit_code, finished_at, updated_at) = if let Some(ref s) = state_opt {
244 (
245 effective_state(s),
246 s.exit_code(),
247 s.finished_at.clone(),
248 Some(s.updated_at.clone()),
249 )
250 } else {
251 ("unknown".to_string(), None, None, None)
252 };
253
254 let job_started_at = state_opt
255 .as_ref()
256 .and_then(|s| s.started_at().map(|t| t.to_string()));
257 jobs.push(JobSummary {
258 job_id: meta.job.id.clone(),
259 short_job_id: short_job_id(&meta.job.id),
260 state: state_str,
261 command: meta.command.clone(),
262 exit_code,
263 created_at: meta.created_at.clone(),
264 started_at: job_started_at,
265 finished_at,
266 updated_at,
267 tags: meta.tags.clone(),
268 });
269 }
270
271 if let Some(filter_state) = opts.state {
273 jobs.retain(|j| j.state == filter_state);
274 }
275
276 jobs.sort_by(|a, b| {
278 b.started_at
279 .cmp(&a.started_at)
280 .then_with(|| b.job_id.cmp(&a.job_id))
281 });
282
283 let truncated = opts.limit > 0 && jobs.len() as u64 > opts.limit;
285 if truncated {
286 jobs.truncate(opts.limit as usize);
287 }
288
289 debug!(
290 root = %root_str,
291 count = jobs.len(),
292 skipped,
293 truncated,
294 "list complete"
295 );
296
297 let response = Response::new(
298 "list",
299 ListData {
300 root: root_str,
301 jobs,
302 truncated,
303 skipped,
304 },
305 );
306 response.print();
307 Ok(())
308}