From c57734244e3ff1440272ac5986d677284e6eb7a2 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 18 Mar 2026 09:31:11 -0400 Subject: [PATCH] Move workflow CLI ownership into fabro-cli --- Cargo.lock | 20 +- lib/crates/fabro-api/src/demo/mod.rs | 44 +- lib/crates/fabro-api/src/serve.rs | 4 +- lib/crates/fabro-api/src/server.rs | 2 +- .../fabro-api/tests/openapi_conformance.rs | 5 +- lib/crates/fabro-cli/Cargo.toml | 18 +- lib/crates/fabro-cli/src/commands/asset.rs | 264 ++ lib/crates/fabro-cli/src/commands/cp.rs | 232 ++ lib/crates/fabro-cli/src/commands/diff.rs | 130 + lib/crates/fabro-cli/src/commands/fork.rs | 63 + lib/crates/fabro-cli/src/commands/graph.rs | 119 + lib/crates/fabro-cli/src/commands/inspect.rs | 63 + .../cli => fabro-cli/src/commands}/logs.rs | 120 +- lib/crates/fabro-cli/src/commands/mod.rs | 18 + lib/crates/fabro-cli/src/commands/parse.rs | 26 + lib/crates/fabro-cli/src/commands/pr.rs | 419 +++ lib/crates/fabro-cli/src/commands/preview.rs | 93 + lib/crates/fabro-cli/src/commands/rewind.rs | 119 + .../src/cli => fabro-cli/src/commands}/run.rs | 501 ++-- .../src/commands/run_progress.rs} | 11 +- lib/crates/fabro-cli/src/commands/runs.rs | 634 +++++ lib/crates/fabro-cli/src/commands/shared.rs | 102 + lib/crates/fabro-cli/src/commands/ssh.rs | 83 + lib/crates/fabro-cli/src/commands/validate.rs | 41 + lib/crates/fabro-cli/src/commands/workflow.rs | 231 ++ lib/crates/fabro-cli/src/main.rs | 105 +- lib/crates/fabro-config/src/sandbox.rs | 2 +- lib/crates/fabro-workflows/Cargo.toml | 4 - lib/crates/fabro-workflows/src/assets.rs | 79 + .../src/{cli/backend.rs => backend/api.rs} | 5 +- .../{cli/cli_backend.rs => backend/cli.rs} | 3 +- lib/crates/fabro-workflows/src/backend/mod.rs | 5 + lib/crates/fabro-workflows/src/cli/asset.rs | 317 --- lib/crates/fabro-workflows/src/cli/cp.rs | 386 --- lib/crates/fabro-workflows/src/cli/diff.rs | 327 --- lib/crates/fabro-workflows/src/cli/graph.rs | 481 ---- lib/crates/fabro-workflows/src/cli/inspect.rs | 203 -- lib/crates/fabro-workflows/src/cli/mod.rs | 437 --- lib/crates/fabro-workflows/src/cli/parse.rs | 111 - lib/crates/fabro-workflows/src/cli/pr.rs | 868 ------ lib/crates/fabro-workflows/src/cli/preview.rs | 206 -- .../fabro-workflows/src/cli/project_config.rs | 2 - .../fabro-workflows/src/cli/run_config.rs | 614 ---- lib/crates/fabro-workflows/src/cli/runs.rs | 2470 ----------------- lib/crates/fabro-workflows/src/cli/ssh.rs | 147 - .../fabro-workflows/src/cli/validate.rs | 169 -- .../fabro-workflows/src/cli/workflow.rs | 421 --- lib/crates/fabro-workflows/src/cost.rs | 70 + lib/crates/fabro-workflows/src/engine.rs | 6 +- .../fabro-workflows/src/graph_render.rs | 178 ++ .../fabro-workflows/src/handler/agent.rs | 3 +- lib/crates/fabro-workflows/src/lib.rs | 11 +- .../fabro-workflows/src/pull_request.rs | 11 +- .../src/{cli/fork.rs => run_fork.rs} | 69 +- lib/crates/fabro-workflows/src/run_lookup.rs | 297 ++ .../src/{cli/rewind.rs => run_rewind.rs} | 114 - lib/crates/fabro-workflows/src/run_status.rs | 6 + .../fabro-workflows/src/sandbox_provider.rs | 121 + .../fabro-workflows/src/sandbox_reconnect.rs | 87 + lib/crates/fabro-workflows/src/transform.rs | 2 +- lib/crates/fabro-workflows/src/vars.rs | 87 + .../fabro-workflows/tests/cp_integration.rs | 2 +- .../tests/daytona_integration.rs | 4 +- .../fabro-workflows/tests/integration.rs | 12 +- 64 files changed, 4058 insertions(+), 7746 deletions(-) create mode 100644 lib/crates/fabro-cli/src/commands/asset.rs create mode 100644 lib/crates/fabro-cli/src/commands/cp.rs create mode 100644 lib/crates/fabro-cli/src/commands/diff.rs create mode 100644 lib/crates/fabro-cli/src/commands/fork.rs create mode 100644 lib/crates/fabro-cli/src/commands/graph.rs create mode 100644 lib/crates/fabro-cli/src/commands/inspect.rs rename lib/crates/{fabro-workflows/src/cli => fabro-cli/src/commands}/logs.rs (91%) create mode 100644 lib/crates/fabro-cli/src/commands/mod.rs create mode 100644 lib/crates/fabro-cli/src/commands/parse.rs create mode 100644 lib/crates/fabro-cli/src/commands/pr.rs create mode 100644 lib/crates/fabro-cli/src/commands/preview.rs create mode 100644 lib/crates/fabro-cli/src/commands/rewind.rs rename lib/crates/{fabro-workflows/src/cli => fabro-cli/src/commands}/run.rs (87%) rename lib/crates/{fabro-workflows/src/cli/progress.rs => fabro-cli/src/commands/run_progress.rs} (99%) create mode 100644 lib/crates/fabro-cli/src/commands/runs.rs create mode 100644 lib/crates/fabro-cli/src/commands/shared.rs create mode 100644 lib/crates/fabro-cli/src/commands/ssh.rs create mode 100644 lib/crates/fabro-cli/src/commands/validate.rs create mode 100644 lib/crates/fabro-cli/src/commands/workflow.rs create mode 100644 lib/crates/fabro-workflows/src/assets.rs rename lib/crates/fabro-workflows/src/{cli/backend.rs => backend/api.rs} (99%) rename lib/crates/fabro-workflows/src/{cli/cli_backend.rs => backend/cli.rs} (99%) create mode 100644 lib/crates/fabro-workflows/src/backend/mod.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/asset.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/cp.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/diff.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/graph.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/inspect.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/mod.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/parse.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/pr.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/preview.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/project_config.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/run_config.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/runs.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/ssh.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/validate.rs delete mode 100644 lib/crates/fabro-workflows/src/cli/workflow.rs create mode 100644 lib/crates/fabro-workflows/src/cost.rs create mode 100644 lib/crates/fabro-workflows/src/graph_render.rs rename lib/crates/fabro-workflows/src/{cli/fork.rs => run_fork.rs} (87%) create mode 100644 lib/crates/fabro-workflows/src/run_lookup.rs rename lib/crates/fabro-workflows/src/{cli/rewind.rs => run_rewind.rs} (89%) create mode 100644 lib/crates/fabro-workflows/src/sandbox_provider.rs create mode 100644 lib/crates/fabro-workflows/src/sandbox_reconnect.rs create mode 100644 lib/crates/fabro-workflows/src/vars.rs diff --git a/Cargo.lock b/Cargo.lock index 4c3d6c640..b9694ae17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/lib/crates/fabro-api/src/demo/mod.rs b/lib/crates/fabro-api/src/demo/mod.rs index 52c1fddd9..b0adc84df 100644 --- a/lib/crates/fabro-api/src/demo/mod.rs +++ b/lib/crates/fabro-api/src/demo/mod.rs @@ -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, diff --git a/lib/crates/fabro-api/src/serve.rs b/lib/crates/fabro-api/src/serve.rs index d79d20772..3791cfb13 100644 --- a/lib/crates/fabro-api/src/serve.rs +++ b/lib/crates/fabro-api/src/serve.rs @@ -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 { diff --git a/lib/crates/fabro-api/src/server.rs b/lib/crates/fabro-api/src/server.rs index e1e13d393..3fd2efee8 100644 --- a/lib/crates/fabro-api/src/server.rs +++ b/lib/crates/fabro-api/src/server.rs @@ -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 { diff --git a/lib/crates/fabro-api/tests/openapi_conformance.rs b/lib/crates/fabro-api/tests/openapi_conformance.rs index 5d8b41881..76a7e4c56 100644 --- a/lib/crates/fabro-api/tests/openapi_conformance.rs +++ b/lib/crates/fabro-api/tests/openapi_conformance.rs @@ -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()], diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index 03d5597f7..da4aef5eb 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -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" diff --git a/lib/crates/fabro-cli/src/commands/asset.rs b/lib/crates/fabro-cli/src/commands/asset.rs new file mode 100644 index 000000000..06ce83c6a --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/asset.rs @@ -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, + + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct AssetCpArgs { + /// Source: RUN_ID (all assets) or RUN_ID:path (specific asset) + pub source: String, + + /// Destination directory (defaults to current directory) + #[arg(default_value = ".")] + pub dest: PathBuf, + + /// Filter to assets from a specific node + #[arg(long)] + pub node: Option, + + /// Preserve {node_slug}/retry_{N}/ directory structure + #[arg(long)] + pub tree: bool, +} + +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!( + "{:retry_width$} {:>size_width$} PATH", + "NODE", "RETRY", "SIZE" + ); + let total_size: u64 = entries.iter().map(|entry| entry.size).sum(); + for entry in &entries { + println!( + "{:retry_width$} {:>size_width$} {}", + entry.node_slug, + entry.retry, + format_size(entry.size), + entry.relative_path + ); + } + println!(); + println!( + "{} asset(s), {} total", + entries.len(), + format_size(total_size) + ); + + Ok(()) +} + +pub fn cp_command(args: &AssetCpArgs) -> Result<()> { + let base = 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); + } +} diff --git a/lib/crates/fabro-cli/src/commands/cp.rs b/lib/crates/fabro-cli/src/commands/cp.rs new file mode 100644 index 000000000..2445b5f62 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/cp.rs @@ -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: : or local path + pub src: String, + /// Destination: : 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 { + 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. :)"), + } +} + +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> { + 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); + } +} diff --git a/lib/crates/fabro-cli/src/commands/diff.rs b/lib/crates/fabro-cli/src/commands/diff.rs new file mode 100644 index 000000000..0531f9de8 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/diff.rs @@ -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, + /// 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 { + 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() + } +} diff --git a/lib/crates/fabro-cli/src/commands/fork.rs b/lib/crates/fabro-cli/src/commands/fork.rs new file mode 100644 index 000000000..d17f67cb5 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/fork.rs @@ -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, + + /// Show the checkpoint timeline instead of forking + #[arg(long)] + pub list: bool, + + /// Skip pushing new branches to the remote + #[arg(long)] + pub no_push: bool, +} + +pub fn run(args: &ForkArgs, styles: &Styles) -> Result<()> { + let repo = Repository::discover(".").context("not in a git repository")?; + let run_id = fabro_workflows::run_rewind::find_run_id_by_prefix(&repo, &args.run_id)?; + let store = Store::new(repo); + + let timeline = fabro_workflows::run_rewind::build_timeline(&store, &run_id)?; + + if args.list { + let parallel_map = fabro_workflows::run_rewind::load_parallel_map(&store, &run_id); + super::rewind::print_timeline(&timeline, ¶llel_map, styles); + return Ok(()); + } + + let entry = if let Some(target_str) = &args.target { + let target = fabro_workflows::run_rewind::parse_target(target_str)?; + let parallel_map = fabro_workflows::run_rewind::load_parallel_map(&store, &run_id); + fabro_workflows::run_rewind::resolve_target(&timeline, &target, ¶llel_map)? + } else { + timeline + .last() + .ok_or_else(|| anyhow::anyhow!("no checkpoints found for run {run_id}"))? + }; + + let new_run_id = + fabro_workflows::run_fork::execute_fork(&store, &run_id, entry, !args.no_push)?; + + eprintln!( + "\nForked run {} -> {}", + &run_id[..8.min(run_id.len())], + &new_run_id[..8.min(new_run_id.len())] + ); + eprintln!( + "To resume: fabro run --run-branch {}{}", + fabro_workflows::git::RUN_BRANCH_PREFIX, + new_run_id + ); + + Ok(()) +} diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs new file mode 100644 index 000000000..9f362aaaf --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -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, + + /// Graph layout direction (overrides the DOT file's rankdir) + #[arg(short = 'd', long)] + pub direction: Option, +} + +static RANKDIR_RE: LazyLock = + 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 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) -> Cow<'a, str> { + match direction { + Some(dir) => { + let replacement = format!("rankdir={dir}"); + RANKDIR_RE.replace(source, replacement.as_str()) + } + None => Cow::Borrowed(source), + } +} diff --git a/lib/crates/fabro-cli/src/commands/inspect.rs b/lib/crates/fabro-cli/src/commands/inspect.rs new file mode 100644 index 000000000..6bebeee79 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/inspect.rs @@ -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, + pub conclusion: Option, + pub checkpoint: Option, + pub sandbox: Option, +} + +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 { + 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, + }) +} diff --git a/lib/crates/fabro-workflows/src/cli/logs.rs b/lib/crates/fabro-cli/src/commands/logs.rs similarity index 91% rename from lib/crates/fabro-workflows/src/cli/logs.rs rename to lib/crates/fabro-cli/src/commands/logs.rs index d84313912..09fb2126c 100644 --- a/lib/crates/fabro-workflows/src/cli/logs.rs +++ b/lib/crates/fabro-cli/src/commands/logs.rs @@ -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> { ts_str.parse::>().ok() } -/// Parse a `--since` value: relative duration (e.g. "42m", "2h", "7d") or ISO 8601 timestamp. pub fn parse_since(s: &str) -> Result> { 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::>() { 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 { @@ -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::>() .join("\n") } -pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) -> Option { +pub fn format_event_pretty(line: &str, styles: &Styles) -> Option { 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::>() .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 = (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}"); } diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs new file mode 100644 index 000000000..1b412cf23 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/parse.rs b/lib/crates/fabro-cli/src/commands/parse.rs new file mode 100644 index 000000000..0b6e54dce --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/parse.rs @@ -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(()) +} diff --git a/lib/crates/fabro-cli/src/commands/pr.rs b/lib/crates/fabro-cli/src/commands/pr.rs new file mode 100644 index 000000000..3137f0960 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/pr.rs @@ -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, +} + +#[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, +) -> 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, +) -> 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::(&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, +) -> 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, +) -> 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, +) -> 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, +) -> 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, +) -> 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, +) -> 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, +) -> 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, +) -> 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(()) +} diff --git a/lib/crates/fabro-cli/src/commands/preview.rs b/lib/crates/fabro-cli/src/commands/preview.rs new file mode 100644 index 000000000..cd3abe884 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/preview.rs @@ -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") +} diff --git a/lib/crates/fabro-cli/src/commands/rewind.rs b/lib/crates/fabro-cli/src/commands/rewind.rs new file mode 100644 index 000000000..7c98c830d --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/rewind.rs @@ -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, + + /// Show the checkpoint timeline instead of rewinding + #[arg(long)] + pub list: bool, + + /// Skip force-pushing rewound refs to the remote + #[arg(long)] + pub no_push: bool, +} + +pub fn run(args: &RewindArgs, styles: &Styles) -> Result<()> { + let repo = Repository::discover(".").context("not in a git repository")?; + let run_id = fabro_workflows::run_rewind::find_run_id_by_prefix(&repo, &args.run_id)?; + let store = Store::new(repo); + + let timeline = fabro_workflows::run_rewind::build_timeline(&store, &run_id)?; + + if args.list || args.target.is_none() { + let parallel_map = fabro_workflows::run_rewind::load_parallel_map(&store, &run_id); + print_timeline(&timeline, ¶llel_map, styles); + return Ok(()); + } + + let target = fabro_workflows::run_rewind::parse_target(args.target.as_deref().unwrap())?; + let parallel_map = fabro_workflows::run_rewind::load_parallel_map(&store, &run_id); + let entry = fabro_workflows::run_rewind::resolve_target(&timeline, &target, ¶llel_map)?; + + fabro_workflows::run_rewind::execute_rewind(&store, &run_id, entry, !args.no_push)?; + + eprintln!( + "\nTo resume: fabro run --run-branch {}{}", + fabro_workflows::git::RUN_BRANCH_PREFIX, + run_id + ); + + Ok(()) +} + +pub(crate) fn print_timeline( + timeline: &[fabro_workflows::run_rewind::TimelineEntry], + parallel_map: &std::collections::HashMap, + 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> = 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 { + if use_color { + Some(color) + } else { + None + } +} diff --git a/lib/crates/fabro-workflows/src/cli/run.rs b/lib/crates/fabro-cli/src/commands/run.rs similarity index 87% rename from lib/crates/fabro-workflows/src/cli/run.rs rename to lib/crates/fabro-cli/src/commands/run.rs index 8fcfaf348..6e824065f 100644 --- a/lib/crates/fabro-workflows/src/cli/run.rs +++ b/lib/crates/fabro-cli/src/commands/run.rs @@ -6,36 +6,135 @@ use std::time::Instant; use anyhow::{bail, Context}; use chrono::{Local, Utc}; +use clap::{Args, ValueEnum}; use fabro_agent::{DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox}; -use fabro_util::terminal::Styles; -use tracing::debug; - -use super::{relative_path, tilde_path}; -use crate::checkpoint::Checkpoint; -use crate::engine::{RunConfig, WorkflowRunEngine}; -use crate::event::EventEmitter; -use crate::handler::default_registry; -use crate::outcome::StageStatus; -use crate::workflow::WorkflowBuilder; +use fabro_config::run::{RunDefaults, WorkflowRunConfig}; +use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config}; use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, Interviewer}; -use fabro_validate::Severity; - use fabro_llm::provider::Provider; - -use super::backend::AgentApiBackend; -use super::cli_backend::{AgentCliBackend, BackendRouter}; -use super::progress; -use super::run_config; -use super::run_config::{RunDefaults, WorkflowRunConfig}; -use crate::devcontainer_bridge; +use fabro_util::terminal::Styles; +use fabro_validate::Severity; +use fabro_workflows::backend::{AgentApiBackend, AgentCliBackend, BackendRouter}; +use fabro_workflows::checkpoint::Checkpoint; +use fabro_workflows::cost::{compute_stage_cost, format_cost}; +use fabro_workflows::devcontainer_bridge; +use fabro_workflows::engine::{RunConfig, WorkflowRunEngine}; +use fabro_workflows::event::EventEmitter; +use fabro_workflows::handler::default_registry; +use fabro_workflows::outcome::StageStatus; +use fabro_workflows::sandbox_provider::SandboxProvider; +use fabro_workflows::workflow::WorkflowBuilder; use indicatif::HumanDuration; use std::time::Duration; +use tracing::debug; -use super::{ - compute_stage_cost, format_cost, format_tokens_human, print_diagnostics, read_workflow_file, - RunArgs, SandboxProvider, +use super::run_progress; +use crate::commands::shared::{ + format_tokens_human, print_diagnostics, read_workflow_file, relative_path, tilde_path, }; +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum CliSandboxProvider { + Local, + Docker, + Daytona, + #[cfg(feature = "exedev")] + Exe, + Ssh, +} + +impl From for SandboxProvider { + fn from(value: CliSandboxProvider) -> Self { + match value { + CliSandboxProvider::Local => Self::Local, + CliSandboxProvider::Docker => Self::Docker, + CliSandboxProvider::Daytona => Self::Daytona, + #[cfg(feature = "exedev")] + CliSandboxProvider::Exe => Self::Exe, + CliSandboxProvider::Ssh => Self::Ssh, + } + } +} + +#[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, + + /// Run output directory + #[arg(long)] + pub run_dir: Option, + + /// 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, + + /// Resume from a git run branch (reads checkpoint and graph from metadata branch) + #[arg(long, conflicts_with = "resume")] + pub run_branch: Option, + + /// Override the workflow goal (exposed as $goal in prompts) + #[arg(long)] + pub goal: Option, + + /// Read the workflow goal from a file + #[arg(long, conflicts_with = "goal")] + pub goal_file: Option, + + /// Override default LLM model + #[arg(long)] + pub model: Option, + + /// Override default LLM provider + #[arg(long)] + pub provider: Option, + + /// Enable verbose output + #[arg(short, long)] + pub verbose: bool, + + /// Sandbox for agent tools + #[arg(long, value_enum)] + pub sandbox: Option, + + /// Attach a label to this run (repeatable, format: KEY=VALUE) + #[arg(long = "label", value_name = "KEY=VALUE")] + pub label: Vec, + + /// 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, +} + /// Resolve goal from `--goal` string or `--goal-file` path. fn resolve_cli_goal( goal: &Option, @@ -124,7 +223,7 @@ fn resolve_model_provider( /// Parse sandbox provider from an optional `SandboxConfig`. fn parse_sandbox_provider( - sandbox: Option<&run_config::SandboxConfig>, + sandbox: Option<&sandbox_config::SandboxConfig>, ) -> anyhow::Result> { sandbox .and_then(|s| s.provider.as_deref()) @@ -164,7 +263,7 @@ fn resolve_preserve_sandbox( fn resolve_worktree_mode( run_cfg: Option<&WorkflowRunConfig>, run_defaults: &RunDefaults, -) -> run_config::WorktreeMode { +) -> sandbox_config::WorktreeMode { run_cfg .and_then(|c| c.sandbox.as_ref()) .and_then(|s| s.local.as_ref()) @@ -321,7 +420,7 @@ pub async fn run_command( mut run_defaults: RunDefaults, styles: &'static Styles, github_app: Option, - git_author: crate::git::GitAuthor, + git_author: fabro_workflows::git::GitAuthor, ) -> anyhow::Result<()> { // Handle --run-branch resume: read everything from git metadata if let Some(branch) = args.run_branch.clone() { @@ -336,7 +435,7 @@ pub async fn run_command( // Apply project-level config overrides (fabro.toml) on top of CLI defaults. // Precedence: workflow.toml > fabro.toml > cli.toml/server.toml if let Ok(Some((_config_path, project_config))) = - super::project_config::discover_project_config(&std::env::current_dir().unwrap_or_default()) + project_config::discover_project_config(&std::env::current_dir().unwrap_or_default()) { tracing::debug!("Applying run defaults from fabro.toml"); run_defaults.merge_overlay(project_config.into_run_defaults()); @@ -344,7 +443,7 @@ pub async fn run_command( // 0. Resolve workflow arg, load run config if TOML, resolve DOT path, apply defaults let (dot_path, run_cfg) = { - let (dot, cfg) = super::project_config::resolve_workflow(workflow_path)?; + let (dot, cfg) = project_config::resolve_workflow(workflow_path)?; match cfg { Some(mut cfg) => { cfg.apply_defaults(&run_defaults); @@ -390,7 +489,7 @@ pub async fn run_command( .and_then(|c| c.vars.as_ref()) .or(run_defaults.vars.as_ref()); let source = match vars { - Some(vars) => run_config::expand_vars(&source, vars)?, + Some(vars) => fabro_workflows::vars::expand_vars(&source, vars)?, None => source, }; let dot_dir = dot_path.parent().unwrap_or(std::path::Path::new(".")); @@ -403,7 +502,8 @@ pub async fn run_command( // Inline @file references in the (possibly overridden) goal if let Some(fabro_graphviz::graph::AttrValue::String(goal)) = graph.attrs.get("goal") { let fallback = dirs::home_dir().map(|h| h.join(".fabro")); - let resolved = crate::transform::resolve_file_ref(goal, dot_dir, fallback.as_deref()); + let resolved = + fabro_workflows::transform::resolve_file_ref(goal, dot_dir, fallback.as_deref()); if resolved != *goal { graph.attrs.insert( "goal".to_string(), @@ -445,7 +545,11 @@ pub async fn run_command( let sandbox_provider = if args.dry_run { SandboxProvider::Local } else { - resolve_sandbox_provider(args.sandbox, run_cfg.as_ref(), &run_defaults)? + resolve_sandbox_provider( + args.sandbox.map(Into::into), + run_cfg.as_ref(), + &run_defaults, + )? }; let preserve_sandbox = resolve_preserve_sandbox(args.preserve_sandbox, run_cfg.as_ref(), &run_defaults); @@ -454,14 +558,14 @@ pub async fn run_command( .map(|(url, branch)| (Some(url), branch)) .unwrap_or((None, None)); let git_clean = if sandbox_provider.is_remote() { - crate::git::ensure_clean_and_pushed( + fabro_workflows::git::ensure_clean_and_pushed( &original_cwd, "origin", detected_base_branch.as_deref(), ) .is_ok() } else { - crate::git::ensure_clean(&original_cwd).is_ok() + fabro_workflows::git::ensure_clean(&original_cwd).is_ok() }; if args.preflight { @@ -497,19 +601,19 @@ pub async fn run_command( .context("Failed to activate per-run log")?; tokio::fs::write(run_dir.join("graph.fabro"), &source).await?; tokio::fs::write(run_dir.join("run.pid"), std::process::id().to_string()).await?; - super::runs::write_run_status( + fabro_workflows::run_status::write_run_status( &run_dir, - crate::run_status::RunStatus::Starting, - Some(crate::run_status::StatusReason::SandboxInitializing), + fabro_workflows::run_status::RunStatus::Starting, + Some(fabro_workflows::run_status::StatusReason::SandboxInitializing), ); // Safety net: mark as failed if we exit before engine.run() (e.g. sandbox init failure) let status_run_dir = run_dir.clone(); let status_guard = scopeguard::guard((), move |()| { - super::runs::write_run_status( + fabro_workflows::run_status::write_run_status( &status_run_dir, - crate::run_status::RunStatus::Failed, - Some(crate::run_status::StatusReason::SandboxInitFailed), + fabro_workflows::run_status::RunStatus::Failed, + Some(fabro_workflows::run_status::StatusReason::SandboxInitFailed), ); }); @@ -521,7 +625,10 @@ pub async fn run_command( // Create progress UI (used for both normal and verbose modes) let is_tty = std::io::stderr().is_terminal(); - let progress_ui = Arc::new(Mutex::new(progress::ProgressUI::new(is_tty, args.verbose))); + let progress_ui = Arc::new(Mutex::new(run_progress::ProgressUI::new( + is_tty, + args.verbose, + ))); { let mut ui = progress_ui.lock().expect("progress lock poisoned"); ui.show_version(); @@ -538,7 +645,7 @@ pub async fn run_command( { let sha_clone = Arc::clone(&last_git_sha); emitter.on_event(move |event| { - if let crate::event::WorkflowRunEvent::CheckpointCompleted { + if let fabro_workflows::event::WorkflowRunEvent::CheckpointCompleted { git_commit_sha: Some(sha), .. } = event @@ -552,7 +659,9 @@ pub async fn run_command( let accumulator = Arc::new(Mutex::new(CostAccumulator::default())); let acc_clone = Arc::clone(&accumulator); emitter.on_event(move |event| { - if let crate::event::WorkflowRunEvent::StageCompleted { usage: Some(u), .. } = event { + if let fabro_workflows::event::WorkflowRunEvent::StageCompleted { usage: Some(u), .. } = + event + { let mut acc = acc_clone.lock().unwrap(); acc.total_input_tokens += u.input_tokens; acc.total_output_tokens += u.output_tokens; @@ -573,7 +682,9 @@ pub async fn run_command( let run_id = Arc::new(Mutex::new(run_id.clone())); let run_id_clone = Arc::clone(&run_id); emitter.on_event(move |event| { - if let crate::event::WorkflowRunEvent::WorkflowRunStarted { run_id, .. } = event { + if let fabro_workflows::event::WorkflowRunEvent::WorkflowRunStarted { run_id, .. } = + event + { *run_id_clone.lock().unwrap() = run_id.clone(); } let envelope = build_event_envelope(event, &run_id_clone.lock().unwrap()); @@ -597,13 +708,13 @@ pub async fn run_command( }); } - progress::ProgressUI::register(&progress_ui, &mut emitter); + run_progress::ProgressUI::register(&progress_ui, &mut emitter); // 4. Build interviewer let interviewer: Arc = if args.auto_approve { Arc::new(AutoApproveInterviewer) } else { - Arc::new(progress::ProgressAwareInterviewer::new( + Arc::new(run_progress::ProgressAwareInterviewer::new( ConsoleInterviewer::new(styles), Arc::clone(&progress_ui), )) @@ -617,10 +728,10 @@ pub async fn run_command( false } else { match worktree_mode { - run_config::WorktreeMode::Always => true, - run_config::WorktreeMode::Clean => git_clean, - run_config::WorktreeMode::Dirty => !git_clean, - run_config::WorktreeMode::Never => false, + sandbox_config::WorktreeMode::Always => true, + sandbox_config::WorktreeMode::Clean => git_clean, + sandbox_config::WorktreeMode::Dirty => !git_clean, + sandbox_config::WorktreeMode::Never => false, } }; debug!( @@ -643,7 +754,7 @@ pub async fn run_command( let check_repo = original_cwd.clone(); let check_branch = branch.clone(); let needs_push = tokio::task::spawn_blocking(move || { - crate::git::branch_needs_push(&check_repo, "origin", &check_branch) + fabro_workflows::git::branch_needs_push(&check_repo, "origin", &check_branch) }) .await .unwrap_or(true); @@ -651,8 +762,8 @@ pub async fn run_command( if needs_push { let repo_path = original_cwd.clone(); let branch_owned = branch.clone(); - let result = crate::git::blocking_push_with_timeout(60, move || { - crate::git::push_branch(&repo_path, "origin", &branch_owned) + let result = fabro_workflows::git::blocking_push_with_timeout(60, move || { + fabro_workflows::git::push_branch(&repo_path, "origin", &branch_owned) }) .await; match result { @@ -725,12 +836,14 @@ pub async fn run_command( let lifecycle_command_count = dc.on_create_commands.len() + dc.post_create_commands.len() + dc.post_start_commands.len(); - emitter.emit(&crate::event::WorkflowRunEvent::DevcontainerResolved { - dockerfile_lines: dc.dockerfile.lines().count(), - environment_count: dc.environment.len(), - lifecycle_command_count, - workspace_folder: dc.workspace_folder.clone(), - }); + emitter.emit( + &fabro_workflows::event::WorkflowRunEvent::DevcontainerResolved { + dockerfile_lines: dc.dockerfile.lines().count(), + environment_count: dc.environment.len(), + lifecycle_command_count, + workspace_folder: dc.workspace_folder.clone(), + }, + ); // Override daytona_config with devcontainer dockerfile let snapshot = devcontainer_bridge::devcontainer_to_snapshot_config(&dc); @@ -807,7 +920,9 @@ pub async fn run_command( let deferred_sb = Arc::clone(&deferred_sandbox); let provider = sandbox_provider; // Copy — captured by move closure emitter.on_event(move |event| { - if let crate::event::WorkflowRunEvent::SandboxInitialized { working_directory } = event + if let fabro_workflows::event::WorkflowRunEvent::SandboxInitialized { + working_directory, + } = event { progress_for_listener .lock() @@ -825,7 +940,7 @@ pub async fn run_command( }); let is_docker = provider == SandboxProvider::Docker; - let record = crate::sandbox_record::SandboxRecord { + let record = fabro_workflows::sandbox_record::SandboxRecord { provider: provider.to_string(), working_directory: working_directory.clone(), identifier: sandbox_info_opt, @@ -856,7 +971,7 @@ pub async fn run_command( if args.ssh { let deferred_sb_ssh = Arc::clone(&deferred_sandbox); emitter.on_event(move |event| { - if let crate::event::WorkflowRunEvent::SandboxInitialized { .. } = event { + if let fabro_workflows::event::WorkflowRunEvent::SandboxInitialized { .. } = event { if let Ok(rt) = tokio::runtime::Handle::try_current() { let sb_lock = deferred_sb_ssh.lock().unwrap(); if let Some(ref sb) = *sb_lock { @@ -893,7 +1008,7 @@ pub async fn run_command( .map_err(|e| anyhow::anyhow!("Failed to create Docker environment: {e}"))?; let emitter_cb = Arc::clone(&emitter); env.set_event_callback(Arc::new(move |event| { - emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event }); + emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event }); })); Arc::new(env) } @@ -909,7 +1024,7 @@ pub async fn run_command( .map_err(|e| anyhow::anyhow!("{e}"))?; let emitter_cb = Arc::clone(&emitter); env.set_event_callback(Arc::new(move |event| { - emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event }); + emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event }); })); Arc::new(env) } @@ -930,7 +1045,7 @@ pub async fn run_command( ); let emitter_cb = Arc::clone(&emitter); env.set_event_callback(Arc::new(move |event| { - emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event }); + emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event }); })); Arc::new(env) } @@ -947,7 +1062,7 @@ pub async fn run_command( ); let emitter_cb = Arc::clone(&emitter); env.set_event_callback(Arc::new(move |event| { - emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event }); + emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event }); })); Arc::new(env) } @@ -955,10 +1070,11 @@ pub async fn run_command( let mut env = LocalSandbox::new(cwd.clone()); let emitter_cb = Arc::clone(&emitter); env.set_event_callback(Arc::new(move |event| { - emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event }); + emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event }); })); Arc::new(env) } + _ => bail!("exe.dev sandbox support is not enabled in this build"), }; // Wrap with ReadBeforeWriteSandbox to enforce read-before-write guard @@ -1114,7 +1230,7 @@ pub async fn run_command( // 7. Execute // Set up metadata branch for git checkpointing (host or remote — engine fills remote) let meta_branch = if worktree_work_dir.is_some() { - Some(crate::git::MetadataStore::branch_name(&run_id)) + Some(fabro_workflows::git::MetadataStore::branch_name(&run_id)) } else { None }; @@ -1156,7 +1272,7 @@ pub async fn run_command( }; // Build lifecycle config for sandbox init, setup commands, and devcontainer phases - let lifecycle = crate::engine::LifecycleConfig { + let lifecycle = fabro_workflows::engine::LifecycleConfig { setup_commands, setup_command_timeout_ms: 300_000, devcontainer_phases: if let Some(ref dc) = devcontainer_config { @@ -1206,32 +1322,35 @@ pub async fn run_command( { let (status, failure_reason) = match &engine_result { Ok(ref o) => (o.status.clone(), o.failure_reason().map(String::from)), - Err(e) => (crate::outcome::StageStatus::Fail, Some(e.to_string())), + Err(e) => ( + fabro_workflows::outcome::StageStatus::Fail, + Some(e.to_string()), + ), }; // Map engine result to RunStatus + StatusReason let (run_status, status_reason) = match &engine_result { Ok(ref o) => match o.status { StageStatus::Success | StageStatus::Skipped => ( - crate::run_status::RunStatus::Succeeded, - Some(crate::run_status::StatusReason::Completed), + fabro_workflows::run_status::RunStatus::Succeeded, + Some(fabro_workflows::run_status::StatusReason::Completed), ), StageStatus::PartialSuccess => ( - crate::run_status::RunStatus::Succeeded, - Some(crate::run_status::StatusReason::PartialSuccess), + fabro_workflows::run_status::RunStatus::Succeeded, + Some(fabro_workflows::run_status::StatusReason::PartialSuccess), ), StageStatus::Fail | StageStatus::Retry => ( - crate::run_status::RunStatus::Failed, - Some(crate::run_status::StatusReason::WorkflowError), + fabro_workflows::run_status::RunStatus::Failed, + Some(fabro_workflows::run_status::StatusReason::WorkflowError), ), }, - Err(crate::error::FabroError::Cancelled) => ( - crate::run_status::RunStatus::Failed, - Some(crate::run_status::StatusReason::Cancelled), + Err(fabro_workflows::error::FabroError::Cancelled) => ( + fabro_workflows::run_status::RunStatus::Failed, + Some(fabro_workflows::run_status::StatusReason::Cancelled), ), Err(_) => ( - crate::run_status::RunStatus::Failed, - Some(crate::run_status::StatusReason::WorkflowError), + fabro_workflows::run_status::RunStatus::Failed, + Some(fabro_workflows::run_status::StatusReason::WorkflowError), ), }; @@ -1259,7 +1378,7 @@ pub async fn run_command( *cost_sum.get_or_insert(0.0) += c; } - stages.push(crate::conclusion::StageSummary { + stages.push(fabro_workflows::conclusion::StageSummary { stage_id: node_id.clone(), stage_label: node_id.clone(), duration_ms: stage_durations.get(node_id).copied().unwrap_or(0), @@ -1272,7 +1391,7 @@ pub async fn run_command( (vec![], None, 0) }; - let conclusion = crate::conclusion::Conclusion { + let conclusion = fabro_workflows::conclusion::Conclusion { timestamp: Utc::now(), status, duration_ms: run_duration_ms, @@ -1283,11 +1402,11 @@ pub async fn run_command( total_retries, }; let _ = conclusion.save(&run_dir.join("conclusion.json")); - super::runs::write_run_status(&run_dir, run_status, status_reason); + fabro_workflows::run_status::write_run_status(&run_dir, run_status, status_reason); } // Auto-derive retro (always, cheap) and optionally run retro agent - if !args.no_retro && super::project_config::is_retro_enabled() { + if !args.no_retro && project_config::is_retro_enabled() { let failed = match &engine_result { Ok(ref o) => o.status == StageStatus::Fail, Err(_) => true, @@ -1352,14 +1471,14 @@ pub async fn run_command( } let auto_merge = if pr_cfg.auto_merge { - Some(crate::pull_request::AutoMergeConfig { + Some(fabro_workflows::pull_request::AutoMergeConfig { merge_strategy: pr_cfg.merge_strategy, }) } else { None }; - match crate::pull_request::maybe_open_pull_request( + match fabro_workflows::pull_request::maybe_open_pull_request( creds, origin, base_branch, @@ -1374,11 +1493,13 @@ pub async fn run_command( .await { Ok(Some(record)) => { - emitter.emit(&crate::event::WorkflowRunEvent::PullRequestCreated { - pr_url: record.html_url.clone(), - pr_number: record.number, - draft: pr_cfg.draft, - }); + emitter.emit( + &fabro_workflows::event::WorkflowRunEvent::PullRequestCreated { + pr_url: record.html_url.clone(), + pr_number: record.number, + draft: pr_cfg.draft, + }, + ); pr_url = Some(record.html_url.clone()); if let Err(e) = record.save(&run_dir.join("pull_request.json")) { tracing::warn!(error = %e, "Failed to save pull_request.json"); @@ -1386,9 +1507,11 @@ pub async fn run_command( } Ok(None) => {} // empty diff, logged at DEBUG Err(e) => { - emitter.emit(&crate::event::WorkflowRunEvent::PullRequestFailed { - error: e.to_string(), - }); + emitter.emit( + &fabro_workflows::event::WorkflowRunEvent::PullRequestFailed { + error: e.to_string(), + }, + ); eprintln!( "{} PR creation failed: {e}", styles.yellow.apply_to("Warning:") @@ -1528,12 +1651,14 @@ fn setup_worktree( run_dir: &std::path::Path, run_id: &str, ) -> anyhow::Result<(PathBuf, PathBuf, String, String)> { - let base_sha = crate::git::head_sha(original_cwd).map_err(|e| anyhow::anyhow!("{e}"))?; - let branch_name = format!("{}{run_id}", crate::git::RUN_BRANCH_PREFIX); - crate::git::create_branch(original_cwd, &branch_name).map_err(|e| anyhow::anyhow!("{e}"))?; + let base_sha = + fabro_workflows::git::head_sha(original_cwd).map_err(|e| anyhow::anyhow!("{e}"))?; + let branch_name = format!("{}{run_id}", fabro_workflows::git::RUN_BRANCH_PREFIX); + fabro_workflows::git::create_branch(original_cwd, &branch_name) + .map_err(|e| anyhow::anyhow!("{e}"))?; let worktree_path = run_dir.join("worktree"); - crate::git::replace_worktree(original_cwd, &worktree_path, &branch_name) + fabro_workflows::git::replace_worktree(original_cwd, &worktree_path, &branch_name) .map_err(|e| anyhow::anyhow!("{e}"))?; std::env::set_current_dir(&worktree_path)?; @@ -1550,17 +1675,17 @@ async fn run_from_branch( args: RunArgs, run_branch: &str, styles: &'static Styles, - git_author: crate::git::GitAuthor, + git_author: fabro_workflows::git::GitAuthor, run_defaults: RunDefaults, github_app: Option, ) -> anyhow::Result<()> { // Extract run_id from branch name: "fabro/run/{run_id}" -> "{run_id}" let run_id = run_branch - .strip_prefix(crate::git::RUN_BRANCH_PREFIX) + .strip_prefix(fabro_workflows::git::RUN_BRANCH_PREFIX) .ok_or_else(|| { anyhow::anyhow!( "invalid run branch format: expected '{}', got '{run_branch}'", - crate::git::RUN_BRANCH_PREFIX, + fabro_workflows::git::RUN_BRANCH_PREFIX, ) })? .to_string(); @@ -1568,22 +1693,22 @@ async fn run_from_branch( let original_cwd = std::env::current_dir()?; // Read checkpoint from metadata branch - let checkpoint = crate::git::MetadataStore::read_checkpoint(&original_cwd, &run_id)? + let checkpoint = fabro_workflows::git::MetadataStore::read_checkpoint(&original_cwd, &run_id)? .ok_or_else(|| { anyhow::anyhow!("no checkpoint found on metadata branch for run {run_id}") })?; // Read graph DOT from metadata branch - let source = - crate::git::MetadataStore::read_graph_dot(&original_cwd, &run_id)?.ok_or_else(|| { + let source = fabro_workflows::git::MetadataStore::read_graph_dot(&original_cwd, &run_id)? + .ok_or_else(|| { anyhow::anyhow!("no graph.fabro found on metadata branch for run {run_id}") })?; // If --pipeline was also provided, use it instead (allows overriding) let (mut graph, diagnostics) = if let Some(ref workflow_path) = args.workflow { - crate::workflow::prepare_from_file(workflow_path)? + fabro_workflows::workflow::prepare_from_file(workflow_path)? } else { - crate::workflow::WorkflowBuilder::new().prepare(&source)? + fabro_workflows::workflow::WorkflowBuilder::new().prepare(&source)? }; let cli_goal = resolve_cli_goal(&args.goal, &args.goal_file)?; apply_goal_override(&mut graph, cli_goal.as_deref(), None); @@ -1596,7 +1721,7 @@ async fn run_from_branch( run_id, ); - super::print_diagnostics(&diagnostics, styles); + print_diagnostics(&diagnostics, styles); if diagnostics.iter().any(|d| d.severity == Severity::Error) { anyhow::bail!("Validation failed"); } @@ -1622,14 +1747,14 @@ async fn run_from_branch( .context("Failed to activate per-run log")?; tokio::fs::write(run_dir.join("graph.fabro"), &source).await?; - let base_sha = - crate::git::MetadataStore::read_manifest(&original_cwd, &run_id)?.and_then(|m| m.base_sha); + let base_sha = fabro_workflows::git::MetadataStore::read_manifest(&original_cwd, &run_id)? + .and_then(|m| m.base_sha); // Resolve sandbox provider let sandbox_provider = if args.dry_run { SandboxProvider::Local } else { - resolve_sandbox_provider(args.sandbox, None, &run_defaults)? + resolve_sandbox_provider(args.sandbox.map(Into::into), None, &run_defaults)? }; let emitter = Arc::new(EventEmitter::new()); @@ -1638,14 +1763,14 @@ async fn run_from_branch( SandboxProvider::Local | SandboxProvider::Docker => { // Re-attach worktree to the existing run branch let wt = run_dir.join("worktree"); - crate::git::replace_worktree(&original_cwd, &wt, run_branch).map_err(|e| { - anyhow::anyhow!("failed to attach worktree to {run_branch}: {e}") - })?; + fabro_workflows::git::replace_worktree(&original_cwd, &wt, run_branch).map_err( + |e| anyhow::anyhow!("failed to attach worktree to {run_branch}: {e}"), + )?; std::env::set_current_dir(&wt)?; let mut env = fabro_agent::LocalSandbox::new(wt.clone()); let emitter_cb = Arc::clone(&emitter); env.set_event_callback(Arc::new(move |event| { - emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event }); + emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event }); })); (Arc::new(env), Some(wt)) } @@ -1666,7 +1791,7 @@ async fn run_from_branch( ); let emitter_cb = Arc::clone(&emitter); env.set_event_callback(Arc::new(move |event| { - emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event }); + emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event }); })); (Arc::new(env), None) } @@ -1683,13 +1808,14 @@ async fn run_from_branch( ); let emitter_cb = Arc::clone(&emitter); env.set_event_callback(Arc::new(move |event| { - emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event }); + emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event }); })); (Arc::new(env), None) } SandboxProvider::Daytona => { bail!("--run-branch resume is not yet supported with --sandbox daytona"); } + _ => bail!("exe.dev sandbox support is not enabled in this build"), }; // Wrap with ReadBeforeWriteSandbox to enforce read-before-write guard @@ -1731,7 +1857,7 @@ async fn run_from_branch( // No fallback config available for branch resume; use empty chain. let fallback_chain = Vec::new(); - let registry = crate::handler::default_registry(interviewer.clone(), || { + let registry = fabro_workflows::handler::default_registry(interviewer.clone(), || { if dry_run_mode { None } else { @@ -1740,7 +1866,7 @@ async fn run_from_branch( Some(Box::new(BackendRouter::new(Box::new(api), cli))) } }); - let mut engine = crate::engine::WorkflowRunEngine::with_interviewer( + let mut engine = fabro_workflows::engine::WorkflowRunEngine::with_interviewer( registry, Arc::clone(&emitter), interviewer, @@ -1750,7 +1876,7 @@ async fn run_from_branch( engine.set_dry_run(true); } - let meta_branch = Some(crate::git::MetadataStore::branch_name(&run_id)); + let meta_branch = Some(fabro_workflows::git::MetadataStore::branch_name(&run_id)); let mut config = RunConfig { run_dir: run_dir.clone(), cancel_token: None, @@ -1775,7 +1901,7 @@ async fn run_from_branch( workflow_slug: None, }; - let lifecycle = crate::engine::LifecycleConfig { + let lifecycle = fabro_workflows::engine::LifecycleConfig { setup_commands: resume_setup_commands, setup_command_timeout_ms: 60_000, devcontainer_phases: Vec::new(), @@ -1791,7 +1917,7 @@ async fn run_from_branch( let _ = std::env::set_current_dir(&original_cwd); // Auto-derive retro - if !args.no_retro && super::project_config::is_retro_enabled() { + if !args.no_retro && project_config::is_retro_enabled() { let failed = match &engine_result { Ok(ref o) => o.status == StageStatus::Fail, Err(_) => true, @@ -1883,7 +2009,7 @@ fn print_final_output(run_dir: &std::path::Path, styles: &Styles) { /// Print collected asset paths, if any. fn print_assets(run_dir: &std::path::Path, styles: &Styles) { - let paths = crate::asset_snapshot::collect_asset_paths(run_dir); + let paths = fabro_workflows::asset_snapshot::collect_asset_paths(run_dir); if paths.is_empty() { return; } @@ -2038,6 +2164,7 @@ async fn run_preflight( SandboxProvider::Local => { Ok(Arc::new(LocalSandbox::new(original_cwd.clone())) as Arc) } + _ => Err("exe.dev sandbox support is not enabled in this build".to_string()), }; let sandbox_ok = match sandbox_result { @@ -2254,8 +2381,8 @@ async fn write_finalize_commit(config: &RunConfig, run_dir: &std::path::Path) { return; }; - let store = crate::git::MetadataStore::new(repo_path, &config.git_author); - let mut entries = crate::git::scan_node_files(run_dir); + let store = fabro_workflows::git::MetadataStore::new(repo_path, &config.git_author); + let mut entries = fabro_workflows::git::scan_node_files(run_dir); if let Ok(retro_bytes) = std::fs::read(run_dir.join("retro.json")) { entries.push(("retro.json".to_string(), retro_bytes)); } @@ -2270,8 +2397,13 @@ async fn write_finalize_commit(config: &RunConfig, run_dir: &std::path::Path) { // Push the finalize commit let refspec = format!("refs/heads/{meta_branch}"); - crate::engine::git_push_host(repo_path, &refspec, &config.github_app, "finalize metadata") - .await; + fabro_workflows::engine::git_push_host( + repo_path, + &refspec, + &config.github_app, + "finalize metadata", + ) + .await; } /// Generate a retro report for a completed workflow run. @@ -2305,7 +2437,7 @@ async fn generate_retro( } }; - let completed_stages = crate::build_completed_stages(&cp, failed); + let completed_stages = fabro_workflows::build_completed_stages(&cp, failed); let stage_durations = fabro_retro::retro::extract_stage_durations(run_dir); let mut retro = fabro_retro::retro::derive_retro( run_id, @@ -2331,7 +2463,7 @@ async fn generate_retro( let retro_start = std::time::Instant::now(); if let Some(ref em) = emitter { - em.emit(&crate::event::WorkflowRunEvent::RetroStarted); + em.emit(&fabro_workflows::event::WorkflowRunEvent::RetroStarted); } else { eprintln!( "{}", @@ -2360,7 +2492,7 @@ async fn generate_retro( | fabro_agent::AgentEvent::ToolCallOutputDelta { .. } | fabro_agent::AgentEvent::SkillExpanded { .. } ) { - em.emit(&crate::event::WorkflowRunEvent::Agent { + em.emit(&fabro_workflows::event::WorkflowRunEvent::Agent { stage: "retro".to_string(), event: event.event.clone(), }); @@ -2385,12 +2517,12 @@ async fn generate_retro( if let Some(ref em) = emitter { match &narrative_result { Ok(_) => { - em.emit(&crate::event::WorkflowRunEvent::RetroCompleted { + em.emit(&fabro_workflows::event::WorkflowRunEvent::RetroCompleted { duration_ms: retro_dur_elapsed.as_millis() as u64, }); } Err(e) => { - em.emit(&crate::event::WorkflowRunEvent::RetroFailed { + em.emit(&fabro_workflows::event::WorkflowRunEvent::RetroFailed { error: e.to_string(), duration_ms: retro_dur_elapsed.as_millis() as u64, }); @@ -2398,7 +2530,7 @@ async fn generate_retro( } } - let retro_dur = progress::format_duration_short(retro_dur_elapsed); + let retro_dur = run_progress::format_duration_short(retro_dur_elapsed); match narrative_result { Ok(narrative) => { @@ -2452,7 +2584,7 @@ async fn generate_retro( } // Line 3: file path - let retro_path = format!("{}/retro.json", super::tilde_path(run_dir)); + let retro_path = format!("{}/retro.json", tilde_path(run_dir)); eprintln!( " {} {}", styles.dim.apply_to("Retro saved to"), @@ -2476,8 +2608,11 @@ async fn generate_retro( } } -fn build_event_envelope(event: &crate::event::WorkflowRunEvent, run_id: &str) -> serde_json::Value { - let (event_name, event_fields) = crate::event::flatten_event(event); +fn build_event_envelope( + event: &fabro_workflows::event::WorkflowRunEvent, + run_id: &str, +) -> serde_json::Value { + let (event_name, event_fields) = fabro_workflows::event::flatten_event(event); let mut envelope = serde_json::Map::new(); envelope.insert( "ts".to_string(), @@ -2730,16 +2865,10 @@ mod tests { work_dir: None, llm: None, setup: None, - sandbox: Some(run_config::SandboxConfig { + sandbox: Some(sandbox_config::SandboxConfig { provider: None, preserve: Some(false), - devcontainer: None, - local: None, - daytona: None, - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, + ..Default::default() }), vars: None, hooks: Vec::new(), @@ -2762,16 +2891,10 @@ mod tests { work_dir: None, llm: None, setup: None, - sandbox: Some(run_config::SandboxConfig { + sandbox: Some(sandbox_config::SandboxConfig { provider: None, preserve: Some(true), - devcontainer: None, - local: None, - daytona: None, - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, + ..Default::default() }), vars: None, hooks: Vec::new(), @@ -2782,16 +2905,10 @@ mod tests { github: None, }; let defaults = RunDefaults { - sandbox: Some(run_config::SandboxConfig { + sandbox: Some(sandbox_config::SandboxConfig { provider: None, preserve: Some(false), - devcontainer: None, - local: None, - daytona: None, - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, + ..Default::default() }), ..RunDefaults::default() }; @@ -2801,16 +2918,10 @@ mod tests { #[test] fn resolve_preserve_sandbox_defaults_used() { let defaults = RunDefaults { - sandbox: Some(run_config::SandboxConfig { + sandbox: Some(sandbox_config::SandboxConfig { provider: None, preserve: Some(true), - devcontainer: None, - local: None, - daytona: None, - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, + ..Default::default() }), ..RunDefaults::default() }; @@ -2828,7 +2939,7 @@ mod tests { let defaults = RunDefaults::default(); assert_eq!( resolve_worktree_mode(None, &defaults), - run_config::WorktreeMode::Clean + sandbox_config::WorktreeMode::Clean ); } @@ -2841,18 +2952,14 @@ mod tests { work_dir: None, llm: None, setup: None, - sandbox: Some(run_config::SandboxConfig { + sandbox: Some(sandbox_config::SandboxConfig { provider: None, preserve: None, devcontainer: None, - local: Some(run_config::LocalSandboxConfig { - worktree_mode: run_config::WorktreeMode::Always, + local: Some(sandbox_config::LocalSandboxConfig { + worktree_mode: sandbox_config::WorktreeMode::Always, }), - daytona: None, - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, + ..Default::default() }), vars: None, hooks: Vec::new(), @@ -2865,31 +2972,27 @@ mod tests { let defaults = RunDefaults::default(); assert_eq!( resolve_worktree_mode(Some(&cfg), &defaults), - run_config::WorktreeMode::Always + sandbox_config::WorktreeMode::Always ); } #[test] fn resolve_worktree_mode_from_defaults() { let defaults = RunDefaults { - sandbox: Some(run_config::SandboxConfig { + sandbox: Some(sandbox_config::SandboxConfig { provider: None, preserve: None, devcontainer: None, - local: Some(run_config::LocalSandboxConfig { - worktree_mode: run_config::WorktreeMode::Dirty, + local: Some(sandbox_config::LocalSandboxConfig { + worktree_mode: sandbox_config::WorktreeMode::Dirty, }), - daytona: None, - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, + ..Default::default() }), ..RunDefaults::default() }; assert_eq!( resolve_worktree_mode(None, &defaults), - run_config::WorktreeMode::Dirty + sandbox_config::WorktreeMode::Dirty ); } @@ -2902,18 +3005,14 @@ mod tests { work_dir: None, llm: None, setup: None, - sandbox: Some(run_config::SandboxConfig { + sandbox: Some(sandbox_config::SandboxConfig { provider: None, preserve: None, devcontainer: None, - local: Some(run_config::LocalSandboxConfig { - worktree_mode: run_config::WorktreeMode::Never, + local: Some(sandbox_config::LocalSandboxConfig { + worktree_mode: sandbox_config::WorktreeMode::Never, }), - daytona: None, - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, + ..Default::default() }), vars: None, hooks: Vec::new(), @@ -2924,24 +3023,20 @@ mod tests { github: None, }; let defaults = RunDefaults { - sandbox: Some(run_config::SandboxConfig { + sandbox: Some(sandbox_config::SandboxConfig { provider: None, preserve: None, devcontainer: None, - local: Some(run_config::LocalSandboxConfig { - worktree_mode: run_config::WorktreeMode::Dirty, + local: Some(sandbox_config::LocalSandboxConfig { + worktree_mode: sandbox_config::WorktreeMode::Dirty, }), - daytona: None, - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, + ..Default::default() }), ..RunDefaults::default() }; assert_eq!( resolve_worktree_mode(Some(&cfg), &defaults), - run_config::WorktreeMode::Never + sandbox_config::WorktreeMode::Never ); } @@ -2988,7 +3083,7 @@ mod tests { #[test] fn envelope_field_order_starts_with_ts_run_id_event() { - let event = crate::event::WorkflowRunEvent::StageStarted { + let event = fabro_workflows::event::WorkflowRunEvent::StageStarted { node_id: "plan".to_string(), name: "Plan".to_string(), index: 0, diff --git a/lib/crates/fabro-workflows/src/cli/progress.rs b/lib/crates/fabro-cli/src/commands/run_progress.rs similarity index 99% rename from lib/crates/fabro-workflows/src/cli/progress.rs rename to lib/crates/fabro-cli/src/commands/run_progress.rs index 450a0e14c..038609594 100644 --- a/lib/crates/fabro-workflows/src/cli/progress.rs +++ b/lib/crates/fabro-cli/src/commands/run_progress.rs @@ -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()); diff --git a/lib/crates/fabro-cli/src/commands/runs.rs b/lib/crates/fabro-cli/src/commands/runs.rs new file mode 100644 index 000000000..c0064c0bb --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/runs.rs @@ -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, + + /// Filter by workflow name (substring match) + #[arg(long)] + pub workflow: Option, + + /// Filter by label (KEY=VALUE, repeatable, AND semantics) + #[arg(long = "label", value_name = "KEY=VALUE")] + pub label: Vec, + + /// 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, + + /// 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, + + /// 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> = 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 = 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 { + 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>, + 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![ + 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> = 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 { + 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"); + } +} diff --git a/lib/crates/fabro-cli/src/commands/shared.rs b/lib/crates/fabro-cli/src/commands/shared.rs new file mode 100644 index 000000000..d7abd3f32 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/shared.rs @@ -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 { + 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"); + } +} diff --git a/lib/crates/fabro-cli/src/commands/ssh.rs b/lib/crates/fabro-cli/src/commands/ssh.rs new file mode 100644 index 000000000..d35a10d1b --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/ssh.rs @@ -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"); +} diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/crates/fabro-cli/src/commands/validate.rs new file mode 100644 index 000000000..e4bea600e --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/validate.rs @@ -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(()) +} diff --git a/lib/crates/fabro-cli/src/commands/workflow.rs b/lib/crates/fabro-cli/src/commands/workflow.rs new file mode 100644 index 000000000..ebe0abc00 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/workflow.rs @@ -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, +} + +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!( + " {: 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]) + } +} diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 0c57c7090..bf7b96a66 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -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 } => { diff --git a/lib/crates/fabro-config/src/sandbox.rs b/lib/crates/fabro-config/src/sandbox.rs index d94be3914..1165b4e7c 100644 --- a/lib/crates/fabro-config/src/sandbox.rs +++ b/lib/crates/fabro-config/src/sandbox.rs @@ -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, pub preserve: Option, diff --git a/lib/crates/fabro-workflows/Cargo.toml b/lib/crates/fabro-workflows/Cargo.toml index af1edffa2..bd153cbeb 100644 --- a/lib/crates/fabro-workflows/Cargo.toml +++ b/lib/crates/fabro-workflows/Cargo.toml @@ -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 diff --git a/lib/crates/fabro-workflows/src/assets.rs b/lib/crates/fabro-workflows/src/assets.rs new file mode 100644 index 000000000..b0d7aa633 --- /dev/null +++ b/lib/crates/fabro-workflows/src/assets.rs @@ -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(path: &Path, serializer: S) -> Result { + 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> { + 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::(&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) +} diff --git a/lib/crates/fabro-workflows/src/cli/backend.rs b/lib/crates/fabro-workflows/src/backend/api.rs similarity index 99% rename from lib/crates/fabro-workflows/src/cli/backend.rs rename to lib/crates/fabro-workflows/src/backend/api.rs index f1bdba09a..7d11b5241 100644 --- a/lib/crates/fabro-workflows/src/cli/backend.rs +++ b/lib/crates/fabro-workflows/src/backend/api.rs @@ -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 diff --git a/lib/crates/fabro-workflows/src/cli/cli_backend.rs b/lib/crates/fabro-workflows/src/backend/cli.rs similarity index 99% rename from lib/crates/fabro-workflows/src/cli/cli_backend.rs rename to lib/crates/fabro-workflows/src/backend/cli.rs index a165d54a7..2855b12ce 100644 --- a/lib/crates/fabro-workflows/src/cli/cli_backend.rs +++ b/lib/crates/fabro-workflows/src/backend/cli.rs @@ -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, diff --git a/lib/crates/fabro-workflows/src/backend/mod.rs b/lib/crates/fabro-workflows/src/backend/mod.rs new file mode 100644 index 000000000..b8562dbd1 --- /dev/null +++ b/lib/crates/fabro-workflows/src/backend/mod.rs @@ -0,0 +1,5 @@ +pub mod api; +pub mod cli; + +pub use api::AgentApiBackend; +pub use cli::{parse_cli_response, AgentCliBackend, BackendRouter}; diff --git a/lib/crates/fabro-workflows/src/cli/asset.rs b/lib/crates/fabro-workflows/src/cli/asset.rs deleted file mode 100644 index f6d6c0c7d..000000000 --- a/lib/crates/fabro-workflows/src/cli/asset.rs +++ /dev/null @@ -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(path: &Path, s: S) -> Result { - s.serialize_str(&path.display().to_string()) -} - -/// Walk `{run_dir}/artifacts/assets/*/retry_*/manifest.json`, stat each file, return entries. -pub fn scan_assets(run_dir: &Path, node_filter: Option<&str>) -> Result> { - let assets_dir = run_dir.join("artifacts/assets"); - let nodes = match std::fs::read_dir(&assets_dir) { - Ok(rd) => rd, - Err(_) => return Ok(Vec::new()), - }; - - let mut entries = Vec::new(); - for node_entry in nodes.flatten() { - if !node_entry.path().is_dir() { - continue; - } - let node_slug = node_entry.file_name().to_string_lossy().into_owned(); - - if let Some(filter) = node_filter { - if node_slug != filter { - continue; - } - } - - let Ok(retries) = std::fs::read_dir(node_entry.path()) else { - continue; - }; - for retry_entry in retries.flatten() { - let retry_dir = retry_entry.path(); - let dir_name = retry_entry.file_name().to_string_lossy().into_owned(); - let retry: u32 = dir_name - .strip_prefix("retry_") - .and_then(|n| n.parse().ok()) - .unwrap_or(0); - - let manifest = retry_dir.join("manifest.json"); - let Ok(contents) = std::fs::read_to_string(&manifest) else { - continue; - }; - let Ok(summary) = serde_json::from_str::(&contents) else { - continue; - }; - - for relative_path in &summary.copied_paths { - let absolute_path = retry_dir.join(relative_path); - let size = std::fs::metadata(&absolute_path) - .map(|m| m.len()) - .unwrap_or(0); - entries.push(AssetEntry { - node_slug: node_slug.clone(), - retry, - relative_path: relative_path.clone(), - absolute_path, - size, - }); - } - } - } - Ok(entries) -} - -#[derive(Args)] -pub struct AssetListArgs { - /// Run ID (or prefix) - pub run_id: String, - - /// Filter to assets from a specific node - #[arg(long)] - pub node: Option, - - /// Output as JSON - #[arg(long)] - pub json: bool, -} - -#[derive(Args)] -pub struct AssetCpArgs { - /// Source: RUN_ID (all assets) or RUN_ID:path (specific asset) - pub source: String, - - /// Destination directory (defaults to current directory) - #[arg(default_value = ".")] - pub dest: PathBuf, - - /// Filter to assets from a specific node - #[arg(long)] - pub node: Option, - - /// Preserve {node_slug}/retry_{N}/ directory structure - #[arg(long)] - pub tree: bool, -} - -/// Parse `source` into (run_id, optional_asset_path) using the same colon-split logic as `cp`. -fn parse_source(s: &str) -> (&str, Option<&str>) { - match split_run_path(s) { - Some((run_id, path)) => (run_id, Some(path)), - None => (s, None), - } -} - -pub fn list_command(args: &AssetListArgs) -> Result<()> { - let base = default_runs_base(); - let run_info = resolve_run(&base, &args.run_id)?; - let entries = scan_assets(&run_info.path, args.node.as_deref())?; - - if args.json { - let json = serde_json::to_string_pretty(&entries)?; - println!("{json}"); - return Ok(()); - } - - if entries.is_empty() { - println!("No assets found for this run."); - return Ok(()); - } - - // Compute column widths - let node_width = entries - .iter() - .map(|e| e.node_slug.len()) - .max() - .unwrap_or(4) - .max(4); - let retry_width = 5; // "RETRY" - let size_width = entries - .iter() - .map(|e| format_size(e.size).len()) - .max() - .unwrap_or(4) - .max(4); - - println!( - "{:retry_width$} {:>size_width$} PATH", - "NODE", "RETRY", "SIZE" - ); - let total_size: u64 = entries.iter().map(|e| e.size).sum(); - for entry in &entries { - println!( - "{:retry_width$} {:>size_width$} {}", - entry.node_slug, - entry.retry, - format_size(entry.size), - entry.relative_path - ); - } - println!(); - println!( - "{} asset(s), {} total", - entries.len(), - format_size(total_size) - ); - - Ok(()) -} - -pub fn cp_command(args: &AssetCpArgs) -> Result<()> { - let base = default_runs_base(); - let (run_id, asset_path) = parse_source(&args.source); - let run_info = resolve_run(&base, run_id)?; - let entries = scan_assets(&run_info.path, args.node.as_deref())?; - - if entries.is_empty() { - bail!("No assets found for this run"); - } - - std::fs::create_dir_all(&args.dest) - .with_context(|| format!("Failed to create destination: {}", args.dest.display()))?; - - if let Some(path) = asset_path { - // Copy a specific asset - let matching: Vec<_> = entries.iter().filter(|e| e.relative_path == path).collect(); - if matching.is_empty() { - bail!("No asset matching path '{path}' found in this run"); - } - if matching.len() > 1 && args.node.is_none() { - let nodes: Vec<_> = matching.iter().map(|e| e.node_slug.as_str()).collect(); - bail!( - "Path '{path}' exists in multiple nodes: {}. Use --node to disambiguate.", - nodes.join(", ") - ); - } - let entry = matching[0]; - let dest_file = args.dest.join( - Path::new(&entry.relative_path) - .file_name() - .unwrap_or_else(|| std::ffi::OsStr::new(&entry.relative_path)), - ); - if let Some(parent) = dest_file.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| { - format!( - "Failed to copy {} to {}", - entry.absolute_path.display(), - dest_file.display() - ) - })?; - println!("Copied {} to {}", entry.relative_path, dest_file.display()); - } else { - // Copy all assets - if args.tree { - // Preserve directory structure: {node_slug}/retry_{N}/... - for entry in &entries { - let rel = PathBuf::from(&entry.node_slug) - .join(format!("retry_{}", entry.retry)) - .join(&entry.relative_path); - let dest_file = args.dest.join(&rel); - if let Some(parent) = dest_file.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| { - format!( - "Failed to copy {} to {}", - entry.absolute_path.display(), - dest_file.display() - ) - })?; - } - } else { - // Flat mode: build filename map and check for collisions - let mut by_filename: Vec<(String, &AssetEntry)> = Vec::with_capacity(entries.len()); - for entry in &entries { - let filename = Path::new(&entry.relative_path) - .file_name() - .unwrap_or_else(|| std::ffi::OsStr::new(&entry.relative_path)) - .to_string_lossy() - .into_owned(); - if let Some((_, existing)) = by_filename.iter().find(|(f, _)| f == &filename) { - bail!( - "Filename collision: '{}' exists in both node '{}' and '{}'. \ - Use --tree to preserve directory structure, or --node to filter.", - filename, - existing.node_slug, - entry.node_slug - ); - } - by_filename.push((filename, entry)); - } - - for (filename, entry) in &by_filename { - let dest_file = args.dest.join(filename); - if let Some(parent) = dest_file.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| { - format!( - "Failed to copy {} to {}", - entry.absolute_path.display(), - dest_file.display() - ) - })?; - } - } - println!( - "Copied {} asset(s) to {}", - entries.len(), - args.dest.display() - ); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_source_bare_run_id() { - let (id, path) = parse_source("01ABC"); - assert_eq!(id, "01ABC"); - assert_eq!(path, None); - } - - #[test] - fn parse_source_with_path() { - let (id, path) = parse_source("01ABC:test-results/report.xml"); - assert_eq!(id, "01ABC"); - assert_eq!(path, Some("test-results/report.xml")); - } - - #[test] - fn parse_source_local_absolute_path() { - let (id, path) = parse_source("/tmp/foo"); - assert_eq!(id, "/tmp/foo"); - assert_eq!(path, None); - } - - #[test] - fn parse_source_local_relative_path() { - let (id, path) = parse_source("./foo"); - assert_eq!(id, "./foo"); - assert_eq!(path, None); - } -} diff --git a/lib/crates/fabro-workflows/src/cli/cp.rs b/lib/crates/fabro-workflows/src/cli/cp.rs deleted file mode 100644 index 1774b0f4f..000000000 --- a/lib/crates/fabro-workflows/src/cli/cp.rs +++ /dev/null @@ -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: : or local path - pub src: String, - /// Destination: : 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: `:` 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 { - 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. :)"), - } -} - -/// 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> { - 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> { - 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"), - } - } -} diff --git a/lib/crates/fabro-workflows/src/cli/diff.rs b/lib/crates/fabro-workflows/src/cli/diff.rs deleted file mode 100644 index ef8ce13eb..000000000 --- a/lib/crates/fabro-workflows/src/cli/diff.rs +++ /dev/null @@ -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, - /// 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 { - // --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" - ); - } -} diff --git a/lib/crates/fabro-workflows/src/cli/graph.rs b/lib/crates/fabro-workflows/src/cli/graph.rs deleted file mode 100644 index ff003dfe8..000000000 --- a/lib/crates/fabro-workflows/src/cli/graph.rs +++ /dev/null @@ -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, - - /// Graph layout direction (overrides the DOT file's rankdir) - #[arg(short = 'd', long)] - pub direction: Option, -} - -/// 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##" -"##; - -/// 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 = - 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) -> 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 `` element -/// 2. Insert a dark-mode `"##; + +/// 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 = + LazyLock::new(|| regex::Regex::new(r"rankdir\s*=\s*\w+").unwrap()); +static WHITE_BG_POLYGON_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new( + r#"]*fill="white"[^>]*stroke="none"[^>]*/>|]*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) -> Vec { + 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("').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> { + 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#"x"# + .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(" b }", GraphFormat::Png).unwrap(); + assert!(!png.is_empty()); + } +} diff --git a/lib/crates/fabro-workflows/src/handler/agent.rs b/lib/crates/fabro-workflows/src/handler/agent.rs index b0648306d..6f2736a61 100644 --- a/lib/crates/fabro-workflows/src/handler/agent.rs +++ b/lib/crates/fabro-workflows/src/handler/agent.rs @@ -75,8 +75,7 @@ impl AgentHandler { /// `$gaol` at runtime. pub(crate) fn expand_variables(text: &str, graph: &Graph) -> Result { 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. diff --git a/lib/crates/fabro-workflows/src/lib.rs b/lib/crates/fabro-workflows/src/lib.rs index 1af969ff8..012c6f739 100644 --- a/lib/crates/fabro-workflows/src/lib.rs +++ b/lib/crates/fabro-workflows/src/lib.rs @@ -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; diff --git a/lib/crates/fabro-workflows/src/pull_request.rs b/lib/crates/fabro-workflows/src/pull_request.rs index 814b2e8e2..88e847d0e 100644 --- a/lib/crates/fabro-workflows/src/pull_request.rs +++ b/lib/crates/fabro-workflows/src/pull_request.rs @@ -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) -> 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 diff --git a/lib/crates/fabro-workflows/src/cli/fork.rs b/lib/crates/fabro-workflows/src/run_fork.rs similarity index 87% rename from lib/crates/fabro-workflows/src/cli/fork.rs rename to lib/crates/fabro-workflows/src/run_fork.rs index c5663a713..47e75d33f 100644 --- a/lib/crates/fabro-workflows/src/cli/fork.rs +++ b/lib/crates/fabro-workflows/src/run_fork.rs @@ -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, - - /// Show the checkpoint timeline instead of forking - #[arg(long)] - pub list: bool, - - /// Skip pushing new branches to the remote - #[arg(long)] - pub no_push: bool, -} - -/// Entry point for `fabro fork`. -pub fn fork_command(args: &ForkArgs, styles: &Styles) -> Result<()> { - let repo = Repository::discover(".").context("not in a git repository")?; - let run_id = find_run_id_by_prefix(&repo, &args.run_id)?; - let store = Store::new(repo); - - let timeline = build_timeline(&store, &run_id)?; - - if args.list { - let parallel_map = load_parallel_map(&store, &run_id); - print_timeline(&timeline, ¶llel_map, styles); - return Ok(()); - } - - let entry = if let Some(target_str) = &args.target { - let target = parse_target(target_str)?; - let parallel_map = load_parallel_map(&store, &run_id); - resolve_target(&timeline, &target, ¶llel_map)? - } else { - timeline - .last() - .ok_or_else(|| anyhow::anyhow!("no checkpoints found for run {run_id}"))? - }; - - let new_run_id = execute_fork(&store, &run_id, entry, !args.no_push)?; - - eprintln!( - "\nForked run {} -> {}", - &run_id[..8.min(run_id.len())], - &new_run_id[..8.min(new_run_id.len())] - ); - eprintln!( - "To resume: fabro run --run-branch {}{}", - crate::git::RUN_BRANCH_PREFIX, - new_run_id - ); - - Ok(()) -} +use crate::run_rewind::TimelineEntry; /// Create a new run that branches from an existing run at a specific checkpoint. /// @@ -204,6 +141,8 @@ mod tests { use std::collections::HashMap; use super::*; + use crate::run_rewind::{build_timeline, find_run_id_by_prefix, parse_target, resolve_target}; + use git2::Repository; fn temp_repo() -> (tempfile::TempDir, Store) { let dir = tempfile::TempDir::new().unwrap(); diff --git a/lib/crates/fabro-workflows/src/run_lookup.rs b/lib/crates/fabro-workflows/src/run_lookup.rs new file mode 100644 index 000000000..9a5e5d950 --- /dev/null +++ b/lib/crates/fabro-workflows/src/run_lookup.rs @@ -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, + pub status: RunStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_reason: Option, + pub start_time: String, + pub labels: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_cost: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_repo_path: Option, + pub goal: String, + #[serde(skip)] + pub start_time_dt: Option>, + #[serde(skip)] + pub end_time: Option>, + #[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> { + 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 { 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, + end_time: Option>, + duration_ms: Option, + total_cost: Option, +} + +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 { + 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 { + 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 { + 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() +} diff --git a/lib/crates/fabro-workflows/src/cli/rewind.rs b/lib/crates/fabro-workflows/src/run_rewind.rs similarity index 89% rename from lib/crates/fabro-workflows/src/cli/rewind.rs rename to lib/crates/fabro-workflows/src/run_rewind.rs index f5b39eabe..b4164b1fc 100644 --- a/lib/crates/fabro-workflows/src/cli/rewind.rs +++ b/lib/crates/fabro-workflows/src/run_rewind.rs @@ -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, - - /// 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, - 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> = 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 } } -/// Entry point for `fabro rewind`. -pub fn rewind_command(args: &RewindArgs, styles: &Styles) -> Result<()> { - let repo = Repository::discover(".").context("not in a git repository")?; - let run_id = find_run_id_by_prefix(&repo, &args.run_id)?; - let store = Store::new(repo); - - let timeline = build_timeline(&store, &run_id)?; - - if args.list || args.target.is_none() { - // Read graph for parallel detection - let parallel_map = load_parallel_map(&store, &run_id); - print_timeline(&timeline, ¶llel_map, styles); - return Ok(()); - } - - let target_str = args.target.as_ref().unwrap(); - let target = parse_target(target_str)?; - - let parallel_map = load_parallel_map(&store, &run_id); - let entry = resolve_target(&timeline, &target, ¶llel_map)?; - - execute_rewind(&store, &run_id, entry, !args.no_push)?; - - eprintln!( - "\nTo resume: fabro run --run-branch {}{}", - crate::git::RUN_BRANCH_PREFIX, - run_id - ); - - Ok(()) -} - /// Load the graph from the metadata branch and build the parallel interior map. pub fn load_parallel_map(store: &Store, run_id: &str) -> HashMap { let branch = MetadataStore::branch_name(run_id); diff --git a/lib/crates/fabro-workflows/src/run_status.rs b/lib/crates/fabro-workflows/src/run_status.rs index fc2a0abb2..16e4ac7c7 100644 --- a/lib/crates/fabro-workflows/src/run_status.rs +++ b/lib/crates/fabro-workflows/src/run_status.rs @@ -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) { + let record = RunStatusRecord::new(status, reason); + let _ = record.save(&run_dir.join("status.json")); +} + #[cfg(test)] mod tests { use super::*; diff --git a/lib/crates/fabro-workflows/src/sandbox_provider.rs b/lib/crates/fabro-workflows/src/sandbox_provider.rs new file mode 100644 index 000000000..d8bc872f6 --- /dev/null +++ b/lib/crates/fabro-workflows/src/sandbox_provider.rs @@ -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 { + 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::().unwrap(), + SandboxProvider::Local + ); + assert_eq!( + "docker".parse::().unwrap(), + SandboxProvider::Docker + ); + assert_eq!( + "daytona".parse::().unwrap(), + SandboxProvider::Daytona + ); + assert_eq!( + "LOCAL".parse::().unwrap(), + SandboxProvider::Local + ); + #[cfg(feature = "exedev")] + { + assert_eq!( + "exe".parse::().unwrap(), + SandboxProvider::Exe + ); + assert_eq!( + "EXE".parse::().unwrap(), + SandboxProvider::Exe + ); + } + assert_eq!( + "ssh".parse::().unwrap(), + SandboxProvider::Ssh + ); + assert_eq!( + "SSH".parse::().unwrap(), + SandboxProvider::Ssh + ); + assert!("invalid".parse::().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"); + } +} diff --git a/lib/crates/fabro-workflows/src/sandbox_reconnect.rs b/lib/crates/fabro-workflows/src/sandbox_reconnect.rs new file mode 100644 index 000000000..cd492d377 --- /dev/null +++ b/lib/crates/fabro-workflows/src/sandbox_reconnect.rs @@ -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> { + 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}"), + } +} diff --git a/lib/crates/fabro-workflows/src/transform.rs b/lib/crates/fabro-workflows/src/transform.rs index f423b70d5..fcb7158f6 100644 --- a/lib/crates/fabro-workflows/src/transform.rs +++ b/lib/crates/fabro-workflows/src/transform.rs @@ -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)); diff --git a/lib/crates/fabro-workflows/src/vars.rs b/lib/crates/fabro-workflows/src/vars.rs new file mode 100644 index 000000000..c9cba0f6f --- /dev/null +++ b/lib/crates/fabro-workflows/src/vars.rs @@ -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) -> anyhow::Result { + 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" + ); + } +} diff --git a/lib/crates/fabro-workflows/tests/cp_integration.rs b/lib/crates/fabro-workflows/tests/cp_integration.rs index 9d4bd4900..8db417734 100644 --- a/lib/crates/fabro-workflows/tests/cp_integration.rs +++ b/lib/crates/fabro-workflows/tests/cp_integration.rs @@ -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; // --------------------------------------------------------------------------- diff --git a/lib/crates/fabro-workflows/tests/daytona_integration.rs b/lib/crates/fabro-workflows/tests/daytona_integration.rs index 85ccc7033..0cc61ef89 100644 --- a/lib/crates/fabro-workflows/tests/daytona_integration.rs +++ b/lib/crates/fabro-workflows/tests/daytona_integration.rs @@ -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 diff --git a/lib/crates/fabro-workflows/tests/integration.rs b/lib/crates/fabro-workflows/tests/integration.rs index 44a8b0390..7a979835b 100644 --- a/lib/crates/fabro-workflows/tests/integration.rs +++ b/lib/crates/fabro-workflows/tests/integration.rs @@ -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();