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}