Move workflow CLI ownership into fabro-cli

This commit is contained in:
Bryan Helmkamp 2026-03-18 09:31:11 -04:00
parent 9e0a387f44
commit c57734244e
No known key found for this signature in database
64 changed files with 4058 additions and 7746 deletions

20
Cargo.lock generated
View file

@ -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",

View file

@ -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,

View file

@ -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 {

View file

@ -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 {

View file

@ -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()],

View file

@ -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"

View 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);
}
}

View 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);
}
}

View 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()
}
}

View 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, &parallel_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, &parallel_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(())
}

View 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),
}
}

View 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,
})
}

View file

@ -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}");
}

View 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;

View 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(())
}

View 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(())
}

View 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")
}

View 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, &parallel_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, &parallel_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
}
}

View file

@ -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());

View 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");
}
}

View 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");
}
}

View 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");
}

View 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(())
}

View 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])
}
}

View file

@ -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 } => {

View file

@ -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>,

View file

@ -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

View 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)
}

View file

@ -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

View file

@ -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,

View file

@ -0,0 +1,5 @@
pub mod api;
pub mod cli;
pub use api::AgentApiBackend;
pub use cli::{parse_cli_response, AgentCliBackend, BackendRouter};

View file

@ -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);
}
}

View file

@ -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"),
}
}
}

View file

@ -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"
);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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");
}
}

View 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}");
}
}

View file

@ -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());
}
}

View file

@ -1,2 +0,0 @@
// Re-export all project config types from fabro-config for backward compatibility.
pub use fabro_config::project::*;

View file

@ -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

View file

@ -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");
}
}

View file

@ -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");
}
}

View 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");
}
}

View 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);
}
}

View file

@ -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,

View 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());
}
}

View file

@ -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.

View file

@ -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;

View file

@ -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

View file

@ -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, &parallel_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, &parallel_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();

View 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()
}

View file

@ -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, &parallel_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, &parallel_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);

View file

@ -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::*;

View 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");
}
}

View 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}"),
}
}

View file

@ -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));

View 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"
);
}
}

View file

@ -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;
// ---------------------------------------------------------------------------

View file

@ -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

View file

@ -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();