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