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