From b3f83264dceac0cb83409dc3b5c43c3450defd60 Mon Sep 17 00:00:00 2001 From: "brynary-fabro[bot]" <265161896+brynary-fabro[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:54:31 -0400 Subject: [PATCH] `fabro asset` CLI subcommands (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds `fabro asset list` and `fabro asset cp` CLI subcommands for inspecting and copying run artifacts (screenshots, test reports, traces) that are collected to `~/.fabro/runs/{id}/artifacts/assets/`. Previously, users had to manually browse the filesystem to find these files. The core implementation lives in a new `asset.rs` module that provides `scan_assets()`, which walks the asset manifest files under each node's retry directories to build structured `AssetEntry` records. The `list` subcommand displays a formatted table (or JSON with `--json`) showing node, retry number, size, and path for each asset. The `cp` subcommand supports copying all assets or a specific file (via `RUN_ID:path` syntax), with a `--tree` flag to preserve the `{node}/retry_{N}/` directory structure and collision detection in flat mode. The implementation reuses existing utilities throughout: `split_run_path()` for parsing the colon-separated source syntax (made `pub(crate)`), `resolve_run()` for run ID prefix matching, `format_size()` for human-readable byte formatting (also made `pub(crate)`), and `AssetCollectionSummary` for manifest deserialization. ### Fabro Details
Ran 7 stages in 12m 31s for $3.61 | Stage | Duration | Cost | Retries | |---|---|---|---| | start | 0s | – | 0 | | toolchain | 0s | – | 0 | | preflight_compile | 0s | – | 0 | | preflight_lint | 0s | – | 0 | | implement | 0s | $1.33 | 0 | | simplify | 0s | $2.28 | 0 | | verify | 0s | – | 0 | | **Total** | **12m 31s** | **$3.61** | **0** |
Ran ImplementAndSimplify.fabro (10 nodes and 13 edges) ```dot digraph ImplementAndSimplify { graph [ goal="Implement and simplify", model_stylesheet=" * { backend: api; model: claude-opus-4-6;} " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0] preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check 2>&1", max_retries=0] preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1", max_retries=0] fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan."] simplify [label="Simplify", prompt="@prompts/simplify.md"] verify [label="Verify", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1 && cargo test 2>&1", goal_gate=true, retry_target="fixup"] fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3] start -> toolchain toolchain -> preflight_compile [condition="outcome=success"] toolchain -> exit preflight_compile -> preflight_lint [condition="outcome=success"] preflight_compile -> exit preflight_lint -> implement [condition="outcome=success"] preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify -> verify verify -> exit [condition="outcome=success"] verify -> fixup fixup -> verify } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro Co-authored-by: Bryan Helmkamp Co-authored-by: Claude Opus 4.6 (1M context) --- lib/crates/fabro-cli/src/main.rs | 25 ++ lib/crates/fabro-workflows/src/cli/asset.rs | 317 ++++++++++++++++++++ lib/crates/fabro-workflows/src/cli/cp.rs | 2 +- lib/crates/fabro-workflows/src/cli/mod.rs | 1 + lib/crates/fabro-workflows/src/cli/runs.rs | 2 +- 5 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 lib/crates/fabro-workflows/src/cli/asset.rs diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index e02a37324..8fff95800 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -72,6 +72,11 @@ enum Command { /// Parse a DOT file and print its AST #[command(hide = true)] Parse(fabro_workflows::cli::ParseArgs), + /// Inspect and copy run assets (screenshots, reports, traces) + Asset { + #[command(subcommand)] + command: AssetCommand, + }, /// Copy files to/from a run's sandbox Cp(fabro_workflows::cli::cp::CpArgs), /// Get a preview URL for a port on a run's sandbox @@ -180,6 +185,14 @@ enum WorkflowCommand { Create(fabro_workflows::cli::workflow::WorkflowCreateArgs), } +#[derive(Subcommand)] +enum AssetCommand { + /// List assets for a workflow run + List(fabro_workflows::cli::asset::AssetListArgs), + /// Copy assets from a workflow run + Cp(fabro_workflows::cli::asset::AssetCpArgs), +} + #[derive(Subcommand)] enum LlmCommand { /// Execute a prompt @@ -380,6 +393,10 @@ async fn main_inner() -> (String, Result<()>) { LlmCommand::Prompt(_) => "llm prompt", LlmCommand::Chat(_) => "llm chat", }, + Command::Asset { command } => match command { + AssetCommand::List(_) => "asset list", + AssetCommand::Cp(_) => "asset cp", + }, Command::Exec(_) => "exec", Command::Run(_) => "run", Command::Validate(_) => "validate", @@ -636,6 +653,14 @@ async fn main_inner() -> (String, Result<()>) { Command::Parse(args) => { fabro_workflows::cli::parse::parse_command(&args)?; } + Command::Asset { command } => match command { + AssetCommand::List(args) => { + fabro_workflows::cli::asset::list_command(&args)?; + } + AssetCommand::Cp(args) => { + fabro_workflows::cli::asset::cp_command(&args)?; + } + }, Command::Cp(args) => { fabro_workflows::cli::cp::cp_command(args).await?; } diff --git a/lib/crates/fabro-workflows/src/cli/asset.rs b/lib/crates/fabro-workflows/src/cli/asset.rs new file mode 100644 index 000000000..f6d6c0c7d --- /dev/null +++ b/lib/crates/fabro-workflows/src/cli/asset.rs @@ -0,0 +1,317 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use clap::Args; + +use crate::asset_snapshot::AssetCollectionSummary; +use crate::cli::cp::split_run_path; +use crate::cli::runs::{default_runs_base, format_size, resolve_run}; + +/// An individual asset file discovered from a run's asset manifests. +#[derive(Debug, Clone, serde::Serialize)] +pub struct AssetEntry { + pub node_slug: String, + pub retry: u32, + pub relative_path: String, + #[serde(serialize_with = "serialize_path")] + pub absolute_path: PathBuf, + pub size: u64, +} + +fn serialize_path(path: &Path, s: S) -> Result { + s.serialize_str(&path.display().to_string()) +} + +/// Walk `{run_dir}/artifacts/assets/*/retry_*/manifest.json`, stat each file, return entries. +pub fn scan_assets(run_dir: &Path, node_filter: Option<&str>) -> Result> { + let assets_dir = run_dir.join("artifacts/assets"); + let nodes = match std::fs::read_dir(&assets_dir) { + Ok(rd) => rd, + Err(_) => return Ok(Vec::new()), + }; + + let mut entries = Vec::new(); + for node_entry in nodes.flatten() { + if !node_entry.path().is_dir() { + continue; + } + let node_slug = node_entry.file_name().to_string_lossy().into_owned(); + + if let Some(filter) = node_filter { + if node_slug != filter { + continue; + } + } + + let Ok(retries) = std::fs::read_dir(node_entry.path()) else { + continue; + }; + for retry_entry in retries.flatten() { + let retry_dir = retry_entry.path(); + let dir_name = retry_entry.file_name().to_string_lossy().into_owned(); + let retry: u32 = dir_name + .strip_prefix("retry_") + .and_then(|n| n.parse().ok()) + .unwrap_or(0); + + let manifest = retry_dir.join("manifest.json"); + let Ok(contents) = std::fs::read_to_string(&manifest) else { + continue; + }; + let Ok(summary) = serde_json::from_str::(&contents) else { + continue; + }; + + for relative_path in &summary.copied_paths { + let absolute_path = retry_dir.join(relative_path); + let size = std::fs::metadata(&absolute_path) + .map(|m| m.len()) + .unwrap_or(0); + entries.push(AssetEntry { + node_slug: node_slug.clone(), + retry, + relative_path: relative_path.clone(), + absolute_path, + size, + }); + } + } + } + Ok(entries) +} + +#[derive(Args)] +pub struct AssetListArgs { + /// Run ID (or prefix) + pub run_id: String, + + /// Filter to assets from a specific node + #[arg(long)] + pub node: Option, + + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct AssetCpArgs { + /// Source: RUN_ID (all assets) or RUN_ID:path (specific asset) + pub source: String, + + /// Destination directory (defaults to current directory) + #[arg(default_value = ".")] + pub dest: PathBuf, + + /// Filter to assets from a specific node + #[arg(long)] + pub node: Option, + + /// Preserve {node_slug}/retry_{N}/ directory structure + #[arg(long)] + pub tree: bool, +} + +/// Parse `source` into (run_id, optional_asset_path) using the same colon-split logic as `cp`. +fn parse_source(s: &str) -> (&str, Option<&str>) { + match split_run_path(s) { + Some((run_id, path)) => (run_id, Some(path)), + None => (s, None), + } +} + +pub fn list_command(args: &AssetListArgs) -> Result<()> { + let base = default_runs_base(); + let run_info = resolve_run(&base, &args.run_id)?; + let entries = scan_assets(&run_info.path, args.node.as_deref())?; + + if args.json { + let json = serde_json::to_string_pretty(&entries)?; + println!("{json}"); + return Ok(()); + } + + if entries.is_empty() { + println!("No assets found for this run."); + return Ok(()); + } + + // Compute column widths + let node_width = entries + .iter() + .map(|e| e.node_slug.len()) + .max() + .unwrap_or(4) + .max(4); + let retry_width = 5; // "RETRY" + let size_width = entries + .iter() + .map(|e| format_size(e.size).len()) + .max() + .unwrap_or(4) + .max(4); + + println!( + "{:retry_width$} {:>size_width$} PATH", + "NODE", "RETRY", "SIZE" + ); + let total_size: u64 = entries.iter().map(|e| e.size).sum(); + for entry in &entries { + println!( + "{:retry_width$} {:>size_width$} {}", + entry.node_slug, + entry.retry, + format_size(entry.size), + entry.relative_path + ); + } + println!(); + println!( + "{} asset(s), {} total", + entries.len(), + format_size(total_size) + ); + + Ok(()) +} + +pub fn cp_command(args: &AssetCpArgs) -> Result<()> { + let base = default_runs_base(); + let (run_id, asset_path) = parse_source(&args.source); + let run_info = resolve_run(&base, run_id)?; + let entries = scan_assets(&run_info.path, args.node.as_deref())?; + + if entries.is_empty() { + bail!("No assets found for this run"); + } + + std::fs::create_dir_all(&args.dest) + .with_context(|| format!("Failed to create destination: {}", args.dest.display()))?; + + if let Some(path) = asset_path { + // Copy a specific asset + let matching: Vec<_> = entries.iter().filter(|e| e.relative_path == path).collect(); + if matching.is_empty() { + bail!("No asset matching path '{path}' found in this run"); + } + if matching.len() > 1 && args.node.is_none() { + let nodes: Vec<_> = matching.iter().map(|e| e.node_slug.as_str()).collect(); + bail!( + "Path '{path}' exists in multiple nodes: {}. Use --node to disambiguate.", + nodes.join(", ") + ); + } + let entry = matching[0]; + let dest_file = args.dest.join( + Path::new(&entry.relative_path) + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new(&entry.relative_path)), + ); + if let Some(parent) = dest_file.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| { + format!( + "Failed to copy {} to {}", + entry.absolute_path.display(), + dest_file.display() + ) + })?; + println!("Copied {} to {}", entry.relative_path, dest_file.display()); + } else { + // Copy all assets + if args.tree { + // Preserve directory structure: {node_slug}/retry_{N}/... + for entry in &entries { + let rel = PathBuf::from(&entry.node_slug) + .join(format!("retry_{}", entry.retry)) + .join(&entry.relative_path); + let dest_file = args.dest.join(&rel); + if let Some(parent) = dest_file.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| { + format!( + "Failed to copy {} to {}", + entry.absolute_path.display(), + dest_file.display() + ) + })?; + } + } else { + // Flat mode: build filename map and check for collisions + let mut by_filename: Vec<(String, &AssetEntry)> = Vec::with_capacity(entries.len()); + for entry in &entries { + let filename = Path::new(&entry.relative_path) + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new(&entry.relative_path)) + .to_string_lossy() + .into_owned(); + if let Some((_, existing)) = by_filename.iter().find(|(f, _)| f == &filename) { + bail!( + "Filename collision: '{}' exists in both node '{}' and '{}'. \ + Use --tree to preserve directory structure, or --node to filter.", + filename, + existing.node_slug, + entry.node_slug + ); + } + by_filename.push((filename, entry)); + } + + for (filename, entry) in &by_filename { + let dest_file = args.dest.join(filename); + if let Some(parent) = dest_file.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| { + format!( + "Failed to copy {} to {}", + entry.absolute_path.display(), + dest_file.display() + ) + })?; + } + } + println!( + "Copied {} asset(s) to {}", + entries.len(), + args.dest.display() + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_source_bare_run_id() { + let (id, path) = parse_source("01ABC"); + assert_eq!(id, "01ABC"); + assert_eq!(path, None); + } + + #[test] + fn parse_source_with_path() { + let (id, path) = parse_source("01ABC:test-results/report.xml"); + assert_eq!(id, "01ABC"); + assert_eq!(path, Some("test-results/report.xml")); + } + + #[test] + fn parse_source_local_absolute_path() { + let (id, path) = parse_source("/tmp/foo"); + assert_eq!(id, "/tmp/foo"); + assert_eq!(path, None); + } + + #[test] + fn parse_source_local_relative_path() { + let (id, path) = parse_source("./foo"); + assert_eq!(id, "./foo"); + assert_eq!(path, None); + } +} diff --git a/lib/crates/fabro-workflows/src/cli/cp.rs b/lib/crates/fabro-workflows/src/cli/cp.rs index 17a1461b0..d78ffc8cf 100644 --- a/lib/crates/fabro-workflows/src/cli/cp.rs +++ b/lib/crates/fabro-workflows/src/cli/cp.rs @@ -66,7 +66,7 @@ fn parse_direction(src: &str, dst: &str) -> Result { /// We distinguish local paths from run references by checking: /// - Paths starting with `/`, `./`, or `../` are always local /// - Otherwise, split on the first colon -fn split_run_path(s: &str) -> Option<(&str, &str)> { +pub(crate) fn split_run_path(s: &str) -> Option<(&str, &str)> { if s.starts_with('/') || s.starts_with("./") || s.starts_with("../") { return None; } diff --git a/lib/crates/fabro-workflows/src/cli/mod.rs b/lib/crates/fabro-workflows/src/cli/mod.rs index dc376b0c1..3f3a3027f 100644 --- a/lib/crates/fabro-workflows/src/cli/mod.rs +++ b/lib/crates/fabro-workflows/src/cli/mod.rs @@ -1,3 +1,4 @@ +pub mod asset; pub mod backend; pub mod cli_backend; pub mod cp; diff --git a/lib/crates/fabro-workflows/src/cli/runs.rs b/lib/crates/fabro-workflows/src/cli/runs.rs index 0c55842c8..c83164ef4 100644 --- a/lib/crates/fabro-workflows/src/cli/runs.rs +++ b/lib/crates/fabro-workflows/src/cli/runs.rs @@ -564,7 +564,7 @@ fn dir_size(path: &Path) -> u64 { .sum() } -fn format_size(bytes: u64) -> String { +pub(crate) fn format_size(bytes: u64) -> String { const KB: u64 = 1024; const MB: u64 = 1024 * KB; const GB: u64 = 1024 * MB;