Skip to main content

agent_exec/
list.rs

1//! Implementation of the `list` sub-command.
2//!
3//! Enumerates job directories under root, reads meta.json and state.json
4//! for each, and emits a JSON array sorted by started_at descending.
5//! Directories that cannot be parsed as jobs are silently counted in `skipped`.
6//!
7//! ## CWD filtering (filter-list-by-cwd)
8//!
9//! By default, `list` only returns jobs whose `meta.json.cwd` matches the
10//! caller's current working directory.  Two flags override this behaviour:
11//!
12//! - `--cwd <PATH>`: show only jobs created from `<PATH>` (overrides auto-detect).
13//! - `--all`: disable cwd filtering entirely and show all jobs.
14//!
15//! Jobs that were created before this feature (i.e. `meta.json.cwd` is absent)
16//! are treated as having no cwd and will therefore not appear in the default
17//! filtered view.  Use `--all` to see them.
18//!
19//! ## Tag filtering (add-job-tags)
20//!
21//! `--tag <PATTERN>` filters jobs to those whose persisted `meta.json.tags`
22//! satisfy the pattern.  Repeated `--tag` flags apply logical AND.
23//! Two pattern forms are supported:
24//!   - Exact: `aaa`, `hoge.fuga.geho`
25//!   - Namespace prefix: `hoge.*`, `hoge.fuga.*`
26//!
27//! Tag filtering composes with cwd and state filtering.
28
29use 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/// Options for the `list` sub-command.
100#[derive(Debug)]
101pub struct ListOpts<'a> {
102    pub root: Option<&'a str>,
103    /// Maximum number of jobs to return; 0 = no limit.
104    pub limit: u64,
105    /// Optional state filter: running|exited|killed|failed|unknown.
106    pub state: Option<&'a str>,
107    /// Optional cwd filter: show only jobs created from this directory.
108    /// Conflicts with `all`.
109    pub cwd: Option<&'a str>,
110    /// When true, disable cwd filtering and show all jobs.
111    /// Conflicts with `cwd`.
112    pub all: bool,
113    /// Tag filter patterns (AND semantics); empty means no tag filtering.
114    pub tags: Vec<String>,
115}
116
117/// Execute `list`: enumerate jobs and emit JSON.
118pub fn execute(opts: ListOpts) -> Result<()> {
119    let root = resolve_root(opts.root);
120    let root_str = root.display().to_string();
121
122    // Validate all tag filter patterns upfront before doing any I/O.
123    for pattern in &opts.tags {
124        validate_filter_pattern(pattern).map_err(anyhow::Error::from)?;
125    }
126
127    // Determine the cwd filter to apply.
128    // Priority: --all (no filter) > --cwd <PATH> > current_dir (default).
129    let cwd_filter: Option<String> = if opts.all {
130        // --all: show every job regardless of cwd.
131        None
132    } else if let Some(cwd_arg) = opts.cwd {
133        // --cwd <PATH>: canonicalize and use as filter.
134        Some(resolve_effective_cwd(Some(cwd_arg)))
135    } else {
136        // Default: filter by current process working directory.
137        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 does not exist, return an empty list (normal termination).
147    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    // Read directory entries.
163    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            // Skip non-directory entries (e.g. stray files in root).
182            continue;
183        }
184
185        // meta.json must exist and be parseable to consider this a job.
186        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        // Apply cwd filter: if a filter is active, skip jobs whose cwd doesn't match.
205        if let Some(ref filter_cwd) = cwd_filter {
206            match meta.cwd.as_deref() {
207                Some(job_cwd) if job_cwd == filter_cwd => {
208                    // Match: include this job.
209                }
210                _ => {
211                    // No cwd in meta (old job) or different cwd: exclude.
212                    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        // Apply tag filters: all patterns must match (logical AND).
224        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        // state.json is optional: read if available, continue without it if not.
235        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    // Apply state filter before sorting and limiting.
272    if let Some(filter_state) = opts.state {
273        jobs.retain(|j| j.state == filter_state);
274    }
275
276    // Sort by started_at descending; tie-break by job_id descending.
277    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    // Apply limit.
284    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}