mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
fabro asset CLI subcommands (#10)
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
<details>
<summary>Ran 7 stages in 12m 31s for $3.61</summary>
| 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** |
</details>
<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (10 nodes and 13
edges)</summary>
```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
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d6f1bce7ef
commit
b3f83264dc
5 changed files with 345 additions and 2 deletions
|
|
@ -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?;
|
||||
}
|
||||
|
|
|
|||
317
lib/crates/fabro-workflows/src/cli/asset.rs
Normal file
317
lib/crates/fabro-workflows/src/cli/asset.rs
Normal file
|
|
@ -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<S: serde::Serializer>(path: &Path, s: S) -> Result<S::Ok, S::Error> {
|
||||
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<Vec<AssetEntry>> {
|
||||
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::<AssetCollectionSummary>(&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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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!(
|
||||
"{:<node_width$} {:>retry_width$} {:>size_width$} PATH",
|
||||
"NODE", "RETRY", "SIZE"
|
||||
);
|
||||
let total_size: u64 = entries.iter().map(|e| e.size).sum();
|
||||
for entry in &entries {
|
||||
println!(
|
||||
"{:<node_width$} {:>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);
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ fn parse_direction(src: &str, dst: &str) -> Result<CopyDirection> {
|
|||
/// 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod asset;
|
||||
pub mod backend;
|
||||
pub mod cli_backend;
|
||||
pub mod cp;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue