mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
Move workflow CLI ownership into fabro-cli
This commit is contained in:
parent
9e0a387f44
commit
c57734244e
64 changed files with 4058 additions and 7746 deletions
20
Cargo.lock
generated
20
Cargo.lock
generated
|
|
@ -1339,10 +1339,12 @@ version = "0.174.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"cli-table",
|
||||
"console 0.15.11",
|
||||
"daytona-sdk",
|
||||
"dialoguer",
|
||||
|
|
@ -1352,13 +1354,24 @@ dependencies = [
|
|||
"fabro-api",
|
||||
"fabro-beastie",
|
||||
"fabro-config",
|
||||
"fabro-daytona",
|
||||
"fabro-devcontainer",
|
||||
"fabro-exe",
|
||||
"fabro-git-storage",
|
||||
"fabro-github",
|
||||
"fabro-graphviz",
|
||||
"fabro-hooks",
|
||||
"fabro-interview",
|
||||
"fabro-llm",
|
||||
"fabro-mcp",
|
||||
"fabro-openai-oauth",
|
||||
"fabro-retro",
|
||||
"fabro-ssh",
|
||||
"fabro-util",
|
||||
"fabro-validate",
|
||||
"fabro-workflows",
|
||||
"futures",
|
||||
"git2",
|
||||
"httpmock",
|
||||
"indicatif",
|
||||
"insta",
|
||||
|
|
@ -1371,10 +1384,12 @@ dependencies = [
|
|||
"reqwest 0.12.28",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"scopeguard",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"shlex",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml",
|
||||
|
|
@ -1383,6 +1398,7 @@ dependencies = [
|
|||
"tracing-subscriber",
|
||||
"trycmd",
|
||||
"ulid",
|
||||
"walkdir",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
|
|
@ -1758,9 +1774,6 @@ dependencies = [
|
|||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"cli-table",
|
||||
"console 0.15.11",
|
||||
"dirs",
|
||||
"dotenvy",
|
||||
"fabro-agent",
|
||||
|
|
@ -1782,7 +1795,6 @@ dependencies = [
|
|||
"futures",
|
||||
"git2",
|
||||
"hex",
|
||||
"indicatif",
|
||||
"mockito",
|
||||
"predicates",
|
||||
"rand 0.8.5",
|
||||
|
|
|
|||
|
|
@ -1270,21 +1270,21 @@ mod runs {
|
|||
}
|
||||
|
||||
pub fn configuration() -> serde_json::Value {
|
||||
serde_json::to_value(fabro_workflows::cli::run_config::WorkflowRunConfig {
|
||||
serde_json::to_value(fabro_config::run::WorkflowRunConfig {
|
||||
version: 1,
|
||||
goal: Some("Add rate limiting to auth endpoints".into()),
|
||||
graph: "implement.fabro".into(),
|
||||
work_dir: Some("/workspace/api-server".into()),
|
||||
llm: Some(fabro_workflows::cli::run_config::LlmConfig {
|
||||
llm: Some(fabro_config::run::LlmConfig {
|
||||
model: Some("claude-opus-4-6".into()),
|
||||
provider: Some("anthropic".into()),
|
||||
fallbacks: None,
|
||||
}),
|
||||
setup: Some(fabro_workflows::cli::run_config::SetupConfig {
|
||||
setup: Some(fabro_config::run::SetupConfig {
|
||||
commands: vec!["bun install".into(), "bun run typecheck".into()],
|
||||
timeout_ms: Some(120_000),
|
||||
}),
|
||||
sandbox: Some(fabro_workflows::cli::run_config::SandboxConfig {
|
||||
sandbox: Some(fabro_config::sandbox::SandboxConfig {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
devcontainer: None,
|
||||
|
|
@ -1422,9 +1422,7 @@ mod workflows {
|
|||
]
|
||||
}
|
||||
|
||||
fn run_config_to_api(
|
||||
cfg: fabro_workflows::cli::run_config::WorkflowRunConfig,
|
||||
) -> RunConfiguration {
|
||||
fn run_config_to_api(cfg: fabro_config::run::WorkflowRunConfig) -> RunConfiguration {
|
||||
fn strip_nulls(val: serde_json::Value) -> serde_json::Value {
|
||||
match val {
|
||||
serde_json::Value::Object(map) => serde_json::Value::Object(
|
||||
|
|
@ -1448,18 +1446,18 @@ mod workflows {
|
|||
WorkflowDetail {
|
||||
name: "Fix Build".into(), slug: "fix_build".into(), filename: "fix_build.fabro".into(),
|
||||
description: "Automatically diagnoses and fixes CI build failures by analyzing error logs, identifying root causes, and applying targeted code changes.".into(),
|
||||
config: run_config_to_api(fabro_workflows::cli::run_config::WorkflowRunConfig {
|
||||
config: run_config_to_api(fabro_config::run::WorkflowRunConfig {
|
||||
version: 1,
|
||||
goal: Some("Diagnose and fix CI build failures".into()),
|
||||
graph: "fix_build.fabro".into(),
|
||||
work_dir: None,
|
||||
llm: Some(fabro_workflows::cli::run_config::LlmConfig {
|
||||
llm: Some(fabro_config::run::LlmConfig {
|
||||
model: Some("claude-sonnet".into()),
|
||||
provider: None,
|
||||
fallbacks: None,
|
||||
}),
|
||||
setup: None,
|
||||
sandbox: Some(fabro_workflows::cli::run_config::SandboxConfig {
|
||||
sandbox: Some(fabro_config::sandbox::SandboxConfig {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
devcontainer: None,
|
||||
|
|
@ -1517,21 +1515,21 @@ mod workflows {
|
|||
WorkflowDetail {
|
||||
name: "Implement Feature".into(), slug: "implement".into(), filename: "implement.fabro".into(),
|
||||
description: "Generates production-ready code from a technical blueprint, including tests, documentation, and a pull request ready for review.".into(),
|
||||
config: run_config_to_api(fabro_workflows::cli::run_config::WorkflowRunConfig {
|
||||
config: run_config_to_api(fabro_config::run::WorkflowRunConfig {
|
||||
version: 1,
|
||||
goal: Some("Implement feature from technical blueprint".into()),
|
||||
graph: "implement.fabro".into(),
|
||||
work_dir: None,
|
||||
llm: Some(fabro_workflows::cli::run_config::LlmConfig {
|
||||
llm: Some(fabro_config::run::LlmConfig {
|
||||
model: Some("claude-sonnet".into()),
|
||||
provider: None,
|
||||
fallbacks: None,
|
||||
}),
|
||||
setup: Some(fabro_workflows::cli::run_config::SetupConfig {
|
||||
setup: Some(fabro_config::run::SetupConfig {
|
||||
commands: vec!["bun install".into(), "bun run typecheck".into()],
|
||||
timeout_ms: Some(120_000),
|
||||
}),
|
||||
sandbox: Some(fabro_workflows::cli::run_config::SandboxConfig {
|
||||
sandbox: Some(fabro_config::sandbox::SandboxConfig {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
devcontainer: None,
|
||||
|
|
@ -1604,18 +1602,18 @@ mod workflows {
|
|||
WorkflowDetail {
|
||||
name: "Sync Drift".into(), slug: "sync_drift".into(), filename: "sync_drift.fabro".into(),
|
||||
description: "Detects configuration and code drift between environments, then generates reconciliation patches to bring everything back in sync.".into(),
|
||||
config: run_config_to_api(fabro_workflows::cli::run_config::WorkflowRunConfig {
|
||||
config: run_config_to_api(fabro_config::run::WorkflowRunConfig {
|
||||
version: 1,
|
||||
goal: Some("Detect and reconcile configuration drift across environments".into()),
|
||||
graph: "sync_drift.fabro".into(),
|
||||
work_dir: None,
|
||||
llm: Some(fabro_workflows::cli::run_config::LlmConfig {
|
||||
llm: Some(fabro_config::run::LlmConfig {
|
||||
model: Some("claude-sonnet".into()),
|
||||
provider: None,
|
||||
fallbacks: None,
|
||||
}),
|
||||
setup: None,
|
||||
sandbox: Some(fabro_workflows::cli::run_config::SandboxConfig {
|
||||
sandbox: Some(fabro_config::sandbox::SandboxConfig {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
devcontainer: None,
|
||||
|
|
@ -1679,18 +1677,18 @@ mod workflows {
|
|||
WorkflowDetail {
|
||||
name: "Expand Product".into(), slug: "expand".into(), filename: "expand.fabro".into(),
|
||||
description: "Evolves the product by analyzing usage patterns and specifications to propose and implement incremental improvements.".into(),
|
||||
config: run_config_to_api(fabro_workflows::cli::run_config::WorkflowRunConfig {
|
||||
config: run_config_to_api(fabro_config::run::WorkflowRunConfig {
|
||||
version: 1,
|
||||
goal: Some("Propose and implement incremental product improvements".into()),
|
||||
graph: "expand.fabro".into(),
|
||||
work_dir: None,
|
||||
llm: Some(fabro_workflows::cli::run_config::LlmConfig {
|
||||
llm: Some(fabro_config::run::LlmConfig {
|
||||
model: Some("claude-sonnet".into()),
|
||||
provider: None,
|
||||
fallbacks: None,
|
||||
}),
|
||||
setup: None,
|
||||
sandbox: Some(fabro_workflows::cli::run_config::SandboxConfig {
|
||||
sandbox: Some(fabro_config::sandbox::SandboxConfig {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
devcontainer: None,
|
||||
|
|
@ -3257,15 +3255,15 @@ mod settings {
|
|||
retros: false,
|
||||
},
|
||||
log: Default::default(),
|
||||
run_defaults: fabro_workflows::cli::run_config::RunDefaults {
|
||||
run_defaults: fabro_config::run::RunDefaults {
|
||||
work_dir: None,
|
||||
llm: Some(fabro_workflows::cli::run_config::LlmConfig {
|
||||
llm: Some(fabro_config::run::LlmConfig {
|
||||
model: Some("claude-sonnet".into()),
|
||||
provider: Some("anthropic".into()),
|
||||
fallbacks: None,
|
||||
}),
|
||||
setup: None,
|
||||
sandbox: Some(fabro_workflows::cli::run_config::SandboxConfig {
|
||||
sandbox: Some(fabro_config::sandbox::SandboxConfig {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
devcontainer: None,
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ use crate::jwt_auth::{AuthMode, AuthStrategy};
|
|||
use crate::server::build_router;
|
||||
use crate::tls::ClientAuth;
|
||||
use fabro_interview::Interviewer;
|
||||
use fabro_workflows::cli::backend::AgentApiBackend;
|
||||
use fabro_workflows::cli::SandboxProvider;
|
||||
use fabro_workflows::backend::AgentApiBackend;
|
||||
use fabro_workflows::handler::default_registry;
|
||||
use fabro_workflows::sandbox_provider::SandboxProvider;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ServeArgs {
|
||||
|
|
|
|||
|
|
@ -1428,7 +1428,7 @@ async fn get_retro(
|
|||
|
||||
/// Render DOT source to a styled SVG via `render_dot` on a blocking thread.
|
||||
pub(crate) async fn render_dot_svg(dot_source: &str) -> Response {
|
||||
use fabro_workflows::cli::graph::{render_dot, GraphFormat};
|
||||
use fabro_workflows::graph_render::{render_dot, GraphFormat};
|
||||
|
||||
let source = dot_source.to_owned();
|
||||
match tokio::task::spawn_blocking(move || render_dot(&source, GraphFormat::Svg)).await {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@ use axum::http::{Method, Request, StatusCode};
|
|||
use fabro_api::jwt_auth::AuthMode;
|
||||
use fabro_api::server::{build_router, create_app_state};
|
||||
use fabro_api::server_config::*;
|
||||
use fabro_config::run::*;
|
||||
use fabro_config::sandbox::SandboxConfig;
|
||||
use fabro_daytona::*;
|
||||
use fabro_hooks::*;
|
||||
use fabro_interview::Interviewer;
|
||||
use fabro_workflows::cli::run_config::*;
|
||||
use fabro_workflows::handler::exit::ExitHandler;
|
||||
use fabro_workflows::handler::start::StartHandler;
|
||||
use fabro_workflows::handler::HandlerRegistry;
|
||||
|
|
@ -317,7 +318,7 @@ fn fully_populated_server_config() -> ServerConfig {
|
|||
enabled: true,
|
||||
draft: false,
|
||||
auto_merge: false,
|
||||
merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
|
||||
merge_strategy: MergeStrategy::Squash,
|
||||
}),
|
||||
assets: Some(AssetsConfig {
|
||||
include: vec!["test-results/**".into()],
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ path = "src/main.rs"
|
|||
[features]
|
||||
default = []
|
||||
server = ["dep:fabro-api"]
|
||||
exedev = ["fabro-config/exedev", "fabro-workflows/exedev"]
|
||||
exedev = ["dep:fabro-exe", "fabro-config/exedev", "fabro-workflows/exedev"]
|
||||
sleep_inhibitor = ["dep:fabro-beastie"]
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -21,12 +21,23 @@ fabro-llm = { path = "../fabro-llm" }
|
|||
fabro-openai-oauth = { path = "../fabro-openai-oauth" }
|
||||
fabro-github = { path = "../fabro-github" }
|
||||
fabro-agent = { path = "../fabro-agent" }
|
||||
fabro-devcontainer = { path = "../fabro-devcontainer" }
|
||||
fabro-exe = { path = "../fabro-exe", optional = true }
|
||||
fabro-hooks = { path = "../fabro-hooks" }
|
||||
fabro-interview = { path = "../fabro-interview" }
|
||||
fabro-mcp = { path = "../fabro-mcp" }
|
||||
fabro-daytona = { path = "../fabro-daytona" }
|
||||
fabro-retro = { path = "../fabro-retro" }
|
||||
fabro-ssh = { path = "../fabro-ssh" }
|
||||
fabro-git-storage = { path = "../fabro-git-storage" }
|
||||
fabro-graphviz = { path = "../fabro-graphviz" }
|
||||
fabro-validate = { path = "../fabro-validate" }
|
||||
fabro-workflows = { path = "../fabro-workflows" }
|
||||
fabro-api = { path = "../fabro-api", optional = true }
|
||||
fabro-beastie = { path = "../fabro-beastie", optional = true }
|
||||
fabro-util = { path = "../fabro-util" }
|
||||
clap.workspace = true
|
||||
cli-table.workspace = true
|
||||
console.workspace = true
|
||||
indicatif.workspace = true
|
||||
daytona-sdk.workspace = true
|
||||
|
|
@ -44,19 +55,24 @@ futures.workspace = true
|
|||
regex.workspace = true
|
||||
semver.workspace = true
|
||||
reqwest.workspace = true
|
||||
async-trait.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
base64.workspace = true
|
||||
ulid.workspace = true
|
||||
scopeguard = "1"
|
||||
rustls = { version = "0.23", default-features = false, features = ["std", "ring"] }
|
||||
rustls-pemfile = "2"
|
||||
x509-parser = "0.16"
|
||||
rand.workspace = true
|
||||
dialoguer.workspace = true
|
||||
git2.workspace = true
|
||||
axum = "0.8"
|
||||
open = "5"
|
||||
serde_json.workspace = true
|
||||
tempfile = "3"
|
||||
sha2.workspace = true
|
||||
shlex = "1"
|
||||
walkdir.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
|
|
|||
264
lib/crates/fabro-cli/src/commands/asset.rs
Normal file
264
lib/crates/fabro-cli/src/commands/asset.rs
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
pub fn list_command(args: &AssetListArgs) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
let run = fabro_workflows::run_lookup::resolve_run(&base, &args.run_id)?;
|
||||
let entries = fabro_workflows::assets::scan_assets(&run.path, args.node.as_deref())?;
|
||||
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&entries)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
println!("No assets found for this run.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let node_width = entries
|
||||
.iter()
|
||||
.map(|entry| entry.node_slug.len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
let retry_width = 5;
|
||||
let size_width = entries
|
||||
.iter()
|
||||
.map(|entry| format_size(entry.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(|entry| entry.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 = fabro_workflows::run_lookup::default_runs_base();
|
||||
let (run_id, asset_path) = parse_source(&args.source);
|
||||
let run = fabro_workflows::run_lookup::resolve_run(&base, run_id)?;
|
||||
let entries = fabro_workflows::assets::scan_assets(&run.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 {
|
||||
let matching: Vec<_> = entries
|
||||
.iter()
|
||||
.filter(|entry| entry.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(|entry| entry.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());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if args.tree {
|
||||
for entry in &entries {
|
||||
let relative_dest = PathBuf::from(&entry.node_slug)
|
||||
.join(format!("retry_{}", entry.retry))
|
||||
.join(&entry.relative_path);
|
||||
let dest_file = args.dest.join(relative_dest);
|
||||
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 {
|
||||
let mut by_filename: Vec<(String, &fabro_workflows::assets::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(|(name, _)| name == &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(())
|
||||
}
|
||||
|
||||
fn parse_source(source: &str) -> (&str, Option<&str>) {
|
||||
match split_run_path(source) {
|
||||
Some((run_id, path)) => (run_id, Some(path)),
|
||||
None => (source, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_run_path(s: &str) -> Option<(&str, &str)> {
|
||||
if s.starts_with('/') || s.starts_with("./") || s.starts_with("../") {
|
||||
return None;
|
||||
}
|
||||
s.split_once(':')
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = 1024 * KB;
|
||||
const GB: u64 = 1024 * MB;
|
||||
|
||||
if bytes >= GB {
|
||||
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{:.1} KB", bytes as f64 / KB as f64)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
232
lib/crates/fabro-cli/src/commands/cp.rs
Normal file
232
lib/crates/fabro-cli/src/commands/cp.rs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::{debug, info};
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct CpArgs {
|
||||
/// Source: <run-id>:<path> or local path
|
||||
pub src: String,
|
||||
/// Destination: <run-id>:<path> or local path
|
||||
pub dst: String,
|
||||
/// Recurse into directories
|
||||
#[arg(short, long)]
|
||||
pub recursive: bool,
|
||||
}
|
||||
|
||||
enum CopyDirection {
|
||||
Download {
|
||||
run_prefix: String,
|
||||
remote_path: String,
|
||||
local_path: PathBuf,
|
||||
},
|
||||
Upload {
|
||||
local_path: PathBuf,
|
||||
run_prefix: String,
|
||||
remote_path: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn cp_command(args: CpArgs) -> Result<()> {
|
||||
let direction = parse_direction(&args.src, &args.dst)?;
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
|
||||
match direction {
|
||||
CopyDirection::Download {
|
||||
run_prefix,
|
||||
remote_path,
|
||||
local_path,
|
||||
} => {
|
||||
let sandbox = load_sandbox(&base, &run_prefix).await?;
|
||||
|
||||
if args.recursive {
|
||||
download_recursive(&*sandbox, &remote_path, &local_path).await?;
|
||||
} else {
|
||||
debug!(path = %remote_path, "Downloading file from sandbox");
|
||||
sandbox
|
||||
.download_file_to_local(&remote_path, &local_path)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
}
|
||||
info!(direction = "download", path = %remote_path, "Copy complete");
|
||||
}
|
||||
CopyDirection::Upload {
|
||||
local_path,
|
||||
run_prefix,
|
||||
remote_path,
|
||||
} => {
|
||||
let sandbox = load_sandbox(&base, &run_prefix).await?;
|
||||
|
||||
if args.recursive {
|
||||
upload_recursive(&*sandbox, &local_path, &remote_path).await?;
|
||||
} else {
|
||||
debug!(path = %remote_path, "Uploading file to sandbox");
|
||||
sandbox
|
||||
.upload_file_from_local(&local_path, &remote_path)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
}
|
||||
info!(direction = "upload", path = %remote_path, "Copy complete");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_direction(src: &str, dst: &str) -> Result<CopyDirection> {
|
||||
let src_parts = split_run_path(src);
|
||||
let dst_parts = split_run_path(dst);
|
||||
|
||||
match (src_parts, dst_parts) {
|
||||
(Some((run_prefix, remote_path)), None) => Ok(CopyDirection::Download {
|
||||
run_prefix: run_prefix.to_string(),
|
||||
remote_path: remote_path.to_string(),
|
||||
local_path: PathBuf::from(dst),
|
||||
}),
|
||||
(None, Some((run_prefix, remote_path))) => Ok(CopyDirection::Upload {
|
||||
local_path: PathBuf::from(src),
|
||||
run_prefix: run_prefix.to_string(),
|
||||
remote_path: remote_path.to_string(),
|
||||
}),
|
||||
(Some(_), Some(_)) => {
|
||||
bail!("Cannot copy between two sandboxes; one argument must be a local path")
|
||||
}
|
||||
(None, None) => bail!("One argument must contain a run-id prefix (e.g. <run-id>:<path>)"),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_run_path(s: &str) -> Option<(&str, &str)> {
|
||||
if s.starts_with('/') || s.starts_with("./") || s.starts_with("../") {
|
||||
return None;
|
||||
}
|
||||
s.split_once(':')
|
||||
}
|
||||
|
||||
async fn load_sandbox(
|
||||
base: &Path,
|
||||
run_prefix: &str,
|
||||
) -> Result<Box<dyn fabro_agent::sandbox::Sandbox>> {
|
||||
let run_dir = fabro_workflows::run_lookup::resolve_run(base, run_prefix)?.path;
|
||||
let sandbox_json = run_dir.join("sandbox.json");
|
||||
debug!(path = %sandbox_json.display(), "Loading sandbox record");
|
||||
let record = fabro_workflows::sandbox_record::SandboxRecord::load(&sandbox_json).context(
|
||||
"Failed to load sandbox.json — was this run started with a recent version of arc?",
|
||||
)?;
|
||||
|
||||
info!(run_id = %run_prefix, provider = %record.provider, "Connecting to sandbox");
|
||||
fabro_workflows::sandbox_reconnect::reconnect(&record).await
|
||||
}
|
||||
|
||||
async fn download_recursive(
|
||||
sandbox: &dyn fabro_agent::sandbox::Sandbox,
|
||||
remote_path: &str,
|
||||
local_path: &Path,
|
||||
) -> Result<()> {
|
||||
let entries = sandbox
|
||||
.list_directory(remote_path, Some(100))
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("Failed to list directory {remote_path}: {err}"))?;
|
||||
|
||||
let mut file_count = 0usize;
|
||||
for entry in &entries {
|
||||
if entry.is_dir {
|
||||
continue;
|
||||
}
|
||||
let remote_file = format!("{remote_path}/{}", entry.name);
|
||||
let local_file = local_path.join(&entry.name);
|
||||
if let Some(parent) = local_file.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
|
||||
}
|
||||
debug!(path = %remote_file, "Downloading file from sandbox");
|
||||
sandbox
|
||||
.download_file_to_local(&remote_file, &local_file)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
file_count += 1;
|
||||
}
|
||||
debug!(count = file_count, "Recursive download complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_recursive(
|
||||
sandbox: &dyn fabro_agent::sandbox::Sandbox,
|
||||
local_path: &Path,
|
||||
remote_path: &str,
|
||||
) -> Result<()> {
|
||||
let mut file_count = 0usize;
|
||||
let mut stack = vec![(local_path.to_path_buf(), remote_path.to_string())];
|
||||
|
||||
while let Some((dir_path, dir_remote)) = stack.pop() {
|
||||
let mut entries = tokio::fs::read_dir(&dir_path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read directory {}", dir_path.display()))?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let entry_path = entry.path();
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
let remote_file = format!("{dir_remote}/{file_name}");
|
||||
|
||||
if entry.file_type().await?.is_dir() {
|
||||
stack.push((entry_path, remote_file));
|
||||
} else {
|
||||
debug!(path = %remote_file, "Uploading file to sandbox");
|
||||
sandbox
|
||||
.upload_file_from_local(&entry_path, &remote_file)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
file_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!(count = file_count, "Recursive upload complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_direction_download() {
|
||||
let direction = parse_direction("abc123:/some/file.txt", "./local.txt").unwrap();
|
||||
match direction {
|
||||
CopyDirection::Download {
|
||||
run_prefix,
|
||||
remote_path,
|
||||
local_path,
|
||||
} => {
|
||||
assert_eq!(run_prefix, "abc123");
|
||||
assert_eq!(remote_path, "/some/file.txt");
|
||||
assert_eq!(local_path, PathBuf::from("./local.txt"));
|
||||
}
|
||||
CopyDirection::Upload { .. } => panic!("expected download"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_direction_upload() {
|
||||
let direction = parse_direction("./local.txt", "abc123:/remote.txt").unwrap();
|
||||
match direction {
|
||||
CopyDirection::Upload {
|
||||
local_path,
|
||||
run_prefix,
|
||||
remote_path,
|
||||
} => {
|
||||
assert_eq!(local_path, PathBuf::from("./local.txt"));
|
||||
assert_eq!(run_prefix, "abc123");
|
||||
assert_eq!(remote_path, "/remote.txt");
|
||||
}
|
||||
CopyDirection::Download { .. } => panic!("expected upload"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_run_path_ignores_local_paths() {
|
||||
assert_eq!(split_run_path("/tmp/file"), None);
|
||||
assert_eq!(split_run_path("./file"), None);
|
||||
assert_eq!(split_run_path("../file"), None);
|
||||
}
|
||||
}
|
||||
130
lib/crates/fabro-cli/src/commands/diff.rs
Normal file
130
lib/crates/fabro-cli/src/commands/diff.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
use std::io::{self, IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::{debug, info};
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct DiffArgs {
|
||||
/// Run ID or prefix
|
||||
pub run: String,
|
||||
/// Show diff for a specific node
|
||||
#[arg(long)]
|
||||
pub node: Option<String>,
|
||||
/// Show diffstat instead of full patch (live diffs only)
|
||||
#[arg(long)]
|
||||
pub stat: bool,
|
||||
/// Show only files-changed/insertions/deletions summary (live diffs only)
|
||||
#[arg(long)]
|
||||
pub shortstat: bool,
|
||||
}
|
||||
|
||||
pub async fn run(args: DiffArgs) -> Result<()> {
|
||||
info!(run_id = %args.run, "Showing diff");
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
let run_dir = fabro_workflows::run_lookup::resolve_run(&base, &args.run)?.path;
|
||||
|
||||
let patch = resolve_diff(&run_dir, &args).await?;
|
||||
|
||||
let is_tty = io::stdout().is_terminal();
|
||||
let mut stdout = io::stdout().lock();
|
||||
if is_tty {
|
||||
for line in patch.lines() {
|
||||
writeln!(stdout, "{}", colorize_diff_line(line))?;
|
||||
}
|
||||
} else {
|
||||
stdout.write_all(patch.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_diff(run_dir: &Path, args: &DiffArgs) -> Result<String> {
|
||||
if let Some(ref node_id) = args.node {
|
||||
debug!(node_id, "Reading per-node diff");
|
||||
let node_patch = run_dir.join("nodes").join(node_id).join("diff.patch");
|
||||
return std::fs::read_to_string(&node_patch).with_context(|| {
|
||||
format!("No diff found for node '{node_id}' — check the node ID and try again")
|
||||
});
|
||||
}
|
||||
|
||||
let manifest = fabro_workflows::manifest::Manifest::load(&run_dir.join("manifest.json"))
|
||||
.context("Failed to load manifest.json")?;
|
||||
|
||||
let base_sha = manifest
|
||||
.base_sha
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("This run was not git-checkpointed; no diff available"))?;
|
||||
|
||||
let final_patch_path = run_dir.join("final.patch");
|
||||
if final_patch_path.exists() {
|
||||
debug!("Reading final.patch");
|
||||
return std::fs::read_to_string(&final_patch_path).context("Failed to read final.patch");
|
||||
}
|
||||
|
||||
let conclusion_path = run_dir.join("conclusion.json");
|
||||
if conclusion_path.exists() {
|
||||
bail!(
|
||||
"Run completed but no final.patch exists — the run may not have produced any changes"
|
||||
);
|
||||
}
|
||||
|
||||
debug!("No final.patch found; attempting live diff from sandbox");
|
||||
let sandbox_json = run_dir.join("sandbox.json");
|
||||
let record = fabro_workflows::sandbox_record::SandboxRecord::load(&sandbox_json).context(
|
||||
"Failed to load sandbox.json — was this run started with a recent version of arc?",
|
||||
)?;
|
||||
|
||||
info!(provider = %record.provider, "Reconnecting to sandbox for live diff");
|
||||
let sandbox = fabro_workflows::sandbox_reconnect::reconnect(&record).await?;
|
||||
|
||||
let cmd = build_live_diff_cmd(base_sha, args.stat, args.shortstat);
|
||||
debug!(cmd, "Running git diff in sandbox");
|
||||
|
||||
let result = sandbox
|
||||
.exec_command(&cmd, 30_000, None, None, None)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run git diff in sandbox: {e}"))?;
|
||||
|
||||
if result.exit_code != 0 {
|
||||
let stderr = result.stderr.trim();
|
||||
bail!("git diff failed (exit {}):\n{stderr}", result.exit_code);
|
||||
}
|
||||
|
||||
Ok(result.stdout)
|
||||
}
|
||||
|
||||
fn build_live_diff_cmd(base_sha: &str, stat: bool, shortstat: bool) -> String {
|
||||
let mut flags = String::new();
|
||||
if stat {
|
||||
flags.push_str(" --stat");
|
||||
}
|
||||
if shortstat {
|
||||
flags.push_str(" --shortstat");
|
||||
}
|
||||
let quoted_sha = shlex::try_quote(base_sha).map_or_else(
|
||||
|_| format!("'{}'", base_sha.replace('\'', "'\\''")),
|
||||
|q| q.to_string(),
|
||||
);
|
||||
format!(
|
||||
"{} add -N . && {} diff{flags} {quoted_sha}",
|
||||
fabro_workflows::engine::GIT_REMOTE,
|
||||
fabro_workflows::engine::GIT_REMOTE
|
||||
)
|
||||
}
|
||||
|
||||
fn colorize_diff_line(line: &str) -> String {
|
||||
if line.starts_with("+++") || line.starts_with("---") {
|
||||
format!("\x1b[1m{line}\x1b[0m")
|
||||
} else if line.starts_with('+') {
|
||||
format!("\x1b[32m{line}\x1b[0m")
|
||||
} else if line.starts_with('-') {
|
||||
format!("\x1b[31m{line}\x1b[0m")
|
||||
} else if line.starts_with("@@") {
|
||||
format!("\x1b[36m{line}\x1b[0m")
|
||||
} else if line.starts_with("diff ") {
|
||||
format!("\x1b[1m{line}\x1b[0m")
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
}
|
||||
63
lib/crates/fabro-cli/src/commands/fork.rs
Normal file
63
lib/crates/fabro-cli/src/commands/fork.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use clap::Args;
|
||||
use fabro_git_storage::gitobj::Store;
|
||||
use fabro_util::terminal::Styles;
|
||||
use git2::Repository;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ForkArgs {
|
||||
/// Run ID (or unambiguous prefix)
|
||||
pub run_id: String,
|
||||
|
||||
/// Target checkpoint: node name, node@visit, or @ordinal (omit to fork from latest)
|
||||
pub target: Option<String>,
|
||||
|
||||
/// Show the checkpoint timeline instead of forking
|
||||
#[arg(long)]
|
||||
pub list: bool,
|
||||
|
||||
/// Skip pushing new branches to the remote
|
||||
#[arg(long)]
|
||||
pub no_push: bool,
|
||||
}
|
||||
|
||||
pub fn run(args: &ForkArgs, styles: &Styles) -> Result<()> {
|
||||
let repo = Repository::discover(".").context("not in a git repository")?;
|
||||
let run_id = fabro_workflows::run_rewind::find_run_id_by_prefix(&repo, &args.run_id)?;
|
||||
let store = Store::new(repo);
|
||||
|
||||
let timeline = fabro_workflows::run_rewind::build_timeline(&store, &run_id)?;
|
||||
|
||||
if args.list {
|
||||
let parallel_map = fabro_workflows::run_rewind::load_parallel_map(&store, &run_id);
|
||||
super::rewind::print_timeline(&timeline, ¶llel_map, styles);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entry = if let Some(target_str) = &args.target {
|
||||
let target = fabro_workflows::run_rewind::parse_target(target_str)?;
|
||||
let parallel_map = fabro_workflows::run_rewind::load_parallel_map(&store, &run_id);
|
||||
fabro_workflows::run_rewind::resolve_target(&timeline, &target, ¶llel_map)?
|
||||
} else {
|
||||
timeline
|
||||
.last()
|
||||
.ok_or_else(|| anyhow::anyhow!("no checkpoints found for run {run_id}"))?
|
||||
};
|
||||
|
||||
let new_run_id =
|
||||
fabro_workflows::run_fork::execute_fork(&store, &run_id, entry, !args.no_push)?;
|
||||
|
||||
eprintln!(
|
||||
"\nForked run {} -> {}",
|
||||
&run_id[..8.min(run_id.len())],
|
||||
&new_run_id[..8.min(new_run_id.len())]
|
||||
);
|
||||
eprintln!(
|
||||
"To resume: fabro run --run-branch {}{}",
|
||||
fabro_workflows::git::RUN_BRANCH_PREFIX,
|
||||
new_run_id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
119
lib/crates/fabro-cli/src/commands/graph.rs
Normal file
119
lib/crates/fabro-cli/src/commands/graph.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::bail;
|
||||
use clap::{Args, ValueEnum};
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_validate::Severity;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::commands::shared::{print_diagnostics, read_workflow_file, relative_path};
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum GraphDirection {
|
||||
/// Left to right
|
||||
Lr,
|
||||
/// Top to bottom
|
||||
Tb,
|
||||
}
|
||||
|
||||
impl fmt::Display for GraphDirection {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Lr => write!(f, "LR"),
|
||||
Self::Tb => write!(f, "TB"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct GraphArgs {
|
||||
/// Path to the .fabro workflow file, .toml task config, or project workflow name
|
||||
pub workflow: PathBuf,
|
||||
|
||||
/// Output format
|
||||
#[arg(
|
||||
long,
|
||||
value_enum,
|
||||
default_value_t = GraphOutputFormat::Svg
|
||||
)]
|
||||
pub format: GraphOutputFormat,
|
||||
|
||||
/// Output file path (defaults to stdout)
|
||||
#[arg(short, long)]
|
||||
pub output: Option<PathBuf>,
|
||||
|
||||
/// Graph layout direction (overrides the DOT file's rankdir)
|
||||
#[arg(short = 'd', long)]
|
||||
pub direction: Option<GraphDirection>,
|
||||
}
|
||||
|
||||
static RANKDIR_RE: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"rankdir\s*=\s*\w+").unwrap());
|
||||
|
||||
pub fn run(args: &GraphArgs, styles: &Styles) -> anyhow::Result<()> {
|
||||
let (dot_path, _cfg) = fabro_config::project::resolve_workflow(&args.workflow)?;
|
||||
|
||||
let (_graph, diagnostics) = fabro_workflows::workflow::prepare_from_file(&dot_path)?;
|
||||
|
||||
print_diagnostics(&diagnostics, styles);
|
||||
|
||||
if diagnostics.iter().any(|d| d.severity == Severity::Error) {
|
||||
bail!("Validation failed");
|
||||
}
|
||||
|
||||
let source = read_workflow_file(&dot_path)?;
|
||||
let source = apply_direction(&source, args.direction);
|
||||
let rendered = fabro_workflows::graph_render::render_dot(&source, args.format.into())?;
|
||||
|
||||
if let Some(ref output_path) = args.output {
|
||||
std::fs::write(output_path, &rendered)?;
|
||||
} else {
|
||||
std::io::stdout().write_all(&rendered)?;
|
||||
}
|
||||
|
||||
debug!(
|
||||
path = %relative_path(&dot_path),
|
||||
format = %args.format,
|
||||
"Rendered workflow graph"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum GraphOutputFormat {
|
||||
Svg,
|
||||
Png,
|
||||
}
|
||||
|
||||
impl From<GraphOutputFormat> for fabro_workflows::graph_render::GraphFormat {
|
||||
fn from(value: GraphOutputFormat) -> Self {
|
||||
match value {
|
||||
GraphOutputFormat::Svg => Self::Svg,
|
||||
GraphOutputFormat::Png => Self::Png,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GraphOutputFormat {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Svg => write!(f, "svg"),
|
||||
Self::Png => write!(f, "png"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_direction<'a>(source: &'a str, direction: Option<GraphDirection>) -> Cow<'a, str> {
|
||||
match direction {
|
||||
Some(dir) => {
|
||||
let replacement = format!("rankdir={dir}");
|
||||
RANKDIR_RE.replace(source, replacement.as_str())
|
||||
}
|
||||
None => Cow::Borrowed(source),
|
||||
}
|
||||
}
|
||||
63
lib/crates/fabro-cli/src/commands/inspect.rs
Normal file
63
lib/crates/fabro-cli/src/commands/inspect.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct InspectArgs {
|
||||
/// Run ID prefix or workflow name (most recent run)
|
||||
pub run: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InspectOutput {
|
||||
pub run_id: String,
|
||||
pub run_dir: PathBuf,
|
||||
pub status: fabro_workflows::run_status::RunStatus,
|
||||
pub manifest: Option<serde_json::Value>,
|
||||
pub conclusion: Option<serde_json::Value>,
|
||||
pub checkpoint: Option<serde_json::Value>,
|
||||
pub sandbox: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub fn run(args: &InspectArgs) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
let run = fabro_workflows::run_lookup::resolve_run(&base, &args.run)?;
|
||||
let output = inspect_run_dir(&run.run_id, &run.path, run.status)?;
|
||||
let json = serde_json::to_string_pretty(&[output])?;
|
||||
println!("{json}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_run_dir(
|
||||
run_id: &str,
|
||||
run_dir: &Path,
|
||||
status: fabro_workflows::run_status::RunStatus,
|
||||
) -> Result<InspectOutput> {
|
||||
let manifest = fabro_workflows::manifest::Manifest::load(&run_dir.join("manifest.json"))
|
||||
.ok()
|
||||
.and_then(|v| serde_json::to_value(v).ok());
|
||||
let conclusion =
|
||||
fabro_workflows::conclusion::Conclusion::load(&run_dir.join("conclusion.json"))
|
||||
.ok()
|
||||
.and_then(|v| serde_json::to_value(v).ok());
|
||||
let checkpoint =
|
||||
fabro_workflows::checkpoint::Checkpoint::load(&run_dir.join("checkpoint.json"))
|
||||
.ok()
|
||||
.and_then(|v| serde_json::to_value(v).ok());
|
||||
let sandbox =
|
||||
fabro_workflows::sandbox_record::SandboxRecord::load(&run_dir.join("sandbox.json"))
|
||||
.ok()
|
||||
.and_then(|v| serde_json::to_value(v).ok());
|
||||
|
||||
Ok(InspectOutput {
|
||||
run_id: run_id.to_string(),
|
||||
run_dir: run_dir.to_path_buf(),
|
||||
status,
|
||||
manifest,
|
||||
conclusion,
|
||||
checkpoint,
|
||||
sandbox,
|
||||
})
|
||||
}
|
||||
|
|
@ -4,10 +4,9 @@ use std::path::Path;
|
|||
use anyhow::{bail, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::Args;
|
||||
use fabro_util::terminal::Styles;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::cli::runs::{default_runs_base, resolve_run};
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct LogsArgs {
|
||||
/// Run ID prefix or workflow name (most recent run)
|
||||
|
|
@ -26,9 +25,9 @@ pub struct LogsArgs {
|
|||
pub pretty: bool,
|
||||
}
|
||||
|
||||
pub fn logs_command(args: LogsArgs, styles: &fabro_util::terminal::Styles) -> Result<()> {
|
||||
let base = default_runs_base();
|
||||
let run = resolve_run(&base, &args.run)?;
|
||||
pub fn run(args: LogsArgs, styles: &Styles) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
let run = fabro_workflows::run_lookup::resolve_run(&base, &args.run)?;
|
||||
|
||||
info!(run_id = %run.run_id, "Showing logs");
|
||||
|
||||
|
|
@ -38,11 +37,10 @@ pub fn logs_command(args: LogsArgs, styles: &fabro_util::terminal::Styles) -> Re
|
|||
}
|
||||
|
||||
let since_cutoff = match &args.since {
|
||||
Some(s) => Some(parse_since(s)?),
|
||||
Some(value) => Some(parse_since(value)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Read all lines, apply --since and --tail filters
|
||||
let all_lines = read_lines(&progress_path)?;
|
||||
let filtered = apply_filters(&all_lines, since_cutoff.as_ref(), args.tail);
|
||||
|
||||
|
|
@ -117,24 +115,23 @@ fn extract_timestamp(line: &str) -> Option<DateTime<Utc>> {
|
|||
ts_str.parse::<DateTime<Utc>>().ok()
|
||||
}
|
||||
|
||||
/// Parse a `--since` value: relative duration (e.g. "42m", "2h", "7d") or ISO 8601 timestamp.
|
||||
pub fn parse_since(s: &str) -> Result<DateTime<Utc>> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
bail!("empty --since value");
|
||||
}
|
||||
|
||||
// Try relative duration first
|
||||
if let Some(dur) = try_parse_relative_duration(s) {
|
||||
return Ok(Utc::now() - dur);
|
||||
if let Some(duration) = try_parse_relative_duration(s) {
|
||||
return Ok(Utc::now() - duration);
|
||||
}
|
||||
|
||||
// Try ISO 8601
|
||||
if let Ok(ts) = s.parse::<DateTime<Utc>>() {
|
||||
return Ok(ts);
|
||||
}
|
||||
|
||||
bail!("invalid --since value '{s}' (expected relative like '42m', '2h', '7d' or ISO 8601 timestamp)")
|
||||
bail!(
|
||||
"invalid --since value '{s}' (expected relative like '42m', '2h', '7d' or ISO 8601 timestamp)"
|
||||
)
|
||||
}
|
||||
|
||||
fn try_parse_relative_duration(s: &str) -> Option<chrono::Duration> {
|
||||
|
|
@ -157,7 +154,7 @@ fn follow_logs(
|
|||
run_dir: &Path,
|
||||
mut lines_seen: usize,
|
||||
pretty: bool,
|
||||
styles: &fabro_util::terminal::Styles,
|
||||
styles: &Styles,
|
||||
_is_tty: bool,
|
||||
) -> Result<()> {
|
||||
let conclusion_path = run_dir.join("conclusion.json");
|
||||
|
|
@ -182,7 +179,6 @@ fn follow_logs(
|
|||
lines_seen = all_lines.len();
|
||||
}
|
||||
|
||||
// Stop following when the run has concluded and there are no more new lines
|
||||
if conclusion_path.exists() && all_lines.len() <= lines_seen {
|
||||
debug!("Run concluded, stopping follow");
|
||||
break;
|
||||
|
|
@ -192,25 +188,18 @@ fn follow_logs(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ── Pretty formatter ──────────────────────────────────────────────────
|
||||
|
||||
/// Render markdown text with indentation, wrapping to terminal width.
|
||||
fn render_indented_markdown(
|
||||
styles: &fabro_util::terminal::Styles,
|
||||
text: &str,
|
||||
indent: &str,
|
||||
) -> String {
|
||||
let term_width = fabro_util::terminal::Styles::terminal_width();
|
||||
fn render_indented_markdown(styles: &Styles, text: &str, indent: &str) -> String {
|
||||
let term_width = Styles::terminal_width();
|
||||
let wrap_width = term_width.saturating_sub(indent.len());
|
||||
let rendered = styles.render_markdown_width(text, wrap_width);
|
||||
rendered
|
||||
.lines()
|
||||
.map(|l| format!("{indent}{l}"))
|
||||
.map(|line| format!("{indent}{line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) -> Option<String> {
|
||||
pub fn format_event_pretty(line: &str, styles: &Styles) -> Option<String> {
|
||||
let envelope: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
let event = envelope.get("event")?.as_str()?;
|
||||
let ts = format_timestamp(envelope.get("ts")?.as_str()?);
|
||||
|
|
@ -234,11 +223,10 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
_ => Some(header),
|
||||
}
|
||||
}
|
||||
|
||||
"WorkflowRunCompleted" => {
|
||||
let duration = format_duration_ms(envelope.get("duration_ms"));
|
||||
let status_str = match str_field(&envelope, "status") {
|
||||
Some(s) if !s.is_empty() => s,
|
||||
Some(status) if !status.is_empty() => status,
|
||||
_ => "success",
|
||||
};
|
||||
let status_upper = status_str.to_uppercase();
|
||||
|
|
@ -259,7 +247,7 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
if let Some(usage) = envelope.get("usage") {
|
||||
let total = usage
|
||||
.get("total_tokens")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(|value| value.as_i64())
|
||||
.unwrap_or(0);
|
||||
let pad = " ".repeat(ts.len() + 1);
|
||||
if total > 0 {
|
||||
|
|
@ -271,8 +259,8 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
.apply_to(format!("Tokens: {}", format_tokens(total as u64)))
|
||||
));
|
||||
}
|
||||
if let Some(cr) = usage.get("cache_read_tokens").and_then(|v| v.as_i64()) {
|
||||
let cw = usage
|
||||
if let Some(cache_read) = usage.get("cache_read_tokens").and_then(|v| v.as_i64()) {
|
||||
let cache_write = usage
|
||||
.get("cache_write_tokens")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
|
@ -281,19 +269,20 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
pad,
|
||||
styles.dim.apply_to(format!(
|
||||
"Cache: {} read, {} write",
|
||||
format_tokens(cr as u64),
|
||||
format_tokens(cw as u64)
|
||||
format_tokens(cache_read as u64),
|
||||
format_tokens(cache_write as u64)
|
||||
))
|
||||
));
|
||||
}
|
||||
if let Some(r) = usage.get("reasoning_tokens").and_then(|v| v.as_i64()) {
|
||||
if r > 0 {
|
||||
if let Some(reasoning) = usage.get("reasoning_tokens").and_then(|v| v.as_i64()) {
|
||||
if reasoning > 0 {
|
||||
lines.push(format!(
|
||||
"{}{}",
|
||||
pad,
|
||||
styles
|
||||
.dim
|
||||
.apply_to(format!("Reasoning: {} tokens", format_tokens(r as u64)))
|
||||
styles.dim.apply_to(format!(
|
||||
"Reasoning: {} tokens",
|
||||
format_tokens(reasoning as u64)
|
||||
))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -301,7 +290,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
"WorkflowRunFailed" => {
|
||||
let error = str_field(&envelope, "error").unwrap_or("unknown error");
|
||||
Some(format!(
|
||||
|
|
@ -311,7 +299,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
styles.red.apply_to(error),
|
||||
))
|
||||
}
|
||||
|
||||
"StageStarted" => {
|
||||
let label = str_field(&envelope, "node_label").unwrap_or("?");
|
||||
Some(format!(
|
||||
|
|
@ -321,7 +308,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
styles.bold.apply_to(label),
|
||||
))
|
||||
}
|
||||
|
||||
"StageCompleted" => {
|
||||
let label = str_field(&envelope, "node_label").unwrap_or("?");
|
||||
let duration = format_duration_ms(envelope.get("duration_ms"));
|
||||
|
|
@ -346,7 +332,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
styles.dim.apply_to(&stats),
|
||||
))
|
||||
}
|
||||
|
||||
"StageFailed" => {
|
||||
let label = str_field(&envelope, "node_label").unwrap_or("?");
|
||||
let error = str_field(&envelope, "error").unwrap_or("unknown error");
|
||||
|
|
@ -358,7 +343,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
styles.red.apply_to(error),
|
||||
))
|
||||
}
|
||||
|
||||
"Agent.AssistantMessage" => {
|
||||
let stage = str_field(&envelope, "node_id").unwrap_or("?");
|
||||
let model = str_field(&envelope, "model").unwrap_or("?");
|
||||
|
|
@ -375,12 +359,11 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
let body = render_indented_markdown(styles, text, " ");
|
||||
Some(format!("{header}\n{body}\n"))
|
||||
}
|
||||
|
||||
"Agent.ToolCallStarted" => {
|
||||
let tool = str_field(&envelope, "tool_name").unwrap_or("?");
|
||||
let detail = tool_detail(&envelope);
|
||||
let display = match detail {
|
||||
Some(d) => format!("{tool}({d})"),
|
||||
Some(value) => format!("{tool}({value})"),
|
||||
None => tool.to_string(),
|
||||
};
|
||||
Some(format!(
|
||||
|
|
@ -390,7 +373,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
styles.dim.apply_to(&display),
|
||||
))
|
||||
}
|
||||
|
||||
"Agent.ToolCallCompleted" => {
|
||||
let tool = str_field(&envelope, "tool_name").unwrap_or("?");
|
||||
let is_error = envelope
|
||||
|
|
@ -399,7 +381,7 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
.unwrap_or(false);
|
||||
let detail = tool_detail(&envelope);
|
||||
let display = match detail {
|
||||
Some(d) => format!("{tool}({d})"),
|
||||
Some(value) => format!("{tool}({value})"),
|
||||
None => tool.to_string(),
|
||||
};
|
||||
let glyph = if is_error { "\u{2717}" } else { "\u{2713}" };
|
||||
|
|
@ -411,13 +393,12 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
display,
|
||||
))
|
||||
}
|
||||
|
||||
"EdgeSelected" => {
|
||||
let to = str_field(&envelope, "to_node_id").unwrap_or("?");
|
||||
let reason = str_field(&envelope, "reason").unwrap_or("?");
|
||||
let condition = str_field(&envelope, "condition");
|
||||
let detail = match condition {
|
||||
Some(c) => format!(" [{c}]"),
|
||||
Some(value) => format!(" [{value}]"),
|
||||
None => String::new(),
|
||||
};
|
||||
Some(format!(
|
||||
|
|
@ -429,7 +410,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
styles.dim.apply_to(&detail),
|
||||
))
|
||||
}
|
||||
|
||||
"Sandbox.Ready" => {
|
||||
let provider = str_field(&envelope, "sandbox_provider").unwrap_or("?");
|
||||
let duration = format_duration_ms(envelope.get("duration_ms"));
|
||||
|
|
@ -440,7 +420,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
styles.dim.apply_to(&duration),
|
||||
))
|
||||
}
|
||||
|
||||
"SetupCompleted" => {
|
||||
let count = envelope
|
||||
.get("command_count")
|
||||
|
|
@ -454,9 +433,8 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
styles.dim.apply_to(&duration),
|
||||
))
|
||||
}
|
||||
|
||||
"Agent.CompactionCompleted" => {
|
||||
let orig = envelope
|
||||
let original = envelope
|
||||
.get("original_turn_count")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
|
|
@ -469,10 +447,9 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
styles.dim.apply_to(&ts),
|
||||
styles
|
||||
.dim
|
||||
.apply_to(format!("compaction: {orig}\u{2192}{preserved} turns")),
|
||||
.apply_to(format!("compaction: {original}\u{2192}{preserved} turns")),
|
||||
))
|
||||
}
|
||||
|
||||
"ParallelStarted" => {
|
||||
let count = envelope
|
||||
.get("branch_count")
|
||||
|
|
@ -485,7 +462,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
count,
|
||||
))
|
||||
}
|
||||
|
||||
"ParallelBranchStarted" => {
|
||||
let label = str_field(&envelope, "node_label").unwrap_or("?");
|
||||
Some(format!(
|
||||
|
|
@ -495,7 +471,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
label,
|
||||
))
|
||||
}
|
||||
|
||||
"ParallelBranchCompleted" => {
|
||||
let label = str_field(&envelope, "node_label").unwrap_or("?");
|
||||
Some(format!(
|
||||
|
|
@ -505,7 +480,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
label,
|
||||
))
|
||||
}
|
||||
|
||||
"ParallelCompleted" => {
|
||||
let duration = format_duration_ms(envelope.get("duration_ms"));
|
||||
Some(format!(
|
||||
|
|
@ -515,7 +489,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
duration,
|
||||
))
|
||||
}
|
||||
|
||||
"PullRequestCreated" => {
|
||||
let url = str_field(&envelope, "pr_url").unwrap_or("?");
|
||||
let draft = envelope
|
||||
|
|
@ -530,7 +503,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
url,
|
||||
))
|
||||
}
|
||||
|
||||
"PullRequestFailed" => {
|
||||
let error = str_field(&envelope, "error").unwrap_or("unknown error");
|
||||
Some(format!(
|
||||
|
|
@ -540,7 +512,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
styles.red.apply_to(error),
|
||||
))
|
||||
}
|
||||
|
||||
"RetroCompleted" => {
|
||||
let duration = format_duration_ms(envelope.get("duration_ms"));
|
||||
Some(format!(
|
||||
|
|
@ -550,7 +521,6 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
duration,
|
||||
))
|
||||
}
|
||||
|
||||
"RetroFailed" => {
|
||||
let error = str_field(&envelope, "error").unwrap_or("unknown error");
|
||||
let duration = format_duration_ms(envelope.get("duration_ms"));
|
||||
|
|
@ -562,14 +532,11 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
styles.red.apply_to(error),
|
||||
))
|
||||
}
|
||||
|
||||
"RetroStarted" => Some(format!(
|
||||
"{} {} Retro",
|
||||
styles.dim.apply_to(&ts),
|
||||
styles.bold_cyan.apply_to("\u{25b6}"),
|
||||
)),
|
||||
|
||||
// Noise events — skip
|
||||
"Agent.SessionStarted"
|
||||
| "Agent.SessionEnded"
|
||||
| "Agent.AssistantTextStart"
|
||||
|
|
@ -593,19 +560,15 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
|
|||
| "GitFetch"
|
||||
| "GitReset"
|
||||
| "AssetsCaptured" => None,
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
fn str_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> {
|
||||
value.get(key)?.as_str()
|
||||
}
|
||||
|
||||
fn format_timestamp(ts: &str) -> String {
|
||||
// Parse ISO 8601 and show HH:MM:SS
|
||||
ts.parse::<DateTime<Utc>>()
|
||||
.map(|dt| dt.format("%H:%M:%S").to_string())
|
||||
.unwrap_or_else(|_| ts.to_string())
|
||||
|
|
@ -680,14 +643,15 @@ fn truncate(s: &str, max: usize) -> String {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// === parse_since tests ===
|
||||
fn no_color_styles() -> Styles {
|
||||
Styles::new(false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_since_relative_minutes() {
|
||||
let before = Utc::now();
|
||||
let result = parse_since("42m").unwrap();
|
||||
let after = Utc::now();
|
||||
// Should be roughly 42 minutes ago
|
||||
let expected_lower = after - chrono::Duration::minutes(42) - chrono::Duration::seconds(1);
|
||||
let expected_upper = before - chrono::Duration::minutes(42) + chrono::Duration::seconds(1);
|
||||
assert!(result >= expected_lower && result <= expected_upper);
|
||||
|
|
@ -722,8 +686,6 @@ mod tests {
|
|||
assert!(parse_since("notadate").is_err());
|
||||
}
|
||||
|
||||
// === apply_filters tests ===
|
||||
|
||||
#[test]
|
||||
fn tail_returns_last_n_lines() {
|
||||
let lines: Vec<String> = (0..10).map(|i| format!("line {i}")).collect();
|
||||
|
|
@ -752,8 +714,6 @@ mod tests {
|
|||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
// === raw mode test ===
|
||||
|
||||
#[test]
|
||||
fn raw_lines_pass_through_verbatim() {
|
||||
let lines = vec![
|
||||
|
|
@ -764,12 +724,6 @@ mod tests {
|
|||
assert_eq!(result, lines);
|
||||
}
|
||||
|
||||
// === pretty formatter tests ===
|
||||
|
||||
fn no_color_styles() -> fabro_util::terminal::Styles {
|
||||
fabro_util::terminal::Styles::new(false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pretty_stage_started() {
|
||||
let styles = no_color_styles();
|
||||
|
|
@ -848,7 +802,6 @@ mod tests {
|
|||
assert!(result.contains("smoke"), "got: {result}");
|
||||
assert!(result.contains("abc123"), "got: {result}");
|
||||
assert!(result.contains("Fix the bug"), "got: {result}");
|
||||
// Should be multi-line (header + body)
|
||||
assert!(result.contains('\n'), "got: {result}");
|
||||
}
|
||||
|
||||
|
|
@ -857,7 +810,6 @@ mod tests {
|
|||
let styles = no_color_styles();
|
||||
let line = r#"{"ts":"2026-01-01T14:23:01Z","run_id":"abc123","event":"WorkflowRunStarted","workflow_name":"smoke"}"#;
|
||||
let result = format_event_pretty(line, &styles).unwrap();
|
||||
// Without goal, should be a single line (no newlines)
|
||||
assert!(!result.contains('\n'), "got: {result}");
|
||||
}
|
||||
|
||||
|
|
@ -878,13 +830,11 @@ mod tests {
|
|||
#[test]
|
||||
fn pretty_workflow_run_completed_backward_compat() {
|
||||
let styles = no_color_styles();
|
||||
// Old JSONL without status/usage still renders
|
||||
let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"WorkflowRunCompleted","duration_ms":25000,"total_cost":0.57}"#;
|
||||
let result = format_event_pretty(line, &styles).unwrap();
|
||||
assert!(result.contains("SUCCESS"), "got: {result}");
|
||||
assert!(result.contains("25s"), "got: {result}");
|
||||
assert!(result.contains("$0.57"), "got: {result}");
|
||||
// No usage lines when usage is absent
|
||||
assert!(!result.contains("Tokens:"), "got: {result}");
|
||||
}
|
||||
|
||||
18
lib/crates/fabro-cli/src/commands/mod.rs
Normal file
18
lib/crates/fabro-cli/src/commands/mod.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
pub mod asset;
|
||||
pub mod cp;
|
||||
pub mod diff;
|
||||
pub mod fork;
|
||||
pub mod graph;
|
||||
pub mod inspect;
|
||||
pub mod logs;
|
||||
pub mod parse;
|
||||
pub mod pr;
|
||||
pub mod preview;
|
||||
pub mod rewind;
|
||||
pub mod run;
|
||||
mod run_progress;
|
||||
pub mod runs;
|
||||
mod shared;
|
||||
pub mod ssh;
|
||||
pub mod validate;
|
||||
pub mod workflow;
|
||||
26
lib/crates/fabro-cli/src/commands/parse.rs
Normal file
26
lib/crates/fabro-cli/src/commands/parse.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Args;
|
||||
|
||||
use crate::commands::shared::read_workflow_file;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ParseArgs {
|
||||
/// Path to the .fabro workflow file
|
||||
pub workflow: PathBuf,
|
||||
}
|
||||
|
||||
pub fn run(args: &ParseArgs) -> anyhow::Result<()> {
|
||||
let stdout = std::io::stdout();
|
||||
run_to(args, stdout.lock())
|
||||
}
|
||||
|
||||
fn run_to(args: &ParseArgs, mut out: impl Write) -> anyhow::Result<()> {
|
||||
let (dot_path, _cfg) = fabro_config::project::resolve_workflow(&args.workflow)?;
|
||||
let source = read_workflow_file(&dot_path)?;
|
||||
let ast = fabro_graphviz::parser::parse_ast(&source)?;
|
||||
serde_json::to_writer_pretty(&mut out, &ast)?;
|
||||
writeln!(out)?;
|
||||
Ok(())
|
||||
}
|
||||
419
lib/crates/fabro-cli/src/commands/pr.rs
Normal file
419
lib/crates/fabro-cli/src/commands/pr.rs
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrCreateArgs {
|
||||
/// Run ID or prefix
|
||||
pub run_id: String,
|
||||
/// LLM model for generating PR description
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrListArgs {
|
||||
/// Show all PRs (including closed/merged), not just open
|
||||
#[arg(long)]
|
||||
pub all: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrViewArgs {
|
||||
/// Run ID or prefix
|
||||
pub run_id: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrMergeArgs {
|
||||
/// Run ID or prefix
|
||||
pub run_id: String,
|
||||
/// Merge method: merge, squash, or rebase
|
||||
#[arg(long, default_value = "squash")]
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrCloseArgs {
|
||||
/// Run ID or prefix
|
||||
pub run_id: String,
|
||||
}
|
||||
|
||||
fn load_pr_record(
|
||||
base: &Path,
|
||||
run_id: &str,
|
||||
) -> Result<(fabro_workflows::pull_request::PullRequestRecord, PathBuf)> {
|
||||
let run_dir = fabro_workflows::run_lookup::resolve_run(base, run_id)?.path;
|
||||
let pr_path = run_dir.join("pull_request.json");
|
||||
let content = std::fs::read_to_string(&pr_path).with_context(|| {
|
||||
format!(
|
||||
"No pull_request.json found in run directory. \
|
||||
Create one first with: fabro pr create {run_id}"
|
||||
)
|
||||
})?;
|
||||
let record: fabro_workflows::pull_request::PullRequestRecord =
|
||||
serde_json::from_str(&content).context("Failed to parse pull_request.json")?;
|
||||
Ok((record, run_dir))
|
||||
}
|
||||
|
||||
pub async fn list_command(
|
||||
args: PrListArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
list_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn list_from(
|
||||
base: &Path,
|
||||
args: PrListArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
let runs = fabro_workflows::run_lookup::scan_runs(base).context("Failed to scan runs")?;
|
||||
|
||||
let mut entries: Vec<(String, fabro_workflows::pull_request::PullRequestRecord)> = Vec::new();
|
||||
for run in &runs {
|
||||
let pr_path = run.path.join("pull_request.json");
|
||||
if let Ok(content) = std::fs::read_to_string(&pr_path) {
|
||||
if let Ok(record) =
|
||||
serde_json::from_str::<fabro_workflows::pull_request::PullRequestRecord>(&content)
|
||||
{
|
||||
entries.push((run.run_id.clone(), record));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
println!("No pull requests found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
struct PrRow {
|
||||
run_id: String,
|
||||
number: u64,
|
||||
state: String,
|
||||
title: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
let futures: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|(run_id, record)| {
|
||||
let creds = creds.clone();
|
||||
let run_id = run_id.clone();
|
||||
let record = record.clone();
|
||||
async move {
|
||||
match fabro_github::get_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(detail) => PrRow {
|
||||
run_id,
|
||||
number: detail.number,
|
||||
state: if detail.draft {
|
||||
"draft".to_string()
|
||||
} else {
|
||||
detail.state
|
||||
},
|
||||
title: detail.title,
|
||||
url: detail.html_url,
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::warn!(run_id, error = %err, "Failed to fetch PR state");
|
||||
PrRow {
|
||||
run_id,
|
||||
number: record.number,
|
||||
state: "unknown".to_string(),
|
||||
title: record.title,
|
||||
url: record.html_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let all_rows = futures::future::join_all(futures).await;
|
||||
let rows: Vec<_> = if args.all {
|
||||
all_rows
|
||||
} else {
|
||||
all_rows
|
||||
.into_iter()
|
||||
.filter(|row| row.state == "open" || row.state == "draft" || row.state == "unknown")
|
||||
.collect()
|
||||
};
|
||||
|
||||
if rows.is_empty() {
|
||||
println!("No open pull requests found. Use --all to include closed/merged.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"{:<12} {:<6} {:<8} {:<50} URL",
|
||||
"RUN", "#", "STATE", "TITLE"
|
||||
);
|
||||
for row in &rows {
|
||||
let short_id = if row.run_id.len() > 12 {
|
||||
&row.run_id[..12]
|
||||
} else {
|
||||
&row.run_id
|
||||
};
|
||||
let short_title = if row.title.len() > 50 {
|
||||
format!("{}…", &row.title[..row.title.floor_char_boundary(49)])
|
||||
} else {
|
||||
row.title.clone()
|
||||
};
|
||||
println!(
|
||||
"{:<12} {:<6} {:<8} {:<50} {}",
|
||||
short_id, row.number, row.state, short_title, row.url
|
||||
);
|
||||
}
|
||||
|
||||
info!(count = rows.len(), "Listed pull requests");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn view_command(
|
||||
args: PrViewArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
view_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn view_from(
|
||||
base: &Path,
|
||||
args: PrViewArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let (record, _run_dir) = load_pr_record(base, &args.run_id)?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
let detail = fabro_github::get_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
info!(number = detail.number, owner = %record.owner, repo = %record.repo, "Viewing pull request");
|
||||
|
||||
println!("#{} {}", detail.number, detail.title);
|
||||
let state_display = if detail.draft { "draft" } else { &detail.state };
|
||||
println!("State: {state_display}");
|
||||
println!("URL: {}", detail.html_url);
|
||||
println!(
|
||||
"Branch: {} -> {}",
|
||||
detail.head.ref_name, detail.base.ref_name
|
||||
);
|
||||
println!("Author: {}", detail.user.login);
|
||||
println!(
|
||||
"Changes: +{} -{} ({} files)",
|
||||
detail.additions, detail.deletions, detail.changed_files
|
||||
);
|
||||
if let Some(body) = &detail.body {
|
||||
if !body.is_empty() {
|
||||
println!();
|
||||
println!("{body}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn merge_command(
|
||||
args: PrMergeArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
merge_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn merge_from(
|
||||
base: &Path,
|
||||
args: PrMergeArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let (record, _run_dir) = load_pr_record(base, &args.run_id)?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
fabro_github::merge_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
&args.method,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
info!(number = record.number, owner = %record.owner, repo = %record.repo, method = %args.method, "Merged pull request");
|
||||
println!("Merged #{} ({})", record.number, record.html_url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn close_command(
|
||||
args: PrCloseArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
close_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn close_from(
|
||||
base: &Path,
|
||||
args: PrCloseArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let (record, _run_dir) = load_pr_record(base, &args.run_id)?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
fabro_github::close_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
info!(number = record.number, owner = %record.owner, repo = %record.repo, "Closed pull request");
|
||||
println!("Closed #{} ({})", record.number, record.html_url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_command(
|
||||
args: PrCreateArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
create_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn create_from(
|
||||
base: &Path,
|
||||
args: PrCreateArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let run_dir = fabro_workflows::run_lookup::resolve_run(base, &args.run_id)?.path;
|
||||
|
||||
let manifest = fabro_workflows::manifest::Manifest::load(&run_dir.join("manifest.json"))
|
||||
.context("Failed to load manifest.json")?;
|
||||
|
||||
let conclusion =
|
||||
fabro_workflows::conclusion::Conclusion::load(&run_dir.join("conclusion.json"))
|
||||
.context("Failed to load conclusion.json — is the run finished?")?;
|
||||
|
||||
match conclusion.status {
|
||||
fabro_workflows::outcome::StageStatus::Success
|
||||
| fabro_workflows::outcome::StageStatus::PartialSuccess => {}
|
||||
status => bail!("Run status is '{status}', expected success or partial_success"),
|
||||
}
|
||||
|
||||
let run_branch = manifest
|
||||
.run_branch
|
||||
.as_deref()
|
||||
.context("Run has no run_branch — was it run with git push enabled?")?;
|
||||
|
||||
let diff = std::fs::read_to_string(run_dir.join("final.patch"))
|
||||
.context("Failed to read final.patch — no diff available")?;
|
||||
if diff.trim().is_empty() {
|
||||
bail!("final.patch is empty — nothing to create a PR for");
|
||||
}
|
||||
|
||||
let cwd = std::env::current_dir().context("Failed to get current directory")?;
|
||||
let (origin_url, detected_branch) =
|
||||
fabro_daytona::detect_repo_info(&cwd).map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
let base_branch = manifest
|
||||
.base_branch
|
||||
.as_deref()
|
||||
.or(detected_branch.as_deref())
|
||||
.unwrap_or("main");
|
||||
|
||||
let https_url = fabro_github::ssh_url_to_https(&origin_url);
|
||||
let (owner, repo) = fabro_github::parse_github_owner_repo(&https_url)
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
let branch_found = fabro_github::branch_exists(
|
||||
&creds,
|
||||
&owner,
|
||||
&repo,
|
||||
run_branch,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
if !branch_found {
|
||||
bail!(
|
||||
"Branch '{run_branch}' not found on GitHub. \
|
||||
Was it pushed? Try: git push origin {run_branch}"
|
||||
);
|
||||
}
|
||||
|
||||
let model = args
|
||||
.model
|
||||
.unwrap_or_else(|| fabro_llm::catalog::default_model().id.to_string());
|
||||
|
||||
let record = fabro_workflows::pull_request::maybe_open_pull_request(
|
||||
&creds,
|
||||
&origin_url,
|
||||
base_branch,
|
||||
run_branch,
|
||||
&manifest.goal,
|
||||
&diff,
|
||||
&model,
|
||||
true,
|
||||
None,
|
||||
&run_dir,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
match record {
|
||||
Some(record) => {
|
||||
info!(pr_url = %record.html_url, "Pull request created");
|
||||
if let Err(err) = record.save(&run_dir.join("pull_request.json")) {
|
||||
tracing::warn!(error = %err, "Failed to save pull_request.json");
|
||||
}
|
||||
println!("{}", record.html_url);
|
||||
}
|
||||
None => {
|
||||
println!("No pull request created (empty diff).");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
93
lib/crates/fabro-cli/src/commands/preview.rs
Normal file
93
lib/crates/fabro-cli/src/commands/preview.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PreviewArgs {
|
||||
/// Run ID or prefix
|
||||
pub run: String,
|
||||
/// Port number
|
||||
pub port: u16,
|
||||
/// Generate a signed URL (embeds auth token, no headers needed)
|
||||
#[arg(long)]
|
||||
pub signed: bool,
|
||||
/// Signed URL expiry in seconds (default 3600, requires --signed)
|
||||
#[arg(long, default_value = "3600", requires = "signed")]
|
||||
pub ttl: i32,
|
||||
/// Open URL in browser (implies --signed)
|
||||
#[arg(long)]
|
||||
pub open: bool,
|
||||
}
|
||||
|
||||
impl PreviewArgs {
|
||||
fn use_signed(&self) -> bool {
|
||||
self.signed || self.open
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(args: PreviewArgs) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
let run_dir = fabro_workflows::run_lookup::resolve_run(&base, &args.run)?.path;
|
||||
let sandbox_json = run_dir.join("sandbox.json");
|
||||
let record = fabro_workflows::sandbox_record::SandboxRecord::load(&sandbox_json).context(
|
||||
"Failed to load sandbox.json — was this run started with a recent version of arc?",
|
||||
)?;
|
||||
|
||||
validate_provider(&record)?;
|
||||
|
||||
let name = record
|
||||
.identifier
|
||||
.as_deref()
|
||||
.context("Daytona sandbox record missing identifier (sandbox name)")?;
|
||||
|
||||
info!(run_id = %args.run, provider = %record.provider, port = args.port, "Generating preview URL");
|
||||
|
||||
let daytona = fabro_daytona::DaytonaSandbox::reconnect(name)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
if args.use_signed() {
|
||||
let signed = daytona
|
||||
.get_signed_preview_url(args.port, Some(args.ttl))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
print!("{}", format_signed_output(&signed.url));
|
||||
|
||||
if args.open {
|
||||
std::process::Command::new("open")
|
||||
.arg(&signed.url)
|
||||
.spawn()
|
||||
.context("Failed to open browser")?;
|
||||
}
|
||||
} else {
|
||||
let preview = daytona
|
||||
.get_preview_link(args.port)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
print!("{}", format_standard_output(&preview.url, &preview.token));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_provider(record: &fabro_workflows::sandbox_record::SandboxRecord) -> Result<()> {
|
||||
if record.provider != "daytona" {
|
||||
bail!(
|
||||
"Preview URLs are only supported for Daytona sandboxes (this run uses '{}')",
|
||||
record.provider
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_standard_output(url: &str, token: &str) -> String {
|
||||
let mut out = format!("URL: {url}\nToken: {token}\n");
|
||||
out.push_str(&format!(
|
||||
"\ncurl -H \"x-daytona-preview-token: {token}\" \\\n -H \"X-Daytona-Skip-Preview-Warning: true\" \\\n {url}\n"
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
fn format_signed_output(url: &str) -> String {
|
||||
format!("{url}\n")
|
||||
}
|
||||
119
lib/crates/fabro-cli/src/commands/rewind.rs
Normal file
119
lib/crates/fabro-cli/src/commands/rewind.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use clap::Args;
|
||||
use cli_table::format::{Border, Separator};
|
||||
use cli_table::{print_stderr, Cell, CellStruct, Color, Style, Table};
|
||||
use fabro_git_storage::gitobj::Store;
|
||||
use fabro_util::terminal::Styles;
|
||||
use git2::Repository;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct RewindArgs {
|
||||
/// Run ID (or unambiguous prefix)
|
||||
pub run_id: String,
|
||||
|
||||
/// Target checkpoint: node name, node@visit, or @ordinal (omit with --list)
|
||||
pub target: Option<String>,
|
||||
|
||||
/// Show the checkpoint timeline instead of rewinding
|
||||
#[arg(long)]
|
||||
pub list: bool,
|
||||
|
||||
/// Skip force-pushing rewound refs to the remote
|
||||
#[arg(long)]
|
||||
pub no_push: bool,
|
||||
}
|
||||
|
||||
pub fn run(args: &RewindArgs, styles: &Styles) -> Result<()> {
|
||||
let repo = Repository::discover(".").context("not in a git repository")?;
|
||||
let run_id = fabro_workflows::run_rewind::find_run_id_by_prefix(&repo, &args.run_id)?;
|
||||
let store = Store::new(repo);
|
||||
|
||||
let timeline = fabro_workflows::run_rewind::build_timeline(&store, &run_id)?;
|
||||
|
||||
if args.list || args.target.is_none() {
|
||||
let parallel_map = fabro_workflows::run_rewind::load_parallel_map(&store, &run_id);
|
||||
print_timeline(&timeline, ¶llel_map, styles);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let target = fabro_workflows::run_rewind::parse_target(args.target.as_deref().unwrap())?;
|
||||
let parallel_map = fabro_workflows::run_rewind::load_parallel_map(&store, &run_id);
|
||||
let entry = fabro_workflows::run_rewind::resolve_target(&timeline, &target, ¶llel_map)?;
|
||||
|
||||
fabro_workflows::run_rewind::execute_rewind(&store, &run_id, entry, !args.no_push)?;
|
||||
|
||||
eprintln!(
|
||||
"\nTo resume: fabro run --run-branch {}{}",
|
||||
fabro_workflows::git::RUN_BRANCH_PREFIX,
|
||||
run_id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn print_timeline(
|
||||
timeline: &[fabro_workflows::run_rewind::TimelineEntry],
|
||||
parallel_map: &std::collections::HashMap<String, String>,
|
||||
styles: &Styles,
|
||||
) {
|
||||
if timeline.is_empty() {
|
||||
eprintln!("No checkpoints found.");
|
||||
return;
|
||||
}
|
||||
|
||||
let use_color = styles.use_color;
|
||||
let title = vec![
|
||||
"@".cell().bold(true),
|
||||
"Node".cell().bold(true),
|
||||
"Details".cell().bold(true),
|
||||
];
|
||||
|
||||
let rows: Vec<Vec<CellStruct>> = timeline
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let ordinal_str = format!("@{}", entry.ordinal);
|
||||
let mut details = Vec::new();
|
||||
if entry.visit > 1 {
|
||||
details.push(format!("visit {}, loop", entry.visit));
|
||||
}
|
||||
if parallel_map.contains_key(&entry.node_name) {
|
||||
details.push("parallel interior".to_string());
|
||||
}
|
||||
if entry.run_commit_sha.is_none() {
|
||||
details.push("no run commit".to_string());
|
||||
}
|
||||
|
||||
let detail_str = if details.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("({})", details.join(", "))
|
||||
};
|
||||
|
||||
vec![
|
||||
ordinal_str
|
||||
.cell()
|
||||
.foreground_color(color_if(use_color, Color::Cyan)),
|
||||
entry.node_name.clone().cell(),
|
||||
detail_str
|
||||
.cell()
|
||||
.foreground_color(color_if(use_color, Color::Ansi256(8))),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
|
||||
let table = rows
|
||||
.table()
|
||||
.title(title)
|
||||
.border(Border::builder().build())
|
||||
.separator(Separator::builder().build());
|
||||
let _ = print_stderr(table);
|
||||
}
|
||||
|
||||
fn color_if(use_color: bool, color: Color) -> Option<Color> {
|
||||
if use_color {
|
||||
Some(color)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -7,12 +7,13 @@ use async_trait::async_trait;
|
|||
use console::Style;
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
|
||||
|
||||
use crate::event::{EventEmitter, WorkflowRunEvent};
|
||||
use crate::outcome::StageStatus;
|
||||
use fabro_agent::AgentEvent;
|
||||
use fabro_interview::{Answer, ConsoleInterviewer, Interviewer, Question};
|
||||
use fabro_workflows::event::{EventEmitter, WorkflowRunEvent};
|
||||
use fabro_workflows::outcome::StageStatus;
|
||||
|
||||
use super::{compute_stage_cost, format_cost, format_tokens_human};
|
||||
use crate::commands::shared::{format_tokens_human, tilde_path};
|
||||
use fabro_workflows::cost::{compute_stage_cost, format_cost};
|
||||
|
||||
// ── Cached styles ───────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -785,7 +786,7 @@ impl ProgressUI {
|
|||
// ── Logs dir (called externally) ────────────────────────────────────
|
||||
|
||||
pub fn show_run_dir(&mut self, run_dir: &Path) {
|
||||
let path_str = super::tilde_path(run_dir);
|
||||
let path_str = tilde_path(run_dir);
|
||||
match &self.renderer {
|
||||
ProgressRenderer::Tty(tty) => {
|
||||
let bar = tty.multi.add(ProgressBar::new_spinner());
|
||||
|
|
@ -839,7 +840,7 @@ impl ProgressUI {
|
|||
}
|
||||
|
||||
pub fn show_worktree(&mut self, path: &Path) {
|
||||
let path_str = super::tilde_path(path);
|
||||
let path_str = tilde_path(path);
|
||||
match &self.renderer {
|
||||
ProgressRenderer::Tty(tty) => {
|
||||
let bar = tty.multi.add(ProgressBar::new_spinner());
|
||||
634
lib/crates/fabro-cli/src/commands/runs.rs
Normal file
634
lib/crates/fabro-cli/src/commands/runs.rs
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::Args;
|
||||
use cli_table::format::{Border, Justify, Separator};
|
||||
use cli_table::{print_stdout, Cell, CellStruct, Color, Style, Table};
|
||||
use fabro_util::terminal::Styles;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct RunFilterArgs {
|
||||
/// Only include runs started before this date (YYYY-MM-DD prefix match)
|
||||
#[arg(long)]
|
||||
pub before: Option<String>,
|
||||
|
||||
/// Filter by workflow name (substring match)
|
||||
#[arg(long)]
|
||||
pub workflow: Option<String>,
|
||||
|
||||
/// Filter by label (KEY=VALUE, repeatable, AND semantics)
|
||||
#[arg(long = "label", value_name = "KEY=VALUE")]
|
||||
pub label: Vec<String>,
|
||||
|
||||
/// Include orphan directories (no manifest.json)
|
||||
#[arg(long)]
|
||||
pub orphans: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct RunsListArgs {
|
||||
#[command(flatten)]
|
||||
pub filter: RunFilterArgs,
|
||||
|
||||
/// Output as JSON
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
|
||||
/// Show all runs, not just running (like docker ps -a)
|
||||
#[arg(short = 'a', long)]
|
||||
pub all: bool,
|
||||
|
||||
/// Only display run IDs
|
||||
#[arg(short = 'q', long)]
|
||||
pub quiet: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct RunsPruneArgs {
|
||||
#[command(flatten)]
|
||||
pub filter: RunFilterArgs,
|
||||
|
||||
/// Only prune runs older than this duration (e.g. 24h, 7d). Default: 24h when no explicit filters are set.
|
||||
#[arg(long, value_name = "DURATION", value_parser = parse_duration)]
|
||||
pub older_than: Option<chrono::Duration>,
|
||||
|
||||
/// Actually delete (default is dry-run)
|
||||
#[arg(long)]
|
||||
pub yes: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct RunsRemoveArgs {
|
||||
/// Run IDs or workflow names to remove
|
||||
#[arg(required = true)]
|
||||
pub runs: Vec<String>,
|
||||
|
||||
/// Force removal of active runs
|
||||
#[arg(short, long)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct DfArgs {
|
||||
/// Show per-run breakdown
|
||||
#[arg(short, long)]
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
pub fn list_command(args: &RunsListArgs, styles: &Styles) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
let runs = fabro_workflows::run_lookup::scan_runs(&base)?;
|
||||
let label_filters = parse_label_filters(&args.filter.label);
|
||||
let filtered = fabro_workflows::run_lookup::filter_runs(
|
||||
&runs,
|
||||
args.filter.before.as_deref(),
|
||||
args.filter.workflow.as_deref(),
|
||||
&label_filters,
|
||||
args.filter.orphans,
|
||||
if args.all {
|
||||
fabro_workflows::run_lookup::StatusFilter::All
|
||||
} else {
|
||||
fabro_workflows::run_lookup::StatusFilter::RunningOnly
|
||||
},
|
||||
);
|
||||
|
||||
if args.quiet {
|
||||
for run in &filtered {
|
||||
println!("{}", run.run_id);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&filtered)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if filtered.is_empty() {
|
||||
if args.all {
|
||||
eprintln!("No runs found.");
|
||||
} else {
|
||||
eprintln!("No running processes found. Use -a to show all runs.");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut display_runs = filtered;
|
||||
display_runs.reverse();
|
||||
|
||||
let use_color = styles.use_color;
|
||||
let title = vec![
|
||||
"RUN ID".cell().bold(true),
|
||||
"WORKFLOW".cell().bold(true),
|
||||
"STATUS".cell().bold(true),
|
||||
"DIRECTORY".cell().bold(true),
|
||||
"DURATION".cell().bold(true),
|
||||
"GOAL".cell().bold(true),
|
||||
];
|
||||
|
||||
let rows: Vec<Vec<CellStruct>> = display_runs
|
||||
.iter()
|
||||
.map(|run| {
|
||||
let duration_display = match run.duration_ms {
|
||||
Some(ms) => format_duration_ms(ms),
|
||||
None => match run.start_time_dt {
|
||||
Some(start) => {
|
||||
let elapsed = Utc::now().signed_duration_since(start);
|
||||
format_duration_ms(elapsed.num_milliseconds().max(0) as u64)
|
||||
}
|
||||
None => "-".to_string(),
|
||||
},
|
||||
};
|
||||
let dir_display = run
|
||||
.host_repo_path
|
||||
.as_deref()
|
||||
.map(|p| tilde_path(Path::new(p)))
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
|
||||
vec![
|
||||
short_run_id(&run.run_id)
|
||||
.cell()
|
||||
.foreground_color(color_if(use_color, Color::Ansi256(8))),
|
||||
run.workflow_name.clone().cell(),
|
||||
status_cell(run.status, use_color),
|
||||
dir_display.cell(),
|
||||
duration_display.cell(),
|
||||
truncate_goal(&run.goal, 50)
|
||||
.cell()
|
||||
.foreground_color(color_if(use_color, Color::Ansi256(8))),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
|
||||
let table = rows
|
||||
.table()
|
||||
.title(title)
|
||||
.border(Border::builder().build())
|
||||
.separator(Separator::builder().build());
|
||||
print_stdout(table)?;
|
||||
|
||||
eprintln!("\n{} run(s) listed.", display_runs.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn df_command(args: &DfArgs) -> Result<()> {
|
||||
let data_dir = fabro_workflows::run_lookup::default_data_dir();
|
||||
let runs_base = fabro_workflows::run_lookup::default_runs_base();
|
||||
let logs_base = fabro_workflows::run_lookup::default_logs_base();
|
||||
df_from(args, &data_dir, &runs_base, &logs_base)
|
||||
}
|
||||
|
||||
pub fn prune_command(args: &RunsPruneArgs) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
prune_from(args, &base)
|
||||
}
|
||||
|
||||
pub async fn remove_command(args: &RunsRemoveArgs) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
remove_from(args, &base).await
|
||||
}
|
||||
|
||||
fn status_cell(status: fabro_workflows::run_status::RunStatus, use_color: bool) -> CellStruct {
|
||||
let text = status.to_string();
|
||||
let color = match status {
|
||||
fabro_workflows::run_status::RunStatus::Succeeded => Some(Color::Green),
|
||||
fabro_workflows::run_status::RunStatus::Failed => Some(Color::Red),
|
||||
fabro_workflows::run_status::RunStatus::Running
|
||||
| fabro_workflows::run_status::RunStatus::Starting
|
||||
| fabro_workflows::run_status::RunStatus::Submitted => Some(Color::Cyan),
|
||||
fabro_workflows::run_status::RunStatus::Removing => Some(Color::Yellow),
|
||||
fabro_workflows::run_status::RunStatus::Paused => Some(Color::Magenta),
|
||||
fabro_workflows::run_status::RunStatus::Dead => Some(Color::Ansi256(8)),
|
||||
};
|
||||
text.cell()
|
||||
.bold(use_color && color != Some(Color::Ansi256(8)))
|
||||
.foreground_color(color_if(use_color, color.unwrap_or(Color::Ansi256(8))))
|
||||
}
|
||||
|
||||
fn parse_label_filters(label_args: &[String]) -> Vec<(String, String)> {
|
||||
label_args
|
||||
.iter()
|
||||
.filter_map(|s| s.split_once('='))
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn short_run_id(id: &str) -> &str {
|
||||
if id.len() > 12 {
|
||||
&id[..12]
|
||||
} else {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_goal(goal: &str, max_len: usize) -> String {
|
||||
let line = goal.lines().next().unwrap_or("");
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
if chars.len() <= max_len {
|
||||
return line.to_string();
|
||||
}
|
||||
let truncated: String = chars[..max_len - 3].iter().collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
|
||||
fn color_if(use_color: bool, color: Color) -> Option<Color> {
|
||||
if use_color {
|
||||
Some(color)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn tilde_path(path: &Path) -> String {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
if let Ok(suffix) = path.strip_prefix(&home) {
|
||||
return format!("~/{}", suffix.display());
|
||||
}
|
||||
}
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
fn format_duration_ms(ms: u64) -> String {
|
||||
let duration = Duration::from_millis(ms);
|
||||
let secs = duration.as_secs();
|
||||
if secs >= 60 {
|
||||
format!("{}m{:02}s", secs / 60, secs % 60)
|
||||
} else if duration.as_millis() >= 1000 {
|
||||
format!("{secs}s")
|
||||
} else {
|
||||
format!("{}ms", duration.as_millis())
|
||||
}
|
||||
}
|
||||
|
||||
fn dir_size(path: &Path) -> u64 {
|
||||
walkdir::WalkDir::new(path)
|
||||
.into_iter()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter_map(|entry| entry.metadata().ok())
|
||||
.filter(|metadata| metadata.is_file())
|
||||
.map(|metadata| metadata.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = 1024 * KB;
|
||||
const GB: u64 = 1024 * MB;
|
||||
|
||||
if bytes >= GB {
|
||||
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{:.1} KB", bytes as f64 / KB as f64)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
fn df_from(args: &DfArgs, data_dir: &Path, runs_base: &Path, logs_base: &Path) -> Result<()> {
|
||||
let runs = fabro_workflows::run_lookup::scan_runs(runs_base)?;
|
||||
let mut active_count = 0u64;
|
||||
let mut total_run_size = 0u64;
|
||||
let mut reclaimable_run_size = 0u64;
|
||||
|
||||
struct RunSizeInfo {
|
||||
run_id: String,
|
||||
workflow_name: String,
|
||||
status: fabro_workflows::run_status::RunStatus,
|
||||
start_time_dt: Option<DateTime<Utc>>,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
let mut run_details = Vec::new();
|
||||
for run in &runs {
|
||||
let size = dir_size(&run.path);
|
||||
total_run_size += size;
|
||||
if run.status.is_active() {
|
||||
active_count += 1;
|
||||
} else {
|
||||
reclaimable_run_size += size;
|
||||
}
|
||||
if args.verbose {
|
||||
run_details.push(RunSizeInfo {
|
||||
run_id: run.run_id.clone(),
|
||||
workflow_name: run.workflow_name.clone(),
|
||||
status: run.status,
|
||||
start_time_dt: run.start_time_dt,
|
||||
size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut log_count = 0u64;
|
||||
let mut total_log_size = 0u64;
|
||||
if let Ok(entries) = std::fs::read_dir(logs_base) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if path.extension().is_some_and(|ext| ext == "log") {
|
||||
if let Ok(meta) = path.metadata() {
|
||||
log_count += 1;
|
||||
total_log_size += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut db_count = 0u64;
|
||||
let mut total_db_size = 0u64;
|
||||
if let Ok(entries) = std::fs::read_dir(data_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.ends_with(".db") || name.ends_with(".db-wal") || name.ends_with(".db-shm") {
|
||||
if let Ok(meta) = path.metadata() {
|
||||
db_count += 1;
|
||||
total_db_size += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let run_reclaim_pct = if total_run_size > 0 {
|
||||
(reclaimable_run_size as f64 / total_run_size as f64 * 100.0) as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let log_reclaim_pct = if total_log_size > 0 { 100 } else { 0 };
|
||||
|
||||
let summary_title = vec![
|
||||
"TYPE".cell().bold(true),
|
||||
"COUNT".cell().bold(true).justify(Justify::Right),
|
||||
"ACTIVE".cell().bold(true).justify(Justify::Right),
|
||||
"SIZE".cell().bold(true).justify(Justify::Right),
|
||||
"RECLAIMABLE".cell().bold(true).justify(Justify::Right),
|
||||
];
|
||||
let summary_rows: Vec<Vec<CellStruct>> = vec![
|
||||
vec![
|
||||
"Runs".cell(),
|
||||
runs.len().cell().justify(Justify::Right),
|
||||
active_count.cell().justify(Justify::Right),
|
||||
format_size(total_run_size).cell().justify(Justify::Right),
|
||||
format!("{} ({run_reclaim_pct}%)", format_size(reclaimable_run_size))
|
||||
.cell()
|
||||
.justify(Justify::Right),
|
||||
],
|
||||
vec![
|
||||
"Logs".cell(),
|
||||
log_count.cell().justify(Justify::Right),
|
||||
"-".cell().justify(Justify::Right),
|
||||
format_size(total_log_size).cell().justify(Justify::Right),
|
||||
format!("{} ({log_reclaim_pct}%)", format_size(total_log_size))
|
||||
.cell()
|
||||
.justify(Justify::Right),
|
||||
],
|
||||
vec![
|
||||
"Databases".cell(),
|
||||
db_count.cell().justify(Justify::Right),
|
||||
"-".cell().justify(Justify::Right),
|
||||
format_size(total_db_size).cell().justify(Justify::Right),
|
||||
format!("{} (0%)", format_size(0))
|
||||
.cell()
|
||||
.justify(Justify::Right),
|
||||
],
|
||||
];
|
||||
let summary_table = summary_rows
|
||||
.table()
|
||||
.title(summary_title)
|
||||
.border(Border::builder().build())
|
||||
.separator(Separator::builder().build());
|
||||
print_stdout(summary_table)?;
|
||||
|
||||
println!();
|
||||
println!("Data directory: {}", data_dir.display());
|
||||
|
||||
if !args.verbose {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!();
|
||||
let verbose_title = vec![
|
||||
"RUN ID".cell().bold(true),
|
||||
"WORKFLOW".cell().bold(true),
|
||||
"STATUS".cell().bold(true),
|
||||
"AGE".cell().bold(true).justify(Justify::Right),
|
||||
"SIZE".cell().bold(true).justify(Justify::Right),
|
||||
];
|
||||
|
||||
let now = Utc::now();
|
||||
let verbose_rows: Vec<Vec<CellStruct>> = run_details
|
||||
.iter()
|
||||
.map(|detail| {
|
||||
let age = if let Some(dt) = detail.start_time_dt {
|
||||
let dur = now.signed_duration_since(dt);
|
||||
if dur.num_days() > 0 {
|
||||
format!("{}d", dur.num_days())
|
||||
} else if dur.num_hours() > 0 {
|
||||
format!("{}h", dur.num_hours())
|
||||
} else {
|
||||
format!("{}m", dur.num_minutes().max(1))
|
||||
}
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
let size_display = if detail.status.is_active() {
|
||||
format_size(detail.size)
|
||||
} else {
|
||||
format!("{} *", format_size(detail.size))
|
||||
};
|
||||
vec![
|
||||
short_run_id(&detail.run_id).cell(),
|
||||
truncate_goal(&detail.workflow_name, 16).cell(),
|
||||
detail.status.to_string().cell(),
|
||||
age.cell().justify(Justify::Right),
|
||||
size_display.cell().justify(Justify::Right),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let verbose_table = verbose_rows
|
||||
.table()
|
||||
.title(verbose_title)
|
||||
.border(Border::builder().build())
|
||||
.separator(Separator::builder().build());
|
||||
print_stdout(verbose_table)?;
|
||||
println!();
|
||||
println!("* = reclaimable");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_duration(s: &str) -> Result<chrono::Duration> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
bail!("empty duration string");
|
||||
}
|
||||
let (num_str, unit) = s.split_at(s.len() - 1);
|
||||
let num: u64 = num_str
|
||||
.parse()
|
||||
.with_context(|| format!("invalid duration: {s}"))?;
|
||||
match unit {
|
||||
"h" => Ok(chrono::Duration::hours(num as i64)),
|
||||
"d" => Ok(chrono::Duration::days(num as i64)),
|
||||
_ => bail!("invalid duration unit '{unit}' in '{s}' (expected 'h' or 'd')"),
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_from(args: &RunsPruneArgs, base: &Path) -> Result<()> {
|
||||
let runs = fabro_workflows::run_lookup::scan_runs(base)?;
|
||||
let label_filters = parse_label_filters(&args.filter.label);
|
||||
let mut filtered = fabro_workflows::run_lookup::filter_runs(
|
||||
&runs,
|
||||
args.filter.before.as_deref(),
|
||||
args.filter.workflow.as_deref(),
|
||||
&label_filters,
|
||||
args.filter.orphans,
|
||||
fabro_workflows::run_lookup::StatusFilter::All,
|
||||
);
|
||||
|
||||
let has_explicit_filters =
|
||||
args.filter.before.is_some() || args.filter.workflow.is_some() || !label_filters.is_empty();
|
||||
let staleness_threshold = if let Some(duration) = args.older_than {
|
||||
Some(duration)
|
||||
} else if !has_explicit_filters {
|
||||
Some(chrono::Duration::hours(24))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(threshold) = staleness_threshold {
|
||||
let cutoff = Utc::now() - threshold;
|
||||
filtered.retain(|run| {
|
||||
if run.status.is_active() {
|
||||
return false;
|
||||
}
|
||||
run.end_time
|
||||
.or(run.start_time_dt)
|
||||
.is_some_and(|time| time < cutoff)
|
||||
});
|
||||
}
|
||||
|
||||
if filtered.is_empty() {
|
||||
eprintln!("No matching runs to prune.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let total_bytes: u64 = filtered.iter().map(|run| dir_size(&run.path)).sum();
|
||||
info!(count = filtered.len(), bytes = total_bytes, "pruning runs");
|
||||
|
||||
if args.yes {
|
||||
for run in &filtered {
|
||||
info!(run_id = %run.run_id, path = %run.path.display(), "deleting run");
|
||||
std::fs::remove_dir_all(&run.path)?;
|
||||
}
|
||||
eprintln!(
|
||||
"{} run(s) deleted ({} freed).",
|
||||
filtered.len(),
|
||||
format_size(total_bytes)
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for run in &filtered {
|
||||
debug!(run_id = %run.run_id, "would delete run (dry-run)");
|
||||
println!("would delete: {} ({})", run.dir_name, run.workflow_name);
|
||||
}
|
||||
eprintln!(
|
||||
"\n{} run(s) would be deleted ({} freed). Pass --yes to confirm.",
|
||||
filtered.len(),
|
||||
format_size(total_bytes)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_from(args: &RunsRemoveArgs, base: &Path) -> Result<()> {
|
||||
let mut had_errors = false;
|
||||
|
||||
for identifier in &args.runs {
|
||||
let run = match fabro_workflows::run_lookup::resolve_run(base, identifier) {
|
||||
Ok(run) => run,
|
||||
Err(err) => {
|
||||
eprintln!("error: {identifier}: {err}");
|
||||
had_errors = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if run.status.is_active() && !args.force {
|
||||
eprintln!(
|
||||
"cannot remove active run {} (status: {}, use -f to force)",
|
||||
short_run_id(&run.run_id),
|
||||
run.status
|
||||
);
|
||||
had_errors = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
fabro_workflows::run_status::write_run_status(
|
||||
&run.path,
|
||||
fabro_workflows::run_status::RunStatus::Removing,
|
||||
None,
|
||||
);
|
||||
|
||||
let sandbox_path = run.path.join("sandbox.json");
|
||||
if let Ok(record) = fabro_workflows::sandbox_record::SandboxRecord::load(&sandbox_path) {
|
||||
if record.provider != "local" {
|
||||
match fabro_workflows::sandbox_reconnect::reconnect(&record).await {
|
||||
Ok(sandbox) => {
|
||||
if let Err(err) = sandbox.cleanup().await {
|
||||
warn!(run_id = %run.run_id, error = %err, "sandbox cleanup failed");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(run_id = %run.run_id, error = %err, "sandbox reconnect failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::remove_dir_all(&run.path)
|
||||
.with_context(|| format!("failed to delete {}", run.path.display()))?;
|
||||
eprintln!("{}", short_run_id(&run.run_id));
|
||||
}
|
||||
|
||||
if had_errors {
|
||||
bail!("some runs could not be removed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_duration_hours() {
|
||||
assert_eq!(parse_duration("24h").unwrap(), chrono::Duration::hours(24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_duration_days() {
|
||||
assert_eq!(parse_duration("7d").unwrap(), chrono::Duration::days(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_duration_rejects_invalid_unit() {
|
||||
let err = parse_duration("5m").unwrap_err();
|
||||
assert!(err.to_string().contains("invalid duration unit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_size_humanizes_thresholds() {
|
||||
assert_eq!(format_size(999), "999 B");
|
||||
assert_eq!(format_size(1024), "1.0 KB");
|
||||
assert_eq!(format_size(1024 * 1024), "1.0 MB");
|
||||
}
|
||||
}
|
||||
102
lib/crates/fabro-cli/src/commands/shared.rs
Normal file
102
lib/crates/fabro-cli/src/commands/shared.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
use std::path::Path;
|
||||
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_validate::{Diagnostic, Severity};
|
||||
|
||||
pub fn read_workflow_file(path: &Path) -> anyhow::Result<String> {
|
||||
std::fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
pub fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles) {
|
||||
for d in diagnostics {
|
||||
let location = match (&d.node_id, &d.edge) {
|
||||
(Some(node), _) => format!(" [node: {node}]"),
|
||||
(_, Some((from, to))) => format!(" [edge: {from} -> {to}]"),
|
||||
_ => String::new(),
|
||||
};
|
||||
match d.severity {
|
||||
Severity::Error => eprintln!(
|
||||
"{}{location}: {} ({})",
|
||||
styles.red.apply_to("error"),
|
||||
d.message,
|
||||
styles.dim.apply_to(&d.rule),
|
||||
),
|
||||
Severity::Warning => eprintln!(
|
||||
"{}{location}: {} ({})",
|
||||
styles.yellow.apply_to("warning"),
|
||||
d.message,
|
||||
styles.dim.apply_to(&d.rule),
|
||||
),
|
||||
Severity::Info => eprintln!(
|
||||
"{}",
|
||||
styles
|
||||
.dim
|
||||
.apply_to(format!("info{location}: {} ({})", d.message, d.rule)),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn relative_path(path: &Path) -> String {
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
if let Ok(rel) = path.strip_prefix(&cwd) {
|
||||
return rel.display().to_string();
|
||||
}
|
||||
}
|
||||
tilde_path(path)
|
||||
}
|
||||
|
||||
pub fn format_tokens_human(tokens: i64) -> String {
|
||||
if tokens >= 1_000_000 {
|
||||
format!("{:.1}m", tokens as f64 / 1_000_000.0)
|
||||
} else if tokens >= 1000 {
|
||||
format!("{:.1}k", tokens as f64 / 1000.0)
|
||||
} else {
|
||||
tokens.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tilde_path(path: &Path) -> String {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
if let Ok(suffix) = path.strip_prefix(&home) {
|
||||
return format!("~/{}", suffix.display());
|
||||
}
|
||||
}
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::format_tokens_human;
|
||||
|
||||
#[test]
|
||||
fn format_tokens_human_zero() {
|
||||
assert_eq!(format_tokens_human(0), "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tokens_human_small() {
|
||||
assert_eq!(format_tokens_human(999), "999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tokens_human_thousands() {
|
||||
assert_eq!(format_tokens_human(1000), "1.0k");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tokens_human_mid_thousands() {
|
||||
assert_eq!(format_tokens_human(15234), "15.2k");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tokens_human_millions() {
|
||||
assert_eq!(format_tokens_human(1_000_000), "1.0m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tokens_human_mid_millions() {
|
||||
assert_eq!(format_tokens_human(3_456_789), "3.5m");
|
||||
}
|
||||
}
|
||||
83
lib/crates/fabro-cli/src/commands/ssh.rs
Normal file
83
lib/crates/fabro-cli/src/commands/ssh.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct SshArgs {
|
||||
/// Run ID or prefix
|
||||
pub run: String,
|
||||
/// SSH access expiry in minutes (default 60)
|
||||
#[arg(long, default_value = "60")]
|
||||
pub ttl: f64,
|
||||
/// Print the SSH command instead of connecting
|
||||
#[arg(long)]
|
||||
pub print: bool,
|
||||
}
|
||||
|
||||
pub async fn run(args: SshArgs) -> Result<()> {
|
||||
let base = fabro_workflows::run_lookup::default_runs_base();
|
||||
let run_dir = fabro_workflows::run_lookup::resolve_run(&base, &args.run)?.path;
|
||||
let sandbox_json = run_dir.join("sandbox.json");
|
||||
let record = fabro_workflows::sandbox_record::SandboxRecord::load(&sandbox_json).context(
|
||||
"Failed to load sandbox.json — was this run started with a recent version of arc?",
|
||||
)?;
|
||||
|
||||
validate_provider(&record)?;
|
||||
|
||||
let name = record
|
||||
.identifier
|
||||
.as_deref()
|
||||
.context("Daytona sandbox record missing identifier (sandbox name)")?;
|
||||
|
||||
info!(run_id = %args.run, ttl_minutes = args.ttl, "Creating SSH access");
|
||||
|
||||
let daytona = fabro_daytona::DaytonaSandbox::reconnect(name)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let ssh_cmd = daytona
|
||||
.create_ssh_access(Some(args.ttl))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
if args.print {
|
||||
print!("{}", format_output(&ssh_cmd));
|
||||
} else {
|
||||
exec_ssh(&ssh_cmd)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_provider(record: &fabro_workflows::sandbox_record::SandboxRecord) -> Result<()> {
|
||||
if record.provider != "daytona" {
|
||||
bail!(
|
||||
"SSH access is only supported for Daytona sandboxes (this run uses '{}')",
|
||||
record.provider
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_output(ssh_command: &str) -> String {
|
||||
format!("{ssh_command}\n")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn exec_ssh(ssh_cmd: &str) -> Result<()> {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let parts: Vec<&str> = ssh_cmd.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
bail!("Empty SSH command returned from Daytona");
|
||||
}
|
||||
let err = std::process::Command::new(parts[0])
|
||||
.args(&parts[1..])
|
||||
.exec();
|
||||
Err(anyhow::anyhow!("Failed to exec SSH: {err}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn exec_ssh(_ssh_cmd: &str) -> Result<()> {
|
||||
bail!("Direct SSH connection is only supported on Unix systems; use --print instead");
|
||||
}
|
||||
41
lib/crates/fabro-cli/src/commands/validate.rs
Normal file
41
lib/crates/fabro-cli/src/commands/validate.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::bail;
|
||||
use clap::Args;
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_validate::Severity;
|
||||
|
||||
use crate::commands::shared::{print_diagnostics, relative_path};
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ValidateArgs {
|
||||
/// Path to the .fabro workflow file
|
||||
pub workflow: PathBuf,
|
||||
}
|
||||
|
||||
pub fn run(args: &ValidateArgs, styles: &Styles) -> anyhow::Result<()> {
|
||||
let (dot_path, _cfg) = fabro_config::project::resolve_workflow(&args.workflow)?;
|
||||
|
||||
let (graph, diagnostics) = fabro_workflows::workflow::prepare_from_file(&dot_path)?;
|
||||
|
||||
eprintln!(
|
||||
"{} ({} nodes, {} edges)",
|
||||
styles.bold.apply_to(format!("Workflow: {}", graph.name)),
|
||||
graph.nodes.len(),
|
||||
graph.edges.len(),
|
||||
);
|
||||
eprintln!(
|
||||
"{} {}",
|
||||
styles.dim.apply_to("Graph:"),
|
||||
styles.dim.apply_to(relative_path(&dot_path)),
|
||||
);
|
||||
|
||||
print_diagnostics(&diagnostics, styles);
|
||||
|
||||
if diagnostics.iter().any(|d| d.severity == Severity::Error) {
|
||||
bail!("Validation failed");
|
||||
}
|
||||
|
||||
eprintln!("Validation: {}", styles.green.apply_to("OK"));
|
||||
Ok(())
|
||||
}
|
||||
231
lib/crates/fabro-cli/src/commands/workflow.rs
Normal file
231
lib/crates/fabro-cli/src/commands/workflow.rs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use clap::Args;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::commands::shared::relative_path;
|
||||
|
||||
const GOAL_MAX_LEN: usize = 60;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct WorkflowListArgs {}
|
||||
|
||||
pub fn list_command(_args: &WorkflowListArgs) -> anyhow::Result<()> {
|
||||
let styles = Styles::detect_stderr();
|
||||
let cwd = std::env::current_dir()?;
|
||||
|
||||
let (config_path, config) = match fabro_config::project::discover_project_config(&cwd)? {
|
||||
Some(found) => found,
|
||||
None => bail!(
|
||||
"No fabro.toml found in {cwd} or any parent directory",
|
||||
cwd = cwd.display()
|
||||
),
|
||||
};
|
||||
|
||||
let fabro_root = fabro_config::project::resolve_fabro_root(&config_path, &config);
|
||||
let project_wf_dir = fabro_root.join("workflows");
|
||||
let user_wf_dir = dirs::home_dir().map(|h| h.join(".fabro").join("workflows"));
|
||||
|
||||
let workflows = fabro_config::project::list_workflows_detailed(
|
||||
Some(&project_wf_dir),
|
||||
user_wf_dir.as_deref(),
|
||||
);
|
||||
|
||||
let project: Vec<_> = workflows
|
||||
.iter()
|
||||
.filter(|w| w.source == fabro_config::project::WorkflowSource::Project)
|
||||
.collect();
|
||||
let user: Vec<_> = workflows
|
||||
.iter()
|
||||
.filter(|w| w.source == fabro_config::project::WorkflowSource::User)
|
||||
.collect();
|
||||
|
||||
let name_width = workflows.iter().map(|w| w.name.len()).max().unwrap_or(0);
|
||||
|
||||
eprintln!(
|
||||
"{} workflow(s) found\n",
|
||||
styles.bold.apply_to(workflows.len())
|
||||
);
|
||||
|
||||
let user_path = user_wf_dir
|
||||
.as_deref()
|
||||
.map(relative_path)
|
||||
.unwrap_or_else(|| "~/.fabro/workflows".to_string());
|
||||
print_section("User Workflows", &user_path, &user, name_width, &styles);
|
||||
|
||||
eprintln!();
|
||||
|
||||
print_section(
|
||||
"Project Workflows",
|
||||
&relative_path(&project_wf_dir),
|
||||
&project,
|
||||
name_width,
|
||||
&styles,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct WorkflowCreateArgs {
|
||||
/// Name of the workflow
|
||||
pub name: String,
|
||||
|
||||
/// Goal description for the workflow
|
||||
#[arg(short, long)]
|
||||
goal: Option<String>,
|
||||
}
|
||||
|
||||
pub fn create_command(args: &WorkflowCreateArgs) -> anyhow::Result<()> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
|
||||
let (config_path, config) = match fabro_config::project::discover_project_config(&cwd)? {
|
||||
Some(found) => found,
|
||||
None => bail!(
|
||||
"No fabro.toml found in {cwd} or any parent directory",
|
||||
cwd = cwd.display()
|
||||
),
|
||||
};
|
||||
|
||||
let fabro_root = fabro_config::project::resolve_fabro_root(&config_path, &config);
|
||||
write_workflow_scaffold(args, &fabro_root)?;
|
||||
|
||||
let workflows_dir = fabro_root.join("workflows").join(&args.name);
|
||||
let green = console::Style::new().green();
|
||||
let bold = console::Style::new().bold();
|
||||
let cyan_bold = console::Style::new().cyan().bold();
|
||||
let dim = console::Style::new().dim();
|
||||
|
||||
let rel_dir = relative_path(&workflows_dir);
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
green.apply_to("✔"),
|
||||
dim.apply_to(format!("{rel_dir}/workflow.fabro"))
|
||||
);
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
green.apply_to("✔"),
|
||||
dim.apply_to(format!("{rel_dir}/workflow.toml"))
|
||||
);
|
||||
|
||||
eprintln!("\n{} Next steps:\n", bold.apply_to("Workflow created!"));
|
||||
eprintln!(
|
||||
" 1. Edit the graph: {}",
|
||||
cyan_bold.apply_to(format!("{rel_dir}/workflow.fabro"))
|
||||
);
|
||||
eprintln!(
|
||||
" 2. Validate: {}",
|
||||
cyan_bold.apply_to(format!("fabro validate {}", args.name))
|
||||
);
|
||||
eprintln!(
|
||||
" 3. Run: {}",
|
||||
cyan_bold.apply_to(format!("fabro run {}", args.name))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_workflow_scaffold(args: &WorkflowCreateArgs, fabro_root: &Path) -> anyhow::Result<()> {
|
||||
let workflows_dir = fabro_root.join("workflows").join(&args.name);
|
||||
|
||||
if workflows_dir.exists() {
|
||||
bail!(
|
||||
"Workflow '{}' already exists at {}",
|
||||
args.name,
|
||||
workflows_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&workflows_dir)
|
||||
.with_context(|| format!("failed to create {}", workflows_dir.display()))?;
|
||||
|
||||
let goal = args.goal.as_deref().unwrap_or("TODO: describe the goal");
|
||||
let digraph_name = to_pascal_case(&args.name);
|
||||
|
||||
let fabro_content = format!(
|
||||
r#"digraph {digraph_name} {{
|
||||
graph [goal="{goal}"]
|
||||
rankdir=LR
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
|
||||
main [label="Main", prompt="TODO: describe what this agent should do"]
|
||||
|
||||
start -> main -> exit
|
||||
}}
|
||||
"#
|
||||
);
|
||||
|
||||
let dot_path = workflows_dir.join("workflow.fabro");
|
||||
std::fs::write(&dot_path, &fabro_content)
|
||||
.with_context(|| format!("failed to write {}", dot_path.display()))?;
|
||||
|
||||
let toml_path = workflows_dir.join("workflow.toml");
|
||||
std::fs::write(&toml_path, "version = 1\n")
|
||||
.with_context(|| format!("failed to write {}", toml_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_pascal_case(s: &str) -> String {
|
||||
s.split(['-', '_'])
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(|part| {
|
||||
let mut chars = part.chars();
|
||||
match chars.next() {
|
||||
Some(first) => {
|
||||
let upper: String = first.to_uppercase().collect();
|
||||
format!("{upper}{rest}", rest = chars.as_str())
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn print_section(
|
||||
title: &str,
|
||||
path: &str,
|
||||
workflows: &[&fabro_config::project::WorkflowInfo],
|
||||
name_width: usize,
|
||||
styles: &Styles,
|
||||
) {
|
||||
eprintln!(
|
||||
"{} {}",
|
||||
styles.bold.apply_to(title),
|
||||
styles.dim.apply_to(format!("({path})")),
|
||||
);
|
||||
if workflows.is_empty() {
|
||||
eprintln!(" {}", styles.dim.apply_to("(none)"));
|
||||
return;
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" {:<name_width$} {}",
|
||||
styles.bold_dim.apply_to("NAME"),
|
||||
styles.bold_dim.apply_to("DESCRIPTION"),
|
||||
);
|
||||
for w in workflows {
|
||||
let goal_str = w
|
||||
.goal
|
||||
.as_deref()
|
||||
.map(|g| truncate_str(g, GOAL_MAX_LEN))
|
||||
.unwrap_or_default();
|
||||
eprintln!(
|
||||
" {:<name_width$} {}",
|
||||
styles.cyan.apply_to(&w.name),
|
||||
styles.dim.apply_to(goal_str),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_str(s: &str, max: usize) -> String {
|
||||
let first_line = s.lines().next().unwrap_or(s);
|
||||
if first_line.len() <= max {
|
||||
first_line.to_string()
|
||||
} else {
|
||||
format!("{}...", &first_line[..max - 3])
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
mod cli_config;
|
||||
mod commands;
|
||||
mod doctor;
|
||||
mod init;
|
||||
mod install;
|
||||
|
|
@ -69,32 +70,32 @@ enum Command {
|
|||
#[command(hide = true)]
|
||||
Exec(fabro_agent::cli::AgentArgs),
|
||||
/// Launch a workflow run
|
||||
Run(fabro_workflows::cli::RunArgs),
|
||||
Run(commands::run::RunArgs),
|
||||
/// Validate a workflow
|
||||
Validate(fabro_workflows::cli::ValidateArgs),
|
||||
Validate(commands::validate::ValidateArgs),
|
||||
/// Render a workflow graph as SVG or PNG
|
||||
Graph(fabro_workflows::cli::graph::GraphArgs),
|
||||
Graph(commands::graph::GraphArgs),
|
||||
/// Parse a DOT file and print its AST
|
||||
#[command(hide = true)]
|
||||
Parse(fabro_workflows::cli::ParseArgs),
|
||||
Parse(commands::parse::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),
|
||||
Cp(commands::cp::CpArgs),
|
||||
/// Get a preview URL for a port on a run's sandbox
|
||||
Preview(fabro_workflows::cli::preview::PreviewArgs),
|
||||
Preview(commands::preview::PreviewArgs),
|
||||
/// SSH into a run's Daytona sandbox
|
||||
Ssh(fabro_workflows::cli::ssh::SshArgs),
|
||||
Ssh(commands::ssh::SshArgs),
|
||||
/// Show the diff of changes from a workflow run
|
||||
#[command(hide = true)]
|
||||
Diff(fabro_workflows::cli::diff::DiffArgs),
|
||||
Diff(commands::diff::DiffArgs),
|
||||
/// View the event log of a workflow run
|
||||
Logs(fabro_workflows::cli::logs::LogsArgs),
|
||||
Logs(commands::logs::LogsArgs),
|
||||
/// Show detailed information about a workflow run
|
||||
Inspect(fabro_workflows::cli::inspect::InspectArgs),
|
||||
Inspect(commands::inspect::InspectArgs),
|
||||
/// List and test LLM models
|
||||
Model {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -119,9 +120,9 @@ enum Command {
|
|||
Install,
|
||||
/// List workflow runs
|
||||
#[command(hide = true)]
|
||||
Ps(fabro_workflows::cli::runs::RunsListArgs),
|
||||
Ps(commands::runs::RunsListArgs),
|
||||
/// Remove one or more workflow runs
|
||||
Rm(fabro_workflows::cli::runs::RunsRemoveArgs),
|
||||
Rm(commands::runs::RunsRemoveArgs),
|
||||
/// Pull request operations
|
||||
Pr {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -133,9 +134,9 @@ enum Command {
|
|||
command: SkillCommand,
|
||||
},
|
||||
/// Rewind a workflow run to an earlier checkpoint
|
||||
Rewind(fabro_workflows::cli::rewind::RewindArgs),
|
||||
Rewind(commands::rewind::RewindArgs),
|
||||
/// Fork a workflow run from an earlier checkpoint into a new run
|
||||
Fork(fabro_workflows::cli::fork::ForkArgs),
|
||||
Fork(commands::fork::ForkArgs),
|
||||
/// Workflow operations
|
||||
Workflow {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -169,23 +170,23 @@ enum Command {
|
|||
#[derive(Subcommand)]
|
||||
enum PrCommand {
|
||||
/// Create a pull request from a completed run
|
||||
Create(fabro_workflows::cli::pr::PrCreateArgs),
|
||||
Create(commands::pr::PrCreateArgs),
|
||||
/// List pull requests from workflow runs
|
||||
List(fabro_workflows::cli::pr::PrListArgs),
|
||||
List(commands::pr::PrListArgs),
|
||||
/// View pull request details
|
||||
View(fabro_workflows::cli::pr::PrViewArgs),
|
||||
View(commands::pr::PrViewArgs),
|
||||
/// Merge a pull request
|
||||
Merge(fabro_workflows::cli::pr::PrMergeArgs),
|
||||
Merge(commands::pr::PrMergeArgs),
|
||||
/// Close a pull request
|
||||
Close(fabro_workflows::cli::pr::PrCloseArgs),
|
||||
Close(commands::pr::PrCloseArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SystemCommand {
|
||||
/// Delete old workflow runs
|
||||
Prune(fabro_workflows::cli::runs::RunsPruneArgs),
|
||||
Prune(commands::runs::RunsPruneArgs),
|
||||
/// Show disk usage
|
||||
Df(fabro_workflows::cli::runs::DfArgs),
|
||||
Df(commands::runs::DfArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
|
|
@ -197,17 +198,17 @@ enum SkillCommand {
|
|||
#[derive(Subcommand)]
|
||||
enum WorkflowCommand {
|
||||
/// List available workflows
|
||||
List(fabro_workflows::cli::workflow::WorkflowListArgs),
|
||||
List(commands::workflow::WorkflowListArgs),
|
||||
/// Create a new workflow
|
||||
Create(fabro_workflows::cli::workflow::WorkflowCreateArgs),
|
||||
Create(commands::workflow::WorkflowCreateArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum AssetCommand {
|
||||
/// List assets for a workflow run
|
||||
List(fabro_workflows::cli::asset::AssetListArgs),
|
||||
List(commands::asset::AssetListArgs),
|
||||
/// Copy assets from a workflow run
|
||||
Cp(fabro_workflows::cli::asset::AssetCpArgs),
|
||||
Cp(commands::asset::AssetCpArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
|
|
@ -237,7 +238,7 @@ pub(crate) fn build_github_app_credentials(
|
|||
}
|
||||
|
||||
/// Fork the workflow as a background process, print the run ID, and exit.
|
||||
fn detach_run(args: fabro_workflows::cli::RunArgs) -> Result<()> {
|
||||
fn detach_run(args: commands::run::RunArgs) -> Result<()> {
|
||||
let run_id = ulid::Ulid::new().to_string();
|
||||
|
||||
let run_dir = args.run_dir.clone().unwrap_or_else(|| {
|
||||
|
|
@ -253,7 +254,7 @@ fn detach_run(args: fabro_workflows::cli::RunArgs) -> Result<()> {
|
|||
});
|
||||
std::fs::create_dir_all(&run_dir)?;
|
||||
std::fs::write(run_dir.join("id.txt"), &run_id)?;
|
||||
fabro_workflows::cli::runs::write_run_status(
|
||||
fabro_workflows::run_status::write_run_status(
|
||||
&run_dir,
|
||||
fabro_workflows::run_status::RunStatus::Submitted,
|
||||
None,
|
||||
|
|
@ -665,7 +666,7 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
#[cfg(feature = "sleep_inhibitor")]
|
||||
let _sleep_guard = fabro_beastie::guard(cli_config.prevent_idle_sleep);
|
||||
|
||||
fabro_workflows::cli::run::run_command(
|
||||
commands::run::run_command(
|
||||
args,
|
||||
cli_config.run_defaults,
|
||||
styles,
|
||||
|
|
@ -676,41 +677,41 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
}
|
||||
Command::Validate(args) => {
|
||||
let styles = fabro_util::terminal::Styles::detect_stderr();
|
||||
fabro_workflows::cli::validate::validate_command(&args, &styles)?;
|
||||
commands::validate::run(&args, &styles)?;
|
||||
}
|
||||
Command::Graph(args) => {
|
||||
let styles = fabro_util::terminal::Styles::detect_stderr();
|
||||
fabro_workflows::cli::graph::graph_command(&args, &styles)?;
|
||||
commands::graph::run(&args, &styles)?;
|
||||
}
|
||||
Command::Parse(args) => {
|
||||
fabro_workflows::cli::parse::parse_command(&args)?;
|
||||
commands::parse::run(&args)?;
|
||||
}
|
||||
Command::Asset { command } => match command {
|
||||
AssetCommand::List(args) => {
|
||||
fabro_workflows::cli::asset::list_command(&args)?;
|
||||
commands::asset::list_command(&args)?;
|
||||
}
|
||||
AssetCommand::Cp(args) => {
|
||||
fabro_workflows::cli::asset::cp_command(&args)?;
|
||||
commands::asset::cp_command(&args)?;
|
||||
}
|
||||
},
|
||||
Command::Cp(args) => {
|
||||
fabro_workflows::cli::cp::cp_command(args).await?;
|
||||
commands::cp::cp_command(args).await?;
|
||||
}
|
||||
Command::Preview(args) => {
|
||||
fabro_workflows::cli::preview::preview_command(args).await?;
|
||||
commands::preview::run(args).await?;
|
||||
}
|
||||
Command::Ssh(args) => {
|
||||
fabro_workflows::cli::ssh::ssh_command(args).await?;
|
||||
commands::ssh::run(args).await?;
|
||||
}
|
||||
Command::Diff(args) => {
|
||||
fabro_workflows::cli::diff::diff_command(args).await?;
|
||||
commands::diff::run(args).await?;
|
||||
}
|
||||
Command::Logs(args) => {
|
||||
let styles = fabro_util::terminal::Styles::detect_stdout();
|
||||
fabro_workflows::cli::logs::logs_command(args, &styles)?;
|
||||
commands::logs::run(args, &styles)?;
|
||||
}
|
||||
Command::Inspect(args) => {
|
||||
fabro_workflows::cli::inspect::inspect_command(&args)?;
|
||||
commands::inspect::run(&args)?;
|
||||
}
|
||||
Command::Model { command } => {
|
||||
let server = {
|
||||
|
|
@ -767,46 +768,46 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
}
|
||||
Command::Ps(args) => {
|
||||
let styles = fabro_util::terminal::Styles::detect_stdout();
|
||||
fabro_workflows::cli::runs::list_command(&args, &styles)?;
|
||||
commands::runs::list_command(&args, &styles)?;
|
||||
}
|
||||
Command::Rm(args) => {
|
||||
fabro_workflows::cli::runs::remove_command(&args).await?;
|
||||
commands::runs::remove_command(&args).await?;
|
||||
}
|
||||
Command::Pr { command } => {
|
||||
let cli_config = cli_config::load_cli_config(None)?;
|
||||
let github_app = build_github_app_credentials(cli_config.app_id());
|
||||
match command {
|
||||
PrCommand::Create(args) => {
|
||||
fabro_workflows::cli::pr::pr_create_command(args, github_app).await?;
|
||||
commands::pr::create_command(args, github_app).await?;
|
||||
}
|
||||
PrCommand::List(args) => {
|
||||
fabro_workflows::cli::pr::pr_list_command(args, github_app).await?;
|
||||
commands::pr::list_command(args, github_app).await?;
|
||||
}
|
||||
PrCommand::View(args) => {
|
||||
fabro_workflows::cli::pr::pr_view_command(args, github_app).await?;
|
||||
commands::pr::view_command(args, github_app).await?;
|
||||
}
|
||||
PrCommand::Merge(args) => {
|
||||
fabro_workflows::cli::pr::pr_merge_command(args, github_app).await?;
|
||||
commands::pr::merge_command(args, github_app).await?;
|
||||
}
|
||||
PrCommand::Close(args) => {
|
||||
fabro_workflows::cli::pr::pr_close_command(args, github_app).await?;
|
||||
commands::pr::close_command(args, github_app).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Rewind(args) => {
|
||||
let styles = fabro_util::terminal::Styles::detect_stderr();
|
||||
fabro_workflows::cli::rewind::rewind_command(&args, &styles)?;
|
||||
commands::rewind::run(&args, &styles)?;
|
||||
}
|
||||
Command::Fork(args) => {
|
||||
let styles = fabro_util::terminal::Styles::detect_stderr();
|
||||
fabro_workflows::cli::fork::fork_command(&args, &styles)?;
|
||||
commands::fork::run(&args, &styles)?;
|
||||
}
|
||||
Command::Workflow { command } => match command {
|
||||
WorkflowCommand::List(args) => {
|
||||
fabro_workflows::cli::workflow::workflow_list_command(&args)?;
|
||||
commands::workflow::list_command(&args)?;
|
||||
}
|
||||
WorkflowCommand::Create(args) => {
|
||||
fabro_workflows::cli::workflow::workflow_create_command(&args)?;
|
||||
commands::workflow::create_command(&args)?;
|
||||
}
|
||||
},
|
||||
Command::Skill { command } => match command {
|
||||
|
|
@ -819,10 +820,10 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
}
|
||||
Command::System { command } => match command {
|
||||
SystemCommand::Prune(args) => {
|
||||
fabro_workflows::cli::runs::prune_command(&args)?;
|
||||
commands::runs::prune_command(&args)?;
|
||||
}
|
||||
SystemCommand::Df(args) => {
|
||||
fabro_workflows::cli::runs::df_command(&args)?;
|
||||
commands::runs::df_command(&args)?;
|
||||
}
|
||||
},
|
||||
Command::SendAnalytics { path } => {
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ pub struct LocalSandboxConfig {
|
|||
pub worktree_mode: WorktreeMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct SandboxConfig {
|
||||
pub provider: Option<String>,
|
||||
pub preserve: Option<bool>,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ default = []
|
|||
exedev = ["dep:fabro-exe", "fabro-config/exedev"]
|
||||
|
||||
[dependencies]
|
||||
clap.workspace = true
|
||||
anyhow.workspace = true
|
||||
dotenvy.workspace = true
|
||||
fabro-agent = { path = "../fabro-agent" }
|
||||
|
|
@ -53,9 +52,6 @@ hex.workspace = true
|
|||
sha2 = { workspace = true }
|
||||
shlex = "1"
|
||||
git2.workspace = true
|
||||
cli-table.workspace = true
|
||||
console.workspace = true
|
||||
indicatif.workspace = true
|
||||
tokio-util.workspace = true
|
||||
tracing.workspace = true
|
||||
walkdir.workspace = true
|
||||
|
|
|
|||
79
lib/crates/fabro-workflows/src/assets.rs
Normal file
79
lib/crates/fabro-workflows/src/assets.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::asset_snapshot::AssetCollectionSummary;
|
||||
|
||||
/// 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, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&path.display().to_string())
|
||||
}
|
||||
|
||||
/// Walk `{run_dir}/artifacts/assets/*/retry_*/manifest.json`, stat each file, and 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(read_dir) => read_dir,
|
||||
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(|value| value.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(|metadata| metadata.len())
|
||||
.unwrap_or(0);
|
||||
entries.push(AssetEntry {
|
||||
node_slug: node_slug.clone(),
|
||||
retry,
|
||||
relative_path: relative_path.clone(),
|
||||
absolute_path,
|
||||
size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ use fabro_llm::client::Client;
|
|||
use fabro_llm::provider::Provider;
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::cost::compute_stage_cost;
|
||||
use crate::error::FabroError;
|
||||
use crate::event::WorkflowRunEvent;
|
||||
use crate::handler::agent::{CodergenBackend, CodergenResult};
|
||||
|
|
@ -390,7 +391,7 @@ impl CodergenBackend for AgentApiBackend {
|
|||
reasoning_tokens: response.usage.reasoning_tokens,
|
||||
cost: None,
|
||||
};
|
||||
stage_usage.cost = super::compute_stage_cost(&stage_usage);
|
||||
stage_usage.cost = compute_stage_cost(&stage_usage);
|
||||
|
||||
Ok(CodergenResult::Text {
|
||||
text: response.text(),
|
||||
|
|
@ -584,7 +585,7 @@ impl CodergenBackend for AgentApiBackend {
|
|||
reasoning_tokens: total_usage.reasoning_tokens,
|
||||
cost: None,
|
||||
};
|
||||
stage_usage.cost = super::compute_stage_cost(&stage_usage);
|
||||
stage_usage.cost = compute_stage_cost(&stage_usage);
|
||||
|
||||
// Extract last assistant response from the session history.
|
||||
let response = session
|
||||
|
|
@ -8,6 +8,7 @@ use fabro_agent::Sandbox;
|
|||
use fabro_llm::provider::Provider;
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::cost::compute_stage_cost;
|
||||
use crate::error::FabroError;
|
||||
use crate::event::{EventEmitter, WorkflowRunEvent};
|
||||
use crate::handler::agent::{CodergenBackend, CodergenResult};
|
||||
|
|
@ -707,7 +708,7 @@ impl CodergenBackend for AgentCliBackend {
|
|||
reasoning_tokens: None,
|
||||
cost: None,
|
||||
};
|
||||
stage_usage.cost = super::compute_stage_cost(&stage_usage);
|
||||
stage_usage.cost = compute_stage_cost(&stage_usage);
|
||||
|
||||
Ok(CodergenResult::Text {
|
||||
text: parsed.text,
|
||||
5
lib/crates/fabro-workflows/src/backend/mod.rs
Normal file
5
lib/crates/fabro-workflows/src/backend/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod api;
|
||||
pub mod cli;
|
||||
|
||||
pub use api::AgentApiBackend;
|
||||
pub use cli::{parse_cli_response, AgentCliBackend, BackendRouter};
|
||||
|
|
@ -1,317 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,386 +0,0 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::cli::runs::{default_runs_base, resolve_run};
|
||||
use crate::sandbox_record::SandboxRecord;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct CpArgs {
|
||||
/// Source: <run-id>:<path> or local path
|
||||
pub src: String,
|
||||
/// Destination: <run-id>:<path> or local path
|
||||
pub dst: String,
|
||||
/// Recurse into directories
|
||||
#[arg(short, long)]
|
||||
pub recursive: bool,
|
||||
}
|
||||
|
||||
/// Parsed copy direction.
|
||||
enum CopyDirection {
|
||||
/// Download from sandbox to local
|
||||
Download {
|
||||
run_prefix: String,
|
||||
remote_path: String,
|
||||
local_path: PathBuf,
|
||||
},
|
||||
/// Upload from local to sandbox
|
||||
Upload {
|
||||
local_path: PathBuf,
|
||||
run_prefix: String,
|
||||
remote_path: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parse src/dst to determine direction.
|
||||
///
|
||||
/// The convention is: `<run-id>:<path>` refers to a sandbox path,
|
||||
/// and a plain path (no colon) is local. We split on the first colon.
|
||||
fn parse_direction(src: &str, dst: &str) -> Result<CopyDirection> {
|
||||
let src_parts = split_run_path(src);
|
||||
let dst_parts = split_run_path(dst);
|
||||
|
||||
match (src_parts, dst_parts) {
|
||||
(Some((run_prefix, remote_path)), None) => Ok(CopyDirection::Download {
|
||||
run_prefix: run_prefix.to_string(),
|
||||
remote_path: remote_path.to_string(),
|
||||
local_path: PathBuf::from(dst),
|
||||
}),
|
||||
(None, Some((run_prefix, remote_path))) => Ok(CopyDirection::Upload {
|
||||
local_path: PathBuf::from(src),
|
||||
run_prefix: run_prefix.to_string(),
|
||||
remote_path: remote_path.to_string(),
|
||||
}),
|
||||
(Some(_), Some(_)) => {
|
||||
bail!("Cannot copy between two sandboxes; one argument must be a local path")
|
||||
}
|
||||
(None, None) => bail!("One argument must contain a run-id prefix (e.g. <run-id>:<path>)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `"run-id:path"` on the first colon.
|
||||
/// Returns `None` if the string doesn't look like a run-id:path reference.
|
||||
///
|
||||
/// We distinguish local paths from run references by checking:
|
||||
/// - Paths starting with `/`, `./`, or `../` are always local
|
||||
/// - Otherwise, split on the first colon
|
||||
pub(crate) fn split_run_path(s: &str) -> Option<(&str, &str)> {
|
||||
if s.starts_with('/') || s.starts_with("./") || s.starts_with("../") {
|
||||
return None;
|
||||
}
|
||||
s.split_once(':')
|
||||
}
|
||||
|
||||
/// Reconnect to a sandbox from a saved record.
|
||||
///
|
||||
/// Returns a sandbox that can perform file operations.
|
||||
/// Note: for Docker and Local sandboxes, the container/directory may still
|
||||
/// need to be alive. For Daytona and Exe, we reconnect via their APIs.
|
||||
pub async fn reconnect(record: &SandboxRecord) -> Result<Box<dyn fabro_agent::sandbox::Sandbox>> {
|
||||
debug!(
|
||||
provider = %record.provider,
|
||||
identifier = record.identifier.as_deref().unwrap_or(""),
|
||||
"Reconnecting to sandbox"
|
||||
);
|
||||
|
||||
match record.provider.as_str() {
|
||||
"local" => {
|
||||
let sandbox = fabro_agent::local_sandbox::LocalSandbox::new(PathBuf::from(
|
||||
&record.working_directory,
|
||||
));
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
"docker" => {
|
||||
let host_dir = record
|
||||
.host_working_directory
|
||||
.as_deref()
|
||||
.context("Docker sandbox record missing host_working_directory")?;
|
||||
let mount_point = record
|
||||
.container_mount_point
|
||||
.as_deref()
|
||||
.unwrap_or("/workspace");
|
||||
|
||||
// Docker uses bind mounts — file operations can go directly through
|
||||
// the host filesystem without needing the container running.
|
||||
// We create a DockerSandboxConfig with the bind-mount info and use
|
||||
// a LocalSandbox pointed at the host directory (since we just need
|
||||
// file copy operations, not container exec).
|
||||
let config = fabro_agent::docker_sandbox::DockerSandboxConfig {
|
||||
host_working_directory: host_dir.to_string(),
|
||||
container_mount_point: mount_point.to_string(),
|
||||
..fabro_agent::docker_sandbox::DockerSandboxConfig::default()
|
||||
};
|
||||
let sandbox = fabro_agent::docker_sandbox::DockerSandbox::new(config)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Docker sandbox: {e}"))?;
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
"daytona" => {
|
||||
let name = record
|
||||
.identifier
|
||||
.as_deref()
|
||||
.context("Daytona sandbox record missing identifier (sandbox name)")?;
|
||||
|
||||
let sandbox = fabro_daytona::DaytonaSandbox::reconnect(name)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
#[cfg(feature = "exedev")]
|
||||
"exe" => {
|
||||
let data_host = record
|
||||
.data_host
|
||||
.as_deref()
|
||||
.context("Exe sandbox record missing data_host")?;
|
||||
|
||||
let data_ssh = fabro_exe::OpensshRunner::connect(data_host)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to connect to exe sandbox '{data_host}': {e}")
|
||||
})?;
|
||||
|
||||
let sandbox = fabro_exe::ExeSandbox::from_existing(Box::new(data_ssh));
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
"ssh" => {
|
||||
let destination = record
|
||||
.data_host
|
||||
.as_deref()
|
||||
.context("SSH sandbox record missing data_host (destination)")?;
|
||||
|
||||
let ssh = fabro_ssh::OpensshRunner::connect(destination, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to connect to SSH sandbox '{destination}': {e}")
|
||||
})?;
|
||||
|
||||
let config = fabro_ssh::SshConfig {
|
||||
destination: destination.to_string(),
|
||||
working_directory: record.working_directory.clone(),
|
||||
config_file: None,
|
||||
preview_url_base: None,
|
||||
};
|
||||
let sandbox = fabro_ssh::SshSandbox::from_existing(Box::new(ssh), config);
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
other => bail!("Unknown sandbox provider: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and reconnect to a sandbox from a run directory.
|
||||
async fn load_sandbox(
|
||||
base: &Path,
|
||||
run_prefix: &str,
|
||||
) -> Result<Box<dyn fabro_agent::sandbox::Sandbox>> {
|
||||
let run_dir = resolve_run(base, run_prefix)?.path;
|
||||
let sandbox_json = run_dir.join("sandbox.json");
|
||||
debug!(path = %sandbox_json.display(), "Loading sandbox record");
|
||||
let record = SandboxRecord::load(&sandbox_json).context(
|
||||
"Failed to load sandbox.json — was this run started with a recent version of arc?",
|
||||
)?;
|
||||
|
||||
info!(run_id = %run_prefix, provider = %record.provider, "Connecting to sandbox");
|
||||
reconnect(&record).await
|
||||
}
|
||||
|
||||
pub async fn cp_command(args: CpArgs) -> Result<()> {
|
||||
let direction = parse_direction(&args.src, &args.dst)?;
|
||||
let base = default_runs_base();
|
||||
|
||||
match direction {
|
||||
CopyDirection::Download {
|
||||
run_prefix,
|
||||
remote_path,
|
||||
local_path,
|
||||
} => {
|
||||
let sandbox = load_sandbox(&base, &run_prefix).await?;
|
||||
|
||||
if args.recursive {
|
||||
download_recursive(&*sandbox, &remote_path, &local_path).await?;
|
||||
} else {
|
||||
debug!(path = %remote_path, "Downloading file from sandbox");
|
||||
sandbox
|
||||
.download_file_to_local(&remote_path, &local_path)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
}
|
||||
info!(direction = "download", path = %remote_path, "Copy complete");
|
||||
}
|
||||
CopyDirection::Upload {
|
||||
local_path,
|
||||
run_prefix,
|
||||
remote_path,
|
||||
} => {
|
||||
let sandbox = load_sandbox(&base, &run_prefix).await?;
|
||||
|
||||
if args.recursive {
|
||||
upload_recursive(&*sandbox, &local_path, &remote_path).await?;
|
||||
} else {
|
||||
debug!(path = %remote_path, "Uploading file to sandbox");
|
||||
sandbox
|
||||
.upload_file_from_local(&local_path, &remote_path)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
}
|
||||
info!(direction = "upload", path = %remote_path, "Copy complete");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursively download a directory from the sandbox.
|
||||
async fn download_recursive(
|
||||
sandbox: &dyn fabro_agent::sandbox::Sandbox,
|
||||
remote_path: &str,
|
||||
local_path: &Path,
|
||||
) -> Result<()> {
|
||||
let entries = sandbox
|
||||
.list_directory(remote_path, Some(100))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to list directory {remote_path}: {e}"))?;
|
||||
|
||||
let mut file_count = 0usize;
|
||||
for entry in &entries {
|
||||
if entry.is_dir {
|
||||
continue;
|
||||
}
|
||||
let remote_file = format!("{remote_path}/{}", entry.name);
|
||||
let local_file = local_path.join(&entry.name);
|
||||
if let Some(parent) = local_file.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
|
||||
}
|
||||
debug!(path = %remote_file, "Downloading file from sandbox");
|
||||
sandbox
|
||||
.download_file_to_local(&remote_file, &local_file)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
file_count += 1;
|
||||
}
|
||||
debug!(count = file_count, "Recursive download complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursively upload a directory to the sandbox.
|
||||
async fn upload_recursive(
|
||||
sandbox: &dyn fabro_agent::sandbox::Sandbox,
|
||||
local_path: &Path,
|
||||
remote_path: &str,
|
||||
) -> Result<()> {
|
||||
let mut file_count = 0usize;
|
||||
let mut stack: Vec<(PathBuf, String)> =
|
||||
vec![(local_path.to_path_buf(), remote_path.to_string())];
|
||||
|
||||
while let Some((dir_path, dir_remote)) = stack.pop() {
|
||||
let mut entries = tokio::fs::read_dir(&dir_path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read directory {}", dir_path.display()))?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let entry_path = entry.path();
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
let remote_file = format!("{dir_remote}/{file_name}");
|
||||
|
||||
if entry.file_type().await?.is_dir() {
|
||||
stack.push((entry_path, remote_file));
|
||||
} else {
|
||||
debug!(path = %remote_file, "Uploading file to sandbox");
|
||||
sandbox
|
||||
.upload_file_from_local(&entry_path, &remote_file)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
file_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!(count = file_count, "Recursive upload complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_direction_download() {
|
||||
let dir = parse_direction("abc123:/some/file.txt", "./local.txt").unwrap();
|
||||
match dir {
|
||||
CopyDirection::Download {
|
||||
run_prefix,
|
||||
remote_path,
|
||||
local_path,
|
||||
} => {
|
||||
assert_eq!(run_prefix, "abc123");
|
||||
assert_eq!(remote_path, "/some/file.txt");
|
||||
assert_eq!(local_path, PathBuf::from("./local.txt"));
|
||||
}
|
||||
_ => panic!("Expected Download"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_direction_upload() {
|
||||
let dir = parse_direction("./local.txt", "abc123:/some/file.txt").unwrap();
|
||||
match dir {
|
||||
CopyDirection::Upload {
|
||||
local_path,
|
||||
run_prefix,
|
||||
remote_path,
|
||||
} => {
|
||||
assert_eq!(local_path, PathBuf::from("./local.txt"));
|
||||
assert_eq!(run_prefix, "abc123");
|
||||
assert_eq!(remote_path, "/some/file.txt");
|
||||
}
|
||||
_ => panic!("Expected Upload"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_direction_absolute_local_path() {
|
||||
let dir = parse_direction("abc123:src/main.rs", "/tmp/main.rs").unwrap();
|
||||
match dir {
|
||||
CopyDirection::Download {
|
||||
run_prefix,
|
||||
remote_path,
|
||||
local_path,
|
||||
} => {
|
||||
assert_eq!(run_prefix, "abc123");
|
||||
assert_eq!(remote_path, "src/main.rs");
|
||||
assert_eq!(local_path, PathBuf::from("/tmp/main.rs"));
|
||||
}
|
||||
_ => panic!("Expected Download"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_direction_both_sandbox_errors() {
|
||||
let result = parse_direction("abc:path", "def:path");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_direction_neither_sandbox_errors() {
|
||||
let result = parse_direction("./file.txt", "/tmp/file.txt");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_direction_relative_upload() {
|
||||
let dir = parse_direction("../local.txt", "abc123:remote.txt").unwrap();
|
||||
match dir {
|
||||
CopyDirection::Upload {
|
||||
local_path,
|
||||
run_prefix,
|
||||
remote_path,
|
||||
} => {
|
||||
assert_eq!(local_path, PathBuf::from("../local.txt"));
|
||||
assert_eq!(run_prefix, "abc123");
|
||||
assert_eq!(remote_path, "remote.txt");
|
||||
}
|
||||
_ => panic!("Expected Upload"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
use std::io::{self, IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::cli::runs::{default_runs_base, resolve_run};
|
||||
use crate::engine::GIT_REMOTE;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::sandbox_record::SandboxRecord;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct DiffArgs {
|
||||
/// Run ID or prefix
|
||||
pub run: String,
|
||||
/// Show diff for a specific node
|
||||
#[arg(long)]
|
||||
pub node: Option<String>,
|
||||
/// Show diffstat instead of full patch (live diffs only)
|
||||
#[arg(long)]
|
||||
pub stat: bool,
|
||||
/// Show only files-changed/insertions/deletions summary (live diffs only)
|
||||
#[arg(long)]
|
||||
pub shortstat: bool,
|
||||
}
|
||||
|
||||
pub async fn diff_command(args: DiffArgs) -> Result<()> {
|
||||
info!(run_id = %args.run, "Showing diff");
|
||||
let base = default_runs_base();
|
||||
let run_dir = resolve_run(&base, &args.run)?.path;
|
||||
|
||||
let patch = resolve_diff(&run_dir, &args).await?;
|
||||
|
||||
let is_tty = io::stdout().is_terminal();
|
||||
let mut stdout = io::stdout().lock();
|
||||
if is_tty {
|
||||
for line in patch.lines() {
|
||||
writeln!(stdout, "{}", colorize_diff_line(line))?;
|
||||
}
|
||||
} else {
|
||||
stdout.write_all(patch.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_diff(run_dir: &Path, args: &DiffArgs) -> Result<String> {
|
||||
// --node: read per-node diff.patch
|
||||
if let Some(ref node_id) = args.node {
|
||||
debug!(node_id, "Reading per-node diff");
|
||||
let node_patch = run_dir.join("nodes").join(node_id).join("diff.patch");
|
||||
return std::fs::read_to_string(&node_patch).with_context(|| {
|
||||
format!("No diff found for node '{node_id}' — check the node ID and try again")
|
||||
});
|
||||
}
|
||||
|
||||
let manifest =
|
||||
Manifest::load(&run_dir.join("manifest.json")).context("Failed to load manifest.json")?;
|
||||
|
||||
let base_sha = manifest
|
||||
.base_sha
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("This run was not git-checkpointed; no diff available"))?;
|
||||
|
||||
// Completed run with final.patch
|
||||
let final_patch_path = run_dir.join("final.patch");
|
||||
if final_patch_path.exists() {
|
||||
debug!("Reading final.patch");
|
||||
return std::fs::read_to_string(&final_patch_path).context("Failed to read final.patch");
|
||||
}
|
||||
|
||||
// Check if the run has concluded (no final.patch means no changes or error)
|
||||
let conclusion_path = run_dir.join("conclusion.json");
|
||||
if conclusion_path.exists() {
|
||||
bail!(
|
||||
"Run completed but no final.patch exists — the run may not have produced any changes"
|
||||
);
|
||||
}
|
||||
|
||||
// In-progress run: reconnect to sandbox and run git diff
|
||||
debug!("No final.patch found; attempting live diff from sandbox");
|
||||
let sandbox_json = run_dir.join("sandbox.json");
|
||||
let record = SandboxRecord::load(&sandbox_json).context(
|
||||
"Failed to load sandbox.json — was this run started with a recent version of arc?",
|
||||
)?;
|
||||
|
||||
info!(provider = %record.provider, "Reconnecting to sandbox for live diff");
|
||||
let sandbox = crate::cli::cp::reconnect(&record).await?;
|
||||
|
||||
let cmd = build_live_diff_cmd(base_sha, args.stat, args.shortstat);
|
||||
debug!(cmd, "Running git diff in sandbox");
|
||||
|
||||
let result = sandbox
|
||||
.exec_command(&cmd, 30_000, None, None, None)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run git diff in sandbox: {e}"))?;
|
||||
|
||||
if result.exit_code != 0 {
|
||||
let stderr = result.stderr.trim();
|
||||
bail!("git diff failed (exit {}):\n{stderr}", result.exit_code);
|
||||
}
|
||||
|
||||
Ok(result.stdout)
|
||||
}
|
||||
|
||||
fn build_live_diff_cmd(base_sha: &str, stat: bool, shortstat: bool) -> String {
|
||||
let mut flags = String::new();
|
||||
if stat {
|
||||
flags.push_str(" --stat");
|
||||
}
|
||||
if shortstat {
|
||||
flags.push_str(" --shortstat");
|
||||
}
|
||||
let quoted_sha = shlex::try_quote(base_sha).map_or_else(
|
||||
|_| format!("'{}'", base_sha.replace('\'', "'\\''")),
|
||||
|q| q.to_string(),
|
||||
);
|
||||
// `git add -N .` marks untracked files as intent-to-add so they appear in the diff.
|
||||
// Without this, files created by write_file (which doesn't git-add) are invisible.
|
||||
format!("{GIT_REMOTE} add -N . && {GIT_REMOTE} diff{flags} {quoted_sha}")
|
||||
}
|
||||
|
||||
fn colorize_diff_line(line: &str) -> String {
|
||||
if line.starts_with("+++") || line.starts_with("---") {
|
||||
format!("\x1b[1m{line}\x1b[0m")
|
||||
} else if line.starts_with('+') {
|
||||
format!("\x1b[32m{line}\x1b[0m")
|
||||
} else if line.starts_with('-') {
|
||||
format!("\x1b[31m{line}\x1b[0m")
|
||||
} else if line.starts_with("@@") {
|
||||
format!("\x1b[36m{line}\x1b[0m")
|
||||
} else if line.starts_with("diff ") {
|
||||
format!("\x1b[1m{line}\x1b[0m")
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn create_manifest(dir: &Path, base_sha: Option<&str>) {
|
||||
let manifest = serde_json::json!({
|
||||
"run_id": "test-run-001",
|
||||
"workflow_name": "test",
|
||||
"goal": "test goal",
|
||||
"start_time": "2025-01-01T00:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0,
|
||||
"base_sha": base_sha,
|
||||
"labels": {},
|
||||
});
|
||||
fs::write(
|
||||
dir.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn create_conclusion(dir: &Path) {
|
||||
let conclusion = serde_json::json!({
|
||||
"timestamp": "2025-01-01T00:01:00Z",
|
||||
"status": "success",
|
||||
"duration_ms": 60000,
|
||||
});
|
||||
fs::write(
|
||||
dir.join("conclusion.json"),
|
||||
serde_json::to_string_pretty(&conclusion).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_run_with_final_patch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_manifest(dir.path(), Some("abc123"));
|
||||
|
||||
let patch_content = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n";
|
||||
fs::write(dir.path().join("final.patch"), patch_content).unwrap();
|
||||
|
||||
let args = DiffArgs {
|
||||
run: String::new(),
|
||||
node: None,
|
||||
stat: false,
|
||||
shortstat: false,
|
||||
};
|
||||
let result = resolve_diff(dir.path(), &args).await.unwrap();
|
||||
assert_eq!(result, patch_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn per_node_diff() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let node_dir = dir.path().join("nodes").join("work");
|
||||
fs::create_dir_all(&node_dir).unwrap();
|
||||
|
||||
let patch_content = "diff --git a/src/main.rs b/src/main.rs\n+added line\n";
|
||||
fs::write(node_dir.join("diff.patch"), patch_content).unwrap();
|
||||
|
||||
let args = DiffArgs {
|
||||
run: String::new(),
|
||||
node: Some("work".to_string()),
|
||||
stat: false,
|
||||
shortstat: false,
|
||||
};
|
||||
let result = resolve_diff(dir.path(), &args).await.unwrap();
|
||||
assert_eq!(result, patch_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_base_sha_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_manifest(dir.path(), None);
|
||||
|
||||
let args = DiffArgs {
|
||||
run: String::new(),
|
||||
node: None,
|
||||
stat: false,
|
||||
shortstat: false,
|
||||
};
|
||||
let err = resolve_diff(dir.path(), &args).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("not git-checkpointed"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_run_no_final_patch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_manifest(dir.path(), Some("abc123"));
|
||||
create_conclusion(dir.path());
|
||||
|
||||
let args = DiffArgs {
|
||||
run: String::new(),
|
||||
node: None,
|
||||
stat: false,
|
||||
shortstat: false,
|
||||
};
|
||||
let err = resolve_diff(dir.path(), &args).await.unwrap_err();
|
||||
assert!(err.to_string().contains("no final.patch"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_diff_not_found() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let args = DiffArgs {
|
||||
run: String::new(),
|
||||
node: Some("nonexistent".to_string()),
|
||||
stat: false,
|
||||
shortstat: false,
|
||||
};
|
||||
let err = resolve_diff(dir.path(), &args).await.unwrap_err();
|
||||
assert!(err.to_string().contains("nonexistent"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_added_line() {
|
||||
let result = colorize_diff_line("+added");
|
||||
assert!(result.contains("\x1b[32m"));
|
||||
assert!(result.contains("+added"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_removed_line() {
|
||||
let result = colorize_diff_line("-removed");
|
||||
assert!(result.contains("\x1b[31m"));
|
||||
assert!(result.contains("-removed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_hunk_header() {
|
||||
let result = colorize_diff_line("@@ -1,3 +1,4 @@");
|
||||
assert!(result.contains("\x1b[36m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_diff_header() {
|
||||
let result = colorize_diff_line("diff --git a/file b/file");
|
||||
assert!(result.contains("\x1b[1m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_file_header() {
|
||||
let plus = colorize_diff_line("+++ b/file.txt");
|
||||
assert!(plus.contains("\x1b[1m"), "got: {plus}");
|
||||
|
||||
let minus = colorize_diff_line("--- a/file.txt");
|
||||
assert!(minus.contains("\x1b[1m"), "got: {minus}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_context_line_unchanged() {
|
||||
let result = colorize_diff_line(" context line");
|
||||
assert_eq!(result, " context line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_live_diff_cmd_includes_working_tree() {
|
||||
let cmd = build_live_diff_cmd("abc123", false, false);
|
||||
assert_eq!(
|
||||
cmd,
|
||||
"git -c maintenance.auto=0 -c gc.auto=0 add -N . && git -c maintenance.auto=0 -c gc.auto=0 diff abc123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_live_diff_cmd_stat() {
|
||||
let cmd = build_live_diff_cmd("abc123", true, false);
|
||||
assert_eq!(
|
||||
cmd,
|
||||
"git -c maintenance.auto=0 -c gc.auto=0 add -N . && git -c maintenance.auto=0 -c gc.auto=0 diff --stat abc123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_live_diff_cmd_shortstat() {
|
||||
let cmd = build_live_diff_cmd("abc123", false, true);
|
||||
assert_eq!(
|
||||
cmd,
|
||||
"git -c maintenance.auto=0 -c gc.auto=0 add -N . && git -c maintenance.auto=0 -c gc.auto=0 diff --shortstat abc123"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,481 +0,0 @@
|
|||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::bail;
|
||||
use clap::{Args, ValueEnum};
|
||||
use fabro_util::terminal::Styles;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::workflow::prepare_from_file;
|
||||
use fabro_validate::Severity;
|
||||
|
||||
use super::{print_diagnostics, read_workflow_file, relative_path};
|
||||
|
||||
/// Output format for graph rendering.
|
||||
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
|
||||
pub enum GraphFormat {
|
||||
/// Scalable Vector Graphics
|
||||
#[default]
|
||||
Svg,
|
||||
/// Portable Network Graphics
|
||||
Png,
|
||||
}
|
||||
|
||||
impl fmt::Display for GraphFormat {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Svg => write!(f, "svg"),
|
||||
Self::Png => write!(f, "png"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph layout direction.
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum GraphDirection {
|
||||
/// Left to right
|
||||
Lr,
|
||||
/// Top to bottom
|
||||
Tb,
|
||||
}
|
||||
|
||||
impl fmt::Display for GraphDirection {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Lr => write!(f, "LR"),
|
||||
Self::Tb => write!(f, "TB"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct GraphArgs {
|
||||
/// Path to the .fabro workflow file, .toml task config, or project workflow name
|
||||
pub workflow: PathBuf,
|
||||
|
||||
/// Output format
|
||||
#[arg(long, value_enum, default_value_t = GraphFormat::Svg)]
|
||||
pub format: GraphFormat,
|
||||
|
||||
/// Output file path (defaults to stdout)
|
||||
#[arg(short, long)]
|
||||
pub output: Option<PathBuf>,
|
||||
|
||||
/// Graph layout direction (overrides the DOT file's rankdir)
|
||||
#[arg(short = 'd', long)]
|
||||
pub direction: Option<GraphDirection>,
|
||||
}
|
||||
|
||||
/// Render a workflow graph to SVG or PNG.
|
||||
pub fn graph_command(args: &GraphArgs, styles: &Styles) -> anyhow::Result<()> {
|
||||
let (dot_path, _cfg) = super::project_config::resolve_workflow(&args.workflow)?;
|
||||
|
||||
let (_graph, diagnostics) = prepare_from_file(&dot_path)?;
|
||||
|
||||
print_diagnostics(&diagnostics, styles);
|
||||
|
||||
if diagnostics.iter().any(|d| d.severity == Severity::Error) {
|
||||
bail!("Validation failed");
|
||||
}
|
||||
|
||||
let source = read_workflow_file(&dot_path)?;
|
||||
let source = apply_direction(&source, args.direction);
|
||||
let rendered = render_dot(&source, args.format)?;
|
||||
|
||||
if let Some(ref output_path) = args.output {
|
||||
std::fs::write(output_path, &rendered)?;
|
||||
} else {
|
||||
std::io::stdout().write_all(&rendered)?;
|
||||
}
|
||||
|
||||
debug!(
|
||||
path = %relative_path(&dot_path),
|
||||
format = %args.format,
|
||||
"Rendered workflow graph"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dark mode CSS injected into SVG output (leading newline included for insertion).
|
||||
const DARK_MODE_STYLE: &str = r##"
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
text { fill: #e0e0e0 !important; }
|
||||
[stroke="#357f9e"] { stroke: #5bb8d8; }
|
||||
[stroke="#666666"] { stroke: #999999; }
|
||||
polygon[fill="#357f9e"] { fill: #5bb8d8; }
|
||||
polygon[fill="#666666"] { fill: #999999; }
|
||||
}
|
||||
</style>"##;
|
||||
|
||||
/// DOT graph-level defaults injected after the first `{`.
|
||||
const DOT_STYLE_DEFAULTS: &str = r##"
|
||||
bgcolor="transparent"
|
||||
node [color="#357f9e", fontname="Helvetica", fontsize=12, fontcolor="#1a1a1a"]
|
||||
edge [color="#666666", fontname="Helvetica", fontsize=10, fontcolor="#666666"]
|
||||
"##;
|
||||
|
||||
static RANKDIR_RE: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"rankdir\s*=\s*\w+").unwrap());
|
||||
|
||||
/// If a direction override is given, rewrite `rankdir=…` in the DOT source.
|
||||
fn apply_direction<'a>(source: &'a str, direction: Option<GraphDirection>) -> Cow<'a, str> {
|
||||
match direction {
|
||||
Some(dir) => {
|
||||
let replacement = format!("rankdir={dir}");
|
||||
RANKDIR_RE.replace(source, replacement.as_str())
|
||||
}
|
||||
None => Cow::Borrowed(source),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject DOT graph-level style defaults (transparent background, teal nodes,
|
||||
/// gray edges, Helvetica font) right after the first `{` in the DOT source.
|
||||
/// Per-node/edge attributes override these defaults.
|
||||
fn inject_dot_style_defaults(source: &str) -> String {
|
||||
let Some(pos) = source.find('{') else {
|
||||
return source.to_string();
|
||||
};
|
||||
let (before, after) = source.split_at(pos + 1);
|
||||
format!("{before}{DOT_STYLE_DEFAULTS}{after}")
|
||||
}
|
||||
|
||||
/// Post-process raw SVG output from Graphviz:
|
||||
/// 1. Remove the white background `<polygon>` element
|
||||
/// 2. Insert a dark-mode `<style>` block after the opening `<svg ...>` tag
|
||||
fn postprocess_svg(raw: Vec<u8>) -> Vec<u8> {
|
||||
let mut svg = String::from_utf8(raw)
|
||||
.unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned());
|
||||
|
||||
// Remove white background polygon (single line containing it)
|
||||
svg = svg
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
!(line.contains("<polygon")
|
||||
&& line.contains("fill=\"white\"")
|
||||
&& line.contains("stroke=\"none\""))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
// Insert dark mode style block after the opening <svg ...> tag
|
||||
if let Some(svg_close) = svg
|
||||
.find("<svg")
|
||||
.and_then(|start| svg[start..].find('>').map(|end| start + end))
|
||||
{
|
||||
svg.insert_str(svg_close + 1, DARK_MODE_STYLE);
|
||||
}
|
||||
|
||||
svg.into_bytes()
|
||||
}
|
||||
|
||||
/// Render styled DOT source into the given format via the `dot` command.
|
||||
///
|
||||
/// Injects style defaults (colors, fonts, transparent background) into the DOT
|
||||
/// source, then post-processes SVG output with dark-mode CSS and background removal.
|
||||
pub fn render_dot(source: &str, format: GraphFormat) -> anyhow::Result<Vec<u8>> {
|
||||
let styled_source = inject_dot_style_defaults(source);
|
||||
let mut child = match Command::new("dot")
|
||||
.arg(format!("-T{format}"))
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
bail!("Graphviz is not installed. Install it with: brew install graphviz");
|
||||
}
|
||||
Err(err) => {
|
||||
bail!("Failed to run dot: {err}");
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(styled_source.as_bytes())?;
|
||||
}
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("dot failed: {stderr}");
|
||||
}
|
||||
|
||||
let raw = output.stdout;
|
||||
if matches!(format, GraphFormat::Svg) {
|
||||
Ok(postprocess_svg(raw))
|
||||
} else {
|
||||
Ok(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the `dot` command is available on PATH.
|
||||
#[cfg(test)]
|
||||
fn dot_is_available() -> bool {
|
||||
Command::new("dot")
|
||||
.arg("-V")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
const VALID_DOT: &str = r#"digraph Simple {
|
||||
graph [goal="Run tests and report results"]
|
||||
rankdir=LR
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
|
||||
run_tests [label="Run Tests", prompt="Run the test suite and report results"]
|
||||
report [label="Report", prompt="Summarize the test results"]
|
||||
|
||||
start -> run_tests -> report -> exit
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn graph_missing_file() {
|
||||
let args = GraphArgs {
|
||||
workflow: PathBuf::from("/tmp/nonexistent_workflow_99999.fabro"),
|
||||
format: GraphFormat::Svg,
|
||||
output: None,
|
||||
direction: None,
|
||||
};
|
||||
let styles = Styles::new(false);
|
||||
let result = graph_command(&args, &styles);
|
||||
assert!(result.is_err(), "expected Err for missing file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_invalid_syntax() {
|
||||
let mut tmp = tempfile::Builder::new()
|
||||
.suffix(".fabro")
|
||||
.tempfile()
|
||||
.unwrap();
|
||||
write!(tmp, "not a valid dot file").unwrap();
|
||||
|
||||
let args = GraphArgs {
|
||||
workflow: tmp.path().to_path_buf(),
|
||||
format: GraphFormat::Svg,
|
||||
output: None,
|
||||
direction: None,
|
||||
};
|
||||
let styles = Styles::new(false);
|
||||
let result = graph_command(&args, &styles);
|
||||
assert!(result.is_err(), "expected Err for invalid syntax");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_valid_workflow_svg() {
|
||||
if !dot_is_available() {
|
||||
eprintln!("skipping: graphviz not installed");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut tmp = tempfile::Builder::new()
|
||||
.suffix(".fabro")
|
||||
.tempfile()
|
||||
.unwrap();
|
||||
write!(tmp, "{VALID_DOT}").unwrap();
|
||||
|
||||
let output_dir = tempfile::tempdir().unwrap();
|
||||
let output_path = output_dir.path().join("out.svg");
|
||||
|
||||
let args = GraphArgs {
|
||||
workflow: tmp.path().to_path_buf(),
|
||||
format: GraphFormat::Svg,
|
||||
output: Some(output_path.clone()),
|
||||
direction: None,
|
||||
};
|
||||
let styles = Styles::new(false);
|
||||
let result = graph_command(&args, &styles);
|
||||
assert!(result.is_ok(), "expected Ok but got: {result:?}");
|
||||
|
||||
let content = std::fs::read_to_string(&output_path).unwrap();
|
||||
assert!(content.contains("<svg"), "expected SVG content");
|
||||
assert!(
|
||||
content.contains("prefers-color-scheme: dark"),
|
||||
"expected dark mode style block"
|
||||
);
|
||||
assert!(
|
||||
!content.contains("fill=\"white\""),
|
||||
"white background should be removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_valid_workflow_png() {
|
||||
if !dot_is_available() {
|
||||
eprintln!("skipping: graphviz not installed");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut tmp = tempfile::Builder::new()
|
||||
.suffix(".fabro")
|
||||
.tempfile()
|
||||
.unwrap();
|
||||
write!(tmp, "{VALID_DOT}").unwrap();
|
||||
|
||||
let output_dir = tempfile::tempdir().unwrap();
|
||||
let output_path = output_dir.path().join("out.png");
|
||||
|
||||
let args = GraphArgs {
|
||||
workflow: tmp.path().to_path_buf(),
|
||||
format: GraphFormat::Png,
|
||||
output: Some(output_path.clone()),
|
||||
direction: None,
|
||||
};
|
||||
let styles = Styles::new(false);
|
||||
let result = graph_command(&args, &styles);
|
||||
assert!(result.is_ok(), "expected Ok but got: {result:?}");
|
||||
|
||||
let bytes = std::fs::read(&output_path).unwrap();
|
||||
// PNG magic bytes: 0x89 P N G
|
||||
assert!(
|
||||
bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]),
|
||||
"expected PNG magic bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_output_to_file() {
|
||||
if !dot_is_available() {
|
||||
eprintln!("skipping: graphviz not installed");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut tmp = tempfile::Builder::new()
|
||||
.suffix(".fabro")
|
||||
.tempfile()
|
||||
.unwrap();
|
||||
write!(tmp, "{VALID_DOT}").unwrap();
|
||||
|
||||
let output_dir = tempfile::tempdir().unwrap();
|
||||
let output_path = output_dir.path().join("result.svg");
|
||||
|
||||
let args = GraphArgs {
|
||||
workflow: tmp.path().to_path_buf(),
|
||||
format: GraphFormat::Svg,
|
||||
output: Some(output_path.clone()),
|
||||
direction: None,
|
||||
};
|
||||
let styles = Styles::new(false);
|
||||
graph_command(&args, &styles).unwrap();
|
||||
|
||||
assert!(output_path.exists(), "output file should exist");
|
||||
let content = std::fs::read_to_string(&output_path).unwrap();
|
||||
assert!(!content.is_empty(), "output file should not be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_dot_style_defaults_inserts_attrs() {
|
||||
let source = "digraph G {\n a -> b\n}";
|
||||
let styled = inject_dot_style_defaults(source);
|
||||
assert!(styled.contains("bgcolor=\"transparent\""));
|
||||
assert!(styled.contains("node [color=\"#357f9e\""));
|
||||
assert!(styled.contains("fontname=\"Helvetica\""));
|
||||
assert!(styled.contains("edge [color=\"#666666\""));
|
||||
// Original content preserved
|
||||
assert!(styled.contains("a -> b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_dot_style_defaults_no_brace() {
|
||||
let source = "no brace here";
|
||||
let result = inject_dot_style_defaults(source);
|
||||
assert_eq!(result, source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postprocess_svg_removes_white_bg() {
|
||||
let svg = b"<svg xmlns=\"...\" width=\"100\">\n<polygon fill=\"white\" stroke=\"none\" points=\"0,0 100,0 100,100 0,100\"/>\n<g>content</g>\n</svg>";
|
||||
let result = postprocess_svg(svg.to_vec());
|
||||
let result_str = String::from_utf8(result).unwrap();
|
||||
assert!(
|
||||
!result_str.contains("fill=\"white\""),
|
||||
"white background polygon should be removed"
|
||||
);
|
||||
assert!(result_str.contains("<g>content</g>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postprocess_svg_injects_dark_mode() {
|
||||
let svg = b"<svg xmlns=\"...\" width=\"100\">\n<g>content</g>\n</svg>";
|
||||
let result = postprocess_svg(svg.to_vec());
|
||||
let result_str = String::from_utf8(result).unwrap();
|
||||
assert!(
|
||||
result_str.contains("prefers-color-scheme: dark"),
|
||||
"dark mode style block should be present"
|
||||
);
|
||||
// Style block should come after <svg ...>
|
||||
let svg_tag_end = result_str.find('>').unwrap();
|
||||
let style_pos = result_str.find("<style>").unwrap();
|
||||
assert!(style_pos > svg_tag_end);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_toml_path() {
|
||||
if !dot_is_available() {
|
||||
eprintln!("skipping: graphviz not installed");
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let wf_dir = tmp.path().join("workflows").join("hello");
|
||||
std::fs::create_dir_all(&wf_dir).unwrap();
|
||||
std::fs::write(
|
||||
wf_dir.join("workflow.toml"),
|
||||
"version = 1\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(wf_dir.join("workflow.fabro"), VALID_DOT).unwrap();
|
||||
|
||||
let output_dir = tempfile::tempdir().unwrap();
|
||||
let output_path = output_dir.path().join("out.svg");
|
||||
|
||||
let args = GraphArgs {
|
||||
workflow: wf_dir.join("workflow.toml"),
|
||||
format: GraphFormat::Svg,
|
||||
output: Some(output_path.clone()),
|
||||
direction: None,
|
||||
};
|
||||
let styles = Styles::new(false);
|
||||
let result = graph_command(&args, &styles);
|
||||
assert!(result.is_ok(), "expected Ok but got: {result:?}");
|
||||
|
||||
let content = std::fs::read_to_string(&output_path).unwrap();
|
||||
assert!(content.contains("<svg"), "expected SVG content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_direction_rewrites_rankdir() {
|
||||
let source = "digraph G {\n rankdir=LR\n a -> b\n}";
|
||||
let result = super::apply_direction(source, Some(GraphDirection::Tb));
|
||||
assert!(
|
||||
result.contains("rankdir=TB"),
|
||||
"expected rankdir=TB but got: {result}"
|
||||
);
|
||||
assert!(
|
||||
!result.contains("rankdir=LR"),
|
||||
"should not contain original rankdir=LR"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_direction_none_preserves_source() {
|
||||
let source = "digraph G {\n rankdir=LR\n a -> b\n}";
|
||||
let result = super::apply_direction(source, None);
|
||||
assert_eq!(result, source);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::checkpoint::Checkpoint;
|
||||
use crate::cli::runs::{default_runs_base, resolve_run, RunStatus};
|
||||
use crate::conclusion::Conclusion;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::sandbox_record::SandboxRecord;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct InspectArgs {
|
||||
/// Run ID prefix or workflow name (most recent run)
|
||||
pub run: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InspectOutput {
|
||||
pub run_id: String,
|
||||
pub run_dir: PathBuf,
|
||||
pub status: RunStatus,
|
||||
pub manifest: Option<serde_json::Value>,
|
||||
pub conclusion: Option<serde_json::Value>,
|
||||
pub checkpoint: Option<serde_json::Value>,
|
||||
pub sandbox: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub fn inspect_command(args: &InspectArgs) -> Result<()> {
|
||||
let base = default_runs_base();
|
||||
let run = resolve_run(&base, &args.run)?;
|
||||
let output = inspect_run_dir(&run.run_id, &run.path, run.status)?;
|
||||
let json = serde_json::to_string_pretty(&[output])?;
|
||||
println!("{json}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_run_dir(run_id: &str, run_dir: &Path, status: RunStatus) -> Result<InspectOutput> {
|
||||
let manifest = Manifest::load(&run_dir.join("manifest.json"))
|
||||
.ok()
|
||||
.and_then(|v| serde_json::to_value(v).ok());
|
||||
let conclusion = Conclusion::load(&run_dir.join("conclusion.json"))
|
||||
.ok()
|
||||
.and_then(|v| serde_json::to_value(v).ok());
|
||||
let checkpoint = Checkpoint::load(&run_dir.join("checkpoint.json"))
|
||||
.ok()
|
||||
.and_then(|v| serde_json::to_value(v).ok());
|
||||
let sandbox = SandboxRecord::load(&run_dir.join("sandbox.json"))
|
||||
.ok()
|
||||
.and_then(|v| serde_json::to_value(v).ok());
|
||||
|
||||
Ok(InspectOutput {
|
||||
run_id: run_id.to_string(),
|
||||
run_dir: run_dir.to_path_buf(),
|
||||
status,
|
||||
manifest,
|
||||
conclusion,
|
||||
checkpoint,
|
||||
sandbox,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::runs::RunStatus;
|
||||
use crate::outcome::StageStatus;
|
||||
|
||||
#[test]
|
||||
fn nonexistent_run_returns_error() {
|
||||
let args = InspectArgs {
|
||||
run: "nonexistent-run-id".to_string(),
|
||||
};
|
||||
assert!(inspect_command(&args).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_complete_run_has_all_sections() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let run_dir = dir.path().to_path_buf();
|
||||
|
||||
// Write all four JSON files
|
||||
let manifest = Manifest {
|
||||
run_id: "test-run".to_string(),
|
||||
workflow_name: "test".to_string(),
|
||||
goal: "test goal".to_string(),
|
||||
start_time: chrono::Utc::now(),
|
||||
node_count: 2,
|
||||
edge_count: 1,
|
||||
run_branch: None,
|
||||
base_sha: None,
|
||||
labels: Default::default(),
|
||||
base_branch: None,
|
||||
workflow_slug: None,
|
||||
host_repo_path: None,
|
||||
};
|
||||
manifest.save(&run_dir.join("manifest.json")).unwrap();
|
||||
|
||||
let conclusion = Conclusion {
|
||||
timestamp: chrono::Utc::now(),
|
||||
status: StageStatus::Success,
|
||||
duration_ms: 1000,
|
||||
failure_reason: None,
|
||||
final_git_commit_sha: None,
|
||||
stages: vec![],
|
||||
total_cost: None,
|
||||
total_retries: 0,
|
||||
};
|
||||
conclusion.save(&run_dir.join("conclusion.json")).unwrap();
|
||||
|
||||
let checkpoint = Checkpoint {
|
||||
timestamp: chrono::Utc::now(),
|
||||
current_node: "end".to_string(),
|
||||
completed_nodes: vec!["start".to_string()],
|
||||
node_retries: Default::default(),
|
||||
context_values: Default::default(),
|
||||
logs: vec![],
|
||||
node_outcomes: Default::default(),
|
||||
next_node_id: None,
|
||||
git_commit_sha: None,
|
||||
loop_failure_signatures: Default::default(),
|
||||
restart_failure_signatures: Default::default(),
|
||||
node_visits: Default::default(),
|
||||
};
|
||||
checkpoint.save(&run_dir.join("checkpoint.json")).unwrap();
|
||||
|
||||
let sandbox = SandboxRecord {
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/tmp/work".to_string(),
|
||||
identifier: None,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
};
|
||||
sandbox.save(&run_dir.join("sandbox.json")).unwrap();
|
||||
|
||||
let output = inspect_run_dir("test-run", &run_dir, RunStatus::Succeeded).unwrap();
|
||||
|
||||
assert_eq!(output.run_id, "test-run");
|
||||
assert_eq!(output.run_dir, run_dir);
|
||||
assert!(output.manifest.is_some());
|
||||
assert!(output.conclusion.is_some());
|
||||
assert!(output.checkpoint.is_some());
|
||||
assert!(output.sandbox.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_partial_run_has_null_sections() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let run_dir = dir.path().to_path_buf();
|
||||
|
||||
// Only write manifest
|
||||
let manifest = Manifest {
|
||||
run_id: "partial-run".to_string(),
|
||||
workflow_name: "test".to_string(),
|
||||
goal: "test goal".to_string(),
|
||||
start_time: chrono::Utc::now(),
|
||||
node_count: 1,
|
||||
edge_count: 0,
|
||||
run_branch: None,
|
||||
base_sha: None,
|
||||
labels: Default::default(),
|
||||
base_branch: None,
|
||||
workflow_slug: None,
|
||||
host_repo_path: None,
|
||||
};
|
||||
manifest.save(&run_dir.join("manifest.json")).unwrap();
|
||||
|
||||
let output = inspect_run_dir("partial-run", &run_dir, RunStatus::Running).unwrap();
|
||||
|
||||
assert_eq!(output.run_id, "partial-run");
|
||||
assert!(output.manifest.is_some());
|
||||
assert!(output.conclusion.is_none());
|
||||
assert!(output.checkpoint.is_none());
|
||||
assert!(output.sandbox.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_json_has_expected_keys() {
|
||||
let output = InspectOutput {
|
||||
run_id: "id-1".to_string(),
|
||||
run_dir: PathBuf::from("/tmp/run"),
|
||||
status: RunStatus::Dead,
|
||||
manifest: None,
|
||||
conclusion: None,
|
||||
checkpoint: None,
|
||||
sandbox: None,
|
||||
};
|
||||
|
||||
let json: serde_json::Value = serde_json::to_value(&[output]).unwrap();
|
||||
let obj = json.as_array().unwrap()[0].as_object().unwrap();
|
||||
let keys: Vec<&String> = obj.keys().collect();
|
||||
assert!(keys.contains(&&"run_id".to_string()));
|
||||
assert!(keys.contains(&&"run_dir".to_string()));
|
||||
assert!(keys.contains(&&"status".to_string()));
|
||||
assert!(keys.contains(&&"manifest".to_string()));
|
||||
assert!(keys.contains(&&"conclusion".to_string()));
|
||||
assert!(keys.contains(&&"checkpoint".to_string()));
|
||||
assert!(keys.contains(&&"sandbox".to_string()));
|
||||
assert_eq!(keys.len(), 7);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,437 +0,0 @@
|
|||
pub mod asset;
|
||||
pub mod backend;
|
||||
pub mod cli_backend;
|
||||
pub mod cp;
|
||||
pub mod diff;
|
||||
pub mod fork;
|
||||
pub mod graph;
|
||||
pub mod inspect;
|
||||
pub mod logs;
|
||||
pub mod parse;
|
||||
pub mod pr;
|
||||
pub mod preview;
|
||||
pub mod progress;
|
||||
pub mod project_config;
|
||||
pub mod rewind;
|
||||
pub mod run;
|
||||
pub mod run_config;
|
||||
pub mod runs;
|
||||
pub mod ssh;
|
||||
pub mod validate;
|
||||
pub mod workflow;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
use fabro_util::terminal::Styles;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::outcome::StageUsage;
|
||||
use fabro_validate::{Diagnostic, Severity};
|
||||
|
||||
/// Sandbox provider for agent tool operations.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
|
||||
pub enum SandboxProvider {
|
||||
/// Run tools on the local host (default)
|
||||
#[default]
|
||||
Local,
|
||||
/// Run tools inside a Docker container
|
||||
Docker,
|
||||
/// Run tools inside a Daytona cloud sandbox
|
||||
Daytona,
|
||||
/// Run tools inside an exe.dev VM
|
||||
#[cfg(feature = "exedev")]
|
||||
Exe,
|
||||
/// Run tools on a user-provided SSH host
|
||||
Ssh,
|
||||
}
|
||||
|
||||
impl SandboxProvider {
|
||||
pub fn is_remote(&self) -> bool {
|
||||
match self {
|
||||
Self::Daytona => true,
|
||||
#[cfg(feature = "exedev")]
|
||||
Self::Exe => true,
|
||||
Self::Ssh => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SandboxProvider {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Local => write!(f, "local"),
|
||||
Self::Docker => write!(f, "docker"),
|
||||
Self::Daytona => write!(f, "daytona"),
|
||||
#[cfg(feature = "exedev")]
|
||||
Self::Exe => write!(f, "exe"),
|
||||
Self::Ssh => write!(f, "ssh"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SandboxProvider {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"local" => Ok(Self::Local),
|
||||
"docker" => Ok(Self::Docker),
|
||||
"daytona" => Ok(Self::Daytona),
|
||||
#[cfg(feature = "exedev")]
|
||||
"exe" => Ok(Self::Exe),
|
||||
"ssh" => Ok(Self::Ssh),
|
||||
other => Err(format!("unknown sandbox provider: {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "fabro-workflows",
|
||||
version,
|
||||
about = "Workflow runner for AI workflows"
|
||||
)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum Command {
|
||||
/// Launch a workflow from a .fabro or .toml task file
|
||||
Run(RunArgs),
|
||||
/// Parse and validate a workflow without executing
|
||||
Validate(ValidateArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct RunArgs {
|
||||
/// Path to a .fabro workflow file or .toml task config (not required with --run-branch)
|
||||
#[arg(required_unless_present = "run_branch")]
|
||||
pub workflow: Option<PathBuf>,
|
||||
|
||||
/// Run output directory
|
||||
#[arg(long)]
|
||||
pub run_dir: Option<PathBuf>,
|
||||
|
||||
/// Execute with simulated LLM backend
|
||||
#[arg(long)]
|
||||
pub dry_run: bool,
|
||||
|
||||
/// Validate run configuration without executing
|
||||
#[arg(long, conflicts_with_all = ["resume", "run_branch", "dry_run"])]
|
||||
pub preflight: bool,
|
||||
|
||||
/// Auto-approve all human gates
|
||||
#[arg(long)]
|
||||
pub auto_approve: bool,
|
||||
|
||||
/// Resume from a checkpoint file
|
||||
#[arg(long)]
|
||||
pub resume: Option<PathBuf>,
|
||||
|
||||
/// Resume from a git run branch (reads checkpoint and graph from metadata branch)
|
||||
#[arg(long, conflicts_with = "resume")]
|
||||
pub run_branch: Option<String>,
|
||||
|
||||
/// Override the workflow goal (exposed as $goal in prompts)
|
||||
#[arg(long)]
|
||||
pub goal: Option<String>,
|
||||
|
||||
/// Read the workflow goal from a file
|
||||
#[arg(long, conflicts_with = "goal")]
|
||||
pub goal_file: Option<PathBuf>,
|
||||
|
||||
/// Override default LLM model
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Override default LLM provider
|
||||
#[arg(long)]
|
||||
pub provider: Option<String>,
|
||||
|
||||
/// Enable verbose output
|
||||
#[arg(short, long)]
|
||||
pub verbose: bool,
|
||||
|
||||
/// Sandbox for agent tools
|
||||
#[arg(long, value_enum)]
|
||||
pub sandbox: Option<SandboxProvider>,
|
||||
|
||||
/// Attach a label to this run (repeatable, format: KEY=VALUE)
|
||||
#[arg(long = "label", value_name = "KEY=VALUE")]
|
||||
pub label: Vec<String>,
|
||||
|
||||
/// Skip retro generation after the run
|
||||
#[arg(long)]
|
||||
pub no_retro: bool,
|
||||
|
||||
/// Create SSH access to the Daytona sandbox and print the connection command
|
||||
#[arg(long)]
|
||||
pub ssh: bool,
|
||||
|
||||
/// Keep the sandbox alive after the run finishes (for debugging)
|
||||
#[arg(long)]
|
||||
pub preserve_sandbox: bool,
|
||||
|
||||
/// Run the workflow in the background and print the run ID
|
||||
#[arg(short = 'd', long, conflicts_with_all = ["resume", "run_branch", "preflight"])]
|
||||
pub detach: bool,
|
||||
|
||||
/// Pre-generated run ID (used internally by --detach)
|
||||
#[arg(long, hide = true)]
|
||||
pub run_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ValidateArgs {
|
||||
/// Path to the .fabro workflow file
|
||||
pub workflow: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ParseArgs {
|
||||
/// Path to the .fabro workflow file
|
||||
pub workflow: PathBuf,
|
||||
}
|
||||
|
||||
/// Read a workflow file from disk.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the file cannot be read.
|
||||
pub fn read_workflow_file(path: &Path) -> anyhow::Result<String> {
|
||||
std::fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Print diagnostics to stderr, colored by severity.
|
||||
pub fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles) {
|
||||
for d in diagnostics {
|
||||
let location = match (&d.node_id, &d.edge) {
|
||||
(Some(node), _) => format!(" [node: {node}]"),
|
||||
(_, Some((from, to))) => format!(" [edge: {from} -> {to}]"),
|
||||
_ => String::new(),
|
||||
};
|
||||
match d.severity {
|
||||
Severity::Error => eprintln!(
|
||||
"{}{location}: {} ({})",
|
||||
styles.red.apply_to("error"),
|
||||
d.message,
|
||||
styles.dim.apply_to(&d.rule),
|
||||
),
|
||||
Severity::Warning => eprintln!(
|
||||
"{}{location}: {} ({})",
|
||||
styles.yellow.apply_to("warning"),
|
||||
d.message,
|
||||
styles.dim.apply_to(&d.rule),
|
||||
),
|
||||
Severity::Info => eprintln!(
|
||||
"{}",
|
||||
styles
|
||||
.dim
|
||||
.apply_to(format!("info{location}: {} ({})", d.message, d.rule)),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the dollar cost for a stage's token usage, if pricing is available.
|
||||
#[must_use]
|
||||
pub fn compute_stage_cost(usage: &StageUsage) -> Option<f64> {
|
||||
let info = fabro_llm::catalog::get_model_info(&usage.model)?;
|
||||
let input_rate = info.costs.input_cost_per_mtok?;
|
||||
let output_rate = info.costs.output_cost_per_mtok?;
|
||||
Some(
|
||||
usage.input_tokens as f64 * input_rate / 1_000_000.0
|
||||
+ usage.output_tokens as f64 * output_rate / 1_000_000.0,
|
||||
)
|
||||
}
|
||||
|
||||
/// Format a dollar cost for display (e.g. `"$1.23"`).
|
||||
#[must_use]
|
||||
pub fn format_cost(cost: f64) -> String {
|
||||
format!("${cost:.2}")
|
||||
}
|
||||
|
||||
/// Format a token count for human display (e.g. `"850"`, `"15.2k"`, `"3.4m"`).
|
||||
#[must_use]
|
||||
pub fn format_tokens_human(tokens: i64) -> String {
|
||||
if tokens >= 1_000_000 {
|
||||
format!("{:.1}m", tokens as f64 / 1_000_000.0)
|
||||
} else if tokens >= 1000 {
|
||||
format!("{:.1}k", tokens as f64 / 1000.0)
|
||||
} else {
|
||||
tokens.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce a relative path from cwd; falls back to `tilde_path` if not under cwd.
|
||||
pub fn relative_path(path: &Path) -> String {
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
if let Ok(rel) = path.strip_prefix(&cwd) {
|
||||
return rel.display().to_string();
|
||||
}
|
||||
}
|
||||
tilde_path(path)
|
||||
}
|
||||
|
||||
/// Return `Some(color)` when color is enabled, `None` otherwise.
|
||||
/// Used with `cli_table`'s `.foreground_color()` which accepts `Option<Color>`.
|
||||
pub(crate) fn color_if(use_color: bool, color: cli_table::Color) -> Option<cli_table::Color> {
|
||||
if use_color {
|
||||
Some(color)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten an absolute path by replacing the home directory prefix with `~`.
|
||||
pub fn tilde_path(path: &Path) -> String {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
if let Ok(suffix) = path.strip_prefix(&home) {
|
||||
return format!("~/{}", suffix.display());
|
||||
}
|
||||
}
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sandbox_provider_default_is_local() {
|
||||
assert_eq!(SandboxProvider::default(), SandboxProvider::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_provider_from_str() {
|
||||
assert_eq!(
|
||||
"local".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Local
|
||||
);
|
||||
assert_eq!(
|
||||
"docker".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Docker
|
||||
);
|
||||
assert_eq!(
|
||||
"daytona".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Daytona
|
||||
);
|
||||
assert_eq!(
|
||||
"LOCAL".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Local
|
||||
);
|
||||
#[cfg(feature = "exedev")]
|
||||
{
|
||||
assert_eq!(
|
||||
"exe".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Exe
|
||||
);
|
||||
assert_eq!(
|
||||
"EXE".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Exe
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
"ssh".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Ssh
|
||||
);
|
||||
assert_eq!(
|
||||
"SSH".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Ssh
|
||||
);
|
||||
assert!("invalid".parse::<SandboxProvider>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_provider_display() {
|
||||
assert_eq!(SandboxProvider::Local.to_string(), "local");
|
||||
assert_eq!(SandboxProvider::Docker.to_string(), "docker");
|
||||
assert_eq!(SandboxProvider::Daytona.to_string(), "daytona");
|
||||
#[cfg(feature = "exedev")]
|
||||
assert_eq!(SandboxProvider::Exe.to_string(), "exe");
|
||||
assert_eq!(SandboxProvider::Ssh.to_string(), "ssh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_cost_zero() {
|
||||
assert_eq!(format_cost(0.0), "$0.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_cost_normal() {
|
||||
assert_eq!(format_cost(1.5), "$1.50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_cost_rounds() {
|
||||
assert_eq!(format_cost(123.456), "$123.46");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tokens_human_zero() {
|
||||
assert_eq!(format_tokens_human(0), "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tokens_human_small() {
|
||||
assert_eq!(format_tokens_human(999), "999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tokens_human_thousands() {
|
||||
assert_eq!(format_tokens_human(1000), "1.0k");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tokens_human_mid_thousands() {
|
||||
assert_eq!(format_tokens_human(15234), "15.2k");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tokens_human_millions() {
|
||||
assert_eq!(format_tokens_human(1_000_000), "1.0m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tokens_human_mid_millions() {
|
||||
assert_eq!(format_tokens_human(3_456_789), "3.5m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_stage_cost_known_model() {
|
||||
let usage = StageUsage {
|
||||
model: "claude-sonnet-4-5".into(),
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_read_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
cost: None,
|
||||
};
|
||||
let cost = compute_stage_cost(&usage);
|
||||
assert!(cost.is_some());
|
||||
assert!(cost.unwrap() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_stage_cost_unknown_model() {
|
||||
let usage = StageUsage {
|
||||
model: "nonexistent-model-xyz".into(),
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_read_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
cost: None,
|
||||
};
|
||||
assert_eq!(compute_stage_cost(&usage), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
use std::io::Write;
|
||||
|
||||
use super::{read_workflow_file, ParseArgs};
|
||||
|
||||
/// Parse a DOT file and print its raw AST as JSON.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the file cannot be read, parsed, or contains trailing content.
|
||||
pub fn parse_command(args: &ParseArgs) -> anyhow::Result<()> {
|
||||
let stdout = std::io::stdout();
|
||||
parse_command_to(args, stdout.lock())
|
||||
}
|
||||
|
||||
fn parse_command_to(args: &ParseArgs, mut out: impl Write) -> anyhow::Result<()> {
|
||||
let (dot_path, _cfg) = super::project_config::resolve_workflow(&args.workflow)?;
|
||||
let source = read_workflow_file(&dot_path)?;
|
||||
let ast = fabro_graphviz::parser::parse_ast(&source)?;
|
||||
serde_json::to_writer_pretty(&mut out, &ast)?;
|
||||
writeln!(out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fabro_graphviz::parser::ast::DotGraph;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn parse_command_outputs_json_ast() {
|
||||
let mut tmp = tempfile::Builder::new()
|
||||
.suffix(".fabro")
|
||||
.tempfile()
|
||||
.unwrap();
|
||||
write!(
|
||||
tmp,
|
||||
r#"digraph Hello {{
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
start -> exit
|
||||
}}"#
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let args = ParseArgs {
|
||||
workflow: tmp.path().to_path_buf(),
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
parse_command_to(&args, &mut buf).unwrap();
|
||||
|
||||
let deserialized: DotGraph = serde_json::from_slice(&buf).unwrap();
|
||||
assert_eq!(deserialized.name, "Hello");
|
||||
assert_eq!(deserialized.statements.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_command_rejects_invalid_dot() {
|
||||
let mut tmp = tempfile::Builder::new()
|
||||
.suffix(".fabro")
|
||||
.tempfile()
|
||||
.unwrap();
|
||||
write!(tmp, "not a valid dot file").unwrap();
|
||||
|
||||
let args = ParseArgs {
|
||||
workflow: tmp.path().to_path_buf(),
|
||||
};
|
||||
let result = parse_command_to(&args, Vec::new());
|
||||
assert!(result.is_err(), "expected Err for invalid syntax");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let wf_dir = tmp.path().join("workflows").join("hello");
|
||||
std::fs::create_dir_all(&wf_dir).unwrap();
|
||||
std::fs::write(
|
||||
wf_dir.join("workflow.toml"),
|
||||
"version = 1\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
wf_dir.join("workflow.fabro"),
|
||||
r#"digraph Hello {
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
start -> exit
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let args = ParseArgs {
|
||||
workflow: wf_dir.join("workflow.toml"),
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
parse_command_to(&args, &mut buf).unwrap();
|
||||
|
||||
let deserialized: DotGraph = serde_json::from_slice(&buf).unwrap();
|
||||
assert_eq!(deserialized.name, "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_command_rejects_missing_file() {
|
||||
let args = ParseArgs {
|
||||
workflow: PathBuf::from("/tmp/nonexistent_parse_test_12345.fabro"),
|
||||
};
|
||||
let result = parse_command_to(&args, Vec::new());
|
||||
assert!(result.is_err(), "expected Err for missing file");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,868 +0,0 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::info;
|
||||
|
||||
use crate::cli::runs::{default_runs_base, resolve_run, scan_runs};
|
||||
use crate::conclusion::Conclusion;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::outcome::StageStatus;
|
||||
use crate::pull_request::PullRequestRecord;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrCreateArgs {
|
||||
/// Run ID or prefix
|
||||
pub run_id: String,
|
||||
/// LLM model for generating PR description
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrListArgs {
|
||||
/// Show all PRs (including closed/merged), not just open
|
||||
#[arg(long)]
|
||||
pub all: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrViewArgs {
|
||||
/// Run ID or prefix
|
||||
pub run_id: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrMergeArgs {
|
||||
/// Run ID or prefix
|
||||
pub run_id: String,
|
||||
/// Merge method: merge, squash, or rebase
|
||||
#[arg(long, default_value = "squash")]
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PrCloseArgs {
|
||||
/// Run ID or prefix
|
||||
pub run_id: String,
|
||||
}
|
||||
|
||||
fn load_pr_record(base: &Path, run_id: &str) -> Result<(PullRequestRecord, PathBuf)> {
|
||||
let run_dir = resolve_run(base, run_id)?.path;
|
||||
let pr_path = run_dir.join("pull_request.json");
|
||||
let content = std::fs::read_to_string(&pr_path).with_context(|| {
|
||||
format!(
|
||||
"No pull_request.json found in run directory. \
|
||||
Create one first with: fabro pr create {run_id}"
|
||||
)
|
||||
})?;
|
||||
let record: PullRequestRecord =
|
||||
serde_json::from_str(&content).context("Failed to parse pull_request.json")?;
|
||||
Ok((record, run_dir))
|
||||
}
|
||||
|
||||
pub async fn pr_list_command(
|
||||
args: PrListArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = default_runs_base();
|
||||
pr_list_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn pr_list_from(
|
||||
base: &Path,
|
||||
args: PrListArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
let runs = scan_runs(base).context("Failed to scan runs")?;
|
||||
|
||||
let mut entries: Vec<(String, PullRequestRecord)> = Vec::new();
|
||||
for run in &runs {
|
||||
let pr_path = run.path.join("pull_request.json");
|
||||
if let Ok(content) = std::fs::read_to_string(&pr_path) {
|
||||
if let Ok(record) = serde_json::from_str::<PullRequestRecord>(&content) {
|
||||
entries.push((run.run_id.clone(), record));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
println!("No pull requests found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
struct PrRow {
|
||||
run_id: String,
|
||||
number: u64,
|
||||
state: String,
|
||||
title: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
// Fetch live state for all PRs concurrently
|
||||
let futures: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|(run_id, record)| {
|
||||
let creds = creds.clone();
|
||||
let run_id = run_id.clone();
|
||||
let record = record.clone();
|
||||
async move {
|
||||
match fabro_github::get_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(detail) => PrRow {
|
||||
run_id,
|
||||
number: detail.number,
|
||||
state: if detail.draft {
|
||||
"draft".to_string()
|
||||
} else {
|
||||
detail.state
|
||||
},
|
||||
title: detail.title,
|
||||
url: detail.html_url,
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(run_id, error = %e, "Failed to fetch PR state");
|
||||
PrRow {
|
||||
run_id,
|
||||
number: record.number,
|
||||
state: "unknown".to_string(),
|
||||
title: record.title,
|
||||
url: record.html_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let all_rows = futures::future::join_all(futures).await;
|
||||
let rows: Vec<_> = if args.all {
|
||||
all_rows
|
||||
} else {
|
||||
all_rows
|
||||
.into_iter()
|
||||
.filter(|r| r.state == "open" || r.state == "draft" || r.state == "unknown")
|
||||
.collect()
|
||||
};
|
||||
|
||||
if rows.is_empty() {
|
||||
println!("No open pull requests found. Use --all to include closed/merged.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Print table
|
||||
println!(
|
||||
"{:<12} {:<6} {:<8} {:<50} URL",
|
||||
"RUN", "#", "STATE", "TITLE"
|
||||
);
|
||||
for row in &rows {
|
||||
let short_id = if row.run_id.len() > 12 {
|
||||
&row.run_id[..12]
|
||||
} else {
|
||||
&row.run_id
|
||||
};
|
||||
let short_title = if row.title.len() > 50 {
|
||||
format!("{}…", &row.title[..row.title.floor_char_boundary(49)])
|
||||
} else {
|
||||
row.title.clone()
|
||||
};
|
||||
println!(
|
||||
"{:<12} {:<6} {:<8} {:<50} {}",
|
||||
short_id, row.number, row.state, short_title, row.url
|
||||
);
|
||||
}
|
||||
|
||||
info!(count = rows.len(), "Listed pull requests");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pr_view_command(
|
||||
args: PrViewArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = default_runs_base();
|
||||
pr_view_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn pr_view_from(
|
||||
base: &Path,
|
||||
args: PrViewArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let (record, _run_dir) = load_pr_record(base, &args.run_id)?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
let detail = fabro_github::get_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
info!(number = detail.number, owner = %record.owner, repo = %record.repo, "Viewing pull request");
|
||||
|
||||
println!("#{} {}", detail.number, detail.title);
|
||||
let state_display = if detail.draft { "draft" } else { &detail.state };
|
||||
println!("State: {state_display}");
|
||||
println!("URL: {}", detail.html_url);
|
||||
println!(
|
||||
"Branch: {} → {}",
|
||||
detail.head.ref_name, detail.base.ref_name
|
||||
);
|
||||
println!("Author: {}", detail.user.login);
|
||||
println!(
|
||||
"Changes: +{} -{} ({} files)",
|
||||
detail.additions, detail.deletions, detail.changed_files
|
||||
);
|
||||
if let Some(body) = &detail.body {
|
||||
if !body.is_empty() {
|
||||
println!();
|
||||
println!("{body}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pr_merge_command(
|
||||
args: PrMergeArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = default_runs_base();
|
||||
pr_merge_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn pr_merge_from(
|
||||
base: &Path,
|
||||
args: PrMergeArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let (record, _run_dir) = load_pr_record(base, &args.run_id)?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
fabro_github::merge_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
&args.method,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
info!(number = record.number, owner = %record.owner, repo = %record.repo, method = %args.method, "Merged pull request");
|
||||
println!("Merged #{} ({})", record.number, record.html_url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pr_close_command(
|
||||
args: PrCloseArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = default_runs_base();
|
||||
pr_close_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn pr_close_from(
|
||||
base: &Path,
|
||||
args: PrCloseArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let (record, _run_dir) = load_pr_record(base, &args.run_id)?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
fabro_github::close_pull_request(
|
||||
&creds,
|
||||
&record.owner,
|
||||
&record.repo,
|
||||
record.number,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
info!(number = record.number, owner = %record.owner, repo = %record.repo, "Closed pull request");
|
||||
println!("Closed #{} ({})", record.number, record.html_url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pr_create_command(
|
||||
args: PrCreateArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let base = default_runs_base();
|
||||
pr_create_from(&base, args, github_app).await
|
||||
}
|
||||
|
||||
async fn pr_create_from(
|
||||
base: &Path,
|
||||
args: PrCreateArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) -> Result<()> {
|
||||
let run_dir = resolve_run(base, &args.run_id)?.path;
|
||||
|
||||
let manifest =
|
||||
Manifest::load(&run_dir.join("manifest.json")).context("Failed to load manifest.json")?;
|
||||
|
||||
let conclusion = Conclusion::load(&run_dir.join("conclusion.json"))
|
||||
.context("Failed to load conclusion.json — is the run finished?")?;
|
||||
|
||||
match conclusion.status {
|
||||
StageStatus::Success | StageStatus::PartialSuccess => {}
|
||||
status => bail!("Run status is '{status}', expected success or partial_success"),
|
||||
}
|
||||
|
||||
let run_branch = manifest
|
||||
.run_branch
|
||||
.as_deref()
|
||||
.context("Run has no run_branch — was it run with git push enabled?")?;
|
||||
|
||||
let diff = std::fs::read_to_string(run_dir.join("final.patch"))
|
||||
.context("Failed to read final.patch — no diff available")?;
|
||||
if diff.trim().is_empty() {
|
||||
bail!("final.patch is empty — nothing to create a PR for");
|
||||
}
|
||||
|
||||
let cwd = std::env::current_dir().context("Failed to get current directory")?;
|
||||
let (origin_url, detected_branch) =
|
||||
fabro_daytona::detect_repo_info(&cwd).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let base_branch = manifest
|
||||
.base_branch
|
||||
.as_deref()
|
||||
.or(detected_branch.as_deref())
|
||||
.unwrap_or("main");
|
||||
|
||||
let https_url = fabro_github::ssh_url_to_https(&origin_url);
|
||||
let (owner, repo) =
|
||||
fabro_github::parse_github_owner_repo(&https_url).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let creds = github_app.context(
|
||||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
let branch_found = fabro_github::branch_exists(
|
||||
&creds,
|
||||
&owner,
|
||||
&repo,
|
||||
run_branch,
|
||||
fabro_github::GITHUB_API_BASE_URL,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
if !branch_found {
|
||||
bail!(
|
||||
"Branch '{run_branch}' not found on GitHub. \
|
||||
Was it pushed? Try: git push origin {run_branch}"
|
||||
);
|
||||
}
|
||||
|
||||
let model = args
|
||||
.model
|
||||
.unwrap_or_else(|| fabro_llm::catalog::default_model().id.to_string());
|
||||
|
||||
let record = crate::pull_request::maybe_open_pull_request(
|
||||
&creds,
|
||||
&origin_url,
|
||||
base_branch,
|
||||
run_branch,
|
||||
&manifest.goal,
|
||||
&diff,
|
||||
&model,
|
||||
true,
|
||||
None,
|
||||
&run_dir,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
match record {
|
||||
Some(record) => {
|
||||
info!(pr_url = %record.html_url, "Pull request created");
|
||||
if let Err(e) = record.save(&run_dir.join("pull_request.json")) {
|
||||
tracing::warn!(error = %e, "Failed to save pull_request.json");
|
||||
}
|
||||
println!("{}", record.html_url);
|
||||
}
|
||||
None => {
|
||||
println!("No pull request created (empty diff).");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn make_pr_record(base: &Path, run_id: &str) {
|
||||
let dir_name = format!("20260101-{}", &run_id[..6].to_uppercase());
|
||||
let run_dir = base.join(&dir_name);
|
||||
fs::create_dir_all(&run_dir).unwrap();
|
||||
|
||||
// Write minimal manifest.json so scan_runs finds it
|
||||
let manifest = serde_json::json!({
|
||||
"run_id": run_id,
|
||||
"workflow_name": "test",
|
||||
"goal": "fix bug",
|
||||
"start_time": "2026-01-01T12:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0
|
||||
});
|
||||
fs::write(
|
||||
run_dir.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let record = PullRequestRecord {
|
||||
html_url: format!("https://github.com/owner/repo/pull/42"),
|
||||
number: 42,
|
||||
owner: "owner".to_string(),
|
||||
repo: "repo".to_string(),
|
||||
base_branch: "main".to_string(),
|
||||
head_branch: "fabro/run/abc".to_string(),
|
||||
title: "Fix the thing".to_string(),
|
||||
};
|
||||
fs::write(
|
||||
run_dir.join("pull_request.json"),
|
||||
serde_json::to_string_pretty(&record).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_pr_record_success() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
make_pr_record(tmp.path(), "abc123-test");
|
||||
|
||||
let (record, _dir) = load_pr_record(tmp.path(), "abc123").unwrap();
|
||||
assert_eq!(record.number, 42);
|
||||
assert_eq!(record.owner, "owner");
|
||||
assert_eq!(record.repo, "repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_pr_record_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
// Create run dir without pull_request.json
|
||||
let dir_name = "20260101-ABC123";
|
||||
let run_dir = tmp.path().join(dir_name);
|
||||
fs::create_dir_all(&run_dir).unwrap();
|
||||
fs::write(
|
||||
run_dir.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"run_id": "abc123-test",
|
||||
"workflow_name": "test",
|
||||
"goal": "fix bug",
|
||||
"start_time": "2026-01-01T12:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = load_pr_record(tmp.path(), "abc123").unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("pull_request.json"), "got: {msg}");
|
||||
assert!(msg.contains("fabro pr create"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_list_finds_runs_with_prs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
// Run 1: has PR
|
||||
make_pr_record(tmp.path(), "aaa111-test");
|
||||
|
||||
// Run 2: no PR
|
||||
let dir2 = tmp.path().join("20260101-BBB222");
|
||||
fs::create_dir_all(&dir2).unwrap();
|
||||
fs::write(
|
||||
dir2.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"run_id": "bbb222-test",
|
||||
"workflow_name": "test",
|
||||
"goal": "another task",
|
||||
"start_time": "2026-01-01T13:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Run 3: has PR
|
||||
let dir3 = tmp.path().join("20260101-CCC333");
|
||||
fs::create_dir_all(&dir3).unwrap();
|
||||
fs::write(
|
||||
dir3.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"run_id": "ccc333-test",
|
||||
"workflow_name": "test",
|
||||
"goal": "third task",
|
||||
"start_time": "2026-01-01T14:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let record3 = PullRequestRecord {
|
||||
html_url: "https://github.com/owner/repo/pull/99".to_string(),
|
||||
number: 99,
|
||||
owner: "owner".to_string(),
|
||||
repo: "repo".to_string(),
|
||||
base_branch: "main".to_string(),
|
||||
head_branch: "fabro/run/ccc".to_string(),
|
||||
title: "Another fix".to_string(),
|
||||
};
|
||||
fs::write(
|
||||
dir3.join("pull_request.json"),
|
||||
serde_json::to_string_pretty(&record3).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify scan finds the right runs with PRs
|
||||
let runs = scan_runs(tmp.path()).unwrap();
|
||||
let runs_with_prs: Vec<_> = runs
|
||||
.iter()
|
||||
.filter(|r| r.path.join("pull_request.json").exists())
|
||||
.collect();
|
||||
assert_eq!(runs_with_prs.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pr_view_fails_no_pr_record() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let dir = tmp.path().join("20260101-ABC123");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(
|
||||
dir.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"run_id": "abc123-test",
|
||||
"workflow_name": "test",
|
||||
"goal": "fix bug",
|
||||
"start_time": "2026-01-01T12:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let args = PrViewArgs {
|
||||
run_id: "abc123".to_string(),
|
||||
};
|
||||
let result = pr_view_from(tmp.path(), args, None).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("pull_request.json"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pr_merge_fails_no_pr_record() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let dir = tmp.path().join("20260101-ABC123");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(
|
||||
dir.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"run_id": "abc123-test",
|
||||
"workflow_name": "test",
|
||||
"goal": "fix bug",
|
||||
"start_time": "2026-01-01T12:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let args = PrMergeArgs {
|
||||
run_id: "abc123".to_string(),
|
||||
method: "squash".to_string(),
|
||||
};
|
||||
let result = pr_merge_from(tmp.path(), args, None).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("pull_request.json"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pr_close_fails_no_pr_record() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let dir = tmp.path().join("20260101-ABC123");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(
|
||||
dir.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"run_id": "abc123-test",
|
||||
"workflow_name": "test",
|
||||
"goal": "fix bug",
|
||||
"start_time": "2026-01-01T12:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let args = PrCloseArgs {
|
||||
run_id: "abc123".to_string(),
|
||||
};
|
||||
let result = pr_close_from(tmp.path(), args, None).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("pull_request.json"), "got: {err}");
|
||||
}
|
||||
|
||||
fn make_test_run(
|
||||
base: &Path,
|
||||
manifest_json: serde_json::Value,
|
||||
conclusion_json: Option<serde_json::Value>,
|
||||
diff: Option<&str>,
|
||||
) -> String {
|
||||
let run_id = manifest_json["run_id"].as_str().unwrap();
|
||||
let dir_name = format!("20260101-{}", &run_id[..6].to_uppercase());
|
||||
let run_dir = base.join(&dir_name);
|
||||
fs::create_dir_all(&run_dir).unwrap();
|
||||
fs::write(
|
||||
run_dir.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&manifest_json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(c) = conclusion_json {
|
||||
fs::write(
|
||||
run_dir.join("conclusion.json"),
|
||||
serde_json::to_string_pretty(&c).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
if let Some(d) = diff {
|
||||
fs::write(run_dir.join("final.patch"), d).unwrap();
|
||||
}
|
||||
run_id.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pr_create_fails_missing_conclusion() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let run_id = make_test_run(
|
||||
tmp.path(),
|
||||
serde_json::json!({
|
||||
"run_id": "abc123-test",
|
||||
"workflow_name": "test",
|
||||
"goal": "fix bug",
|
||||
"start_time": "2026-01-01T12:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0,
|
||||
"run_branch": "fabro/run/abc123"
|
||||
}),
|
||||
None,
|
||||
Some("diff content"),
|
||||
);
|
||||
|
||||
let args = PrCreateArgs {
|
||||
run_id,
|
||||
model: None,
|
||||
};
|
||||
let result = pr_create_from(tmp.path(), args, None).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("conclusion"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pr_create_fails_on_failed_run() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let run_id = make_test_run(
|
||||
tmp.path(),
|
||||
serde_json::json!({
|
||||
"run_id": "abc123-test",
|
||||
"workflow_name": "test",
|
||||
"goal": "fix bug",
|
||||
"start_time": "2026-01-01T12:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0,
|
||||
"run_branch": "fabro/run/abc123"
|
||||
}),
|
||||
Some(serde_json::json!({
|
||||
"timestamp": "2026-01-01T12:01:00Z",
|
||||
"status": "fail",
|
||||
"duration_ms": 60000
|
||||
})),
|
||||
Some("diff content"),
|
||||
);
|
||||
|
||||
let args = PrCreateArgs {
|
||||
run_id,
|
||||
model: None,
|
||||
};
|
||||
let result = pr_create_from(tmp.path(), args, None).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("fail"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pr_create_fails_missing_run_branch() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let run_id = make_test_run(
|
||||
tmp.path(),
|
||||
serde_json::json!({
|
||||
"run_id": "abc123-test",
|
||||
"workflow_name": "test",
|
||||
"goal": "fix bug",
|
||||
"start_time": "2026-01-01T12:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0
|
||||
}),
|
||||
Some(serde_json::json!({
|
||||
"timestamp": "2026-01-01T12:01:00Z",
|
||||
"status": "success",
|
||||
"duration_ms": 60000
|
||||
})),
|
||||
Some("diff content"),
|
||||
);
|
||||
|
||||
let args = PrCreateArgs {
|
||||
run_id,
|
||||
model: None,
|
||||
};
|
||||
let result = pr_create_from(tmp.path(), args, None).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("run_branch"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pr_create_fails_missing_diff() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let run_id = make_test_run(
|
||||
tmp.path(),
|
||||
serde_json::json!({
|
||||
"run_id": "abc123-test",
|
||||
"workflow_name": "test",
|
||||
"goal": "fix bug",
|
||||
"start_time": "2026-01-01T12:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0,
|
||||
"run_branch": "fabro/run/abc123"
|
||||
}),
|
||||
Some(serde_json::json!({
|
||||
"timestamp": "2026-01-01T12:01:00Z",
|
||||
"status": "success",
|
||||
"duration_ms": 60000
|
||||
})),
|
||||
None,
|
||||
);
|
||||
|
||||
let args = PrCreateArgs {
|
||||
run_id,
|
||||
model: None,
|
||||
};
|
||||
let result = pr_create_from(tmp.path(), args, None).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("final.patch"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pr_create_fails_empty_diff() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let run_id = make_test_run(
|
||||
tmp.path(),
|
||||
serde_json::json!({
|
||||
"run_id": "abc123-test",
|
||||
"workflow_name": "test",
|
||||
"goal": "fix bug",
|
||||
"start_time": "2026-01-01T12:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0,
|
||||
"run_branch": "fabro/run/abc123"
|
||||
}),
|
||||
Some(serde_json::json!({
|
||||
"timestamp": "2026-01-01T12:01:00Z",
|
||||
"status": "success",
|
||||
"duration_ms": 60000
|
||||
})),
|
||||
Some(" \n "),
|
||||
);
|
||||
|
||||
let args = PrCreateArgs {
|
||||
run_id,
|
||||
model: None,
|
||||
};
|
||||
let result = pr_create_from(tmp.path(), args, None).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("empty"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pr_create_fails_missing_github_creds() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let run_id = make_test_run(
|
||||
tmp.path(),
|
||||
serde_json::json!({
|
||||
"run_id": "abc123-test",
|
||||
"workflow_name": "test",
|
||||
"goal": "fix bug",
|
||||
"start_time": "2026-01-01T12:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0,
|
||||
"run_branch": "fabro/run/abc123"
|
||||
}),
|
||||
Some(serde_json::json!({
|
||||
"timestamp": "2026-01-01T12:01:00Z",
|
||||
"status": "success",
|
||||
"duration_ms": 60000
|
||||
})),
|
||||
Some("diff content"),
|
||||
);
|
||||
|
||||
let args = PrCreateArgs {
|
||||
run_id,
|
||||
model: None,
|
||||
};
|
||||
let result = pr_create_from(tmp.path(), args, None).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("GitHub App"), "got: {err}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::info;
|
||||
|
||||
use crate::cli::runs::{default_runs_base, resolve_run};
|
||||
use crate::sandbox_record::SandboxRecord;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct PreviewArgs {
|
||||
/// Run ID or prefix
|
||||
pub run: String,
|
||||
/// Port number
|
||||
pub port: u16,
|
||||
/// Generate a signed URL (embeds auth token, no headers needed)
|
||||
#[arg(long)]
|
||||
pub signed: bool,
|
||||
/// Signed URL expiry in seconds (default 3600, requires --signed)
|
||||
#[arg(long, default_value = "3600", requires = "signed")]
|
||||
pub ttl: i32,
|
||||
/// Open URL in browser (implies --signed)
|
||||
#[arg(long)]
|
||||
pub open: bool,
|
||||
}
|
||||
|
||||
impl PreviewArgs {
|
||||
fn use_signed(&self) -> bool {
|
||||
self.signed || self.open
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_provider(record: &SandboxRecord) -> Result<()> {
|
||||
if record.provider != "daytona" {
|
||||
bail!(
|
||||
"Preview URLs are only supported for Daytona sandboxes (this run uses '{}')",
|
||||
record.provider
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_standard_output(url: &str, token: &str) -> String {
|
||||
let mut out = format!("URL: {url}\nToken: {token}\n");
|
||||
out.push_str(&format!(
|
||||
"\ncurl -H \"x-daytona-preview-token: {token}\" \\\n -H \"X-Daytona-Skip-Preview-Warning: true\" \\\n {url}\n"
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
fn format_signed_output(url: &str) -> String {
|
||||
format!("{url}\n")
|
||||
}
|
||||
|
||||
pub async fn preview_command(args: PreviewArgs) -> Result<()> {
|
||||
let base = default_runs_base();
|
||||
let run_dir = resolve_run(&base, &args.run)?.path;
|
||||
let sandbox_json = run_dir.join("sandbox.json");
|
||||
let record = SandboxRecord::load(&sandbox_json).context(
|
||||
"Failed to load sandbox.json — was this run started with a recent version of arc?",
|
||||
)?;
|
||||
|
||||
validate_provider(&record)?;
|
||||
|
||||
let name = record
|
||||
.identifier
|
||||
.as_deref()
|
||||
.context("Daytona sandbox record missing identifier (sandbox name)")?;
|
||||
|
||||
info!(run_id = %args.run, provider = %record.provider, port = args.port, "Generating preview URL");
|
||||
|
||||
let daytona = fabro_daytona::DaytonaSandbox::reconnect(name)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
if args.use_signed() {
|
||||
let signed = daytona
|
||||
.get_signed_preview_url(args.port, Some(args.ttl))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let output = format_signed_output(&signed.url);
|
||||
print!("{output}");
|
||||
|
||||
if args.open {
|
||||
std::process::Command::new("open")
|
||||
.arg(&signed.url)
|
||||
.spawn()
|
||||
.context("Failed to open browser")?;
|
||||
}
|
||||
} else {
|
||||
let preview = daytona
|
||||
.get_preview_link(args.port)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let output = format_standard_output(&preview.url, &preview.token);
|
||||
print!("{output}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_provider_rejects_local() {
|
||||
let record = SandboxRecord {
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/tmp".to_string(),
|
||||
identifier: None,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
};
|
||||
let err = validate_provider(&record).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("Preview URLs are only supported for Daytona sandboxes"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_provider_rejects_docker() {
|
||||
let record = SandboxRecord {
|
||||
provider: "docker".to_string(),
|
||||
working_directory: "/workspace".to_string(),
|
||||
identifier: None,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
};
|
||||
let err = validate_provider(&record).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("this run uses 'docker'"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_provider_accepts_daytona() {
|
||||
let record = SandboxRecord {
|
||||
provider: "daytona".to_string(),
|
||||
working_directory: "/home/daytona/workspace".to_string(),
|
||||
identifier: Some("sandbox-abc".to_string()),
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
};
|
||||
validate_provider(&record).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_standard_output_includes_url_token_curl() {
|
||||
let output = format_standard_output(
|
||||
"https://3000-sandbox-123456.proxy.daytona.work",
|
||||
"vg5c0ylmcimr8b",
|
||||
);
|
||||
assert!(output.contains("URL: https://3000-sandbox-123456.proxy.daytona.work"));
|
||||
assert!(output.contains("Token: vg5c0ylmcimr8b"));
|
||||
assert!(output.contains("curl"));
|
||||
assert!(output.contains("x-daytona-preview-token: vg5c0ylmcimr8b"));
|
||||
assert!(output.contains("X-Daytona-Skip-Preview-Warning: true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_signed_output_is_just_url() {
|
||||
let output = format_signed_output("https://3000-eyJhbGci.proxy.daytona.work");
|
||||
assert_eq!(output, "https://3000-eyJhbGci.proxy.daytona.work\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_signed_false_by_default() {
|
||||
let args = PreviewArgs {
|
||||
run: "abc".to_string(),
|
||||
port: 3000,
|
||||
signed: false,
|
||||
ttl: 3600,
|
||||
open: false,
|
||||
};
|
||||
assert!(!args.use_signed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_signed_true_when_signed() {
|
||||
let args = PreviewArgs {
|
||||
run: "abc".to_string(),
|
||||
port: 3000,
|
||||
signed: true,
|
||||
ttl: 3600,
|
||||
open: false,
|
||||
};
|
||||
assert!(args.use_signed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_signed_true_when_open() {
|
||||
let args = PreviewArgs {
|
||||
run: "abc".to_string(),
|
||||
port: 3000,
|
||||
signed: false,
|
||||
ttl: 3600,
|
||||
open: true,
|
||||
};
|
||||
assert!(args.use_signed());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
// Re-export all project config types from fabro-config for backward compatibility.
|
||||
pub use fabro_config::project::*;
|
||||
|
|
@ -1,614 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
|
||||
// Re-export all config types from fabro-config for backward compatibility.
|
||||
pub use fabro_config::mcp::{McpServerConfig, McpServerEntry, McpTransport};
|
||||
pub use fabro_config::run::{
|
||||
load_run_config, parse_run_config, resolve_env_refs, resolve_graph_path, AssetsConfig,
|
||||
CheckpointConfig, GitHubConfig, LlmConfig, MergeStrategy, PullRequestConfig, RunDefaults,
|
||||
SetupConfig, WorkflowRunConfig,
|
||||
};
|
||||
pub use fabro_config::sandbox::{
|
||||
DaytonaConfig, DaytonaNetwork, DaytonaSnapshotConfig, DockerfileSource, LocalSandboxConfig,
|
||||
SandboxConfig, WorktreeMode,
|
||||
};
|
||||
|
||||
/// Expand `$name` placeholders in `source` using the given variable map.
|
||||
///
|
||||
/// Identifiers match `[a-zA-Z_][a-zA-Z0-9_]*`. A `$` not followed by an
|
||||
/// identifier character is left as-is. Undefined variables produce an error.
|
||||
pub fn expand_vars(source: &str, vars: &HashMap<String, String>) -> anyhow::Result<String> {
|
||||
let mut result = String::with_capacity(source.len());
|
||||
let bytes = source.as_bytes();
|
||||
let len = bytes.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
if bytes[i] == b'$' {
|
||||
let start = i + 1;
|
||||
if start < len && bytes[start] == b'$' {
|
||||
result.push('$');
|
||||
i = start + 1;
|
||||
} else if start < len && (bytes[start].is_ascii_alphabetic() || bytes[start] == b'_') {
|
||||
let mut end = start + 1;
|
||||
while end < len && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
|
||||
end += 1;
|
||||
}
|
||||
let name = &source[start..end];
|
||||
match vars.get(name) {
|
||||
Some(value) => result.push_str(value),
|
||||
None => bail!("Undefined variable: ${name}"),
|
||||
}
|
||||
i = end;
|
||||
} else {
|
||||
result.push('$');
|
||||
i = start;
|
||||
}
|
||||
} else {
|
||||
result.push(source[i..].chars().next().unwrap());
|
||||
i += source[i..].chars().next().unwrap().len_utf8();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Run setup commands sequentially in the given directory.
|
||||
///
|
||||
/// Each command gets the full `timeout_ms` budget. Commands are executed
|
||||
/// via `sh -c` so shell features (pipes, redirects, etc.) work.
|
||||
pub async fn run_setup(setup: &SetupConfig, directory: &Path) -> anyhow::Result<()> {
|
||||
let timeout = std::time::Duration::from_millis(setup.timeout_ms.unwrap_or(300_000));
|
||||
|
||||
for cmd in &setup.commands {
|
||||
let fut = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.current_dir(directory)
|
||||
.output();
|
||||
|
||||
let output = tokio::time::timeout(timeout, fut)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Setup command timed out after {}ms: {cmd}",
|
||||
timeout.as_millis()
|
||||
)
|
||||
})?
|
||||
.with_context(|| format!("Failed to execute setup command: {cmd}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let code = output
|
||||
.status
|
||||
.code()
|
||||
.map_or("unknown".to_string(), |c| c.to_string());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Setup command failed (exit code {code}): {cmd}\n{stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::{DaytonaSnapshotConfig, DockerfileSource};
|
||||
|
||||
#[test]
|
||||
fn parse_toml_with_vars() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "Run tests"
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[vars]
|
||||
repo_url = "https://github.com/org/repo"
|
||||
language = "python"
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
let vars = config.vars.unwrap();
|
||||
assert_eq!(vars["repo_url"], "https://github.com/org/repo");
|
||||
assert_eq!(vars["language"], "python");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_single_var() {
|
||||
let vars = HashMap::from([("name".to_string(), "world".to_string())]);
|
||||
assert_eq!(expand_vars("Hello $name", &vars).unwrap(), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_multiple_vars() {
|
||||
let vars = HashMap::from([
|
||||
("greeting".to_string(), "Hello".to_string()),
|
||||
("name".to_string(), "world".to_string()),
|
||||
]);
|
||||
assert_eq!(
|
||||
expand_vars("$greeting $name!", &vars).unwrap(),
|
||||
"Hello world!"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_undefined_var_errors() {
|
||||
let vars = HashMap::new();
|
||||
let err = expand_vars("Hello $missing", &vars).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("Undefined variable: $missing"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_no_vars_passthrough() {
|
||||
let vars = HashMap::new();
|
||||
assert_eq!(
|
||||
expand_vars("no variables here", &vars).unwrap(),
|
||||
"no variables here"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_dollar_not_followed_by_ident() {
|
||||
let vars = HashMap::new();
|
||||
assert_eq!(expand_vars("costs $5", &vars).unwrap(), "costs $5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_escaped_dollar() {
|
||||
let vars = HashMap::from([("name".to_string(), "world".to_string())]);
|
||||
assert_eq!(
|
||||
expand_vars("literal $$name here", &vars).unwrap(),
|
||||
"literal $name here"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_escaped_dollar_at_end() {
|
||||
let vars = HashMap::new();
|
||||
assert_eq!(expand_vars("trailing $$", &vars).unwrap(), "trailing $");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_escaped_dollar_before_non_ident() {
|
||||
let vars = HashMap::new();
|
||||
assert_eq!(expand_vars("price is $$5", &vars).unwrap(), "price is $5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_with_devcontainer_enabled() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "Run tests"
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[sandbox]
|
||||
provider = "daytona"
|
||||
devcontainer = true
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
let sandbox = config.sandbox.unwrap();
|
||||
assert_eq!(sandbox.devcontainer, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_without_devcontainer() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "Run tests"
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[sandbox]
|
||||
provider = "daytona"
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
let sandbox = config.sandbox.unwrap();
|
||||
assert_eq!(sandbox.devcontainer, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_with_sandbox() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "Run tests"
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[sandbox]
|
||||
provider = "daytona"
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
let sandbox = config.sandbox.unwrap();
|
||||
assert_eq!(sandbox.provider.as_deref(), Some("daytona"));
|
||||
assert!(sandbox.daytona.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_with_daytona_config() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "Run tests"
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[sandbox]
|
||||
provider = "daytona"
|
||||
|
||||
[sandbox.daytona]
|
||||
auto_stop_interval = 60
|
||||
|
||||
[sandbox.daytona.labels]
|
||||
project = "fabro"
|
||||
environment = "ci"
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
let sandbox = config.sandbox.unwrap();
|
||||
let daytona = sandbox.daytona.unwrap();
|
||||
assert_eq!(daytona.auto_stop_interval, Some(60));
|
||||
let labels = daytona.labels.unwrap();
|
||||
assert_eq!(labels["project"], "fabro");
|
||||
assert_eq!(labels["environment"], "ci");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_with_daytona_snapshot() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "Run tests"
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[sandbox]
|
||||
provider = "daytona"
|
||||
|
||||
[sandbox.daytona.snapshot]
|
||||
name = "my-snapshot"
|
||||
cpu = 4
|
||||
memory = 8
|
||||
disk = 32
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
let snap = config.sandbox.unwrap().daytona.unwrap().snapshot.unwrap();
|
||||
assert_eq!(snap.name, "my-snapshot");
|
||||
assert_eq!(snap.cpu, Some(4));
|
||||
assert_eq!(snap.memory, Some(8));
|
||||
assert_eq!(snap.disk, Some(32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_with_inline_dockerfile() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "test"
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[sandbox.daytona.snapshot]
|
||||
name = "custom"
|
||||
dockerfile = "FROM rust:1.85-slim-bookworm"
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
let snap = config.sandbox.unwrap().daytona.unwrap().snapshot.unwrap();
|
||||
assert_eq!(
|
||||
snap.dockerfile,
|
||||
Some(DockerfileSource::Inline(
|
||||
"FROM rust:1.85-slim-bookworm".into()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_with_path_dockerfile() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "test"
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[sandbox.daytona.snapshot]
|
||||
name = "custom"
|
||||
|
||||
[sandbox.daytona.snapshot.dockerfile]
|
||||
path = "./Dockerfile"
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
let snap = config.sandbox.unwrap().daytona.unwrap().snapshot.unwrap();
|
||||
assert_eq!(
|
||||
snap.dockerfile,
|
||||
Some(DockerfileSource::Path {
|
||||
path: "./Dockerfile".into()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_dockerfile_replaces_path_with_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dockerfile_path = dir.path().join("Dockerfile");
|
||||
std::fs::write(&dockerfile_path, "FROM ubuntu:24.04\nRUN apt-get update").unwrap();
|
||||
let toml_path = dir.path().join("workflow.toml");
|
||||
std::fs::write(
|
||||
&toml_path,
|
||||
r#"
|
||||
version = 1
|
||||
goal = "test"
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[sandbox.daytona.snapshot]
|
||||
name = "custom"
|
||||
|
||||
[sandbox.daytona.snapshot.dockerfile]
|
||||
path = "./Dockerfile"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = load_run_config(&toml_path).unwrap();
|
||||
let snap = config.sandbox.unwrap().daytona.unwrap().snapshot.unwrap();
|
||||
assert_eq!(
|
||||
snap.dockerfile,
|
||||
Some(DockerfileSource::Inline(
|
||||
"FROM ubuntu:24.04\nRUN apt-get update".into()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_defaults_fills_missing_fields() {
|
||||
let defaults = RunDefaults {
|
||||
work_dir: Some("/ws".into()),
|
||||
llm: Some(LlmConfig {
|
||||
model: Some("m".into()),
|
||||
provider: Some("p".into()),
|
||||
fallbacks: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let toml = r#"
|
||||
version = 1
|
||||
graph = "workflow.fabro"
|
||||
"#;
|
||||
let mut config = parse_run_config(toml).unwrap();
|
||||
config.apply_defaults(&defaults);
|
||||
assert_eq!(config.work_dir.as_deref(), Some("/ws"));
|
||||
assert_eq!(config.llm.as_ref().unwrap().model.as_deref(), Some("m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_defaults_task_wins() {
|
||||
let defaults = RunDefaults {
|
||||
work_dir: Some("/default".into()),
|
||||
llm: Some(LlmConfig {
|
||||
model: Some("default-m".into()),
|
||||
provider: Some("default-p".into()),
|
||||
fallbacks: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let toml = r#"
|
||||
version = 1
|
||||
graph = "workflow.fabro"
|
||||
work_dir = "/task"
|
||||
|
||||
[llm]
|
||||
model = "task-m"
|
||||
"#;
|
||||
let mut config = parse_run_config(toml).unwrap();
|
||||
config.apply_defaults(&defaults);
|
||||
assert_eq!(config.work_dir.as_deref(), Some("/task"));
|
||||
assert_eq!(
|
||||
config.llm.as_ref().unwrap().model.as_deref(),
|
||||
Some("task-m")
|
||||
);
|
||||
// provider filled from defaults
|
||||
assert_eq!(
|
||||
config.llm.as_ref().unwrap().provider.as_deref(),
|
||||
Some("default-p")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_defaults_merges_vars() {
|
||||
let defaults = RunDefaults {
|
||||
vars: Some(HashMap::from([
|
||||
("a".into(), "1".into()),
|
||||
("b".into(), "2".into()),
|
||||
])),
|
||||
..Default::default()
|
||||
};
|
||||
let toml = r#"
|
||||
version = 1
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[vars]
|
||||
b = "override"
|
||||
c = "3"
|
||||
"#;
|
||||
let mut config = parse_run_config(toml).unwrap();
|
||||
config.apply_defaults(&defaults);
|
||||
let vars = config.vars.unwrap();
|
||||
assert_eq!(vars["a"], "1");
|
||||
assert_eq!(vars["b"], "override");
|
||||
assert_eq!(vars["c"], "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_defaults_daytona_deep_merge() {
|
||||
let defaults = RunDefaults {
|
||||
sandbox: Some(SandboxConfig {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
devcontainer: None,
|
||||
local: None,
|
||||
daytona: Some(DaytonaConfig {
|
||||
auto_stop_interval: Some(30),
|
||||
labels: Some(HashMap::from([("env".into(), "prod".into())])),
|
||||
snapshot: Some(DaytonaSnapshotConfig {
|
||||
name: "base".into(),
|
||||
cpu: Some(2),
|
||||
memory: None,
|
||||
disk: None,
|
||||
dockerfile: None,
|
||||
}),
|
||||
network: None,
|
||||
}),
|
||||
#[cfg(feature = "exedev")]
|
||||
exe: None,
|
||||
ssh: None,
|
||||
env: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let toml = r#"
|
||||
version = 1
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[sandbox.daytona]
|
||||
auto_stop_interval = 60
|
||||
|
||||
[sandbox.daytona.labels]
|
||||
team = "a"
|
||||
"#;
|
||||
let mut config = parse_run_config(toml).unwrap();
|
||||
config.apply_defaults(&defaults);
|
||||
let d = config.sandbox.unwrap().daytona.unwrap();
|
||||
assert_eq!(d.auto_stop_interval, Some(60));
|
||||
let labels = d.labels.unwrap();
|
||||
assert_eq!(labels["env"], "prod");
|
||||
assert_eq!(labels["team"], "a");
|
||||
assert_eq!(d.snapshot.unwrap().name, "base");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_overlay_basic() {
|
||||
let mut base = RunDefaults {
|
||||
work_dir: Some("/base".into()),
|
||||
llm: Some(LlmConfig {
|
||||
model: Some("base-m".into()),
|
||||
provider: None,
|
||||
fallbacks: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let overlay = RunDefaults {
|
||||
llm: Some(LlmConfig {
|
||||
model: None,
|
||||
provider: Some("overlay-p".into()),
|
||||
fallbacks: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
base.merge_overlay(overlay);
|
||||
assert_eq!(base.work_dir.as_deref(), Some("/base"));
|
||||
assert_eq!(base.llm.as_ref().unwrap().model.as_deref(), Some("base-m"));
|
||||
assert_eq!(
|
||||
base.llm.as_ref().unwrap().provider.as_deref(),
|
||||
Some("overlay-p")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_graph_path_relative() {
|
||||
let toml_path = std::path::Path::new("/home/user/workflows/wf/workflow.toml");
|
||||
let dot = resolve_graph_path(toml_path, "workflow.fabro");
|
||||
assert_eq!(
|
||||
dot,
|
||||
std::path::PathBuf::from("/home/user/workflows/wf/workflow.fabro")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_graph_path_absolute() {
|
||||
let toml_path = std::path::Path::new("/home/user/workflows/wf/workflow.toml");
|
||||
let dot = resolve_graph_path(toml_path, "/absolute/path.fabro");
|
||||
assert_eq!(dot, std::path::PathBuf::from("/absolute/path.fabro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_with_mcp_servers() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "test"
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[mcp_servers.filesystem]
|
||||
type = "stdio"
|
||||
command = ["npx", "-y", "@modelcontextprotocol/server-filesystem"]
|
||||
startup_timeout_secs = 20
|
||||
tool_timeout_secs = 120
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
assert_eq!(config.mcp_servers.len(), 1);
|
||||
let entry = &config.mcp_servers["filesystem"];
|
||||
assert_eq!(entry.startup_timeout_secs, 20);
|
||||
assert_eq!(entry.tool_timeout_secs, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_with_sandbox_env() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "test"
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[sandbox.env]
|
||||
MY_VAR = "hello"
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
let env = config.sandbox.unwrap().env.unwrap();
|
||||
assert_eq!(env["MY_VAR"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_run_config_with_hooks() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "test"
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[[hooks]]
|
||||
event = "run_start"
|
||||
command = "echo starting"
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
assert_eq!(config.hooks.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_run_config_with_github() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "test"
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[github]
|
||||
permissions = { contents = "read" }
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
let github = config.github.unwrap();
|
||||
assert_eq!(github.permissions["contents"], "read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_worktree_modes() {
|
||||
for (mode, expected) in [
|
||||
("always", WorktreeMode::Always),
|
||||
("clean", WorktreeMode::Clean),
|
||||
("dirty", WorktreeMode::Dirty),
|
||||
("never", WorktreeMode::Never),
|
||||
] {
|
||||
let toml = format!(
|
||||
r#"
|
||||
version = 1
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[sandbox.local]
|
||||
worktree_mode = "{mode}"
|
||||
"#
|
||||
);
|
||||
let config = parse_run_config(&toml).unwrap();
|
||||
assert_eq!(
|
||||
config.sandbox.unwrap().local.unwrap().worktree_mode,
|
||||
expected,
|
||||
"failed for mode: {mode}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,147 +0,0 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::info;
|
||||
|
||||
use crate::cli::runs::{default_runs_base, resolve_run};
|
||||
use crate::sandbox_record::SandboxRecord;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct SshArgs {
|
||||
/// Run ID or prefix
|
||||
pub run: String,
|
||||
/// SSH access expiry in minutes (default 60)
|
||||
#[arg(long, default_value = "60")]
|
||||
pub ttl: f64,
|
||||
/// Print the SSH command instead of connecting
|
||||
#[arg(long)]
|
||||
pub print: bool,
|
||||
}
|
||||
|
||||
fn validate_provider(record: &SandboxRecord) -> Result<()> {
|
||||
if record.provider != "daytona" {
|
||||
bail!(
|
||||
"SSH access is only supported for Daytona sandboxes (this run uses '{}')",
|
||||
record.provider
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_output(ssh_command: &str) -> String {
|
||||
format!("{ssh_command}\n")
|
||||
}
|
||||
|
||||
pub async fn ssh_command(args: SshArgs) -> Result<()> {
|
||||
let base = default_runs_base();
|
||||
let run_dir = resolve_run(&base, &args.run)?.path;
|
||||
let sandbox_json = run_dir.join("sandbox.json");
|
||||
let record = SandboxRecord::load(&sandbox_json).context(
|
||||
"Failed to load sandbox.json — was this run started with a recent version of arc?",
|
||||
)?;
|
||||
|
||||
validate_provider(&record)?;
|
||||
|
||||
let name = record
|
||||
.identifier
|
||||
.as_deref()
|
||||
.context("Daytona sandbox record missing identifier (sandbox name)")?;
|
||||
|
||||
info!(run_id = %args.run, ttl_minutes = args.ttl, "Creating SSH access");
|
||||
|
||||
let daytona = fabro_daytona::DaytonaSandbox::reconnect(name)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let ssh_cmd = daytona
|
||||
.create_ssh_access(Some(args.ttl))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
if args.print {
|
||||
let output = format_output(&ssh_cmd);
|
||||
print!("{output}");
|
||||
} else {
|
||||
exec_ssh(&ssh_cmd)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn exec_ssh(ssh_cmd: &str) -> Result<()> {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let parts: Vec<&str> = ssh_cmd.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
bail!("Empty SSH command returned from Daytona");
|
||||
}
|
||||
let err = std::process::Command::new(parts[0])
|
||||
.args(&parts[1..])
|
||||
.exec();
|
||||
// exec() only returns on error
|
||||
Err(anyhow::anyhow!("Failed to exec SSH: {err}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn exec_ssh(ssh_cmd: &str) -> Result<()> {
|
||||
bail!("Direct SSH connection is only supported on Unix systems; use --print instead");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_provider_rejects_local() {
|
||||
let record = SandboxRecord {
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/tmp".to_string(),
|
||||
identifier: None,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
};
|
||||
let err = validate_provider(&record).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("SSH access is only supported for Daytona sandboxes"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_provider_rejects_docker() {
|
||||
let record = SandboxRecord {
|
||||
provider: "docker".to_string(),
|
||||
working_directory: "/workspace".to_string(),
|
||||
identifier: None,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
};
|
||||
let err = validate_provider(&record).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("this run uses 'docker'"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_provider_accepts_daytona() {
|
||||
let record = SandboxRecord {
|
||||
provider: "daytona".to_string(),
|
||||
working_directory: "/home/daytona/workspace".to_string(),
|
||||
identifier: Some("sandbox-abc".to_string()),
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
};
|
||||
validate_provider(&record).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_output_produces_ssh_command_with_newline() {
|
||||
let output = format_output("ssh -p 2222 daytona@sandbox-123.daytona.work");
|
||||
assert_eq!(output, "ssh -p 2222 daytona@sandbox-123.daytona.work\n");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
use anyhow::bail;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::workflow::prepare_from_file;
|
||||
use fabro_validate::Severity;
|
||||
|
||||
use super::{print_diagnostics, relative_path, ValidateArgs};
|
||||
|
||||
/// Parse and validate a workflow file without executing it.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the file cannot be read, parsed, or has validation errors.
|
||||
pub fn validate_command(args: &ValidateArgs, styles: &Styles) -> anyhow::Result<()> {
|
||||
let (dot_path, _cfg) = super::project_config::resolve_workflow(&args.workflow)?;
|
||||
|
||||
let (graph, diagnostics) = prepare_from_file(&dot_path)?;
|
||||
|
||||
eprintln!(
|
||||
"{} ({} nodes, {} edges)",
|
||||
styles.bold.apply_to(format!("Workflow: {}", graph.name)),
|
||||
graph.nodes.len(),
|
||||
graph.edges.len(),
|
||||
);
|
||||
eprintln!(
|
||||
"{} {}",
|
||||
styles.dim.apply_to("Graph:"),
|
||||
styles.dim.apply_to(relative_path(&dot_path)),
|
||||
);
|
||||
|
||||
print_diagnostics(&diagnostics, styles);
|
||||
|
||||
if diagnostics.iter().any(|d| d.severity == Severity::Error) {
|
||||
bail!("Validation failed");
|
||||
}
|
||||
|
||||
eprintln!("Validation: {}", styles.green.apply_to("OK"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn validate_valid_workflow() {
|
||||
let mut tmp = tempfile::Builder::new()
|
||||
.suffix(".fabro")
|
||||
.tempfile()
|
||||
.unwrap();
|
||||
write!(
|
||||
tmp,
|
||||
r#"digraph Simple {{
|
||||
graph [goal="Run tests and report results"]
|
||||
rankdir=LR
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
|
||||
run_tests [label="Run Tests", prompt="Run the test suite and report results"]
|
||||
report [label="Report", prompt="Summarize the test results"]
|
||||
|
||||
start -> run_tests -> report -> exit
|
||||
}}"#
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let args = ValidateArgs {
|
||||
workflow: tmp.path().to_path_buf(),
|
||||
};
|
||||
let styles = Styles::new(false);
|
||||
let result = validate_command(&args, &styles);
|
||||
assert!(result.is_ok(), "expected Ok but got: {result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_invalid_syntax() {
|
||||
let mut tmp = tempfile::Builder::new()
|
||||
.suffix(".fabro")
|
||||
.tempfile()
|
||||
.unwrap();
|
||||
write!(tmp, "not a valid dot file").unwrap();
|
||||
|
||||
let args = ValidateArgs {
|
||||
workflow: tmp.path().to_path_buf(),
|
||||
};
|
||||
let styles = Styles::new(false);
|
||||
let result = validate_command(&args, &styles);
|
||||
assert!(result.is_err(), "expected Err for invalid syntax");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_file_references_resolved() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// Initialize a git repo so the workflow can be loaded
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Write a referenced prompt file
|
||||
let prompts_dir = dir.path().join("prompts");
|
||||
std::fs::create_dir_all(&prompts_dir).unwrap();
|
||||
std::fs::write(prompts_dir.join("plan.md"), "Plan the work carefully.").unwrap();
|
||||
|
||||
// Write a .fabro file that uses @prompts/plan.md
|
||||
let dot_path = dir.path().join("workflow.fabro");
|
||||
std::fs::write(
|
||||
&dot_path,
|
||||
r#"digraph FileRef {
|
||||
rankdir=LR
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
plan [label="Plan", prompt="@prompts/plan.md"]
|
||||
start -> plan -> exit
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let args = ValidateArgs { workflow: dot_path };
|
||||
let styles = Styles::new(false);
|
||||
let result = validate_command(&args, &styles);
|
||||
assert!(result.is_ok(), "expected Ok but got: {result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_toml_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let wf_dir = tmp.path().join("workflows").join("hello");
|
||||
std::fs::create_dir_all(&wf_dir).unwrap();
|
||||
std::fs::write(
|
||||
wf_dir.join("workflow.toml"),
|
||||
"version = 1\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
wf_dir.join("workflow.fabro"),
|
||||
r#"digraph Hello {
|
||||
graph [goal="Test"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
run [label="Run", prompt="Do it"]
|
||||
start -> run -> exit
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let args = ValidateArgs {
|
||||
workflow: wf_dir.join("workflow.toml"),
|
||||
};
|
||||
let styles = Styles::new(false);
|
||||
let result = validate_command(&args, &styles);
|
||||
assert!(result.is_ok(), "expected Ok but got: {result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_missing_file() {
|
||||
let args = ValidateArgs {
|
||||
workflow: PathBuf::from("/tmp/nonexistent_workflow_12345.fabro"),
|
||||
};
|
||||
let styles = Styles::new(false);
|
||||
let result = validate_command(&args, &styles);
|
||||
assert!(result.is_err(), "expected Err for missing file");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,421 +0,0 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use clap::Args;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use super::project_config::{
|
||||
discover_project_config, list_workflows_detailed, resolve_fabro_root, WorkflowInfo,
|
||||
WorkflowSource,
|
||||
};
|
||||
use super::relative_path;
|
||||
|
||||
const GOAL_MAX_LEN: usize = 60;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct WorkflowListArgs {}
|
||||
|
||||
pub fn workflow_list_command(_args: &WorkflowListArgs) -> anyhow::Result<()> {
|
||||
let styles = Styles::detect_stderr();
|
||||
let cwd = std::env::current_dir()?;
|
||||
|
||||
let (config_path, config) = match discover_project_config(&cwd)? {
|
||||
Some(found) => found,
|
||||
None => bail!(
|
||||
"No fabro.toml found in {cwd} or any parent directory",
|
||||
cwd = cwd.display()
|
||||
),
|
||||
};
|
||||
|
||||
let fabro_root = resolve_fabro_root(&config_path, &config);
|
||||
let project_wf_dir = fabro_root.join("workflows");
|
||||
let user_wf_dir = dirs::home_dir().map(|h| h.join(".fabro").join("workflows"));
|
||||
|
||||
let workflows = list_workflows_detailed(Some(&project_wf_dir), user_wf_dir.as_deref());
|
||||
|
||||
let project: Vec<_> = workflows
|
||||
.iter()
|
||||
.filter(|w| w.source == WorkflowSource::Project)
|
||||
.collect();
|
||||
let user: Vec<_> = workflows
|
||||
.iter()
|
||||
.filter(|w| w.source == WorkflowSource::User)
|
||||
.collect();
|
||||
|
||||
let name_width = workflows.iter().map(|w| w.name.len()).max().unwrap_or(0);
|
||||
|
||||
eprintln!(
|
||||
"{} workflow(s) found\n",
|
||||
styles.bold.apply_to(workflows.len())
|
||||
);
|
||||
|
||||
let user_path = user_wf_dir
|
||||
.as_deref()
|
||||
.map(relative_path)
|
||||
.unwrap_or_else(|| "~/.fabro/workflows".to_string());
|
||||
print_section("User Workflows", &user_path, &user, name_width, &styles);
|
||||
|
||||
eprintln!();
|
||||
|
||||
print_section(
|
||||
"Project Workflows",
|
||||
&relative_path(&project_wf_dir),
|
||||
&project,
|
||||
name_width,
|
||||
&styles,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct WorkflowCreateArgs {
|
||||
/// Name of the workflow
|
||||
pub name: String,
|
||||
|
||||
/// Goal description for the workflow
|
||||
#[arg(short, long)]
|
||||
goal: Option<String>,
|
||||
}
|
||||
|
||||
pub fn workflow_create_command(args: &WorkflowCreateArgs) -> anyhow::Result<()> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
|
||||
let (config_path, config) = match discover_project_config(&cwd)? {
|
||||
Some(found) => found,
|
||||
None => bail!(
|
||||
"No fabro.toml found in {cwd} or any parent directory",
|
||||
cwd = cwd.display()
|
||||
),
|
||||
};
|
||||
|
||||
let fabro_root = resolve_fabro_root(&config_path, &config);
|
||||
write_workflow_scaffold(args, &fabro_root)?;
|
||||
|
||||
let workflows_dir = fabro_root.join("workflows").join(&args.name);
|
||||
let green = console::Style::new().green();
|
||||
let bold = console::Style::new().bold();
|
||||
let cyan_bold = console::Style::new().cyan().bold();
|
||||
let dim = console::Style::new().dim();
|
||||
|
||||
let rel_dir = relative_path(&workflows_dir);
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
green.apply_to("✔"),
|
||||
dim.apply_to(format!("{rel_dir}/workflow.fabro"))
|
||||
);
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
green.apply_to("✔"),
|
||||
dim.apply_to(format!("{rel_dir}/workflow.toml"))
|
||||
);
|
||||
|
||||
eprintln!("\n{} Next steps:\n", bold.apply_to("Workflow created!"));
|
||||
eprintln!(
|
||||
" 1. Edit the graph: {}",
|
||||
cyan_bold.apply_to(format!("{rel_dir}/workflow.fabro"))
|
||||
);
|
||||
eprintln!(
|
||||
" 2. Validate: {}",
|
||||
cyan_bold.apply_to(format!("fabro validate {}", args.name))
|
||||
);
|
||||
eprintln!(
|
||||
" 3. Run: {}",
|
||||
cyan_bold.apply_to(format!("fabro run {}", args.name))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a workflow in a specific project (for testing).
|
||||
pub fn workflow_create_in(args: &WorkflowCreateArgs, config_path: &Path) -> anyhow::Result<()> {
|
||||
let config = super::project_config::load_project_config(config_path)?;
|
||||
let fabro_root = resolve_fabro_root(config_path, &config);
|
||||
write_workflow_scaffold(args, &fabro_root)
|
||||
}
|
||||
|
||||
fn write_workflow_scaffold(args: &WorkflowCreateArgs, fabro_root: &Path) -> anyhow::Result<()> {
|
||||
let workflows_dir = fabro_root.join("workflows").join(&args.name);
|
||||
|
||||
if workflows_dir.exists() {
|
||||
bail!(
|
||||
"Workflow '{}' already exists at {}",
|
||||
args.name,
|
||||
workflows_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&workflows_dir)
|
||||
.with_context(|| format!("failed to create {}", workflows_dir.display()))?;
|
||||
|
||||
let goal = args.goal.as_deref().unwrap_or("TODO: describe the goal");
|
||||
let digraph_name = to_pascal_case(&args.name);
|
||||
|
||||
let fabro_content = format!(
|
||||
r#"digraph {digraph_name} {{
|
||||
graph [goal="{goal}"]
|
||||
rankdir=LR
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
|
||||
main [label="Main", prompt="TODO: describe what this agent should do"]
|
||||
|
||||
start -> main -> exit
|
||||
}}
|
||||
"#
|
||||
);
|
||||
|
||||
let dot_path = workflows_dir.join("workflow.fabro");
|
||||
std::fs::write(&dot_path, &fabro_content)
|
||||
.with_context(|| format!("failed to write {}", dot_path.display()))?;
|
||||
|
||||
let toml_path = workflows_dir.join("workflow.toml");
|
||||
std::fs::write(&toml_path, "version = 1\n")
|
||||
.with_context(|| format!("failed to write {}", toml_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_pascal_case(s: &str) -> String {
|
||||
s.split(['-', '_'])
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(|part| {
|
||||
let mut chars = part.chars();
|
||||
match chars.next() {
|
||||
Some(first) => {
|
||||
let upper: String = first.to_uppercase().collect();
|
||||
format!("{upper}{rest}", rest = chars.as_str())
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn print_section(
|
||||
title: &str,
|
||||
path: &str,
|
||||
workflows: &[&WorkflowInfo],
|
||||
name_width: usize,
|
||||
styles: &Styles,
|
||||
) {
|
||||
eprintln!(
|
||||
"{} {}",
|
||||
styles.bold.apply_to(title),
|
||||
styles.dim.apply_to(format!("({path})")),
|
||||
);
|
||||
if workflows.is_empty() {
|
||||
eprintln!(" {}", styles.dim.apply_to("(none)"));
|
||||
return;
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" {:<name_width$} {}",
|
||||
styles.bold_dim.apply_to("NAME"),
|
||||
styles.bold_dim.apply_to("DESCRIPTION"),
|
||||
);
|
||||
for w in workflows {
|
||||
let goal_str = w
|
||||
.goal
|
||||
.as_deref()
|
||||
.map(|g| truncate_str(g, GOAL_MAX_LEN))
|
||||
.unwrap_or_default();
|
||||
eprintln!(
|
||||
" {:<name_width$} {}",
|
||||
styles.cyan.apply_to(&w.name),
|
||||
styles.dim.apply_to(goal_str),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_str(s: &str, max: usize) -> String {
|
||||
let first_line = s.lines().next().unwrap_or(s);
|
||||
if first_line.len() <= max {
|
||||
first_line.to_string()
|
||||
} else {
|
||||
format!("{}...", &first_line[..max - 3])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_project(tmp: &TempDir) -> std::path::PathBuf {
|
||||
let config_path = tmp.path().join("fabro.toml");
|
||||
fs::write(&config_path, "version = 1\n\n[fabro]\nroot = \"fabro/\"\n").unwrap();
|
||||
config_path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_workflow_directory_and_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = setup_project(&tmp);
|
||||
|
||||
let args = WorkflowCreateArgs {
|
||||
name: "deploy".to_string(),
|
||||
goal: None,
|
||||
};
|
||||
workflow_create_in(&args, &config_path).unwrap();
|
||||
|
||||
let wf_dir = tmp.path().join("fabro/workflows/deploy");
|
||||
assert!(wf_dir.join("workflow.fabro").exists());
|
||||
assert!(wf_dir.join("workflow.toml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_appears_in_generated_fabro() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = setup_project(&tmp);
|
||||
|
||||
let args = WorkflowCreateArgs {
|
||||
name: "deploy".to_string(),
|
||||
goal: Some("Deploy the app".to_string()),
|
||||
};
|
||||
workflow_create_in(&args, &config_path).unwrap();
|
||||
|
||||
let content =
|
||||
fs::read_to_string(tmp.path().join("fabro/workflows/deploy/workflow.fabro")).unwrap();
|
||||
assert!(content.contains(r#"goal="Deploy the app""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_goal_is_todo_placeholder() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = setup_project(&tmp);
|
||||
|
||||
let args = WorkflowCreateArgs {
|
||||
name: "deploy".to_string(),
|
||||
goal: None,
|
||||
};
|
||||
workflow_create_in(&args, &config_path).unwrap();
|
||||
|
||||
let content =
|
||||
fs::read_to_string(tmp.path().join("fabro/workflows/deploy/workflow.fabro")).unwrap();
|
||||
assert!(content.contains(r#"goal="TODO:"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_if_workflow_already_exists() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = setup_project(&tmp);
|
||||
|
||||
let wf_dir = tmp.path().join("fabro/workflows/deploy");
|
||||
fs::create_dir_all(&wf_dir).unwrap();
|
||||
|
||||
let args = WorkflowCreateArgs {
|
||||
name: "deploy".to_string(),
|
||||
goal: None,
|
||||
};
|
||||
let err = workflow_create_in(&args, &config_path).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("already exists"),
|
||||
"expected 'already exists' in: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_if_no_fabro_toml() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("fabro.toml");
|
||||
|
||||
let args = WorkflowCreateArgs {
|
||||
name: "deploy".to_string(),
|
||||
goal: None,
|
||||
};
|
||||
let result = workflow_create_in(&args, &config_path);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digraph_name_derived_from_workflow_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = setup_project(&tmp);
|
||||
|
||||
let args = WorkflowCreateArgs {
|
||||
name: "my-workflow".to_string(),
|
||||
goal: None,
|
||||
};
|
||||
workflow_create_in(&args, &config_path).unwrap();
|
||||
|
||||
let content = fs::read_to_string(
|
||||
tmp.path()
|
||||
.join("fabro/workflows/my-workflow/workflow.fabro"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
content.contains("digraph MyWorkflow"),
|
||||
"expected 'digraph MyWorkflow' in:\n{content}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_fabro_parses_and_validates() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = setup_project(&tmp);
|
||||
|
||||
let args = WorkflowCreateArgs {
|
||||
name: "test-wf".to_string(),
|
||||
goal: Some("Test goal".to_string()),
|
||||
};
|
||||
workflow_create_in(&args, &config_path).unwrap();
|
||||
|
||||
let content =
|
||||
fs::read_to_string(tmp.path().join("fabro/workflows/test-wf/workflow.fabro")).unwrap();
|
||||
|
||||
let graph = fabro_graphviz::parser::parse(&content).expect("generated .fabro should parse");
|
||||
let diagnostics = fabro_validate::validate(&graph, &[]);
|
||||
let errors: Vec<_> = diagnostics
|
||||
.iter()
|
||||
.filter(|d| d.severity == fabro_validate::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "validation errors: {errors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_pascal_case_simple() {
|
||||
assert_eq!(to_pascal_case("hello"), "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_pascal_case_hyphenated() {
|
||||
assert_eq!(to_pascal_case("my-workflow"), "MyWorkflow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_pascal_case_underscored() {
|
||||
assert_eq!(to_pascal_case("my_workflow"), "MyWorkflow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_pascal_case_mixed() {
|
||||
assert_eq!(to_pascal_case("my-cool_workflow"), "MyCoolWorkflow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_str_short() {
|
||||
assert_eq!(truncate_str("hello", 60), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_str_exact_limit() {
|
||||
let s = "a".repeat(60);
|
||||
assert_eq!(truncate_str(&s, 60), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_str_over_limit() {
|
||||
let s = "a".repeat(70);
|
||||
let result = truncate_str(&s, 60);
|
||||
assert_eq!(result.len(), 60);
|
||||
assert!(result.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_str_multiline_uses_first_line() {
|
||||
assert_eq!(truncate_str("first\nsecond\nthird", 60), "first");
|
||||
}
|
||||
}
|
||||
70
lib/crates/fabro-workflows/src/cost.rs
Normal file
70
lib/crates/fabro-workflows/src/cost.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
use crate::outcome::StageUsage;
|
||||
|
||||
/// Compute the dollar cost for a stage's token usage, if pricing is available.
|
||||
#[must_use]
|
||||
pub fn compute_stage_cost(usage: &StageUsage) -> Option<f64> {
|
||||
let info = fabro_llm::catalog::get_model_info(&usage.model)?;
|
||||
let input_rate = info.costs.input_cost_per_mtok?;
|
||||
let output_rate = info.costs.output_cost_per_mtok?;
|
||||
Some(
|
||||
usage.input_tokens as f64 * input_rate / 1_000_000.0
|
||||
+ usage.output_tokens as f64 * output_rate / 1_000_000.0,
|
||||
)
|
||||
}
|
||||
|
||||
/// Format a dollar cost for display (e.g. `"$1.23"`).
|
||||
#[must_use]
|
||||
pub fn format_cost(cost: f64) -> String {
|
||||
format!("${cost:.2}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{compute_stage_cost, format_cost};
|
||||
use crate::outcome::StageUsage;
|
||||
|
||||
#[test]
|
||||
fn format_cost_zero() {
|
||||
assert_eq!(format_cost(0.0), "$0.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_cost_normal() {
|
||||
assert_eq!(format_cost(1.5), "$1.50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_cost_rounds() {
|
||||
assert_eq!(format_cost(123.456), "$123.46");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_stage_cost_known_model() {
|
||||
let usage = StageUsage {
|
||||
model: "claude-sonnet-4-5".into(),
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_read_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
cost: None,
|
||||
};
|
||||
let cost = compute_stage_cost(&usage);
|
||||
assert!(cost.is_some());
|
||||
assert!(cost.unwrap() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_stage_cost_unknown_model() {
|
||||
let usage = StageUsage {
|
||||
model: "nonexistent-model-xyz".into(),
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_read_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
cost: None,
|
||||
};
|
||||
assert_eq!(compute_stage_cost(&usage), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,6 @@ use fabro_git_storage::trailerlink::{self, Trailer};
|
|||
use crate::artifact::{offload_large_values, sync_artifacts_to_env, ArtifactStore};
|
||||
use crate::asset_snapshot;
|
||||
use crate::checkpoint::Checkpoint;
|
||||
use crate::cli::run_config::PullRequestConfig;
|
||||
use crate::condition::evaluate_condition;
|
||||
use crate::context;
|
||||
use crate::context::Context;
|
||||
|
|
@ -26,6 +25,7 @@ use crate::handler::{EngineServices, HandlerRegistry};
|
|||
use crate::millis_u64;
|
||||
use crate::outcome::{Outcome, StageStatus};
|
||||
use crate::preamble::build_preamble;
|
||||
use fabro_config::run::PullRequestConfig;
|
||||
use fabro_graphviz::graph::{Edge, Graph, Node};
|
||||
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner};
|
||||
use fabro_interview::Interviewer;
|
||||
|
|
@ -715,7 +715,7 @@ pub async fn git_checkpoint(
|
|||
///
|
||||
/// Authenticates via a GitHub App installation token so we don't depend
|
||||
/// on the host's ambient git credentials.
|
||||
pub(crate) async fn git_push_host(
|
||||
pub async fn git_push_host(
|
||||
repo_path: &Path,
|
||||
refspec: &str,
|
||||
github_app: &Option<fabro_github::GitHubAppCredentials>,
|
||||
|
|
@ -1537,7 +1537,7 @@ impl WorkflowRunEngine {
|
|||
|
||||
// Write manifest.json (spec 5.6)
|
||||
let manifest = write_manifest(&config.run_dir, graph, config);
|
||||
crate::cli::runs::write_run_status(
|
||||
crate::run_status::write_run_status(
|
||||
&config.run_dir,
|
||||
crate::run_status::RunStatus::Running,
|
||||
None,
|
||||
|
|
|
|||
178
lib/crates/fabro-workflows/src/graph_render.rs
Normal file
178
lib/crates/fabro-workflows/src/graph_render.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::process::Command;
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::bail;
|
||||
|
||||
/// Output format for graph rendering.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum GraphFormat {
|
||||
/// Scalable Vector Graphics
|
||||
#[default]
|
||||
Svg,
|
||||
/// Portable Network Graphics
|
||||
Png,
|
||||
}
|
||||
|
||||
impl fmt::Display for GraphFormat {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Svg => write!(f, "svg"),
|
||||
Self::Png => write!(f, "png"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dark mode CSS injected into SVG output (leading newline included for insertion).
|
||||
const DARK_MODE_STYLE: &str = r##"
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
text { fill: #e0e0e0 !important; }
|
||||
[stroke="#357f9e"] { stroke: #5bb8d8; }
|
||||
[stroke="#666666"] { stroke: #999999; }
|
||||
polygon[fill="#357f9e"] { fill: #5bb8d8; }
|
||||
polygon[fill="#666666"] { fill: #999999; }
|
||||
}
|
||||
</style>"##;
|
||||
|
||||
/// DOT graph-level defaults injected after the first `{`.
|
||||
const DOT_STYLE_DEFAULTS: &str = r##"
|
||||
bgcolor="transparent"
|
||||
node [color="#357f9e", fontname="Helvetica", fontsize=12, fontcolor="#1a1a1a"]
|
||||
edge [color="#666666", fontname="Helvetica", fontsize=10, fontcolor="#666666"]
|
||||
"##;
|
||||
|
||||
static RANKDIR_RE: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"rankdir\s*=\s*\w+").unwrap());
|
||||
static WHITE_BG_POLYGON_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(
|
||||
r#"<polygon\b[^>]*fill="white"[^>]*stroke="none"[^>]*/>|<polygon\b[^>]*stroke="none"[^>]*fill="white"[^>]*/>"#,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
/// Rewrite `rankdir=...` in DOT source.
|
||||
#[must_use]
|
||||
pub fn apply_direction<'a>(source: &'a str, direction: &str) -> std::borrow::Cow<'a, str> {
|
||||
let replacement = format!("rankdir={direction}");
|
||||
RANKDIR_RE.replace(source, replacement.as_str())
|
||||
}
|
||||
|
||||
/// Inject DOT graph-level style defaults.
|
||||
#[must_use]
|
||||
pub fn inject_dot_style_defaults(source: &str) -> String {
|
||||
let Some(pos) = source.find('{') else {
|
||||
return source.to_string();
|
||||
};
|
||||
let (before, after) = source.split_at(pos + 1);
|
||||
format!("{before}{DOT_STYLE_DEFAULTS}{after}")
|
||||
}
|
||||
|
||||
/// Post-process raw SVG output from Graphviz.
|
||||
#[must_use]
|
||||
pub fn postprocess_svg(raw: Vec<u8>) -> Vec<u8> {
|
||||
let mut svg = String::from_utf8(raw)
|
||||
.unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned());
|
||||
|
||||
svg = WHITE_BG_POLYGON_RE.replace_all(&svg, "").into_owned();
|
||||
|
||||
if let Some(svg_close) = svg
|
||||
.find("<svg")
|
||||
.and_then(|start| svg[start..].find('>').map(|end| start + end))
|
||||
{
|
||||
svg.insert_str(svg_close + 1, DARK_MODE_STYLE);
|
||||
}
|
||||
|
||||
svg.into_bytes()
|
||||
}
|
||||
|
||||
/// Render styled DOT source into the given format via the `dot` command.
|
||||
pub fn render_dot(source: &str, format: GraphFormat) -> anyhow::Result<Vec<u8>> {
|
||||
let styled_source = inject_dot_style_defaults(source);
|
||||
let mut child = match Command::new("dot")
|
||||
.arg(format!("-T{format}"))
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
bail!("Graphviz is not installed. Install it with: brew install graphviz");
|
||||
}
|
||||
Err(err) => {
|
||||
bail!("Failed to run dot: {err}");
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(styled_source.as_bytes())?;
|
||||
}
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("dot failed: {stderr}");
|
||||
}
|
||||
|
||||
let raw = output.stdout;
|
||||
if matches!(format, GraphFormat::Svg) {
|
||||
Ok(postprocess_svg(raw))
|
||||
} else {
|
||||
Ok(raw)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn dot_is_available() -> bool {
|
||||
Command::new("dot")
|
||||
.arg("-V")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apply_direction_rewrites_rankdir() {
|
||||
let source = "digraph { rankdir=LR a -> b }";
|
||||
let rewritten = apply_direction(source, "TB");
|
||||
assert!(rewritten.contains("rankdir=TB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_style_defaults_adds_graph_defaults() {
|
||||
let source = "digraph X { a -> b }";
|
||||
let styled = inject_dot_style_defaults(source);
|
||||
assert!(styled.contains("bgcolor=\"transparent\""));
|
||||
assert!(styled.contains("node [color=\"#357f9e\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postprocess_svg_removes_white_background() {
|
||||
let raw = br#"<svg><polygon fill="white" stroke="none" points="0,0"/><text>x</text></svg>"#
|
||||
.to_vec();
|
||||
let svg = String::from_utf8(postprocess_svg(raw)).unwrap();
|
||||
assert!(!svg.contains("fill=\"white\""));
|
||||
assert!(svg.contains("@media (prefers-color-scheme: dark)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_dot_svg_or_png_if_graphviz_is_available() {
|
||||
if !dot_is_available() {
|
||||
return;
|
||||
}
|
||||
let svg = render_dot("digraph { a -> b }", GraphFormat::Svg).unwrap();
|
||||
assert!(String::from_utf8(svg).unwrap().contains("<svg"));
|
||||
|
||||
let png = render_dot("digraph { a -> b }", GraphFormat::Png).unwrap();
|
||||
assert!(!png.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -75,8 +75,7 @@ impl AgentHandler {
|
|||
/// `$gaol` at runtime.
|
||||
pub(crate) fn expand_variables(text: &str, graph: &Graph) -> Result<String, FabroError> {
|
||||
let vars = HashMap::from([("goal".to_string(), graph.goal().to_string())]);
|
||||
crate::cli::run_config::expand_vars(text, &vars)
|
||||
.map_err(|e| FabroError::Validation(e.to_string()))
|
||||
crate::vars::expand_vars(text, &vars).map_err(|e| FabroError::Validation(e.to_string()))
|
||||
}
|
||||
|
||||
/// Status fields that indicate a JSON object contains routing directives.
|
||||
|
|
|
|||
|
|
@ -95,23 +95,32 @@ pub fn build_completed_stages(
|
|||
|
||||
pub mod artifact;
|
||||
pub mod asset_snapshot;
|
||||
pub mod assets;
|
||||
pub mod backend;
|
||||
pub mod checkpoint;
|
||||
pub mod cli;
|
||||
pub mod conclusion;
|
||||
pub mod condition;
|
||||
pub mod context;
|
||||
pub mod cost;
|
||||
pub mod devcontainer_bridge;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod git;
|
||||
pub mod graph_render;
|
||||
pub mod handler;
|
||||
pub mod manifest;
|
||||
pub mod outcome;
|
||||
pub mod preamble;
|
||||
pub mod pull_request;
|
||||
pub mod run_fork;
|
||||
pub mod run_lookup;
|
||||
pub mod run_rewind;
|
||||
pub mod run_status;
|
||||
pub mod sandbox_provider;
|
||||
pub mod sandbox_reconnect;
|
||||
pub mod sandbox_record;
|
||||
pub mod stylesheet;
|
||||
pub mod transform;
|
||||
pub mod vars;
|
||||
pub mod workflow;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use std::path::Path;
|
||||
|
||||
use fabro_config::run::MergeStrategy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, info};
|
||||
|
||||
|
|
@ -62,7 +63,7 @@ fn truncate_pr_body(body: &str) -> String {
|
|||
|
||||
/// Format an optional cost as `$X.XX` or an en-dash when absent.
|
||||
fn format_cost(cost: Option<f64>) -> String {
|
||||
cost.map(crate::cli::format_cost)
|
||||
cost.map(crate::cost::format_cost)
|
||||
.unwrap_or_else(|| "\u{2013}".to_string())
|
||||
}
|
||||
|
||||
|
|
@ -344,7 +345,7 @@ pub async fn build_pr_body(
|
|||
|
||||
/// Auto-merge configuration for a pull request.
|
||||
pub struct AutoMergeConfig {
|
||||
pub merge_strategy: crate::cli::run_config::MergeStrategy,
|
||||
pub merge_strategy: MergeStrategy,
|
||||
}
|
||||
|
||||
/// Optionally open a pull request after a successful workflow run.
|
||||
|
|
@ -393,9 +394,9 @@ pub async fn maybe_open_pull_request(
|
|||
|
||||
if let Some(am_cfg) = auto_merge {
|
||||
let merge_method = match am_cfg.merge_strategy {
|
||||
crate::cli::run_config::MergeStrategy::Squash => github_app::AutoMergeMethod::Squash,
|
||||
crate::cli::run_config::MergeStrategy::Merge => github_app::AutoMergeMethod::Merge,
|
||||
crate::cli::run_config::MergeStrategy::Rebase => github_app::AutoMergeMethod::Rebase,
|
||||
MergeStrategy::Squash => github_app::AutoMergeMethod::Squash,
|
||||
MergeStrategy::Merge => github_app::AutoMergeMethod::Merge,
|
||||
MergeStrategy::Rebase => github_app::AutoMergeMethod::Rebase,
|
||||
};
|
||||
match github_app::enable_auto_merge(creds, &owner, &repo, &created.node_id, merge_method)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -1,75 +1,12 @@
|
|||
use anyhow::{Context, Result};
|
||||
use clap::Args;
|
||||
use fabro_git_storage::branchstore::BranchStore;
|
||||
use fabro_git_storage::gitobj::Store;
|
||||
use fabro_util::terminal::Styles;
|
||||
use git2::{Oid, Repository, Signature};
|
||||
use git2::{Oid, Signature};
|
||||
|
||||
use crate::git::MetadataStore;
|
||||
use crate::manifest::Manifest;
|
||||
|
||||
use super::rewind::{
|
||||
build_timeline, find_run_id_by_prefix, load_parallel_map, parse_target, print_timeline,
|
||||
resolve_target, TimelineEntry,
|
||||
};
|
||||
|
||||
/// Fork a workflow run from an earlier checkpoint into a new run.
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ForkArgs {
|
||||
/// Run ID (or unambiguous prefix)
|
||||
pub run_id: String,
|
||||
|
||||
/// Target checkpoint: node name, node@visit, or @ordinal (omit to fork from latest)
|
||||
pub target: Option<String>,
|
||||
|
||||
/// Show the checkpoint timeline instead of forking
|
||||
#[arg(long)]
|
||||
pub list: bool,
|
||||
|
||||
/// Skip pushing new branches to the remote
|
||||
#[arg(long)]
|
||||
pub no_push: bool,
|
||||
}
|
||||
|
||||
/// Entry point for `fabro fork`.
|
||||
pub fn fork_command(args: &ForkArgs, styles: &Styles) -> Result<()> {
|
||||
let repo = Repository::discover(".").context("not in a git repository")?;
|
||||
let run_id = find_run_id_by_prefix(&repo, &args.run_id)?;
|
||||
let store = Store::new(repo);
|
||||
|
||||
let timeline = build_timeline(&store, &run_id)?;
|
||||
|
||||
if args.list {
|
||||
let parallel_map = load_parallel_map(&store, &run_id);
|
||||
print_timeline(&timeline, ¶llel_map, styles);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entry = if let Some(target_str) = &args.target {
|
||||
let target = parse_target(target_str)?;
|
||||
let parallel_map = load_parallel_map(&store, &run_id);
|
||||
resolve_target(&timeline, &target, ¶llel_map)?
|
||||
} else {
|
||||
timeline
|
||||
.last()
|
||||
.ok_or_else(|| anyhow::anyhow!("no checkpoints found for run {run_id}"))?
|
||||
};
|
||||
|
||||
let new_run_id = execute_fork(&store, &run_id, entry, !args.no_push)?;
|
||||
|
||||
eprintln!(
|
||||
"\nForked run {} -> {}",
|
||||
&run_id[..8.min(run_id.len())],
|
||||
&new_run_id[..8.min(new_run_id.len())]
|
||||
);
|
||||
eprintln!(
|
||||
"To resume: fabro run --run-branch {}{}",
|
||||
crate::git::RUN_BRANCH_PREFIX,
|
||||
new_run_id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
use crate::run_rewind::TimelineEntry;
|
||||
|
||||
/// Create a new run that branches from an existing run at a specific checkpoint.
|
||||
///
|
||||
|
|
@ -204,6 +141,8 @@ mod tests {
|
|||
use std::collections::HashMap;
|
||||
|
||||
use super::*;
|
||||
use crate::run_rewind::{build_timeline, find_run_id_by_prefix, parse_target, resolve_target};
|
||||
use git2::Repository;
|
||||
|
||||
fn temp_repo() -> (tempfile::TempDir, Store) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
297
lib/crates/fabro-workflows/src/run_lookup.rs
Normal file
297
lib/crates/fabro-workflows/src/run_lookup.rs
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::run_status::{RunStatus, RunStatusRecord, StatusReason};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RunInfo {
|
||||
pub run_id: String,
|
||||
pub dir_name: String,
|
||||
pub workflow_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workflow_slug: Option<String>,
|
||||
pub status: RunStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status_reason: Option<StatusReason>,
|
||||
pub start_time: String,
|
||||
pub labels: HashMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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>,
|
||||
pub goal: String,
|
||||
#[serde(skip)]
|
||||
pub start_time_dt: Option<DateTime<Utc>>,
|
||||
#[serde(skip)]
|
||||
pub end_time: Option<DateTime<Utc>>,
|
||||
#[serde(skip)]
|
||||
pub path: PathBuf,
|
||||
#[serde(skip)]
|
||||
pub is_orphan: bool,
|
||||
}
|
||||
|
||||
pub fn default_data_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.expect("could not determine home directory")
|
||||
.join(".fabro")
|
||||
}
|
||||
|
||||
pub fn default_logs_base() -> PathBuf {
|
||||
default_data_dir().join("logs")
|
||||
}
|
||||
|
||||
pub fn default_runs_base() -> PathBuf {
|
||||
default_data_dir().join("runs")
|
||||
}
|
||||
|
||||
pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
|
||||
let entries = match std::fs::read_dir(base) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
let mut runs = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||
let manifest_path = path.join("manifest.json");
|
||||
|
||||
if let Ok(manifest) = crate::manifest::Manifest::load(&manifest_path) {
|
||||
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 goal = manifest.goal;
|
||||
let start_time_dt = manifest.start_time;
|
||||
let start_time = start_time_dt.to_rfc3339();
|
||||
let labels = manifest.labels;
|
||||
let status_info = read_status(&path);
|
||||
|
||||
runs.push(RunInfo {
|
||||
run_id,
|
||||
dir_name,
|
||||
workflow_name,
|
||||
workflow_slug,
|
||||
status: status_info.status,
|
||||
status_reason: status_info.reason,
|
||||
start_time,
|
||||
labels,
|
||||
duration_ms: status_info.duration_ms,
|
||||
total_cost: status_info.total_cost,
|
||||
host_repo_path,
|
||||
start_time_dt: Some(start_time_dt),
|
||||
end_time: status_info.end_time,
|
||||
path,
|
||||
goal,
|
||||
is_orphan: false,
|
||||
});
|
||||
} else {
|
||||
let mtime_dt = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.map(|time| -> DateTime<Utc> { time.into() });
|
||||
let mtime = mtime_dt.map(|dt| dt.to_rfc3339()).unwrap_or_default();
|
||||
|
||||
let run_id = std::fs::read_to_string(path.join("id.txt"))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| dir_name.clone());
|
||||
|
||||
let status_info = read_status(&path);
|
||||
let is_orphan = matches!(status_info.status, RunStatus::Dead);
|
||||
runs.push(RunInfo {
|
||||
run_id,
|
||||
dir_name,
|
||||
workflow_name: if is_orphan {
|
||||
"[no manifest]"
|
||||
} else {
|
||||
"[starting]"
|
||||
}
|
||||
.to_string(),
|
||||
workflow_slug: None,
|
||||
status: status_info.status,
|
||||
status_reason: status_info.reason,
|
||||
start_time: mtime,
|
||||
labels: HashMap::new(),
|
||||
duration_ms: status_info.duration_ms,
|
||||
total_cost: status_info.total_cost,
|
||||
host_repo_path: None,
|
||||
start_time_dt: mtime_dt,
|
||||
end_time: status_info.end_time,
|
||||
path,
|
||||
goal: String::new(),
|
||||
is_orphan,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
runs.sort_by(|a, b| b.start_time.cmp(&a.start_time));
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
struct StatusInfo {
|
||||
status: RunStatus,
|
||||
reason: Option<StatusReason>,
|
||||
end_time: Option<DateTime<Utc>>,
|
||||
duration_ms: Option<u64>,
|
||||
total_cost: Option<f64>,
|
||||
}
|
||||
|
||||
impl StatusInfo {
|
||||
fn simple(status: RunStatus) -> Self {
|
||||
Self {
|
||||
status,
|
||||
reason: None,
|
||||
end_time: None,
|
||||
duration_ms: None,
|
||||
total_cost: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_status(run_dir: &Path) -> StatusInfo {
|
||||
if let Ok(record) = RunStatusRecord::load(&run_dir.join("status.json")) {
|
||||
if record.status.is_terminal() {
|
||||
if let Ok(conclusion) =
|
||||
crate::conclusion::Conclusion::load(&run_dir.join("conclusion.json"))
|
||||
{
|
||||
return StatusInfo {
|
||||
status: record.status,
|
||||
reason: record.reason,
|
||||
end_time: Some(conclusion.timestamp),
|
||||
duration_ms: Some(conclusion.duration_ms),
|
||||
total_cost: conclusion.total_cost,
|
||||
};
|
||||
}
|
||||
}
|
||||
return StatusInfo {
|
||||
status: record.status,
|
||||
reason: record.reason,
|
||||
end_time: None,
|
||||
duration_ms: None,
|
||||
total_cost: None,
|
||||
};
|
||||
}
|
||||
|
||||
StatusInfo::simple(RunStatus::Dead)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StatusFilter {
|
||||
RunningOnly,
|
||||
All,
|
||||
}
|
||||
|
||||
pub fn filter_runs(
|
||||
runs: &[RunInfo],
|
||||
before: Option<&str>,
|
||||
workflow: Option<&str>,
|
||||
labels: &[(String, String)],
|
||||
include_orphans: bool,
|
||||
status_filter: StatusFilter,
|
||||
) -> Vec<RunInfo> {
|
||||
runs.iter()
|
||||
.filter(|run| {
|
||||
if status_filter == StatusFilter::RunningOnly && !run.status.is_active() {
|
||||
return false;
|
||||
}
|
||||
if run.is_orphan && !include_orphans {
|
||||
return false;
|
||||
}
|
||||
if let Some(before) = before {
|
||||
if !run.start_time.is_empty() && run.start_time.as_str() >= before {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(pattern) = workflow {
|
||||
if !run.workflow_name.contains(pattern) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (key, value) in labels {
|
||||
match run.labels.get(key) {
|
||||
Some(current) if current == value => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn find_run_by_prefix(base: &Path, prefix: &str) -> Result<PathBuf> {
|
||||
let runs = scan_runs(base).context("Failed to scan runs")?;
|
||||
let matches: Vec<_> = runs
|
||||
.iter()
|
||||
.filter(|run| run.run_id.starts_with(prefix))
|
||||
.collect();
|
||||
|
||||
match matches.len() {
|
||||
0 => bail!("No run found matching prefix '{prefix}'"),
|
||||
1 => Ok(matches[0].path.clone()),
|
||||
count => {
|
||||
let ids: Vec<&str> = matches.iter().map(|run| run.run_id.as_str()).collect();
|
||||
bail!(
|
||||
"Ambiguous prefix '{prefix}': {count} runs match: {}",
|
||||
ids.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_run(base: &Path, identifier: &str) -> Result<RunInfo> {
|
||||
let runs = scan_runs(base).context("Failed to scan runs")?;
|
||||
|
||||
let id_matches: Vec<_> = runs
|
||||
.iter()
|
||||
.filter(|run| run.run_id.starts_with(identifier))
|
||||
.collect();
|
||||
|
||||
match id_matches.len() {
|
||||
1 => return Ok(id_matches[0].clone()),
|
||||
count if count > 1 => {
|
||||
let ids: Vec<&str> = id_matches.iter().map(|run| run.run_id.as_str()).collect();
|
||||
bail!(
|
||||
"Ambiguous prefix '{identifier}': {count} runs match: {}",
|
||||
ids.join(", ")
|
||||
)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let id_lower = identifier.to_lowercase();
|
||||
let id_collapsed = collapse_separators(&id_lower);
|
||||
let workflow_match = runs.iter().filter(|run| !run.is_orphan).find(|run| {
|
||||
if let Some(slug) = &run.workflow_slug {
|
||||
if slug.to_lowercase() == id_lower {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let name_lower = run.workflow_name.to_lowercase();
|
||||
name_lower.contains(&id_lower) || collapse_separators(&name_lower).contains(&id_collapsed)
|
||||
});
|
||||
|
||||
match workflow_match {
|
||||
Some(run) => Ok(run.clone()),
|
||||
None => {
|
||||
bail!("No run found matching '{identifier}' (tried run ID prefix and workflow name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collapse_separators(s: &str) -> String {
|
||||
s.chars().filter(|c| *c != '-' && *c != '_').collect()
|
||||
}
|
||||
|
|
@ -1,36 +1,14 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use cli_table::format::{Border, Separator};
|
||||
use cli_table::{print_stderr, Cell, CellStruct, Color, Style, Table};
|
||||
use fabro_git_storage::branchstore::{BranchStore, CommitInfo};
|
||||
use fabro_git_storage::gitobj::Store;
|
||||
use fabro_util::terminal::Styles;
|
||||
use git2::{Oid, Repository, Signature};
|
||||
|
||||
use crate::checkpoint::Checkpoint;
|
||||
use crate::git::MetadataStore;
|
||||
use fabro_graphviz::graph::Graph;
|
||||
|
||||
/// Rewind a workflow run to an earlier checkpoint.
|
||||
#[derive(Debug, Args)]
|
||||
pub struct RewindArgs {
|
||||
/// Run ID (or unambiguous prefix)
|
||||
pub run_id: String,
|
||||
|
||||
/// Target checkpoint: node name, node@visit, or @ordinal (omit with --list)
|
||||
pub target: Option<String>,
|
||||
|
||||
/// Show the checkpoint timeline instead of rewinding
|
||||
#[arg(long)]
|
||||
pub list: bool,
|
||||
|
||||
/// Skip force-pushing rewound refs to the remote
|
||||
#[arg(long)]
|
||||
pub no_push: bool,
|
||||
}
|
||||
|
||||
/// Parsed rewind target.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RewindTarget {
|
||||
|
|
@ -271,66 +249,6 @@ pub fn resolve_target<'a>(
|
|||
}
|
||||
}
|
||||
|
||||
/// Print the timeline table to stderr.
|
||||
pub fn print_timeline(
|
||||
timeline: &[TimelineEntry],
|
||||
parallel_map: &HashMap<String, String>,
|
||||
styles: &Styles,
|
||||
) {
|
||||
if timeline.is_empty() {
|
||||
eprintln!("No checkpoints found.");
|
||||
return;
|
||||
}
|
||||
|
||||
let use_color = styles.use_color;
|
||||
|
||||
let title = vec![
|
||||
"@".cell().bold(true),
|
||||
"Node".cell().bold(true),
|
||||
"Details".cell().bold(true),
|
||||
];
|
||||
|
||||
let rows: Vec<Vec<CellStruct>> = timeline
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let ordinal_str = format!("@{}", entry.ordinal);
|
||||
let mut details = Vec::new();
|
||||
if entry.visit > 1 {
|
||||
details.push(format!("visit {}, loop", entry.visit));
|
||||
}
|
||||
if parallel_map.contains_key(&entry.node_name) {
|
||||
details.push("parallel interior".to_string());
|
||||
}
|
||||
if entry.run_commit_sha.is_none() {
|
||||
details.push("no run commit".to_string());
|
||||
}
|
||||
|
||||
let detail_str = if details.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("({})", details.join(", "))
|
||||
};
|
||||
|
||||
vec![
|
||||
ordinal_str
|
||||
.cell()
|
||||
.foreground_color(super::color_if(use_color, Color::Cyan)),
|
||||
entry.node_name.clone().cell(),
|
||||
detail_str
|
||||
.cell()
|
||||
.foreground_color(super::color_if(use_color, Color::Ansi256(8))),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
|
||||
let table = rows
|
||||
.table()
|
||||
.title(title)
|
||||
.border(Border::builder().build())
|
||||
.separator(Separator::builder().build());
|
||||
let _ = print_stderr(table);
|
||||
}
|
||||
|
||||
/// Move both refs backward to the target checkpoint.
|
||||
pub fn execute_rewind(
|
||||
store: &Store,
|
||||
|
|
@ -439,38 +357,6 @@ pub fn find_run_id_by_prefix(repo: &Repository, prefix: &str) -> Result<String>
|
|||
}
|
||||
}
|
||||
|
||||
/// Entry point for `fabro rewind`.
|
||||
pub fn rewind_command(args: &RewindArgs, styles: &Styles) -> Result<()> {
|
||||
let repo = Repository::discover(".").context("not in a git repository")?;
|
||||
let run_id = find_run_id_by_prefix(&repo, &args.run_id)?;
|
||||
let store = Store::new(repo);
|
||||
|
||||
let timeline = build_timeline(&store, &run_id)?;
|
||||
|
||||
if args.list || args.target.is_none() {
|
||||
// Read graph for parallel detection
|
||||
let parallel_map = load_parallel_map(&store, &run_id);
|
||||
print_timeline(&timeline, ¶llel_map, styles);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let target_str = args.target.as_ref().unwrap();
|
||||
let target = parse_target(target_str)?;
|
||||
|
||||
let parallel_map = load_parallel_map(&store, &run_id);
|
||||
let entry = resolve_target(&timeline, &target, ¶llel_map)?;
|
||||
|
||||
execute_rewind(&store, &run_id, entry, !args.no_push)?;
|
||||
|
||||
eprintln!(
|
||||
"\nTo resume: fabro run --run-branch {}{}",
|
||||
crate::git::RUN_BRANCH_PREFIX,
|
||||
run_id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load the graph from the metadata branch and build the parallel interior map.
|
||||
pub fn load_parallel_map(store: &Store, run_id: &str) -> HashMap<String, String> {
|
||||
let branch = MetadataStore::branch_name(run_id);
|
||||
|
|
@ -176,6 +176,12 @@ impl RunStatusRecord {
|
|||
}
|
||||
}
|
||||
|
||||
/// Write the run status to `status.json` (best-effort).
|
||||
pub fn write_run_status(run_dir: &Path, status: RunStatus, reason: Option<StatusReason>) {
|
||||
let record = RunStatusRecord::new(status, reason);
|
||||
let _ = record.save(&run_dir.join("status.json"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
121
lib/crates/fabro-workflows/src/sandbox_provider.rs
Normal file
121
lib/crates/fabro-workflows/src/sandbox_provider.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Sandbox provider for agent tool operations.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SandboxProvider {
|
||||
/// Run tools on the local host (default)
|
||||
#[default]
|
||||
Local,
|
||||
/// Run tools inside a Docker container
|
||||
Docker,
|
||||
/// Run tools inside a Daytona cloud sandbox
|
||||
Daytona,
|
||||
/// Run tools inside an exe.dev VM
|
||||
#[cfg(feature = "exedev")]
|
||||
Exe,
|
||||
/// Run tools on a user-provided SSH host
|
||||
Ssh,
|
||||
}
|
||||
|
||||
impl SandboxProvider {
|
||||
#[must_use]
|
||||
pub fn is_remote(&self) -> bool {
|
||||
match self {
|
||||
Self::Daytona => true,
|
||||
#[cfg(feature = "exedev")]
|
||||
Self::Exe => true,
|
||||
Self::Ssh => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SandboxProvider {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Local => write!(f, "local"),
|
||||
Self::Docker => write!(f, "docker"),
|
||||
Self::Daytona => write!(f, "daytona"),
|
||||
#[cfg(feature = "exedev")]
|
||||
Self::Exe => write!(f, "exe"),
|
||||
Self::Ssh => write!(f, "ssh"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SandboxProvider {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"local" => Ok(Self::Local),
|
||||
"docker" => Ok(Self::Docker),
|
||||
"daytona" => Ok(Self::Daytona),
|
||||
#[cfg(feature = "exedev")]
|
||||
"exe" => Ok(Self::Exe),
|
||||
"ssh" => Ok(Self::Ssh),
|
||||
other => Err(format!("unknown sandbox provider: {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SandboxProvider;
|
||||
|
||||
#[test]
|
||||
fn sandbox_provider_default_is_local() {
|
||||
assert_eq!(SandboxProvider::default(), SandboxProvider::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_provider_from_str() {
|
||||
assert_eq!(
|
||||
"local".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Local
|
||||
);
|
||||
assert_eq!(
|
||||
"docker".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Docker
|
||||
);
|
||||
assert_eq!(
|
||||
"daytona".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Daytona
|
||||
);
|
||||
assert_eq!(
|
||||
"LOCAL".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Local
|
||||
);
|
||||
#[cfg(feature = "exedev")]
|
||||
{
|
||||
assert_eq!(
|
||||
"exe".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Exe
|
||||
);
|
||||
assert_eq!(
|
||||
"EXE".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Exe
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
"ssh".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Ssh
|
||||
);
|
||||
assert_eq!(
|
||||
"SSH".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Ssh
|
||||
);
|
||||
assert!("invalid".parse::<SandboxProvider>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_provider_display() {
|
||||
assert_eq!(SandboxProvider::Local.to_string(), "local");
|
||||
assert_eq!(SandboxProvider::Docker.to_string(), "docker");
|
||||
assert_eq!(SandboxProvider::Daytona.to_string(), "daytona");
|
||||
#[cfg(feature = "exedev")]
|
||||
assert_eq!(SandboxProvider::Exe.to_string(), "exe");
|
||||
assert_eq!(SandboxProvider::Ssh.to_string(), "ssh");
|
||||
}
|
||||
}
|
||||
87
lib/crates/fabro-workflows/src/sandbox_reconnect.rs
Normal file
87
lib/crates/fabro-workflows/src/sandbox_reconnect.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use crate::sandbox_record::SandboxRecord;
|
||||
|
||||
/// Reconnect to a sandbox from a saved record.
|
||||
///
|
||||
/// Returns a sandbox that can perform file operations.
|
||||
pub async fn reconnect(record: &SandboxRecord) -> Result<Box<dyn fabro_agent::sandbox::Sandbox>> {
|
||||
match record.provider.as_str() {
|
||||
"local" => {
|
||||
let sandbox = fabro_agent::local_sandbox::LocalSandbox::new(PathBuf::from(
|
||||
&record.working_directory,
|
||||
));
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
"docker" => {
|
||||
let host_dir = record
|
||||
.host_working_directory
|
||||
.as_deref()
|
||||
.context("Docker sandbox record missing host_working_directory")?;
|
||||
let mount_point = record
|
||||
.container_mount_point
|
||||
.as_deref()
|
||||
.unwrap_or("/workspace");
|
||||
|
||||
let config = fabro_agent::docker_sandbox::DockerSandboxConfig {
|
||||
host_working_directory: host_dir.to_string(),
|
||||
container_mount_point: mount_point.to_string(),
|
||||
..fabro_agent::docker_sandbox::DockerSandboxConfig::default()
|
||||
};
|
||||
let sandbox = fabro_agent::docker_sandbox::DockerSandbox::new(config)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Docker sandbox: {e}"))?;
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
"daytona" => {
|
||||
let name = record
|
||||
.identifier
|
||||
.as_deref()
|
||||
.context("Daytona sandbox record missing identifier (sandbox name)")?;
|
||||
|
||||
let sandbox = fabro_daytona::DaytonaSandbox::reconnect(name)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
#[cfg(feature = "exedev")]
|
||||
"exe" => {
|
||||
let data_host = record
|
||||
.data_host
|
||||
.as_deref()
|
||||
.context("Exe sandbox record missing data_host")?;
|
||||
|
||||
let data_ssh = fabro_exe::OpensshRunner::connect(data_host)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to connect to exe sandbox '{data_host}': {e}")
|
||||
})?;
|
||||
|
||||
let sandbox = fabro_exe::ExeSandbox::from_existing(Box::new(data_ssh));
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
"ssh" => {
|
||||
let destination = record
|
||||
.data_host
|
||||
.as_deref()
|
||||
.context("SSH sandbox record missing data_host (destination)")?;
|
||||
|
||||
let ssh = fabro_ssh::OpensshRunner::connect(destination, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to connect to SSH sandbox '{destination}': {e}")
|
||||
})?;
|
||||
|
||||
let config = fabro_ssh::SshConfig {
|
||||
destination: destination.to_string(),
|
||||
working_directory: record.working_directory.clone(),
|
||||
config_file: None,
|
||||
preview_url_base: None,
|
||||
};
|
||||
let sandbox = fabro_ssh::SshSandbox::from_existing(Box::new(ssh), config);
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
other => bail!("Unknown sandbox provider: {other}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ impl Transform for VariableExpansionTransform {
|
|||
let vars = HashMap::from([("goal".to_string(), goal)]);
|
||||
for node in graph.nodes.values_mut() {
|
||||
if let Some(AttrValue::String(prompt)) = node.attrs.get("prompt") {
|
||||
if let Ok(expanded) = crate::cli::run_config::expand_vars(prompt, &vars) {
|
||||
if let Ok(expanded) = crate::vars::expand_vars(prompt, &vars) {
|
||||
if expanded != *prompt {
|
||||
node.attrs
|
||||
.insert("prompt".to_string(), AttrValue::String(expanded));
|
||||
|
|
|
|||
87
lib/crates/fabro-workflows/src/vars.rs
Normal file
87
lib/crates/fabro-workflows/src/vars.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::bail;
|
||||
|
||||
/// Expand `$name` placeholders in `source` using the given variable map.
|
||||
///
|
||||
/// Identifiers match `[a-zA-Z_][a-zA-Z0-9_]*`. A `$` not followed by an
|
||||
/// identifier character is left as-is. Undefined variables produce an error.
|
||||
pub fn expand_vars(source: &str, vars: &HashMap<String, String>) -> anyhow::Result<String> {
|
||||
let mut result = String::with_capacity(source.len());
|
||||
let bytes = source.as_bytes();
|
||||
let len = bytes.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
if bytes[i] == b'$' {
|
||||
let start = i + 1;
|
||||
if start < len && bytes[start] == b'$' {
|
||||
result.push('$');
|
||||
i = start + 1;
|
||||
} else if start < len && (bytes[start].is_ascii_alphabetic() || bytes[start] == b'_') {
|
||||
let mut end = start + 1;
|
||||
while end < len && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
|
||||
end += 1;
|
||||
}
|
||||
let name = &source[start..end];
|
||||
match vars.get(name) {
|
||||
Some(value) => result.push_str(value),
|
||||
None => bail!("Undefined variable: ${name}"),
|
||||
}
|
||||
i = end;
|
||||
} else {
|
||||
result.push('$');
|
||||
i = start;
|
||||
}
|
||||
} else {
|
||||
result.push(source[i..].chars().next().unwrap());
|
||||
i += source[i..].chars().next().unwrap().len_utf8();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::expand_vars;
|
||||
|
||||
#[test]
|
||||
fn expand_single_var() {
|
||||
let vars = HashMap::from([("name".to_string(), "world".to_string())]);
|
||||
assert_eq!(expand_vars("Hello $name", &vars).unwrap(), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_multiple_vars() {
|
||||
let vars = HashMap::from([
|
||||
("greeting".to_string(), "Hello".to_string()),
|
||||
("name".to_string(), "world".to_string()),
|
||||
]);
|
||||
assert_eq!(
|
||||
expand_vars("$greeting $name!", &vars).unwrap(),
|
||||
"Hello world!"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_undefined_var_errors() {
|
||||
let vars = HashMap::new();
|
||||
let err = expand_vars("Hello $missing", &vars).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("Undefined variable: $missing"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_escaped_dollar() {
|
||||
let vars = HashMap::from([("name".to_string(), "world".to_string())]);
|
||||
assert_eq!(
|
||||
expand_vars("literal $$name here", &vars).unwrap(),
|
||||
"literal $name here"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
//! Docker tests require a Docker daemon and are marked `#[ignore]`.
|
||||
//! Run Docker tests with: `cargo test --package arc-workflows --test cp_integration -- --ignored`
|
||||
|
||||
use fabro_workflows::cli::cp::reconnect;
|
||||
use fabro_workflows::sandbox_reconnect::reconnect;
|
||||
use fabro_workflows::sandbox_record::SandboxRecord;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -905,7 +905,7 @@ async fn daytona_parallel_git_branching_e2e() {
|
|||
// CLI Backend on Daytona — real CLI tools via exec_command
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use fabro_workflows::cli::cli_backend::AgentCliBackend;
|
||||
use fabro_workflows::backend::AgentCliBackend;
|
||||
use fabro_workflows::handler::agent::{CodergenBackend, CodergenResult};
|
||||
|
||||
/// Helper: run a real CLI backend test on Daytona.
|
||||
|
|
@ -1753,7 +1753,7 @@ async fn daytona_toolbox_idle_diagnostic() {
|
|||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn daytona_cp_upload_download_round_trip() {
|
||||
use fabro_workflows::cli::cp::reconnect;
|
||||
use fabro_workflows::sandbox_reconnect::reconnect;
|
||||
use fabro_workflows::sandbox_record::SandboxRecord;
|
||||
|
||||
// 1. Create and initialize a real Daytona sandbox
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::path::Path;
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_config::run::WorkflowRunConfig;
|
||||
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
|
||||
use fabro_graphviz::parser::parse;
|
||||
use fabro_interview::{
|
||||
|
|
@ -11,8 +12,9 @@ use fabro_interview::{
|
|||
};
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_validate::{validate, validate_or_raise, Severity};
|
||||
use fabro_workflows::backend::cli::{parse_cli_response, AgentCliBackend, BackendRouter};
|
||||
use fabro_workflows::backend::AgentApiBackend;
|
||||
use fabro_workflows::checkpoint::Checkpoint;
|
||||
use fabro_workflows::cli::backend::AgentApiBackend;
|
||||
use fabro_workflows::context::Context;
|
||||
use fabro_workflows::engine::{RunConfig, WorkflowRunEngine};
|
||||
use fabro_workflows::error::FabroError;
|
||||
|
|
@ -8127,7 +8129,7 @@ event = "run_complete"
|
|||
command = "echo done"
|
||||
"#;
|
||||
|
||||
let cfg: fabro_workflows::cli::run_config::WorkflowRunConfig = toml::from_str(toml).unwrap();
|
||||
let cfg: WorkflowRunConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.hooks.len(), 2);
|
||||
assert_eq!(cfg.hooks[0].event, fabro_hooks::HookEvent::StageStart);
|
||||
assert_eq!(cfg.hooks[0].matcher.as_deref(), Some("agent_loop"));
|
||||
|
|
@ -8307,7 +8309,7 @@ max_tool_rounds = 10
|
|||
timeout_ms = 120000
|
||||
"#;
|
||||
|
||||
let cfg: fabro_workflows::cli::run_config::WorkflowRunConfig = toml::from_str(toml).unwrap();
|
||||
let cfg: WorkflowRunConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.hooks.len(), 2);
|
||||
|
||||
// Prompt hook
|
||||
|
|
@ -9311,8 +9313,6 @@ async fn node_dir_uses_visit_count_on_revisit() {
|
|||
// CLI Backend end-to-end tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use fabro_workflows::cli::cli_backend::{AgentCliBackend, BackendRouter};
|
||||
|
||||
/// A mock sandbox for CLI backend e2e tests.
|
||||
/// Records all exec_command and write_file calls, and returns configurable
|
||||
/// responses based on command content.
|
||||
|
|
@ -10403,8 +10403,6 @@ async fn stylesheet_backend_property_routes_to_cli() {
|
|||
// Real CLI backend e2e tests (require actual CLI tools installed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use fabro_workflows::cli::cli_backend::parse_cli_response;
|
||||
|
||||
/// Run a real CLI tool via LocalSandbox and verify the full flow.
|
||||
async fn run_real_cli_test(provider: Provider, model: &str) {
|
||||
let env = local_env();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue