use anyhow::Result;
use tracing::debug;
use crate::jobstore::resolve_root;
use crate::jobstore::short_job_id;
use crate::run::resolve_effective_cwd;
use crate::schema::{JobStatus, JobSummary, ListData, Response};
use crate::tag::{matches_all_patterns, validate_filter_pattern};
#[cfg(unix)]
fn pid_is_alive(pid: u32) -> bool {
let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
if ret == 0 {
return true;
}
let err = std::io::Error::last_os_error();
matches!(err.raw_os_error(), Some(libc::EPERM))
}
#[cfg(windows)]
fn pid_is_alive(pid: u32) -> bool {
use windows::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
use windows::Win32::System::Threading::{
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
let handle = match unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } {
Ok(handle) => handle,
Err(_) => return false,
};
let mut exit_code = 0u32;
let ok = unsafe { GetExitCodeProcess(handle, &mut exit_code) }.is_ok();
unsafe {
let _ = CloseHandle(handle);
}
ok && exit_code == STILL_ACTIVE.0
}
#[cfg(not(any(unix, windows)))]
fn pid_is_alive(_pid: u32) -> bool {
true
}
fn effective_state(state: &crate::schema::JobState) -> String {
if *state.status() != JobStatus::Running {
return state.status().as_str().to_string();
}
match state.pid {
Some(pid) if pid_is_alive(pid) => "running".to_string(),
Some(pid) => {
debug!(
job_id = %state.job_id(),
pid,
"list: persisted running job has dead pid; presenting as unknown"
);
"unknown".to_string()
}
None => {
debug!(
job_id = %state.job_id(),
"list: persisted running job has no pid; presenting as unknown"
);
"unknown".to_string()
}
}
}
#[derive(Debug)]
pub struct ListOpts<'a> {
pub root: Option<&'a str>,
pub limit: u64,
pub state: Option<&'a str>,
pub cwd: Option<&'a str>,
pub all: bool,
pub tags: Vec<String>,
}
pub fn execute(opts: ListOpts) -> Result<()> {
let root = resolve_root(opts.root);
let root_str = root.display().to_string();
for pattern in &opts.tags {
validate_filter_pattern(pattern).map_err(anyhow::Error::from)?;
}
let cwd_filter: Option<String> = if opts.all {
None
} else if let Some(cwd_arg) = opts.cwd {
Some(resolve_effective_cwd(Some(cwd_arg)))
} else {
Some(resolve_effective_cwd(None))
};
debug!(
cwd_filter = ?cwd_filter,
all = opts.all,
"list: cwd filter determined"
);
if !root.exists() {
debug!(root = %root_str, "root does not exist; returning empty list");
let response = Response::new(
"list",
ListData {
root: root_str,
jobs: vec![],
truncated: false,
skipped: 0,
},
);
response.print();
return Ok(());
}
let read_dir = std::fs::read_dir(&root)
.map_err(|e| anyhow::anyhow!("failed to read root directory {}: {}", root_str, e))?;
let mut jobs: Vec<JobSummary> = Vec::new();
let mut skipped: u64 = 0;
for entry in read_dir {
let entry = match entry {
Ok(e) => e,
Err(e) => {
debug!(error = %e, "failed to read directory entry; skipping");
skipped += 1;
continue;
}
};
let path = entry.path();
if !path.is_dir() {
continue;
}
let meta_path = path.join("meta.json");
let meta_bytes = match std::fs::read(&meta_path) {
Ok(b) => b,
Err(_) => {
debug!(path = %path.display(), "meta.json missing or unreadable; skipping");
skipped += 1;
continue;
}
};
let meta: crate::schema::JobMeta = match serde_json::from_slice(&meta_bytes) {
Ok(m) => m,
Err(e) => {
debug!(path = %path.display(), error = %e, "meta.json parse error; skipping");
skipped += 1;
continue;
}
};
if let Some(ref filter_cwd) = cwd_filter {
match meta.cwd.as_deref() {
Some(job_cwd) if job_cwd == filter_cwd => {
}
_ => {
debug!(
path = %path.display(),
job_cwd = ?meta.cwd,
filter_cwd = %filter_cwd,
"list: skipping job (cwd mismatch)"
);
continue;
}
}
}
if !opts.tags.is_empty() && !matches_all_patterns(&meta.tags, &opts.tags) {
debug!(
path = %path.display(),
job_tags = ?meta.tags,
patterns = ?opts.tags,
"list: skipping job (tag mismatch)"
);
continue;
}
let state_opt: Option<crate::schema::JobState> = {
let state_path = path.join("state.json");
match std::fs::read(&state_path) {
Ok(b) => serde_json::from_slice(&b).ok(),
Err(_) => None,
}
};
let (state_str, exit_code, finished_at, updated_at) = if let Some(ref s) = state_opt {
(
effective_state(s),
s.exit_code(),
s.finished_at.clone(),
Some(s.updated_at.clone()),
)
} else {
("unknown".to_string(), None, None, None)
};
let job_started_at = state_opt
.as_ref()
.and_then(|s| s.started_at().map(|t| t.to_string()));
jobs.push(JobSummary {
job_id: meta.job.id.clone(),
short_job_id: short_job_id(&meta.job.id),
state: state_str,
command: meta.command.clone(),
exit_code,
created_at: meta.created_at.clone(),
started_at: job_started_at,
finished_at,
updated_at,
tags: meta.tags.clone(),
});
}
if let Some(filter_state) = opts.state {
jobs.retain(|j| j.state == filter_state);
}
jobs.sort_by(|a, b| {
b.started_at
.cmp(&a.started_at)
.then_with(|| b.job_id.cmp(&a.job_id))
});
let truncated = opts.limit > 0 && jobs.len() as u64 > opts.limit;
if truncated {
jobs.truncate(opts.limit as usize);
}
debug!(
root = %root_str,
count = jobs.len(),
skipped,
truncated,
"list complete"
);
let response = Response::new(
"list",
ListData {
root: root_str,
jobs,
truncated,
skipped,
},
);
response.print();
Ok(())
}