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::{JobSummary, ListData, Response};
36use crate::tag::{matches_all_patterns, validate_filter_pattern};
37
38/// Options for the `list` sub-command.
39#[derive(Debug)]
40pub struct ListOpts<'a> {
41    pub root: Option<&'a str>,
42    /// Maximum number of jobs to return; 0 = no limit.
43    pub limit: u64,
44    /// Optional state filter: running|exited|killed|failed|unknown.
45    pub state: Option<&'a str>,
46    /// Optional cwd filter: show only jobs created from this directory.
47    /// Conflicts with `all`.
48    pub cwd: Option<&'a str>,
49    /// When true, disable cwd filtering and show all jobs.
50    /// Conflicts with `cwd`.
51    pub all: bool,
52    /// Tag filter patterns (AND semantics); empty means no tag filtering.
53    pub tags: Vec<String>,
54}
55
56/// Execute `list`: enumerate jobs and emit JSON.
57pub fn execute(opts: ListOpts) -> Result<()> {
58    let root = resolve_root(opts.root);
59    let root_str = root.display().to_string();
60
61    // Validate all tag filter patterns upfront before doing any I/O.
62    for pattern in &opts.tags {
63        validate_filter_pattern(pattern).map_err(anyhow::Error::from)?;
64    }
65
66    // Determine the cwd filter to apply.
67    // Priority: --all (no filter) > --cwd <PATH> > current_dir (default).
68    let cwd_filter: Option<String> = if opts.all {
69        // --all: show every job regardless of cwd.
70        None
71    } else if let Some(cwd_arg) = opts.cwd {
72        // --cwd <PATH>: canonicalize and use as filter.
73        Some(resolve_effective_cwd(Some(cwd_arg)))
74    } else {
75        // Default: filter by current process working directory.
76        Some(resolve_effective_cwd(None))
77    };
78
79    debug!(
80        cwd_filter = ?cwd_filter,
81        all = opts.all,
82        "list: cwd filter determined"
83    );
84
85    // If root does not exist, return an empty list (normal termination).
86    if !root.exists() {
87        debug!(root = %root_str, "root does not exist; returning empty list");
88        let response = Response::new(
89            "list",
90            ListData {
91                root: root_str,
92                jobs: vec![],
93                truncated: false,
94                skipped: 0,
95            },
96        );
97        response.print();
98        return Ok(());
99    }
100
101    // Read directory entries.
102    let read_dir = std::fs::read_dir(&root)
103        .map_err(|e| anyhow::anyhow!("failed to read root directory {}: {}", root_str, e))?;
104
105    let mut jobs: Vec<JobSummary> = Vec::new();
106    let mut skipped: u64 = 0;
107
108    for entry in read_dir {
109        let entry = match entry {
110            Ok(e) => e,
111            Err(e) => {
112                debug!(error = %e, "failed to read directory entry; skipping");
113                skipped += 1;
114                continue;
115            }
116        };
117
118        let path = entry.path();
119        if !path.is_dir() {
120            // Skip non-directory entries (e.g. stray files in root).
121            continue;
122        }
123
124        // meta.json must exist and be parseable to consider this a job.
125        let meta_path = path.join("meta.json");
126        let meta_bytes = match std::fs::read(&meta_path) {
127            Ok(b) => b,
128            Err(_) => {
129                debug!(path = %path.display(), "meta.json missing or unreadable; skipping");
130                skipped += 1;
131                continue;
132            }
133        };
134        let meta: crate::schema::JobMeta = match serde_json::from_slice(&meta_bytes) {
135            Ok(m) => m,
136            Err(e) => {
137                debug!(path = %path.display(), error = %e, "meta.json parse error; skipping");
138                skipped += 1;
139                continue;
140            }
141        };
142
143        // Apply cwd filter: if a filter is active, skip jobs whose cwd doesn't match.
144        if let Some(ref filter_cwd) = cwd_filter {
145            match meta.cwd.as_deref() {
146                Some(job_cwd) if job_cwd == filter_cwd => {
147                    // Match: include this job.
148                }
149                _ => {
150                    // No cwd in meta (old job) or different cwd: exclude.
151                    debug!(
152                        path = %path.display(),
153                        job_cwd = ?meta.cwd,
154                        filter_cwd = %filter_cwd,
155                        "list: skipping job (cwd mismatch)"
156                    );
157                    continue;
158                }
159            }
160        }
161
162        // Apply tag filters: all patterns must match (logical AND).
163        if !opts.tags.is_empty() && !matches_all_patterns(&meta.tags, &opts.tags) {
164            debug!(
165                path = %path.display(),
166                job_tags = ?meta.tags,
167                patterns = ?opts.tags,
168                "list: skipping job (tag mismatch)"
169            );
170            continue;
171        }
172
173        // state.json is optional: read if available, continue without it if not.
174        let state_opt: Option<crate::schema::JobState> = {
175            let state_path = path.join("state.json");
176            match std::fs::read(&state_path) {
177                Ok(b) => serde_json::from_slice(&b).ok(),
178                Err(_) => None,
179            }
180        };
181
182        let (state_str, exit_code, finished_at, updated_at) = if let Some(ref s) = state_opt {
183            (
184                s.status().as_str().to_string(),
185                s.exit_code(),
186                s.finished_at.clone(),
187                Some(s.updated_at.clone()),
188            )
189        } else {
190            ("unknown".to_string(), None, None, None)
191        };
192
193        let job_started_at = state_opt
194            .as_ref()
195            .and_then(|s| s.started_at().map(|t| t.to_string()));
196        jobs.push(JobSummary {
197            job_id: meta.job.id.clone(),
198            short_job_id: short_job_id(&meta.job.id),
199            state: state_str,
200            exit_code,
201            created_at: meta.created_at.clone(),
202            started_at: job_started_at,
203            finished_at,
204            updated_at,
205            tags: meta.tags.clone(),
206        });
207    }
208
209    // Apply state filter before sorting and limiting.
210    if let Some(filter_state) = opts.state {
211        jobs.retain(|j| j.state == filter_state);
212    }
213
214    // Sort by started_at descending; tie-break by job_id descending.
215    jobs.sort_by(|a, b| {
216        b.started_at
217            .cmp(&a.started_at)
218            .then_with(|| b.job_id.cmp(&a.job_id))
219    });
220
221    // Apply limit.
222    let truncated = opts.limit > 0 && jobs.len() as u64 > opts.limit;
223    if truncated {
224        jobs.truncate(opts.limit as usize);
225    }
226
227    debug!(
228        root = %root_str,
229        count = jobs.len(),
230        skipped,
231        truncated,
232        "list complete"
233    );
234
235    let response = Response::new(
236        "list",
237        ListData {
238            root: root_str,
239            jobs,
240            truncated,
241            skipped,
242        },
243    );
244    response.print();
245    Ok(())
246}