Make fabro ps behave like docker ps

Default to showing only running processes (use -a for all), remove row
limit, display oldest-first, truncate run IDs to 12 chars, add DIRECTORY
column from host_repo_path, and drop STARTED/COST/LABELS columns.

- Add host_repo_path to Manifest and populate from RunConfig
- Add StatusFilter enum (RunningOnly/All) to filter_runs
- Replace --limit with -a/--all flag (docker-ps semantics)
- Add host_repo_path to RunInfo, extract in scan_runs
- New column layout: RUN ID | WORKFLOW | STATUS | DIRECTORY | DURATION

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-15 15:28:41 -04:00
parent 38cfb4286d
commit ceca61e952
No known key found for this signature in database
4 changed files with 152 additions and 67 deletions

View file

@ -93,6 +93,7 @@ mod tests {
labels: Default::default(),
base_branch: None,
workflow_slug: None,
host_repo_path: None,
};
manifest.save(&run_dir.join("manifest.json")).unwrap();
@ -167,6 +168,7 @@ mod tests {
labels: Default::default(),
base_branch: None,
workflow_slug: None,
host_repo_path: None,
};
manifest.save(&run_dir.join("manifest.json")).unwrap();

View file

@ -72,12 +72,8 @@ pub struct RunsListArgs {
#[arg(long)]
pub json: bool,
/// Maximum number of runs to display (default: 10)
#[arg(long, default_value = "10")]
pub limit: usize,
/// Show all runs (overrides --limit)
#[arg(long)]
/// Show all runs, not just running (like docker ps -a)
#[arg(short = 'a', long)]
pub all: bool,
}
@ -109,6 +105,8 @@ pub struct RunInfo {
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_repo_path: Option<String>,
#[serde(skip)]
pub start_time_dt: Option<DateTime<Utc>>,
#[serde(skip)]
@ -146,6 +144,7 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
let run_id = manifest.run_id;
let workflow_name = manifest.workflow_name;
let workflow_slug = manifest.workflow_slug;
let host_repo_path = manifest.host_repo_path;
let start_time_dt = manifest.start_time;
let start_time = start_time_dt.to_rfc3339();
let labels = manifest.labels;
@ -162,6 +161,7 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
labels,
duration_ms: si.duration_ms,
total_cost: si.total_cost,
host_repo_path,
start_time_dt: Some(start_time_dt),
end_time: si.end_time,
path,
@ -190,6 +190,7 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
labels: HashMap::new(),
duration_ms: None,
total_cost: None,
host_repo_path: None,
start_time_dt: mtime_dt,
end_time: None,
path,
@ -235,6 +236,15 @@ fn read_status(run_dir: &Path) -> StatusInfo {
}
}
/// Which run statuses to include in filtered results.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusFilter {
/// Only include runs that are currently running.
RunningOnly,
/// Include runs of any status.
All,
}
/// Filter runs by criteria. Orphans are excluded unless `include_orphans` is true.
pub fn filter_runs(
runs: &[RunInfo],
@ -242,9 +252,13 @@ pub fn filter_runs(
workflow: Option<&str>,
labels: &[(String, String)],
include_orphans: bool,
status_filter: StatusFilter,
) -> Vec<RunInfo> {
runs.iter()
.filter(|r| {
if status_filter == StatusFilter::RunningOnly && !r.status.is_running() {
return false;
}
if r.is_orphan && !include_orphans {
return false;
}
@ -385,26 +399,6 @@ fn collapse_separators(s: &str) -> String {
s.chars().filter(|c| *c != '-' && *c != '_').collect()
}
/// Format a `DateTime<Utc>` as a human-friendly relative string like "2m ago", "3h ago", "5d ago".
fn format_relative_time(dt: &DateTime<Utc>) -> String {
let now = Utc::now();
let dur = now.signed_duration_since(*dt);
let secs = dur.num_seconds();
if secs < 60 {
return "just now".to_string();
}
let mins = dur.num_minutes();
if mins < 60 {
return format!("{mins}m ago");
}
let hours = dur.num_hours();
if hours < 24 {
return format!("{hours}h ago");
}
let days = dur.num_days();
format!("{days}d ago")
}
fn style_status(status: &RunStatus, styles: &Styles) -> String {
let text = status.to_string();
match status {
@ -429,6 +423,11 @@ pub fn list_command(args: &RunsListArgs, styles: &Styles) -> Result<()> {
args.filter.workflow.as_deref(),
&label_filters,
args.filter.orphans,
if args.all {
StatusFilter::All
} else {
StatusFilter::RunningOnly
},
);
if args.json {
@ -437,68 +436,54 @@ pub fn list_command(args: &RunsListArgs, styles: &Styles) -> Result<()> {
}
if filtered.is_empty() {
eprintln!("No runs found.");
if args.all {
eprintln!("No runs found.");
} else {
eprintln!("No running processes found. Use -a to show all runs.");
}
return Ok(());
}
let total = filtered.len();
let limit = if args.all { total } else { args.limit };
let display_runs: Vec<_> = filtered.iter().take(limit).collect();
// Reverse to oldest-first for display (scan_runs returns newest-first)
let mut display_runs = filtered;
display_runs.reverse();
// Print table header
let header = format!(
"{:<30} {:<25} {:<17} {:<10} {:<10} {:<10} LABELS",
"RUN ID", "WORKFLOW", "STATUS", "STARTED", "DURATION", "COST"
"{:<17} {:<25} {:<17} {:<20} {:<10}",
"RUN ID", "WORKFLOW", "STATUS", "DIRECTORY", "DURATION"
);
println!("{}", styles.bold.apply_to(&header));
println!("{}", styles.dim.apply_to("-".repeat(header.len())));
for run in &display_runs {
let labels_str = run
.labels
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(", ");
let run_id_display = if run.run_id.len() > 28 {
format!("{}...", &run.run_id[..25])
let run_id_display = if run.run_id.len() > 12 {
run.run_id[..12].to_string()
} else {
run.run_id.clone()
};
let start_display = run
.start_time_dt
.as_ref()
.map(format_relative_time)
.unwrap_or_else(|| "-".to_string());
let duration_display = run
.duration_ms
.map(super::progress::format_duration_ms)
.unwrap_or_else(|| "-".to_string());
let cost_display = run
.total_cost
.map(super::format_cost)
let dir_display = run
.host_repo_path
.as_deref()
.and_then(|p| Path::new(p).file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "-".to_string());
let status_display = style_status(&run.status, styles);
println!(
"{:<30} {:<25} {:<17} {:<10} {:<10} {:<10} {}",
"{:<17} {:<25} {:<17} {:<20} {:<10}",
styles.dim.apply_to(&run_id_display),
run.workflow_name,
status_display,
start_display,
dir_display,
duration_display,
cost_display,
styles.dim.apply_to(&labels_str),
);
}
if total > limit {
eprintln!(
"\nShowing {} of {} run(s). Use --all to see all.",
limit, total
);
} else {
eprintln!("\n{} run(s) listed.", total);
}
eprintln!("\n{} run(s) listed.", display_runs.len());
Ok(())
}
@ -666,8 +651,8 @@ pub fn df_from(args: &DfArgs, data_dir: &Path, runs_base: &Path, logs_base: &Pat
let now = chrono::Utc::now();
for detail in &run_details {
let run_id_display = if detail.run_id.len() > 28 {
format!("{}...", &detail.run_id[..25])
let run_id_display = if detail.run_id.len() > 12 {
detail.run_id[..12].to_string()
} else {
detail.run_id.clone()
};
@ -741,6 +726,7 @@ pub fn prune_from(args: &RunsPruneArgs, base: &Path) -> Result<()> {
args.filter.workflow.as_deref(),
&label_filters,
args.filter.orphans,
StatusFilter::All,
);
// Determine if the user passed any explicit filters
@ -933,6 +919,7 @@ mod tests {
labels: HashMap::new(),
duration_ms: None,
total_cost: None,
host_repo_path: None,
start_time_dt: None,
end_time: None,
path: PathBuf::from("/tmp/d1"),
@ -948,13 +935,21 @@ mod tests {
labels: HashMap::new(),
duration_ms: None,
total_cost: None,
host_repo_path: None,
start_time_dt: None,
end_time: None,
path: PathBuf::from("/tmp/d2"),
is_orphan: false,
},
];
let filtered = filter_runs(&runs, Some("2026-01-01"), None, &[], false);
let filtered = filter_runs(
&runs,
Some("2026-01-01"),
None,
&[],
false,
StatusFilter::All,
);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].run_id, "old");
}
@ -972,6 +967,7 @@ mod tests {
labels: HashMap::new(),
duration_ms: None,
total_cost: None,
host_repo_path: None,
start_time_dt: None,
end_time: None,
path: PathBuf::from("/tmp/d1"),
@ -987,13 +983,14 @@ mod tests {
labels: HashMap::new(),
duration_ms: None,
total_cost: None,
host_repo_path: None,
start_time_dt: None,
end_time: None,
path: PathBuf::from("/tmp/d2"),
is_orphan: false,
},
];
let filtered = filter_runs(&runs, None, Some("deploy"), &[], false);
let filtered = filter_runs(&runs, None, Some("deploy"), &[], false, StatusFilter::All);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].run_id, "a");
}
@ -1011,6 +1008,7 @@ mod tests {
labels: HashMap::from([("env".into(), "prod".into())]),
duration_ms: None,
total_cost: None,
host_repo_path: None,
start_time_dt: None,
end_time: None,
path: PathBuf::from("/tmp/d1"),
@ -1026,6 +1024,7 @@ mod tests {
labels: HashMap::from([("env".into(), "staging".into())]),
duration_ms: None,
total_cost: None,
host_repo_path: None,
start_time_dt: None,
end_time: None,
path: PathBuf::from("/tmp/d2"),
@ -1038,6 +1037,7 @@ mod tests {
None,
&[("env".to_string(), "prod".to_string())],
false,
StatusFilter::All,
);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].run_id, "a");
@ -1055,18 +1055,93 @@ mod tests {
labels: HashMap::new(),
duration_ms: None,
total_cost: None,
host_repo_path: None,
start_time_dt: None,
end_time: None,
path: PathBuf::from("/tmp/d1"),
is_orphan: true,
}];
let filtered = filter_runs(&runs, None, None, &[], false);
let filtered = filter_runs(&runs, None, None, &[], false, StatusFilter::All);
assert!(filtered.is_empty());
let filtered = filter_runs(&runs, None, None, &[], true);
let filtered = filter_runs(&runs, None, None, &[], true, StatusFilter::All);
assert_eq!(filtered.len(), 1);
}
#[test]
fn filter_runs_running_only() {
let runs = vec![
RunInfo {
run_id: "running-1".into(),
dir_name: "d1".into(),
workflow_name: "p".into(),
workflow_slug: None,
status: RunStatus::Running,
start_time: "2026-01-01T00:00:00Z".into(),
labels: HashMap::new(),
duration_ms: None,
total_cost: None,
host_repo_path: None,
start_time_dt: None,
end_time: None,
path: PathBuf::from("/tmp/d1"),
is_orphan: false,
},
RunInfo {
run_id: "done-1".into(),
dir_name: "d2".into(),
workflow_name: "p".into(),
workflow_slug: None,
status: RunStatus::Concluded(crate::outcome::StageStatus::Success),
start_time: "2026-01-01T00:00:00Z".into(),
labels: HashMap::new(),
duration_ms: None,
total_cost: None,
host_repo_path: None,
start_time_dt: None,
end_time: None,
path: PathBuf::from("/tmp/d2"),
is_orphan: false,
},
];
let filtered = filter_runs(&runs, None, None, &[], false, StatusFilter::RunningOnly);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].run_id, "running-1");
let filtered = filter_runs(&runs, None, None, &[], false, StatusFilter::All);
assert_eq!(filtered.len(), 2);
}
#[test]
fn scan_runs_extracts_host_repo_path() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
make_run_dir(
base,
"20260101-ABC123",
Some(serde_json::json!({
"run_id": "abc123",
"workflow_name": "my-pipeline",
"goal": "test goal",
"start_time": "2026-01-01T12:00:00Z",
"node_count": 2,
"edge_count": 1,
"host_repo_path": "/home/user/myproject"
})),
None,
true,
);
let runs = scan_runs(base).unwrap();
assert_eq!(runs.len(), 1);
assert_eq!(
runs[0].host_repo_path.as_deref(),
Some("/home/user/myproject")
);
}
#[test]
fn prune_dry_run_preserves_dirs() {
let tmp = tempfile::tempdir().unwrap();
@ -1992,4 +2067,4 @@ mod tests {
"10-day-old run should be pruned with 7d threshold"
);
}
}
}

View file

@ -301,6 +301,10 @@ fn write_manifest(run_dir: &Path, graph: &Graph, config: &RunConfig) -> crate::m
labels: config.labels.clone(),
base_branch: config.base_branch.clone(),
workflow_slug: config.workflow_slug.clone(),
host_repo_path: config
.host_repo_path
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
};
let _ = std::fs::create_dir_all(run_dir);
let _ = manifest.save(&run_dir.join("manifest.json"));

View file

@ -24,6 +24,8 @@ pub struct Manifest {
pub base_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_slug: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_repo_path: Option<String>,
}
impl Manifest {
@ -53,6 +55,7 @@ mod tests {
labels: HashMap::from([("env".into(), "test".into())]),
base_branch: None,
workflow_slug: None,
host_repo_path: None,
}
}
@ -121,5 +124,6 @@ mod tests {
assert!(raw.get("run_branch").is_none());
assert!(raw.get("base_sha").is_none());
assert!(raw.get("workflow_slug").is_none());
assert!(raw.get("host_repo_path").is_none());
}
}