1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
//! `run list` — walk `<root>/runs/` and emit a manifest summary per run.
use serde::Serialize;
use octl_core::{read_manifest_opt, RunLock, RunPaths};
use crate::error::CliError;
use crate::output::{self, OutputFormat, OutputSpec};
use crate::run::dto::{RunSummary, SupervisorView};
use crate::run::{from_core, runs_root};
pub struct Args<'a> {
pub status: Option<String>,
pub kind: Option<String>,
pub spec: &'a OutputSpec,
pub warnings: &'a [String],
}
#[derive(Serialize)]
struct ListPayload {
runs: Vec<RunSummary>,
}
pub fn run(args: Args<'_>) -> Result<(), CliError> {
let root = crate::home::root_dir()?;
let runs_dir = runs_root(&root);
// Strict-input rule from AGENTS-AI-FIRST-CLI §1: only validate that
// the filter values are well-formed strings. We don't reject unknown
// kinds/statuses up front because a filter that matches nothing is a
// legitimate empty result — different from a malformed value.
if let Some(s) = &args.status {
if s.trim().is_empty() {
return Err(CliError::user(
"invalid_value",
"--status must not be empty",
));
}
}
if let Some(k) = &args.kind {
if k.trim().is_empty() {
return Err(CliError::user("invalid_value", "--kind must not be empty"));
}
}
let mut out: Vec<RunSummary> = Vec::new();
let entries = match std::fs::read_dir(&runs_dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return emit(out, args.spec, args.warnings);
}
Err(e) => {
return Err(CliError::system(
"io_error",
format!("read_dir {}: {}", runs_dir.display(), e),
));
}
};
for ent in entries {
let ent = ent.map_err(|e| CliError::system("io_error", e.to_string()))?;
if !ent.file_type().is_ok_and(|t| t.is_dir()) {
continue;
}
// The directory name must be a valid run id; foreign dirs are skipped.
let Some(run_id) = ent.file_name().to_str().map(str::to_string) else {
continue;
};
let Ok(paths) = RunPaths::new(ent.path(), run_id) else {
continue;
};
// Each run carries its own `.lock`; take that run's shared lock for its
// manifest read so the summary never reflects a manifest a reducer is
// mid-rewrite on (design.md §4). A run with no `.lock` yet reads
// lock-free (see `RunLock::acquire_shared`).
// Read the manifest AND probe supervisor liveness under the SAME shared
// lock so `status` and `supervisor` form one consistent snapshot — a
// caller reasons "status pending + supervisor dead => orphaned", so the
// pair must not straddle a reducer's status rollup + pid-file removal
// (see show.rs). Costs one extra pid-file read per run; negligible for
// realistic run counts.
let scanned = RunLock::with_shared_lock(&paths.lock(), || {
Ok(read_manifest_opt(&paths)?.map(|m| {
let supervisor = SupervisorView::probe(&paths);
(m, supervisor)
}))
})
.map_err(from_core)?;
let (m, supervisor) = match scanned {
Some(v) => v,
None => continue, // half-initialized run dir; skip silently
};
// Shape the manifest into its wire DTO once, then filter on the
// canonical kebab strings it carries — the DTO's `From` renders
// `kind` / `status` through the `run/mod.rs` helpers rather than
// round-tripping the enums through `serde_json::to_value`.
let summary = RunSummary::from(&m).with_supervisor(supervisor);
if let Some(filter) = &args.status {
if &summary.status != filter {
continue;
}
}
if let Some(filter) = &args.kind {
if &summary.kind != filter {
continue;
}
}
out.push(summary);
}
out.sort_by_key(|r| std::cmp::Reverse(r.created_at));
emit(out, args.spec, args.warnings)
}
fn emit(runs: Vec<RunSummary>, spec: &OutputSpec, warnings: &[String]) -> Result<(), CliError> {
match spec.format {
OutputFormat::Json | OutputFormat::Jsonl => {
output::emit_envelope(&ListPayload { runs }, spec, warnings)?;
}
OutputFormat::Text => {
if runs.is_empty() {
println!("(no runs)");
}
for r in &runs {
let sup = match r.supervisor.pid {
Some(pid) if r.supervisor.alive => format!("sup:alive({pid})"),
Some(pid) => format!("sup:dead({pid})"),
None => "sup:none".to_string(),
};
println!(
"{}\t{}\t{}\t{}\t{}\t{}",
r.run_id,
r.kind,
r.status,
r.node_count,
sup,
output::escape_one_line(&r.title)
);
}
output::emit_text_warnings(warnings);
}
}
Ok(())
}