From 8a256bb68eaaece0490ba95385a2e0bb079fcea8 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 27 Mar 2026 14:52:11 -0400 Subject: [PATCH] Refactor config layering into combine plus settings --- Cargo.lock | 10 + lib/crates/fabro-api/src/demo/mod.rs | 54 +- lib/crates/fabro-api/src/jwt_auth.rs | 2 +- lib/crates/fabro-api/src/lib.rs | 2 +- lib/crates/fabro-api/src/serve.rs | 11 +- lib/crates/fabro-api/src/server.rs | 4 +- lib/crates/fabro-api/src/tls.rs | 4 +- lib/crates/fabro-api/tests/integration.rs | 12 +- .../fabro-api/tests/openapi_conformance.rs | 40 +- lib/crates/fabro-cli/src/args.rs | 2 +- lib/crates/fabro-cli/src/cli_config.rs | 45 +- .../fabro-cli/src/commands/config/mod.rs | 16 +- lib/crates/fabro-cli/src/commands/doctor.rs | 6 +- lib/crates/fabro-cli/src/commands/install.rs | 6 +- lib/crates/fabro-cli/src/commands/llm/chat.rs | 4 +- .../fabro-cli/src/commands/llm/prompt.rs | 4 +- .../fabro-cli/src/commands/preflight.rs | 43 +- .../fabro-cli/src/commands/run/create.rs | 54 +- .../fabro-cli/src/commands/run/execute.rs | 349 ++++++----- lib/crates/fabro-cli/src/commands/run/mod.rs | 2 +- .../fabro-cli/src/commands/run/start.rs | 4 +- lib/crates/fabro-cli/src/main.rs | 16 +- lib/crates/fabro-cli/tests/cli.rs | 6 +- lib/crates/fabro-config-derive/Cargo.toml | 14 + lib/crates/fabro-config-derive/src/lib.rs | 52 ++ lib/crates/fabro-config/Cargo.toml | 1 + lib/crates/fabro-config/src/cli.rs | 453 ++------------ lib/crates/fabro-config/src/combine.rs | 60 ++ lib/crates/fabro-config/src/config.rs | 328 ++-------- lib/crates/fabro-config/src/lib.rs | 6 + lib/crates/fabro-config/src/mcp.rs | 14 + lib/crates/fabro-config/src/project.rs | 58 +- lib/crates/fabro-config/src/run.rs | 122 +++- lib/crates/fabro-config/src/sandbox.rs | 159 ++++- lib/crates/fabro-config/src/server.rs | 590 ++++++------------ lib/crates/fabro-config/src/settings.rs | 225 +++++++ lib/crates/fabro-sandbox/src/daytona/mod.rs | 3 +- lib/crates/fabro-sandbox/src/exe/mod.rs | 2 +- lib/crates/fabro-sandbox/src/ssh/mod.rs | 2 +- .../src/handler/manager_loop.rs | 2 +- .../fabro-workflows/src/operations/create.rs | 22 +- .../fabro-workflows/src/operations/start.rs | 6 +- .../src/pipeline/execute/tests.rs | 6 +- .../fabro-workflows/src/pipeline/finalize.rs | 4 +- .../src/pipeline/initialize.rs | 6 +- .../fabro-workflows/src/pipeline/persist.rs | 4 +- .../fabro-workflows/src/pipeline/retro.rs | 4 +- .../fabro-workflows/src/pipeline/types.rs | 2 +- lib/crates/fabro-workflows/src/records/run.rs | 6 +- lib/crates/fabro-workflows/src/run_options.rs | 8 +- .../tests/daytona_integration.rs | 18 +- .../fabro-workflows/tests/integration.rs | 248 ++++---- 52 files changed, 1502 insertions(+), 1619 deletions(-) create mode 100644 lib/crates/fabro-config-derive/Cargo.toml create mode 100644 lib/crates/fabro-config-derive/src/lib.rs create mode 100644 lib/crates/fabro-config/src/combine.rs create mode 100644 lib/crates/fabro-config/src/settings.rs diff --git a/Cargo.lock b/Cargo.lock index 36898d1b2..e75e734e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1395,6 +1395,7 @@ dependencies = [ "anyhow", "clap", "dirs", + "fabro-config-derive", "fabro-util", "serde", "serde_json", @@ -1404,6 +1405,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "fabro-config-derive" +version = "0.176.2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "fabro-core" version = "0.176.2" diff --git a/lib/crates/fabro-api/src/demo/mod.rs b/lib/crates/fabro-api/src/demo/mod.rs index 7e95d1914..e5aa519d4 100644 --- a/lib/crates/fabro-api/src/demo/mod.rs +++ b/lib/crates/fabro-api/src/demo/mod.rs @@ -1277,21 +1277,21 @@ mod runs { } pub fn configuration() -> serde_json::Value { - serde_json::to_value(fabro_config::FabroConfig { + serde_json::to_value(fabro_config::FabroSettings { version: Some(1), goal: Some("Add rate limiting to auth endpoints".into()), graph: Some("implement.fabro".into()), work_dir: Some("/workspace/api-server".into()), - llm: Some(fabro_config::run::LlmConfig { + llm: Some(fabro_config::run::LlmSettings { model: Some("claude-opus-4-6".into()), provider: Some("anthropic".into()), fallbacks: None, }), - setup: Some(fabro_config::run::SetupConfig { + setup: Some(fabro_config::run::SetupSettings { commands: vec!["bun install".into(), "bun run typecheck".into()], timeout_ms: Some(120_000), }), - sandbox: Some(fabro_config::sandbox::SandboxConfig { + sandbox: Some(fabro_config::sandbox::SandboxSettings { provider: Some("daytona".into()), preserve: None, devcontainer: None, @@ -1431,7 +1431,7 @@ mod workflows { ] } - fn run_config_to_api(cfg: fabro_config::FabroConfig) -> RunConfiguration { + fn run_config_to_api(cfg: fabro_config::FabroSettings) -> RunConfiguration { fn strip_nulls(val: serde_json::Value) -> serde_json::Value { match val { serde_json::Value::Object(map) => serde_json::Value::Object( @@ -1455,18 +1455,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_config::FabroConfig { + config: run_config_to_api(fabro_config::FabroSettings { version: Some(1), goal: Some("Diagnose and fix CI build failures".into()), graph: Some("fix_build.fabro".into()), work_dir: None, - llm: Some(fabro_config::run::LlmConfig { + llm: Some(fabro_config::run::LlmSettings { model: Some("claude-sonnet".into()), provider: None, fallbacks: None, }), setup: None, - sandbox: Some(fabro_config::sandbox::SandboxConfig { + sandbox: Some(fabro_config::sandbox::SandboxSettings { provider: Some("daytona".into()), preserve: None, devcontainer: None, @@ -1526,21 +1526,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_config::FabroConfig { + config: run_config_to_api(fabro_config::FabroSettings { version: Some(1), goal: Some("Implement feature from technical blueprint".into()), graph: Some("implement.fabro".into()), work_dir: None, - llm: Some(fabro_config::run::LlmConfig { + llm: Some(fabro_config::run::LlmSettings { model: Some("claude-sonnet".into()), provider: None, fallbacks: None, }), - setup: Some(fabro_config::run::SetupConfig { + setup: Some(fabro_config::run::SetupSettings { commands: vec!["bun install".into(), "bun run typecheck".into()], timeout_ms: Some(120_000), }), - sandbox: Some(fabro_config::sandbox::SandboxConfig { + sandbox: Some(fabro_config::sandbox::SandboxSettings { provider: Some("daytona".into()), preserve: None, devcontainer: None, @@ -1615,18 +1615,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_config::FabroConfig { + config: run_config_to_api(fabro_config::FabroSettings { version: Some(1), goal: Some("Detect and reconcile configuration drift across environments".into()), graph: Some("sync_drift.fabro".into()), work_dir: None, - llm: Some(fabro_config::run::LlmConfig { + llm: Some(fabro_config::run::LlmSettings { model: Some("claude-sonnet".into()), provider: None, fallbacks: None, }), setup: None, - sandbox: Some(fabro_config::sandbox::SandboxConfig { + sandbox: Some(fabro_config::sandbox::SandboxSettings { provider: Some("daytona".into()), preserve: None, devcontainer: None, @@ -1692,18 +1692,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_config::FabroConfig { + config: run_config_to_api(fabro_config::FabroSettings { version: Some(1), goal: Some("Propose and implement incremental product improvements".into()), graph: Some("expand.fabro".into()), work_dir: None, - llm: Some(fabro_config::run::LlmConfig { + llm: Some(fabro_config::run::LlmSettings { model: Some("claude-sonnet".into()), provider: None, fallbacks: None, }), setup: None, - sandbox: Some(fabro_config::sandbox::SandboxConfig { + sandbox: Some(fabro_config::sandbox::SandboxSettings { provider: Some("daytona".into()), preserve: None, devcontainer: None, @@ -3242,25 +3242,25 @@ mod insights { mod settings { use fabro_config::server::*; - use fabro_config::FabroConfig; + use fabro_config::FabroSettings; pub fn server_config() -> serde_json::Value { - serde_json::to_value(FabroConfig { + serde_json::to_value(FabroSettings { storage_dir: Some("/home/fabro/.fabro".into()), max_concurrent_runs: Some(10), - web: Some(WebConfig { + web: Some(WebSettings { url: "https://arc.example.com".into(), - auth: AuthConfig { + auth: AuthSettings { provider: AuthProvider::Github, allowed_usernames: vec!["brynary".into(), "alice".into()], }, }), - api: Some(ApiConfig { + api: Some(ApiSettings { base_url: "https://api.fabro.example.com".into(), authentication_strategies: vec![ApiAuthStrategy::Jwt], tls: None, }), - git: Some(GitConfig { + git: Some(GitSettings { provider: GitProvider::Github, app_id: Some("12345".into()), client_id: Some("Iv1.abc123".into()), @@ -3268,18 +3268,18 @@ mod settings { author: Default::default(), webhooks: None, }), - features: Some(Features { + features: Some(FeaturesSettings { session_sandboxes: false, retros: false, }), log: Default::default(), - llm: Some(fabro_config::run::LlmConfig { + llm: Some(fabro_config::run::LlmSettings { model: Some("claude-sonnet".into()), provider: Some("anthropic".into()), fallbacks: None, }), setup: None, - sandbox: Some(fabro_config::sandbox::SandboxConfig { + sandbox: Some(fabro_config::sandbox::SandboxSettings { provider: Some("daytona".into()), preserve: None, devcontainer: None, diff --git a/lib/crates/fabro-api/src/jwt_auth.rs b/lib/crates/fabro-api/src/jwt_auth.rs index 413c94c58..32a9c49f1 100644 --- a/lib/crates/fabro-api/src/jwt_auth.rs +++ b/lib/crates/fabro-api/src/jwt_auth.rs @@ -68,7 +68,7 @@ pub fn decode_pem_env(name: &str, value: &str) -> String { /// Call this once at startup before serving requests. Panics if the /// configuration is invalid (JWT strategy but no public key, or mTLS without TLS config). pub fn resolve_auth_mode( - api_config: &fabro_config::server::ApiConfig, + api_config: &fabro_config::server::ApiSettings, allowed_usernames: Vec, ) -> AuthMode { use fabro_config::server::ApiAuthStrategy; diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index ffb78c120..4e5f48bb6 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -6,7 +6,7 @@ pub mod serve; pub mod server; pub mod server_config { pub use fabro_config::server::*; - pub use fabro_config::FabroConfig; + pub use fabro_config::FabroSettings; } pub mod sessions; pub mod tls; diff --git a/lib/crates/fabro-api/src/serve.rs b/lib/crates/fabro-api/src/serve.rs index a8ff5e44f..df33aab0b 100644 --- a/lib/crates/fabro-api/src/serve.rs +++ b/lib/crates/fabro-api/src/serve.rs @@ -9,7 +9,7 @@ use tracing::{error, info, warn}; use clap::Args; -use fabro_config::FabroConfig; +use fabro_config::FabroSettings; use crate::jwt_auth::{AuthMode, AuthStrategy}; use crate::server::build_router; @@ -83,7 +83,8 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: // Initialize data directory and SQLite database let config_path = args.config; - let server_config = fabro_config::server::load_server_config(config_path.as_deref())?; + let server_config: FabroSettings = + fabro_config::server::load_server_config(config_path.as_deref())?.try_into()?; let data_dir = fabro_config::server::resolve_storage_dir(&server_config); // Shared config for live reloading @@ -216,6 +217,10 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: interval.tick().await; match fabro_config::server::load_server_config(config_path_for_poll.as_deref()) { Ok(new_config) => { + let Ok(new_config) = FabroSettings::try_from(new_config) else { + warn!("Failed to finalize reloaded server config"); + continue; + }; let changed = { let cfg = config_for_poll.read().expect("config lock poisoned"); *cfg != new_config @@ -263,7 +268,7 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: /// Resolve model and provider from shared config, with CLI overrides taking precedence. fn resolve_model_provider( - shared_config: &RwLock, + shared_config: &RwLock, cli_model: Option<&str>, cli_provider: Option<&str>, ) -> (String, Provider) { diff --git a/lib/crates/fabro-api/src/server.rs b/lib/crates/fabro-api/src/server.rs index 5f1ac01fe..b20aaeb7b 100644 --- a/lib/crates/fabro-api/src/server.rs +++ b/lib/crates/fabro-api/src/server.rs @@ -520,10 +520,10 @@ async fn start_run( let run_id = ulid::Ulid::new().to_string(); info!(run_id = %run_id, "Run queued"); let run_dir = std::env::temp_dir().join(format!("fabro-{}", uuid::Uuid::new_v4())); - let config = fabro_config::config::FabroConfig { + let config = fabro_config::FabroSettings { dry_run: Some(state.dry_run), hooks: state.hooks.clone(), - sandbox: Some(fabro_config::sandbox::SandboxConfig { + sandbox: Some(fabro_config::sandbox::SandboxSettings { provider: Some("local".to_string()), ..Default::default() }), diff --git a/lib/crates/fabro-api/src/tls.rs b/lib/crates/fabro-api/src/tls.rs index 5ab6d3dec..bb7fd32f6 100644 --- a/lib/crates/fabro-api/src/tls.rs +++ b/lib/crates/fabro-api/src/tls.rs @@ -7,7 +7,7 @@ use rustls_pki_types::{CertificateDer, PrivateKeyDer}; use tokio::net::TcpListener; use tracing::error; -use fabro_config::server::TlsConfig; +use fabro_config::server::TlsSettings; use crate::jwt_auth::PeerCertificates; @@ -22,7 +22,7 @@ pub enum ClientAuth { } /// Build a rustls `ServerConfig` from the `[api.tls]` configuration. -pub fn build_rustls_config(tls_config: &TlsConfig, client_auth: ClientAuth) -> Arc { +pub fn build_rustls_config(tls_config: &TlsSettings, client_auth: ClientAuth) -> Arc { let certs = load_certs(&tls_config.cert); let key = load_private_key(&tls_config.key); diff --git a/lib/crates/fabro-api/tests/integration.rs b/lib/crates/fabro-api/tests/integration.rs index 8e8e59835..87a8df2d4 100644 --- a/lib/crates/fabro-api/tests/integration.rs +++ b/lib/crates/fabro-api/tests/integration.rs @@ -10,7 +10,7 @@ mod mtls_e2e { use fabro_api::jwt_auth::{AuthMode, AuthStrategy}; use fabro_api::server::{build_router, create_app_state}; - use fabro_api::server_config::TlsConfig; + use fabro_api::server_config::TlsSettings; use fabro_api::tls::{build_rustls_config, ClientAuth}; use fabro_workflows::pipeline::LlmSpec; use tokio::net::TcpListener; @@ -181,7 +181,7 @@ mod mtls_e2e { /// Start a TLS server on a random port, returning the bound address. async fn start_tls_server( - tls_config: &TlsConfig, + tls_config: &TlsSettings, client_auth: ClientAuth, auth_mode: AuthMode, ) -> std::net::SocketAddr { @@ -236,7 +236,7 @@ mod mtls_e2e { let dir = tempfile::tempdir().unwrap(); let pki = generate_pki(dir.path(), "TestCA", "localhost", "testuser"); - let tls_config = TlsConfig { + let tls_config = TlsSettings { cert: pki.server_cert.clone(), key: pki.server_key.clone(), ca: pki.ca_cert.clone(), @@ -262,7 +262,7 @@ mod mtls_e2e { let dir = tempfile::tempdir().unwrap(); let pki = generate_pki(dir.path(), "TestCA", "localhost", "testuser"); - let tls_config = TlsConfig { + let tls_config = TlsSettings { cert: pki.server_cert.clone(), key: pki.server_key.clone(), ca: pki.ca_cert.clone(), @@ -303,7 +303,7 @@ mod mtls_e2e { let dir = tempfile::tempdir().unwrap(); let pki = generate_pki(dir.path(), "TestCA", "localhost", "testuser"); - let tls_config = TlsConfig { + let tls_config = TlsSettings { cert: pki.server_cert.clone(), key: pki.server_key.clone(), ca: pki.ca_cert.clone(), @@ -378,7 +378,7 @@ mod mtls_e2e { let dir = tempfile::tempdir().unwrap(); let pki = generate_pki(dir.path(), "TestCA", "localhost", "testuser"); - let tls_config = TlsConfig { + let tls_config = TlsSettings { cert: pki.server_cert.clone(), key: pki.server_key.clone(), ca: pki.ca_cert.clone(), diff --git a/lib/crates/fabro-api/tests/openapi_conformance.rs b/lib/crates/fabro-api/tests/openapi_conformance.rs index 2dcaae9fb..a28fbadee 100644 --- a/lib/crates/fabro-api/tests/openapi_conformance.rs +++ b/lib/crates/fabro-api/tests/openapi_conformance.rs @@ -8,7 +8,7 @@ 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_config::sandbox::SandboxSettings; use fabro_hooks::*; use fabro_sandbox::daytona::*; use fabro_workflows::pipeline::LlmSpec; @@ -235,59 +235,59 @@ fn compare_schema( } } -/// Build a FabroConfig with every Option set to Some so all keys appear +/// Build a FabroSettings with every Option set to Some so all keys appear /// in the serialized JSON. -fn fully_populated_server_config() -> FabroConfig { - FabroConfig { +fn fully_populated_server_config() -> FabroSettings { + FabroSettings { storage_dir: Some("/data".into()), max_concurrent_runs: Some(10), - web: Some(WebConfig { + web: Some(WebSettings { url: "https://example.com".into(), - auth: AuthConfig { + auth: AuthSettings { provider: AuthProvider::Github, allowed_usernames: vec!["user".into()], }, }), - api: Some(ApiConfig { + api: Some(ApiSettings { base_url: "https://api.example.com".into(), authentication_strategies: vec![ApiAuthStrategy::Jwt], - tls: Some(TlsConfig { + tls: Some(TlsSettings { cert: "c".into(), key: "k".into(), ca: "ca".into(), }), }), - git: Some(GitConfig { + git: Some(GitSettings { provider: GitProvider::Github, app_id: Some("123".into()), client_id: Some("456".into()), slug: Some("fabro".into()), - author: GitAuthorConfig { + author: GitAuthorSettings { name: Some("bot".into()), email: Some("bot@x".into()), }, - webhooks: Some(WebhookConfig { + webhooks: Some(WebhookSettings { strategy: WebhookStrategy::TailscaleFunnel, }), }), - features: Some(Features { + features: Some(FeaturesSettings { session_sandboxes: true, retros: false, }), - log: Some(LogConfig { + log: Some(LogSettings { level: Some("debug".into()), }), work_dir: Some("/work".into()), - llm: Some(LlmConfig { + llm: Some(LlmSettings { model: Some("m".into()), provider: Some("p".into()), fallbacks: Some(Default::default()), }), - setup: Some(SetupConfig { + setup: Some(SetupSettings { commands: vec!["echo hi".into()], timeout_ms: Some(5000), }), - sandbox: Some(SandboxConfig { + sandbox: Some(SandboxSettings { provider: Some("daytona".into()), preserve: Some(true), devcontainer: None, @@ -310,16 +310,16 @@ fn fully_populated_server_config() -> FabroConfig { env: Some(Default::default()), }), vars: Some(Default::default()), - checkpoint: CheckpointConfig { + checkpoint: CheckpointSettings { exclude_globs: vec!["**/node_modules/**".into()], }, - pull_request: Some(PullRequestConfig { + pull_request: Some(PullRequestSettings { enabled: true, draft: false, auto_merge: false, merge_strategy: MergeStrategy::Squash, }), - assets: Some(AssetsConfig { + assets: Some(AssetsSettings { include: vec!["test-results/**".into()], }), // One hook per HookType variant so the key union covers all fields. @@ -388,7 +388,7 @@ fn fully_populated_server_config() -> FabroConfig { tool_timeout_secs: fabro_config::mcp::default_tool_timeout_secs(), }, )]), - github: Some(GitHubConfig { + github: Some(GitHubSettings { permissions: std::collections::HashMap::from([("contents".into(), "read".into())]), }), ..Default::default() diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 1cc98adff..08c78a51a 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -938,7 +938,7 @@ pub(crate) struct ConfigNamespace { #[derive(Subcommand)] pub(crate) enum ConfigCommand { - /// Print the merged FabroConfig as YAML + /// Print the merged FabroSettings as YAML Show(ConfigShowArgs), } diff --git a/lib/crates/fabro-cli/src/cli_config.rs b/lib/crates/fabro-cli/src/cli_config.rs index b3e8d2fde..676b45063 100644 --- a/lib/crates/fabro-cli/src/cli_config.rs +++ b/lib/crates/fabro-cli/src/cli_config.rs @@ -1,16 +1,23 @@ +#[allow(unused_imports)] pub use fabro_config::cli::*; -#[cfg(feature = "server")] -use fabro_config::FabroConfig; +use std::path::Path; + +use fabro_config::FabroSettings; + #[cfg(feature = "server")] use tracing::debug; +pub fn load_cli_config(path: Option<&Path>) -> anyhow::Result { + fabro_config::cli::load_cli_config(path)?.try_into() +} + #[cfg(feature = "server")] #[derive(Debug, PartialEq)] pub struct ResolvedMode { pub mode: ExecutionMode, pub server_base_url: String, - pub tls: Option, + pub tls: Option, } #[cfg(feature = "server")] @@ -20,7 +27,7 @@ const DEFAULT_SERVER_URL: &str = "http://localhost:3000"; pub fn resolve_mode( cli_mode: Option, cli_server_url: Option<&str>, - config: &FabroConfig, + config: &FabroSettings, ) -> ResolvedMode { let mode = cli_mode.or_else(|| config.mode.clone()).unwrap_or_default(); @@ -43,7 +50,7 @@ pub fn resolve_mode( } #[cfg(feature = "server")] -pub fn build_server_client(tls: Option<&ClientTlsConfig>) -> anyhow::Result { +pub fn build_server_client(tls: Option<&ClientTlsSettings>) -> anyhow::Result { let Some(tls) = tls else { return Ok(reqwest::Client::new()); }; @@ -82,7 +89,7 @@ mod tests { #[test] fn resolve_mode_defaults_to_standalone() { - let config = FabroConfig::default(); + let config = FabroSettings::default(); let resolved = resolve_mode(None, None, &config); assert_eq!(resolved.mode, ExecutionMode::Standalone); assert_eq!(resolved.server_base_url, DEFAULT_SERVER_URL); @@ -91,13 +98,13 @@ mod tests { #[test] fn resolve_mode_config_overrides_default() { - let config = FabroConfig { + let config = FabroSettings { mode: Some(ExecutionMode::Server), - server: Some(ServerDefaults { + server: Some(ServerSettings { base_url: Some("https://config.example.com".to_string()), tls: None, }), - ..FabroConfig::default() + ..FabroSettings::default() }; let resolved = resolve_mode(None, None, &config); assert_eq!(resolved.mode, ExecutionMode::Server); @@ -106,13 +113,13 @@ mod tests { #[test] fn resolve_mode_cli_overrides_config() { - let config = FabroConfig { + let config = FabroSettings { mode: Some(ExecutionMode::Standalone), - server: Some(ServerDefaults { + server: Some(ServerSettings { base_url: Some("https://config.example.com".to_string()), tls: None, }), - ..FabroConfig::default() + ..FabroSettings::default() }; let resolved = resolve_mode( Some(ExecutionMode::Server), @@ -125,12 +132,12 @@ mod tests { #[test] fn resolve_mode_cli_url_overrides_config_url() { - let config = FabroConfig { - server: Some(ServerDefaults { + let config = FabroSettings { + server: Some(ServerSettings { base_url: Some("https://config.example.com".to_string()), tls: None, }), - ..FabroConfig::default() + ..FabroSettings::default() }; let resolved = resolve_mode(None, Some("https://cli.example.com"), &config); assert_eq!(resolved.server_base_url, "https://cli.example.com"); @@ -138,17 +145,17 @@ mod tests { #[test] fn resolve_mode_tls_from_config() { - let tls = ClientTlsConfig { + let tls = ClientTlsSettings { cert: PathBuf::from("cert.pem"), key: PathBuf::from("key.pem"), ca: PathBuf::from("ca.pem"), }; - let config = FabroConfig { - server: Some(ServerDefaults { + let config = FabroSettings { + server: Some(ServerSettings { base_url: None, tls: Some(tls.clone()), }), - ..FabroConfig::default() + ..FabroSettings::default() }; let resolved = resolve_mode(None, None, &config); assert_eq!(resolved.tls, Some(tls)); diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 62191783f..b997b8c18 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -1,11 +1,9 @@ use std::io::Write; use std::path::Path; -use anyhow::bail; -use fabro_config::FabroConfig; - use crate::args::{ConfigCommand, ConfigNamespace, ConfigShowArgs}; -use crate::cli_config; +use anyhow::bail; +use fabro_config::FabroSettings; pub fn dispatch(ns: ConfigNamespace) -> anyhow::Result<()> { match ns.command { @@ -13,14 +11,14 @@ pub fn dispatch(ns: ConfigNamespace) -> anyhow::Result<()> { } } -fn merged_config(workflow: Option<&Path>) -> anyhow::Result { - let mut config = cli_config::load_cli_config(None)?; +fn merged_config(workflow: Option<&Path>) -> anyhow::Result { + let mut config = fabro_config::cli::load_cli_config(None)?; let cwd = std::env::current_dir()?; if let Some((_config_path, project_config)) = fabro_config::project::discover_project_config(&cwd)? { - config.merge_overlay(project_config); + config = config.combine(project_config); } if let Some(workflow) = workflow { @@ -28,13 +26,13 @@ fn merged_config(workflow: Option<&Path>) -> anyhow::Result { crate::commands::run::execute::resolve_workflow_source(workflow)?; if let Some(run_config) = run_config { - config.merge_overlay(run_config); + config = config.combine(run_config); } else if !resolved_path.is_file() { bail!("Workflow not found: {}", resolved_path.display()); } } - Ok(config) + config.try_into() } pub fn show_command(args: &ConfigShowArgs) -> anyhow::Result<()> { diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs index 44b8e3200..ea7f3c2d6 100644 --- a/lib/crates/fabro-cli/src/commands/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -928,7 +928,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 { spinner.enable_steady_tick(std::time::Duration::from_millis(80)); // Gather state - let cli_config = fabro_config::cli::load_cli_config(None).unwrap_or_default(); + let cli_config = crate::cli_config::load_cli_config(None).unwrap_or_default(); let config_path = dirs::home_dir().map(|h| h.join(".fabro").join("cli.toml")); let config_exists = config_path.as_ref().is_some_and(|p| p.exists()); @@ -943,7 +943,9 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 { let daytona_configured = std::env::var("DAYTONA_API_KEY").is_ok(); #[cfg(feature = "server")] - let server_config = fabro_config::server::load_server_config(None).unwrap_or_default(); + let server_config = fabro_config::server::load_server_config(None) + .and_then(fabro_config::FabroSettings::try_from) + .unwrap_or_default(); #[cfg(feature = "server")] let api_status = { diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 84bae5128..36b497d07 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -931,7 +931,7 @@ mod tests { #[cfg(feature = "server")] fn config_toml_roundtrips() { let toml_str = format_config_toml("brynary"); - let config: fabro_config::FabroConfig = + let config: fabro_config::FabroSettings = toml::from_str(&toml_str).expect("config should parse"); assert_eq!(config.web.unwrap().auth.allowed_usernames, vec!["brynary"]); } @@ -940,7 +940,7 @@ mod tests { #[cfg(feature = "server")] fn config_toml_has_auth_strategies() { let toml_str = format_config_toml("alice"); - let config: fabro_config::FabroConfig = toml::from_str(&toml_str).unwrap(); + let config: fabro_config::FabroSettings = toml::from_str(&toml_str).unwrap(); assert_eq!( config.api.unwrap().authentication_strategies, vec![ @@ -955,7 +955,7 @@ mod tests { fn config_toml_has_tls_paths() { use std::path::PathBuf; let toml_str = format_config_toml("bob"); - let config: fabro_config::FabroConfig = toml::from_str(&toml_str).unwrap(); + let config: fabro_config::FabroSettings = toml::from_str(&toml_str).unwrap(); let tls = config.api.unwrap().tls.expect("tls should be set"); assert_eq!(tls.cert, PathBuf::from("~/.fabro/certs/server.crt")); assert_eq!(tls.key, PathBuf::from("~/.fabro/certs/server.key")); diff --git a/lib/crates/fabro-cli/src/commands/llm/chat.rs b/lib/crates/fabro-cli/src/commands/llm/chat.rs index 10dddeead..7d690934c 100644 --- a/lib/crates/fabro-cli/src/commands/llm/chat.rs +++ b/lib/crates/fabro-cli/src/commands/llm/chat.rs @@ -1,11 +1,11 @@ use anyhow::Result; -use fabro_config::FabroConfig; +use fabro_config::FabroSettings; use crate::args::GlobalArgs; pub async fn execute( mut args: fabro_llm::cli::ChatArgs, - cli_config: &FabroConfig, + cli_config: &FabroSettings, globals: &GlobalArgs, ) -> Result<()> { let llm_defaults = cli_config.llm.as_ref(); diff --git a/lib/crates/fabro-cli/src/commands/llm/prompt.rs b/lib/crates/fabro-cli/src/commands/llm/prompt.rs index 7a3ffd7b0..3647e52ed 100644 --- a/lib/crates/fabro-cli/src/commands/llm/prompt.rs +++ b/lib/crates/fabro-cli/src/commands/llm/prompt.rs @@ -1,11 +1,11 @@ use anyhow::Result; -use fabro_config::FabroConfig; +use fabro_config::FabroSettings; use crate::args::GlobalArgs; pub async fn execute( mut args: fabro_llm::cli::PromptArgs, - cli_config: &FabroConfig, + cli_config: &FabroSettings, globals: &GlobalArgs, ) -> Result<()> { let llm_defaults = cli_config.llm.as_ref(); diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index 4089912ae..8d515cafc 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -1,32 +1,25 @@ use std::path::Path; use anyhow::bail; +use fabro_config::{FabroConfig, FabroSettings}; use fabro_util::terminal::Styles; -use crate::args::PreflightArgs; -use crate::cli_config; - use super::run::execute::{ - apply_execution_overrides, load_workflow_source_input, print_workflow_report, - resolve_sandbox_provider, run_preflight, ExecutionOverrides, + load_workflow_source_input, print_workflow_report, resolve_sandbox_provider, run_preflight, }; +use crate::args::PreflightArgs; pub async fn execute(mut args: PreflightArgs) -> anyhow::Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let cli_config = cli_config::load_cli_config(None)?; + let cli_defaults = fabro_config::cli::load_cli_config(None)?; + let cli_config: FabroSettings = cli_defaults.clone().try_into()?; args.verbose = args.verbose || cli_config.verbose_enabled(); let github_app = crate::shared::github::build_github_app_credentials(cli_config.app_id()); + let cli_args_config = FabroConfig::try_from(&args)?; - let run_defaults = cli_config; - - let source_input = load_workflow_source_input( - &args.workflow, - args.goal.as_deref(), - args.goal_file.as_deref(), - run_defaults, - true, - )?; + let source_input = + load_workflow_source_input(&args.workflow, cli_args_config, cli_defaults, true)?; let original_cwd = std::env::current_dir()?; let (origin_url, detected_base_branch) = @@ -42,22 +35,6 @@ pub async fn execute(mut args: PreflightArgs) -> anyhow::Result<()> { &source_input.run_defaults, )?; - let mut config = source_input.config.clone(); - apply_execution_overrides( - &mut config, - &ExecutionOverrides { - dry_run: false, - auto_approve: false, - no_retro: false, - verbose: args.verbose, - preserve_sandbox: false, - model: args.model.as_deref(), - provider: args.provider.as_deref(), - sandbox_provider, - storage_dir: None, - }, - ); - let validated = fabro_workflows::operations::validate( &source_input.raw_source, fabro_workflows::operations::ValidateOptions { @@ -68,7 +45,7 @@ pub async fn execute(mut args: PreflightArgs) -> anyhow::Result<()> { .unwrap_or(Path::new(".")) .to_path_buf(), ), - config: Some(config.clone()), + config: Some(source_input.config.clone()), goal_override: source_input.goal_override.clone(), ..Default::default() }, @@ -80,7 +57,7 @@ pub async fn execute(mut args: PreflightArgs) -> anyhow::Result<()> { run_preflight( validated.graph(), - &Some(config), + &Some(source_input.config), args.model.as_deref(), args.provider.as_deref(), &source_input.run_defaults, diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index 2f4e7751f..c45a03994 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -1,14 +1,12 @@ use std::path::PathBuf; -use fabro_config::config::FabroConfig; -use fabro_sandbox::SandboxProvider; - use crate::args::RunArgs; +use fabro_config::FabroConfig; use super::execute::{ - apply_execution_overrides, cached_graph_path, default_run_dir, load_workflow_source_input, - make_run_dir, parse_labels, print_diagnostics_from_error, print_workflow_report_from_persisted, - resolve_sandbox_provider, write_run_config_snapshot, ExecutionOverrides, + cached_graph_path, default_run_dir, load_workflow_source_input, make_run_dir, parse_labels, + print_diagnostics_from_error, print_workflow_report_from_persisted, resolve_sandbox_provider, + write_run_config_snapshot, }; use fabro_util::terminal::Styles; @@ -17,7 +15,7 @@ use fabro_util::terminal::Styles; /// This does NOT execute the workflow — it only prepares the run directory. pub async fn create_run( args: &RunArgs, - run_defaults: FabroConfig, + cli_defaults: FabroConfig, styles: &Styles, quiet: bool, ) -> anyhow::Result<(String, PathBuf)> { @@ -25,18 +23,18 @@ pub async fn create_run( .workflow .as_ref() .ok_or_else(|| anyhow::anyhow!("--workflow is required"))?; - let source_input = load_workflow_source_input( - workflow_path, - args.goal.as_deref(), - args.goal_file.as_deref(), - run_defaults, - true, - )?; + let cli_args_config = FabroConfig::try_from(args)?; + let source_input = + load_workflow_source_input(workflow_path, cli_args_config, cli_defaults, true)?; let run_id = args .run_id .clone() .unwrap_or_else(|| ulid::Ulid::new().to_string()); - let run_dir = match &args.storage_dir { + let run_dir = match args + .storage_dir + .clone() + .or_else(|| source_input.config.storage_dir.clone()) + { Some(sd) => make_run_dir(&sd.join("runs"), &run_id, args.dry_run), None => default_run_dir(&run_id, args.dry_run), }; @@ -44,31 +42,15 @@ pub async fn create_run( let base_branch = fabro_sandbox::daytona::detect_repo_info(&working_directory) .ok() .and_then(|(_, branch)| branch); - let sandbox_provider = if args.dry_run { - SandboxProvider::Local - } else { - resolve_sandbox_provider( + if !args.dry_run { + let _ = resolve_sandbox_provider( args.sandbox.map(Into::into), Some(&source_input.config), &source_input.run_defaults, - )? - }; + )?; + } - let mut config = source_input.config.clone(); - apply_execution_overrides( - &mut config, - &ExecutionOverrides { - dry_run: args.dry_run, - auto_approve: args.auto_approve, - no_retro: args.no_retro, - verbose: args.verbose, - preserve_sandbox: args.preserve_sandbox, - model: args.model.as_deref(), - provider: args.provider.as_deref(), - sandbox_provider, - storage_dir: args.storage_dir.as_deref(), - }, - ); + let config = source_input.config.clone(); let persisted = match fabro_workflows::operations::create( &source_input.raw_source, diff --git a/lib/crates/fabro-cli/src/commands/run/execute.rs b/lib/crates/fabro-cli/src/commands/run/execute.rs index d7098ed66..337af1cc8 100644 --- a/lib/crates/fabro-cli/src/commands/run/execute.rs +++ b/lib/crates/fabro-cli/src/commands/run/execute.rs @@ -7,8 +7,8 @@ use std::time::Instant; use anyhow::{bail, Context}; use chrono::Local; use fabro_agent::{DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox}; -use fabro_config::config::FabroConfig; use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config}; +use fabro_config::{FabroConfig, FabroSettings}; use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, FileInterviewer, Interviewer}; use fabro_model::{Catalog, FallbackTarget, Provider}; use fabro_sandbox::SandboxProvider; @@ -32,8 +32,7 @@ use tracing::debug; use super::detached::{DetachedRunBootstrapGuard, DetachedRunCompletionGuard}; use super::run_progress; -use crate::args::{CliSandboxProvider, GlobalArgs, RunArgs}; -use crate::cli_config; +use crate::args::{CliSandboxProvider, GlobalArgs, PreflightArgs, RunArgs}; use crate::shared::{ format_tokens_human, print_diagnostics, read_workflow_file, relative_path, tilde_path, }; @@ -58,6 +57,80 @@ pub(crate) fn resolve_cli_goal( pub(crate) use fabro_workflows::operations::{default_run_dir, make_run_dir}; +fn sparse_flag(value: bool) -> Option { + value.then_some(true) +} + +impl TryFrom<&RunArgs> for FabroConfig { + type Error = anyhow::Error; + + fn try_from(args: &RunArgs) -> Result { + let goal = resolve_cli_goal(args.goal.as_deref(), args.goal_file.as_deref())?; + let llm = if args.model.is_some() || args.provider.is_some() { + Some(fabro_config::run::LlmConfig { + model: args.model.clone(), + provider: args.provider.clone(), + fallbacks: None, + }) + } else { + None + }; + let sandbox = if args.sandbox.is_some() || args.preserve_sandbox { + Some(sandbox_config::SandboxConfig { + provider: args + .sandbox + .map(Into::into) + .map(|provider: SandboxProvider| provider.to_string()), + preserve: sparse_flag(args.preserve_sandbox), + ..Default::default() + }) + } else { + None + }; + + Ok(Self { + goal, + llm, + sandbox, + verbose: sparse_flag(args.verbose), + dry_run: sparse_flag(args.dry_run), + auto_approve: sparse_flag(args.auto_approve), + no_retro: sparse_flag(args.no_retro), + storage_dir: args.storage_dir.clone(), + ..Default::default() + }) + } +} + +impl TryFrom<&PreflightArgs> for FabroConfig { + type Error = anyhow::Error; + + fn try_from(args: &PreflightArgs) -> Result { + let goal = resolve_cli_goal(args.goal.as_deref(), args.goal_file.as_deref())?; + let llm = if args.model.is_some() || args.provider.is_some() { + Some(fabro_config::run::LlmConfig { + model: args.model.clone(), + provider: args.provider.clone(), + fallbacks: None, + }) + } else { + None + }; + let sandbox = args.sandbox.map(|sandbox| sandbox_config::SandboxConfig { + provider: Some(SandboxProvider::from(sandbox).to_string()), + ..Default::default() + }); + + Ok(Self { + goal, + llm, + sandbox, + verbose: sparse_flag(args.verbose), + ..Default::default() + }) + } +} + pub(crate) fn workflow_slug_from_path(workflow_path: &Path) -> Option { let file_name = workflow_path.file_name()?.to_string_lossy(); if workflow_path.extension().is_none() { @@ -89,8 +162,8 @@ fn is_cached_run_restart(workflow_path: &Path, run_dir: &Path) -> bool { pub(crate) fn resolve_model_provider( cli_model: Option<&str>, cli_provider: Option<&str>, - run_cfg: Option<&FabroConfig>, - run_defaults: &FabroConfig, + run_cfg: Option<&FabroSettings>, + run_defaults: &FabroSettings, graph: &fabro_graphviz::graph::Graph, ) -> (String, Option) { let toml_model = run_cfg @@ -139,7 +212,7 @@ pub(crate) fn resolve_model_provider( /// Parse sandbox provider from an optional `SandboxConfig`. pub(crate) fn parse_sandbox_provider( - sandbox: Option<&sandbox_config::SandboxConfig>, + sandbox: Option<&sandbox_config::SandboxSettings>, ) -> anyhow::Result> { sandbox .and_then(|s| s.provider.as_deref()) @@ -151,8 +224,8 @@ pub(crate) fn parse_sandbox_provider( /// Resolve sandbox provider: CLI flag > TOML config > run defaults > default. pub(crate) fn resolve_sandbox_provider( cli: Option, - run_cfg: Option<&FabroConfig>, - run_defaults: &FabroConfig, + run_cfg: Option<&FabroSettings>, + run_defaults: &FabroSettings, ) -> anyhow::Result { let toml = parse_sandbox_provider(run_cfg.and_then(|c| c.sandbox.as_ref()))?; let defaults = parse_sandbox_provider(run_defaults.sandbox.as_ref())?; @@ -162,8 +235,8 @@ pub(crate) fn resolve_sandbox_provider( /// Resolve preserve-sandbox: CLI flag > TOML config > run defaults > false. pub(crate) fn resolve_preserve_sandbox( cli: bool, - run_cfg: Option<&FabroConfig>, - run_defaults: &FabroConfig, + run_cfg: Option<&FabroSettings>, + run_defaults: &FabroSettings, ) -> bool { if cli { return true; @@ -177,8 +250,8 @@ pub(crate) fn resolve_preserve_sandbox( /// Resolve worktree mode: TOML config > run defaults > Clean. fn resolve_worktree_mode( - run_cfg: Option<&FabroConfig>, - run_defaults: &FabroConfig, + run_cfg: Option<&FabroSettings>, + run_defaults: &FabroSettings, ) -> sandbox_config::WorktreeMode { run_cfg .and_then(|c| c.sandbox.as_ref()) @@ -196,8 +269,8 @@ fn resolve_worktree_mode( /// Resolve daytona config: TOML config > run defaults. pub(crate) fn resolve_daytona_config( - run_cfg: Option<&FabroConfig>, - run_defaults: &FabroConfig, + run_cfg: Option<&FabroSettings>, + run_defaults: &FabroSettings, ) -> Option { run_cfg .and_then(|c| c.sandbox.as_ref()) @@ -213,8 +286,8 @@ pub(crate) fn resolve_daytona_config( #[cfg(feature = "exedev")] /// Resolve exe.dev config: TOML config > run defaults. pub(crate) fn resolve_exe_config( - run_cfg: Option<&FabroConfig>, - run_defaults: &FabroConfig, + run_cfg: Option<&FabroSettings>, + run_defaults: &FabroSettings, ) -> Option { run_cfg .and_then(|c| c.sandbox.as_ref()) @@ -243,8 +316,8 @@ pub(crate) fn resolve_exe_clone_params( /// Resolve SSH sandbox config: TOML config > run defaults. pub(crate) fn resolve_ssh_config( - run_cfg: Option<&FabroConfig>, - run_defaults: &FabroConfig, + run_cfg: Option<&FabroSettings>, + run_defaults: &FabroSettings, ) -> Option { run_cfg .and_then(|c| c.sandbox.as_ref()) @@ -270,14 +343,11 @@ pub(crate) fn resolve_ssh_clone_params( Some(fabro_sandbox::ssh::GitCloneParams { url, branch }) } -/// Resolve the fallback chain from config. -/// -/// `merge_overlay` must be called before this — it merges -/// `run_defaults.llm.fallbacks` into `run_cfg.llm.fallbacks` already. +/// Resolve the fallback chain from the effective settings. pub(crate) fn resolve_fallback_chain( provider: Provider, model: &str, - run_cfg: Option<&FabroConfig>, + run_cfg: Option<&FabroSettings>, ) -> Vec { let fallbacks = run_cfg .and_then(|c| c.llm.as_ref()) @@ -386,41 +456,6 @@ pub(crate) fn resolve_workflow_source( } } -pub(crate) struct ExecutionOverrides<'a> { - pub dry_run: bool, - pub auto_approve: bool, - pub no_retro: bool, - pub verbose: bool, - pub preserve_sandbox: bool, - pub model: Option<&'a str>, - pub provider: Option<&'a str>, - pub sandbox_provider: SandboxProvider, - pub storage_dir: Option<&'a Path>, -} - -pub(crate) fn apply_execution_overrides(config: &mut FabroConfig, overrides: &ExecutionOverrides) { - config.dry_run = Some(overrides.dry_run); - config.auto_approve = Some(overrides.auto_approve); - config.no_retro = Some(overrides.no_retro); - config.verbose = Some(overrides.verbose); - - if let Some(model) = overrides.model { - config.llm.get_or_insert_default().model = Some(model.to_string()); - } - if let Some(provider) = overrides.provider { - config.llm.get_or_insert_default().provider = Some(provider.to_string()); - } - - config.sandbox.get_or_insert_default().provider = Some(overrides.sandbox_provider.to_string()); - if overrides.preserve_sandbox { - config.sandbox.get_or_insert_default().preserve = Some(true); - } - - if let Some(storage_dir) = overrides.storage_dir { - config.storage_dir = Some(storage_dir.to_path_buf()); - } -} - pub(crate) fn parse_labels(labels: &[String]) -> HashMap { labels .iter() @@ -481,9 +516,9 @@ pub(crate) fn print_diagnostics_from_error( pub(crate) struct WorkflowSourceInput { pub raw_source: String, - pub config: FabroConfig, + pub config: FabroSettings, pub workflow_slug: Option, - pub run_defaults: FabroConfig, + pub run_defaults: FabroSettings, pub workflow_toml_path: Option, pub dot_path: PathBuf, pub goal_override: Option, @@ -497,33 +532,23 @@ enum WorkflowState { pub(crate) fn load_workflow_source_input( workflow: &Path, - goal: Option<&str>, - goal_file: Option<&Path>, - mut run_defaults: FabroConfig, + cli_args_config: FabroConfig, + cli_defaults: FabroConfig, apply_project_config: bool, ) -> anyhow::Result { - if apply_project_config { - // Apply project-level config overrides (fabro.toml) on top of CLI defaults. - if let Ok(Some((_config_path, project_config))) = - 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); - } - } - - // Resolve workflow arg, load run config if TOML, merge with defaults. - let (resolved_workflow_path, dot_path, config) = { - let (resolved, dot, cfg) = resolve_workflow_source(workflow)?; - match cfg { - Some(cfg) => { - let mut merged = run_defaults.clone(); - merged.merge_overlay(cfg); - (resolved, dot, merged) - } - None => (resolved, dot, run_defaults.clone()), - } + let project_config = if apply_project_config { + project_config::discover_project_config(&std::env::current_dir().unwrap_or_default())? + .map(|(_, config)| config) + } else { + None }; + + let (resolved_workflow_path, dot_path, workflow_config) = resolve_workflow_source(workflow)?; + let config = cli_args_config + .combine(workflow_config.unwrap_or_default()) + .combine(project_config.unwrap_or_default()) + .combine(cli_defaults); + let config: FabroSettings = config.try_into()?; let workflow_slug = workflow_slug_from_path(&resolved_workflow_path); if let Some(dir) = config.work_dir.as_deref() { @@ -532,8 +557,7 @@ pub(crate) fn load_workflow_source_input( } let raw_source = read_workflow_file(&dot_path)?; - let cli_goal = resolve_cli_goal(goal, goal_file)?; - let goal_override = cli_goal.or_else(|| config.goal.clone()).or_else(|| { + let goal_override = config.goal.clone().or_else(|| { config .goal_file .as_ref() @@ -551,9 +575,9 @@ pub(crate) fn load_workflow_source_input( Ok(WorkflowSourceInput { raw_source, - config, + config: config.clone(), workflow_slug, - run_defaults, + run_defaults: config, workflow_toml_path, dot_path, goal_override, @@ -563,7 +587,7 @@ pub(crate) fn load_workflow_source_input( /// Pre-prepared run state, used to skip workflow preparation in `run_command_impl`. struct RecordBasedRun { workflow: WorkflowState, - run_defaults: FabroConfig, + run_defaults: FabroSettings, } /// Execute a workflow run from a saved RunRecord, bypassing workflow preparation. @@ -572,7 +596,7 @@ struct RecordBasedRun { pub async fn run_from_record( persisted: Persisted, _run_dir: PathBuf, - run_defaults: FabroConfig, + run_defaults: FabroSettings, styles: &'static Styles, github_app: Option, git_author: fabro_workflows::git::GitAuthor, @@ -647,7 +671,7 @@ pub async fn run_from_record( pub async fn resume_from_record( persisted: Persisted, _run_dir: PathBuf, - run_defaults: FabroConfig, + run_defaults: FabroSettings, styles: &'static Styles, github_app: Option, git_author: fabro_workflows::git::GitAuthor, @@ -743,12 +767,13 @@ fn ensure_resume_target_is_not_already_successful(run_dir: &Path) -> anyhow::Res pub async fn execute(mut args: RunArgs, _globals: &GlobalArgs) -> anyhow::Result<()> { let styles: &'static fabro_util::terminal::Styles = Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr())); - let cli_config = cli_config::load_cli_config(None)?; + let cli_defaults = fabro_config::cli::load_cli_config(None)?; + let cli_config: FabroSettings = cli_defaults.clone().try_into()?; args.verbose = args.verbose || cli_config.verbose_enabled(); let quiet = args.detach; let _prevent_idle_sleep = cli_config.prevent_idle_sleep_enabled(); - let (run_id, run_dir) = super::create::create_run(&args, cli_config, styles, quiet).await?; + let (run_id, run_dir) = super::create::create_run(&args, cli_defaults, styles, quiet).await?; #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = fabro_beastie::guard(_prevent_idle_sleep); @@ -797,7 +822,6 @@ async fn run_command_impl( let auto_approve_flag = args.auto_approve; let no_retro_flag = args.no_retro; let verbose_flag = args.verbose; - let preserve_sandbox_flag = args.preserve_sandbox; let label_vec = args.label.clone(); let run_id = args .run_id @@ -829,30 +853,7 @@ async fn run_command_impl( source_input.workflow_toml_path, ), WorkflowState::Source(source_input) => { - let mut config = source_input.config.clone(); - let sandbox_provider = if dry_run_flag { - SandboxProvider::Local - } else { - resolve_sandbox_provider( - args.sandbox.map(Into::into), - Some(&config), - &source_input.run_defaults, - )? - }; - apply_execution_overrides( - &mut config, - &ExecutionOverrides { - dry_run: dry_run_flag, - auto_approve: auto_approve_flag, - no_retro: no_retro_flag, - verbose: verbose_flag, - preserve_sandbox: preserve_sandbox_flag, - model: args.model.as_deref(), - provider: args.provider.as_deref(), - sandbox_provider, - storage_dir: args.storage_dir.as_deref(), - }, - ); + let config = source_input.config.clone(); match fabro_workflows::operations::create( &source_input.raw_source, @@ -942,7 +943,13 @@ async fn run_command_impl( // Now resolve ${env.VARNAME} references for runtime use. if let Some(ref mut cfg) = run_cfg { - run_config::resolve_sandbox_env(cfg)?; + if let Some(env) = cfg + .sandbox + .as_mut() + .and_then(|sandbox| sandbox.env.as_mut()) + { + run_config::resolve_env_refs(env)?; + } } // Create progress UI (used for both normal and verbose modes) @@ -1448,10 +1455,10 @@ pub(crate) fn print_assets(run_dir: &std::path::Path, styles: &Styles) { #[allow(clippy::too_many_arguments)] pub(crate) async fn run_preflight( graph: &fabro_graphviz::graph::Graph, - run_cfg: &Option, + run_cfg: &Option, cli_model: Option<&str>, cli_provider: Option<&str>, - run_defaults: &FabroConfig, + run_defaults: &FabroSettings, git_status: GitSyncStatus, sandbox_provider: SandboxProvider, styles: &'static Styles, @@ -1927,9 +1934,13 @@ include = ["*.md"] let workflow_path = dir.path().join("workflow.toml"); - let source_input = - load_workflow_source_input(&workflow_path, None, None, FabroConfig::default(), false) - .unwrap(); + let source_input = load_workflow_source_input( + &workflow_path, + FabroConfig::default(), + FabroConfig::default(), + false, + ) + .unwrap(); let validated = fabro_workflows::operations::validate( &source_input.raw_source, fabro_workflows::operations::ValidateOptions { @@ -2007,7 +2018,7 @@ include = ["*.md"] #[test] fn resolve_model_provider_defaults() { let graph = fabro_graphviz::graph::Graph::new("test"); - let defaults = FabroConfig::default(); + let defaults = FabroSettings::default(); let (model, provider) = resolve_model_provider(None, None, None, &defaults, &graph); assert_eq!(model, "claude-sonnet-4-6"); // Catalog resolves anthropic as the provider for claude-sonnet-4-6 @@ -2017,12 +2028,12 @@ include = ["*.md"] #[test] fn resolve_model_provider_cli_overrides_toml() { let graph = fabro_graphviz::graph::Graph::new("test"); - let defaults = FabroConfig::default(); - let cfg = FabroConfig { + let defaults = FabroSettings::default(); + let cfg = FabroSettings { version: Some(1), goal: Some("test".to_string()), graph: Some("test.fabro".to_string()), - llm: Some(run_config::LlmConfig { + llm: Some(run_config::LlmSettings { model: Some("toml-model".to_string()), provider: Some("openai".to_string()), fallbacks: None, @@ -2053,12 +2064,12 @@ include = ["*.md"] AttrValue::String("gemini".to_string()), ); - let defaults = FabroConfig::default(); - let cfg = FabroConfig { + let defaults = FabroSettings::default(); + let cfg = FabroSettings { version: Some(1), goal: Some("test".to_string()), graph: Some("test.fabro".to_string()), - llm: Some(run_config::LlmConfig { + llm: Some(run_config::LlmSettings { model: Some("toml-model".to_string()), provider: Some("openai".to_string()), fallbacks: None, @@ -2083,7 +2094,7 @@ include = ["*.md"] AttrValue::String("openai".to_string()), ); - let defaults = FabroConfig::default(); + let defaults = FabroSettings::default(); let (model, provider) = resolve_model_provider(None, None, None, &defaults, &graph); assert_eq!(model, "gpt-5.2"); assert_eq!(provider, Some("openai".to_string())); @@ -2092,7 +2103,7 @@ include = ["*.md"] #[test] fn resolve_model_provider_alias_expansion() { let graph = fabro_graphviz::graph::Graph::new("test"); - let defaults = FabroConfig::default(); + let defaults = FabroSettings::default(); let (model, provider) = resolve_model_provider(Some("opus"), None, None, &defaults, &graph); assert_eq!(model, "claude-opus-4-6"); assert_eq!(provider, Some("anthropic".to_string())); @@ -2101,13 +2112,13 @@ include = ["*.md"] #[test] fn resolve_model_provider_run_defaults_used() { let graph = fabro_graphviz::graph::Graph::new("test"); - let defaults = FabroConfig { - llm: Some(run_config::LlmConfig { + let defaults = FabroSettings { + llm: Some(run_config::LlmSettings { model: Some("default-model".to_string()), provider: Some("openai".to_string()), fallbacks: None, }), - ..FabroConfig::default() + ..FabroSettings::default() }; let (model, provider) = resolve_model_provider(None, None, None, &defaults, &graph); assert_eq!(model, "default-model"); @@ -2117,19 +2128,19 @@ include = ["*.md"] #[test] fn resolve_model_provider_toml_overrides_run_defaults() { let graph = fabro_graphviz::graph::Graph::new("test"); - let defaults = FabroConfig { - llm: Some(run_config::LlmConfig { + let defaults = FabroSettings { + llm: Some(run_config::LlmSettings { model: Some("default-model".to_string()), provider: Some("anthropic".to_string()), fallbacks: None, }), - ..FabroConfig::default() + ..FabroSettings::default() }; - let cfg = FabroConfig { + let cfg = FabroSettings { version: Some(1), goal: Some("test".to_string()), graph: Some("test.fabro".to_string()), - llm: Some(run_config::LlmConfig { + llm: Some(run_config::LlmSettings { model: Some("toml-model".to_string()), provider: Some("openai".to_string()), fallbacks: None, @@ -2143,61 +2154,61 @@ include = ["*.md"] #[test] fn resolve_preserve_sandbox_cli_wins() { - let cfg = FabroConfig { - sandbox: Some(sandbox_config::SandboxConfig { + let cfg = FabroSettings { + sandbox: Some(sandbox_config::SandboxSettings { provider: None, preserve: Some(false), ..Default::default() }), ..Default::default() }; - let defaults = FabroConfig::default(); + let defaults = FabroSettings::default(); assert!(resolve_preserve_sandbox(true, Some(&cfg), &defaults)); } #[test] fn resolve_preserve_sandbox_toml_wins_over_defaults() { - let cfg = FabroConfig { - sandbox: Some(sandbox_config::SandboxConfig { + let cfg = FabroSettings { + sandbox: Some(sandbox_config::SandboxSettings { provider: None, preserve: Some(true), ..Default::default() }), ..Default::default() }; - let defaults = FabroConfig { - sandbox: Some(sandbox_config::SandboxConfig { + let defaults = FabroSettings { + sandbox: Some(sandbox_config::SandboxSettings { provider: None, preserve: Some(false), ..Default::default() }), - ..FabroConfig::default() + ..FabroSettings::default() }; assert!(resolve_preserve_sandbox(false, Some(&cfg), &defaults)); } #[test] fn resolve_preserve_sandbox_defaults_used() { - let defaults = FabroConfig { - sandbox: Some(sandbox_config::SandboxConfig { + let defaults = FabroSettings { + sandbox: Some(sandbox_config::SandboxSettings { provider: None, preserve: Some(true), ..Default::default() }), - ..FabroConfig::default() + ..FabroSettings::default() }; assert!(resolve_preserve_sandbox(false, None, &defaults)); } #[test] fn resolve_preserve_sandbox_defaults_to_false() { - let defaults = FabroConfig::default(); + let defaults = FabroSettings::default(); assert!(!resolve_preserve_sandbox(false, None, &defaults)); } #[test] fn resolve_worktree_mode_defaults_to_clean() { - let defaults = FabroConfig::default(); + let defaults = FabroSettings::default(); assert_eq!( resolve_worktree_mode(None, &defaults), sandbox_config::WorktreeMode::Clean @@ -2206,16 +2217,16 @@ include = ["*.md"] #[test] fn resolve_worktree_mode_from_toml() { - let cfg = FabroConfig { - sandbox: Some(sandbox_config::SandboxConfig { - local: Some(sandbox_config::LocalSandboxConfig { + let cfg = FabroSettings { + sandbox: Some(sandbox_config::SandboxSettings { + local: Some(sandbox_config::LocalSandboxSettings { worktree_mode: sandbox_config::WorktreeMode::Always, }), ..Default::default() }), ..Default::default() }; - let defaults = FabroConfig::default(); + let defaults = FabroSettings::default(); assert_eq!( resolve_worktree_mode(Some(&cfg), &defaults), sandbox_config::WorktreeMode::Always @@ -2224,17 +2235,17 @@ include = ["*.md"] #[test] fn resolve_worktree_mode_from_defaults() { - let defaults = FabroConfig { - sandbox: Some(sandbox_config::SandboxConfig { + let defaults = FabroSettings { + sandbox: Some(sandbox_config::SandboxSettings { provider: None, preserve: None, devcontainer: None, - local: Some(sandbox_config::LocalSandboxConfig { + local: Some(sandbox_config::LocalSandboxSettings { worktree_mode: sandbox_config::WorktreeMode::Dirty, }), ..Default::default() }), - ..FabroConfig::default() + ..FabroSettings::default() }; assert_eq!( resolve_worktree_mode(None, &defaults), @@ -2244,26 +2255,26 @@ include = ["*.md"] #[test] fn resolve_worktree_mode_toml_overrides_defaults() { - let cfg = FabroConfig { - sandbox: Some(sandbox_config::SandboxConfig { - local: Some(sandbox_config::LocalSandboxConfig { + let cfg = FabroSettings { + sandbox: Some(sandbox_config::SandboxSettings { + local: Some(sandbox_config::LocalSandboxSettings { worktree_mode: sandbox_config::WorktreeMode::Never, }), ..Default::default() }), ..Default::default() }; - let defaults = FabroConfig { - sandbox: Some(sandbox_config::SandboxConfig { + let defaults = FabroSettings { + sandbox: Some(sandbox_config::SandboxSettings { provider: None, preserve: None, devcontainer: None, - local: Some(sandbox_config::LocalSandboxConfig { + local: Some(sandbox_config::LocalSandboxSettings { worktree_mode: sandbox_config::WorktreeMode::Dirty, }), ..Default::default() }), - ..FabroConfig::default() + ..FabroSettings::default() }; assert_eq!( resolve_worktree_mode(Some(&cfg), &defaults), diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index ad456b799..d449e9aea 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -24,7 +24,7 @@ pub async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<()> { RunCommands::Create(args) => { let styles: &'static fabro_util::terminal::Styles = Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr())); - let cli_config = crate::cli_config::load_cli_config(None)?; + let cli_config = fabro_config::cli::load_cli_config(None)?; let (run_id, _run_dir) = create::create_run(&args, cli_config, styles, true).await?; println!("{run_id}"); Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/run/start.rs b/lib/crates/fabro-cli/src/commands/run/start.rs index 81d82250a..e0647518e 100644 --- a/lib/crates/fabro-cli/src/commands/run/start.rs +++ b/lib/crates/fabro-cli/src/commands/run/start.rs @@ -119,7 +119,7 @@ fn kill_child_best_effort(child: &mut std::process::Child) { mod tests { use super::*; use chrono::Utc; - use fabro_config::config::FabroConfig; + use fabro_config::FabroSettings; use fabro_graphviz::graph::Graph; use fabro_workflows::records::RunRecord; use fabro_workflows::run_status::{write_run_status, RunStatus, RunStatusRecord, StatusReason}; @@ -130,7 +130,7 @@ mod tests { RunRecord { run_id: "run-test123".to_string(), created_at: Utc::now(), - config: FabroConfig::default(), + config: FabroSettings::default(), graph: Graph { name: "test".to_string(), ..Default::default() diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index bde97e1f0..76517127d 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -98,14 +98,18 @@ async fn main_inner() -> (String, Result<()>) { { if let Commands::Serve(args) = command.as_ref() { match fabro_config::server::load_server_config(args.config.as_deref()) { - Ok(server_config) => ( - server_config.log.as_ref().and_then(|l| l.level.clone()), - false, - ), + Ok(server_config) => match fabro_config::FabroSettings::try_from(server_config) + { + Ok(server_settings) => ( + server_settings.log.as_ref().and_then(|l| l.level.clone()), + false, + ), + Err(err) => return (command_name, Err(err)), + }, Err(err) => return (command_name, Err(err)), } } else { - match fabro_config::cli::load_cli_config(None) { + match crate::cli_config::load_cli_config(None) { Ok(cli_config) => ( cli_config.log.as_ref().and_then(|l| l.level.clone()), cli_config.upgrade_check_enabled(), @@ -116,7 +120,7 @@ async fn main_inner() -> (String, Result<()>) { } #[cfg(not(feature = "server"))] { - match fabro_config::cli::load_cli_config(None) { + match crate::cli_config::load_cli_config(None) { Ok(cli_config) => ( cli_config.log.as_ref().and_then(|l| l.level.clone()), cli_config.upgrade_check_enabled(), diff --git a/lib/crates/fabro-cli/tests/cli.rs b/lib/crates/fabro-cli/tests/cli.rs index 9f6320eea..dc1d98a95 100644 --- a/lib/crates/fabro-cli/tests/cli.rs +++ b/lib/crates/fabro-cli/tests/cli.rs @@ -1,6 +1,6 @@ use assert_cmd::Command; use fabro_config::mcp::McpTransport; -use fabro_config::FabroConfig; +use fabro_config::FabroSettings; use predicates::prelude::*; #[allow(deprecated)] @@ -10,8 +10,8 @@ fn arc() -> Command { cmd } -fn parse_config_show(stdout: &[u8]) -> FabroConfig { - serde_yaml::from_slice(stdout).expect("stdout should be valid YAML FabroConfig") +fn parse_config_show(stdout: &[u8]) -> FabroSettings { + serde_yaml::from_slice(stdout).expect("stdout should be valid YAML FabroSettings") } fn setup_config_show_fixture() -> (tempfile::TempDir, tempfile::TempDir) { diff --git a/lib/crates/fabro-config-derive/Cargo.toml b/lib/crates/fabro-config-derive/Cargo.toml new file mode 100644 index 000000000..93bcbe8a5 --- /dev/null +++ b/lib/crates/fabro-config-derive/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "fabro-config-derive" +edition.workspace = true +version.workspace = true +license.workspace = true +description = "Derive macros for fabro-config" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } diff --git a/lib/crates/fabro-config-derive/src/lib.rs b/lib/crates/fabro-config-derive/src/lib.rs new file mode 100644 index 000000000..fe211483d --- /dev/null +++ b/lib/crates/fabro-config-derive/src/lib.rs @@ -0,0 +1,52 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, Data, DeriveInput, Fields}; + +#[proc_macro_derive(Combine)] +pub fn derive_combine(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let ident = input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let body = match input.data { + Data::Struct(data) => match data.fields { + Fields::Named(fields) => { + let combined = fields.named.into_iter().map(|field| { + let ident = field.ident.expect("named field"); + quote! { + #ident: ::fabro_config::combine::Combine::combine(self.#ident, other.#ident) + } + }); + quote! { + Self { + #(#combined,)* + } + } + } + Fields::Unnamed(fields) => { + let combined = fields.unnamed.iter().enumerate().map(|(index, _)| { + let index = syn::Index::from(index); + quote! { + ::fabro_config::combine::Combine::combine(self.#index, other.#index) + } + }); + quote! { + Self(#(#combined),*) + } + } + Fields::Unit => quote!(Self), + }, + Data::Enum(_) | Data::Union(_) => { + quote!(self) + } + }; + + quote! { + impl #impl_generics ::fabro_config::combine::Combine for #ident #ty_generics #where_clause { + fn combine(self, other: Self) -> Self { + #body + } + } + } + .into() +} diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml index 447c9e79e..ddc311b12 100644 --- a/lib/crates/fabro-config/Cargo.toml +++ b/lib/crates/fabro-config/Cargo.toml @@ -16,6 +16,7 @@ clap = ["dep:clap"] [dependencies] anyhow.workspace = true clap = { workspace = true, optional = true } +fabro-config-derive = { path = "../fabro-config-derive" } fabro-util = { path = "../fabro-util" } dirs.workspace = true serde.workspace = true diff --git a/lib/crates/fabro-config/src/cli.rs b/lib/crates/fabro-config/src/cli.rs index 4f88e8bc9..f32017b6f 100644 --- a/lib/crates/fabro-config/src/cli.rs +++ b/lib/crates/fabro-config/src/cli.rs @@ -1,8 +1,9 @@ use std::path::{Path, PathBuf}; +use anyhow::anyhow; use serde::{Deserialize, Serialize}; -#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, crate::Combine)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[serde(rename_all = "kebab-case")] pub enum OutputFormat { @@ -10,7 +11,7 @@ pub enum OutputFormat { Json, } -#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, crate::Combine)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[serde(rename_all = "kebab-case")] pub enum PermissionLevel { @@ -19,7 +20,7 @@ pub enum PermissionLevel { Full, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] #[serde(rename_all = "lowercase")] pub enum ExecutionMode { #[default] @@ -27,406 +28,90 @@ pub enum ExecutionMode { Server, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct ClientTlsConfig { + pub cert: Option, + pub key: Option, + pub ca: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct ClientTlsSettings { pub cert: PathBuf, pub key: PathBuf, pub ca: PathBuf, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ServerDefaults { +impl TryFrom for ClientTlsSettings { + type Error = anyhow::Error; + + fn try_from(value: ClientTlsConfig) -> Result { + Ok(Self { + cert: value.cert.ok_or_else(|| { + anyhow!("server.tls.cert is required when server.tls is configured") + })?, + key: value.key.ok_or_else(|| { + anyhow!("server.tls.key is required when server.tls is configured") + })?, + ca: value.ca.ok_or_else(|| { + anyhow!("server.tls.ca is required when server.tls is configured") + })?, + }) + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] +pub struct ServerConfig { pub base_url: Option, pub tls: Option, } #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ExecDefaults { +pub struct ServerSettings { + pub base_url: Option, + pub tls: Option, +} + +impl TryFrom for ServerSettings { + type Error = anyhow::Error; + + fn try_from(value: ServerConfig) -> Result { + Ok(Self { + base_url: value.base_url, + tls: value.tls.map(TryInto::try_into).transpose()?, + }) + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] +pub struct ExecConfig { pub provider: Option, pub model: Option, pub permissions: Option, pub output_format: Option, } +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct ExecSettings { + pub provider: Option, + pub model: Option, + pub permissions: Option, + pub output_format: Option, +} + +impl From for ExecSettings { + fn from(value: ExecConfig) -> Self { + Self { + provider: value.provider, + model: value.model, + permissions: value.permissions, + output_format: value.output_format, + } + } +} + /// Load CLI config from an explicit path or `~/.fabro/cli.toml`, returning defaults if the /// default file doesn't exist. An explicit path that doesn't exist is an error. pub fn load_cli_config(path: Option<&Path>) -> anyhow::Result { crate::load_config_file(path, "cli.toml") } - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::FabroConfig; - use crate::mcp::{McpServerEntry, McpTransport}; - use std::collections::HashMap; - - #[test] - fn parse_empty_config_defaults() { - let config: FabroConfig = toml::from_str("").unwrap(); - assert_eq!(config, FabroConfig::default()); - } - - #[test] - fn parse_full_config() { - let toml = r#" -[exec] -provider = "anthropic" -model = "claude-opus-4-6" -permissions = "read-write" -output_format = "text" - -[llm] -model = "claude-sonnet-4-5" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let exec = config.exec.unwrap(); - assert_eq!(exec.provider.as_deref(), Some("anthropic")); - assert_eq!(exec.model.as_deref(), Some("claude-opus-4-6")); - assert_eq!(exec.permissions, Some(PermissionLevel::ReadWrite)); - assert_eq!(exec.output_format, Some(OutputFormat::Text)); - let llm = config.llm.unwrap(); - assert_eq!(llm.model.as_deref(), Some("claude-sonnet-4-5")); - } - - #[test] - fn parse_partial_exec_config() { - let toml = r#" -[exec] -provider = "openai" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let exec = config.exec.unwrap(); - assert_eq!(exec.provider.as_deref(), Some("openai")); - assert_eq!(exec.model, None); - assert_eq!(exec.permissions, None); - assert_eq!(exec.output_format, None); - assert_eq!(config.llm, None); - } - - #[test] - fn load_cli_config_from_explicit_path() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("custom.toml"); - std::fs::write( - &path, - r#" -[exec] -provider = "gemini" -model = "gemini-pro" -"#, - ) - .unwrap(); - let config = load_cli_config(Some(&path)).unwrap(); - let exec = config.exec.unwrap(); - assert_eq!(exec.provider.as_deref(), Some("gemini")); - assert_eq!(exec.model.as_deref(), Some("gemini-pro")); - } - - #[test] - fn load_cli_config_explicit_path_missing_is_error() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("nonexistent.toml"); - let result = load_cli_config(Some(&path)); - assert!(result.is_err()); - } - - #[test] - fn parse_mode_server() { - let toml = r#"mode = "server""#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.mode, Some(ExecutionMode::Server)); - } - - #[test] - fn parse_mode_standalone() { - let toml = r#"mode = "standalone""#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.mode, Some(ExecutionMode::Standalone)); - } - - #[test] - fn parse_mode_absent() { - let config: FabroConfig = toml::from_str("").unwrap(); - assert_eq!(config.mode, None); - } - - #[test] - fn parse_server_base_url() { - let toml = r#" -[server] -base_url = "https://arc.example.com:3000" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let server = config.server.unwrap(); - assert_eq!( - server.base_url.as_deref(), - Some("https://arc.example.com:3000") - ); - assert_eq!(server.tls, None); - } - - #[test] - fn parse_server_tls() { - let toml = r#" -[server] -base_url = "https://arc.example.com:3000" - -[server.tls] -cert = "~/.fabro/tls/client.crt" -key = "~/.fabro/tls/client.key" -ca = "~/.fabro/tls/ca.crt" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let tls = config.server.unwrap().tls.unwrap(); - assert_eq!(tls.cert, PathBuf::from("~/.fabro/tls/client.crt")); - assert_eq!(tls.key, PathBuf::from("~/.fabro/tls/client.key")); - assert_eq!(tls.ca, PathBuf::from("~/.fabro/tls/ca.crt")); - } - - #[test] - fn parse_git_author_config() { - let toml = r#" -[git.author] -name = "my-arc" -email = "me@local" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let git = config.git.unwrap(); - assert_eq!(git.author.name.as_deref(), Some("my-arc")); - assert_eq!(git.author.email.as_deref(), Some("me@local")); - } - - #[test] - fn parse_git_author_absent() { - let config: FabroConfig = toml::from_str("").unwrap(); - assert_eq!(config.git, None); - } - - #[test] - fn parse_prevent_idle_sleep_true() { - let config: FabroConfig = toml::from_str("prevent_idle_sleep = true").unwrap(); - assert_eq!(config.prevent_idle_sleep, Some(true)); - } - - #[test] - fn parse_prevent_idle_sleep_defaults_to_none() { - let config: FabroConfig = toml::from_str("").unwrap(); - assert_eq!(config.prevent_idle_sleep, None); - } - - #[test] - fn parse_verbose_true() { - let config: FabroConfig = toml::from_str("verbose = true").unwrap(); - assert_eq!(config.verbose, Some(true)); - } - - #[test] - fn parse_log_level() { - let toml = "[log]\nlevel = \"debug\""; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!( - config.log.as_ref().and_then(|l| l.level.as_deref()), - Some("debug") - ); - } - - #[test] - fn parse_pull_request_enabled() { - let toml = r#" -[pull_request] -enabled = true -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let pr = config.pull_request.unwrap(); - assert!(pr.enabled); - } - - #[test] - fn parse_pull_request_absent() { - let config: FabroConfig = toml::from_str("").unwrap(); - assert_eq!(config.pull_request, None); - } - - #[test] - fn parse_git_config_with_app_id() { - let toml = r#" -[git] -app_id = "12345" -slug = "my-app" - -[git.author] -name = "fabro-bot" -email = "arc@test.com" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let git = config.git.unwrap(); - assert_eq!(git.app_id.as_deref(), Some("12345")); - assert_eq!(git.slug.as_deref(), Some("my-app")); - assert_eq!(git.author.name.as_deref(), Some("fabro-bot")); - } - - #[test] - fn parse_git_config_with_client_id() { - let toml = r#" -[git] -app_id = "12345" -slug = "my-app" -client_id = "Iv1.abc123" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.client_id(), Some("Iv1.abc123")); - let git = config.git.unwrap(); - assert_eq!(git.client_id.as_deref(), Some("Iv1.abc123")); - } - - #[test] - fn parse_llm_with_provider_and_fallbacks() { - let toml = r#" -[llm] -model = "claude-sonnet-4-5" -provider = "anthropic" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let llm = config.llm.unwrap(); - assert_eq!(llm.model.as_deref(), Some("claude-sonnet-4-5")); - assert_eq!(llm.provider.as_deref(), Some("anthropic")); - } - - #[test] - fn parse_sandbox_config() { - let toml = r#" -[sandbox] -provider = "daytona" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let sandbox = config.sandbox.unwrap(); - assert_eq!(sandbox.provider.as_deref(), Some("daytona")); - } - - #[test] - fn parse_mcp_stdio_server_with_env_and_timeouts() { - let toml = r#" -[mcp_servers.filesystem] -type = "stdio" -command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"] -startup_timeout_secs = 15 -tool_timeout_secs = 90 - -[mcp_servers.filesystem.env] -NODE_ENV = "production" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.mcp_servers.len(), 1); - let entry = &config.mcp_servers["filesystem"]; - assert_eq!(entry.startup_timeout_secs, 15); - assert_eq!(entry.tool_timeout_secs, 90); - match &entry.transport { - McpTransport::Stdio { command, env } => { - assert_eq!( - command, - &[ - "npx", - "-y", - "@modelcontextprotocol/server-filesystem", - "/workspace" - ] - ); - assert_eq!(env.get("NODE_ENV").unwrap(), "production"); - } - _ => panic!("expected Stdio transport"), - } - } - - #[test] - fn parse_mcp_http_server_with_headers() { - let toml = r#" -[mcp_servers.sentry] -type = "http" -url = "https://mcp.sentry.dev/mcp" - -[mcp_servers.sentry.headers] -Authorization = "Bearer sk-xxx" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.mcp_servers.len(), 1); - let entry = &config.mcp_servers["sentry"]; - match &entry.transport { - McpTransport::Http { url, headers } => { - assert_eq!(url, "https://mcp.sentry.dev/mcp"); - assert_eq!(headers.get("Authorization").unwrap(), "Bearer sk-xxx"); - } - _ => panic!("expected Http transport"), - } - } - - #[test] - fn parse_mcp_empty_backward_compat() { - let config: FabroConfig = toml::from_str("").unwrap(); - assert!(config.mcp_servers.is_empty()); - } - - #[test] - fn parse_mcp_both_transports() { - let toml = r#" -[mcp_servers.local] -type = "stdio" -command = ["python3", "server.py"] - -[mcp_servers.remote] -type = "http" -url = "https://mcp.example.com" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.mcp_servers.len(), 2); - assert!(matches!( - config.mcp_servers["local"].transport, - McpTransport::Stdio { .. } - )); - assert!(matches!( - config.mcp_servers["remote"].transport, - McpTransport::Http { .. } - )); - } - - #[test] - fn parse_mcp_defaults_applied_when_timeouts_omitted() { - let toml = r#" -[mcp_servers.minimal] -type = "stdio" -command = ["echo"] -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let entry = &config.mcp_servers["minimal"]; - assert_eq!(entry.startup_timeout_secs, 10); - assert_eq!(entry.tool_timeout_secs, 60); - } - - #[test] - fn mcp_server_entry_into_config() { - let entry = McpServerEntry { - transport: McpTransport::Stdio { - command: vec!["node".into(), "server.js".into()], - env: HashMap::new(), - }, - startup_timeout_secs: 15, - tool_timeout_secs: 90, - }; - let config = entry.into_config("my-server".into()); - assert_eq!(config.name, "my-server"); - assert_eq!(config.startup_timeout_secs, 15); - assert_eq!(config.tool_timeout_secs, 90); - } - - #[test] - fn parse_upgrade_check_false() { - let config: FabroConfig = toml::from_str("upgrade_check = false").unwrap(); - assert_eq!(config.upgrade_check, Some(false)); - } - - #[test] - fn parse_upgrade_check_default_none() { - let config: FabroConfig = toml::from_str("").unwrap(); - assert_eq!(config.upgrade_check, None); - } -} diff --git a/lib/crates/fabro-config/src/combine.rs b/lib/crates/fabro-config/src/combine.rs new file mode 100644 index 000000000..fdf4c5226 --- /dev/null +++ b/lib/crates/fabro-config/src/combine.rs @@ -0,0 +1,60 @@ +use std::collections::HashMap; +use std::hash::Hash; +use std::path::PathBuf; + +pub trait Combine { + fn combine(self, other: Self) -> Self; +} + +impl Combine for Option { + fn combine(self, other: Self) -> Self { + match (self, other) { + (Some(this), Some(other)) => Some(this.combine(other)), + (Some(this), None) => Some(this), + (None, Some(other)) => Some(other), + (None, None) => None, + } + } +} + +impl Combine for Vec { + fn combine(mut self, other: Self) -> Self { + self.extend(other); + self + } +} + +impl Combine for HashMap +where + K: Eq + Hash, + V: Combine, +{ + fn combine(mut self, other: Self) -> Self { + for (key, value) in other { + match self.remove(&key) { + Some(existing) => { + self.insert(key, existing.combine(value)); + } + None => { + self.insert(key, value); + } + } + } + + self + } +} + +macro_rules! impl_left_wins { + ($($ty:ty),* $(,)?) => { + $( + impl Combine for $ty { + fn combine(self, _other: Self) -> Self { + self + } + } + )* + }; +} + +impl_left_wins!(bool, i32, u16, u32, u64, usize, String, PathBuf,); diff --git a/lib/crates/fabro-config/src/config.rs b/lib/crates/fabro-config/src/config.rs index 23078dfd6..eccc771ad 100644 --- a/lib/crates/fabro-config/src/config.rs +++ b/lib/crates/fabro-config/src/config.rs @@ -3,7 +3,8 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; -use crate::cli::{ExecDefaults, ExecutionMode, ServerDefaults}; +use crate::cli::{ExecConfig, ExecutionMode, ServerConfig}; +use crate::combine::Combine; use crate::hook::{HookConfig, HookDefinition}; use crate::mcp::McpServerEntry; use crate::project::ProjectFabroConfig; @@ -12,16 +13,17 @@ use crate::run::{ }; use crate::sandbox::SandboxConfig; use crate::server::{ApiConfig, Features, GitConfig, LogConfig, WebConfig}; +use crate::settings::FabroSettings; fn is_default_checkpoint(c: &CheckpointConfig) -> bool { c.exclude_globs.is_empty() } -/// Unified configuration type for all Fabro config sources. +/// Unified sparse configuration type for all Fabro config sources. /// /// Loading functions (`load_cli_config`, `load_server_config`, `load_run_config`, /// `parse_project_config`) all return this type. Fields irrelevant to a -/// particular source are left at their defaults (None / empty). +/// particular source are left unset (`None` / empty). #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] pub struct FabroConfig { // --- Workflow run config fields --- @@ -79,10 +81,10 @@ pub struct FabroConfig { pub mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub server: Option, + pub server: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec: Option, + pub exec: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub prevent_idle_sleep: Option, @@ -130,268 +132,62 @@ pub struct FabroConfig { pub fabro: Option, } -impl FabroConfig { - // --- Convenience methods (ported from CliConfig) --- +impl Combine for FabroConfig { + fn combine(self, other: Self) -> Self { + let hooks = if self.hooks.is_empty() { + other.hooks + } else if other.hooks.is_empty() { + self.hooks + } else { + HookConfig { hooks: self.hooks } + .merge(HookConfig { hooks: other.hooks }) + .hooks + }; - /// Resolve the storage directory: config value > default `~/.fabro`. - pub fn storage_dir(&self) -> PathBuf { - self.storage_dir.clone().unwrap_or_else(|| { - dirs::home_dir() - .expect("could not determine home directory") - .join(".fabro") - }) - } - - pub fn app_id(&self) -> Option<&str> { - self.git.as_ref().and_then(|g| g.app_id.as_deref()) - } - - pub fn slug(&self) -> Option<&str> { - self.git.as_ref().and_then(|g| g.slug.as_deref()) - } - - pub fn client_id(&self) -> Option<&str> { - self.git.as_ref().and_then(|g| g.client_id.as_deref()) - } - - pub fn git_author(&self) -> Option<&crate::server::GitAuthorConfig> { - self.git.as_ref().map(|g| &g.author) - } - - pub fn verbose_enabled(&self) -> bool { - self.verbose.unwrap_or(false) - } - - pub fn prevent_idle_sleep_enabled(&self) -> bool { - self.prevent_idle_sleep.unwrap_or(false) - } - - pub fn upgrade_check_enabled(&self) -> bool { - self.upgrade_check.unwrap_or(true) - } - - pub fn dry_run_enabled(&self) -> bool { - self.dry_run.unwrap_or(false) - } - - pub fn auto_approve_enabled(&self) -> bool { - self.auto_approve.unwrap_or(false) - } - - pub fn no_retro_enabled(&self) -> bool { - self.no_retro.unwrap_or(false) - } - - /// Merge an overlay on top of this base. The overlay takes precedence - /// for simple fields; compound fields (vars, hooks, mcp_servers) are - /// deep-merged with the overlay winning on collision. - pub fn merge_overlay(&mut self, overlay: FabroConfig) { - // --- Workflow run config fields --- - if overlay.version.is_some() { - self.version = overlay.version; - } - if overlay.goal.is_some() { - self.goal = overlay.goal; - } - if overlay.goal_file.is_some() { - self.goal_file = overlay.goal_file; - } - if overlay.graph.is_some() { - self.graph = overlay.graph; - } - if !overlay.labels.is_empty() { - let mut merged = std::mem::take(&mut self.labels); - merged.extend(overlay.labels); - self.labels = merged; - } - - // --- Run defaults fields --- - if overlay.work_dir.is_some() { - self.work_dir = overlay.work_dir; - } - - match (&mut self.llm, overlay.llm) { - (Some(base), Some(over)) => { - if over.model.is_some() { - base.model = over.model; - } - if over.provider.is_some() { - base.provider = over.provider; - } - if over.fallbacks.is_some() { - base.fallbacks = over.fallbacks; - } - } - (None, Some(over)) => self.llm = Some(over), - _ => {} - } - - match (&mut self.setup, overlay.setup) { - (Some(base), Some(over)) => { - if over.timeout_ms.is_some() { - base.timeout_ms = over.timeout_ms; - } - } - (None, Some(over)) => self.setup = Some(over), - _ => {} - } - - match (&mut self.sandbox, overlay.sandbox) { - (Some(base), Some(over)) => { - if over.provider.is_some() { - base.provider = over.provider; - } - if over.preserve.is_some() { - base.preserve = over.preserve; - } - if over.devcontainer.is_some() { - base.devcontainer = over.devcontainer; - } - if over.local.is_some() { - base.local = over.local; - } - match (&mut base.daytona, over.daytona) { - (Some(base_d), Some(over_d)) => { - if over_d.auto_stop_interval.is_some() { - base_d.auto_stop_interval = over_d.auto_stop_interval; - } - if over_d.snapshot.is_some() { - base_d.snapshot = over_d.snapshot; - } - if let Some(over_labels) = over_d.labels { - let mut merged = base_d.labels.take().unwrap_or_default(); - merged.extend(over_labels); - base_d.labels = Some(merged); - } - if over_d.network.is_some() { - base_d.network = over_d.network; - } - } - (None, Some(over_d)) => base.daytona = Some(over_d), - _ => {} - } - #[cfg(feature = "exedev")] - match (&mut base.exe, over.exe) { - (Some(base_e), Some(over_e)) => { - if over_e.image.is_some() { - base_e.image = over_e.image; - } - } - (None, Some(over_e)) => base.exe = Some(over_e), - _ => {} - } - if over.ssh.is_some() { - base.ssh = over.ssh; - } - if let Some(over_env) = over.env { - let mut merged = base.env.take().unwrap_or_default(); - merged.extend(over_env); - base.env = Some(merged); - } - } - (None, Some(over)) => self.sandbox = Some(over), - _ => {} - } - - if let Some(overlay_vars) = overlay.vars { - let mut merged = self.vars.take().unwrap_or_default(); - merged.extend(overlay_vars); - self.vars = Some(merged); - } - - if !overlay.checkpoint.exclude_globs.is_empty() { - self.checkpoint - .exclude_globs - .extend(overlay.checkpoint.exclude_globs); - self.checkpoint.exclude_globs.sort(); - self.checkpoint.exclude_globs.dedup(); - } - - if overlay.pull_request.is_some() { - self.pull_request = overlay.pull_request; - } - - if overlay.assets.is_some() { - self.assets = overlay.assets; - } - - if !overlay.hooks.is_empty() { - let base = HookConfig { - hooks: std::mem::take(&mut self.hooks), - }; - let over = HookConfig { - hooks: overlay.hooks, - }; - self.hooks = base.merge(over).hooks; - } - - if !overlay.mcp_servers.is_empty() { - let mut merged = std::mem::take(&mut self.mcp_servers); - merged.extend(overlay.mcp_servers); - self.mcp_servers = merged; - } - - if overlay.github.is_some() { - self.github = overlay.github; - } - - // --- CLI config fields --- - if overlay.mode.is_some() { - self.mode = overlay.mode; - } - if overlay.server.is_some() { - self.server = overlay.server; - } - if overlay.exec.is_some() { - self.exec = overlay.exec; - } - if overlay.prevent_idle_sleep.is_some() { - self.prevent_idle_sleep = overlay.prevent_idle_sleep; - } - if overlay.verbose.is_some() { - self.verbose = overlay.verbose; - } - if overlay.upgrade_check.is_some() { - self.upgrade_check = overlay.upgrade_check; - } - if overlay.dry_run.is_some() { - self.dry_run = overlay.dry_run; - } - if overlay.auto_approve.is_some() { - self.auto_approve = overlay.auto_approve; - } - if overlay.no_retro.is_some() { - self.no_retro = overlay.no_retro; - } - - // --- Server config fields --- - if overlay.storage_dir.is_some() { - self.storage_dir = overlay.storage_dir; - } - if overlay.max_concurrent_runs.is_some() { - self.max_concurrent_runs = overlay.max_concurrent_runs; - } - if overlay.web.is_some() { - self.web = overlay.web; - } - if overlay.api.is_some() { - self.api = overlay.api; - } - if overlay.features.is_some() { - self.features = overlay.features; - } - - // --- Shared fields --- - if overlay.log.is_some() { - self.log = overlay.log; - } - if overlay.git.is_some() { - self.git = overlay.git; - } - - // --- Project config fields --- - if overlay.fabro.is_some() { - self.fabro = overlay.fabro; + Self { + version: self.version.combine(other.version), + goal: self.goal.combine(other.goal), + goal_file: self.goal_file.combine(other.goal_file), + graph: self.graph.combine(other.graph), + labels: self.labels.combine(other.labels), + work_dir: self.work_dir.combine(other.work_dir), + llm: self.llm.combine(other.llm), + setup: self.setup.combine(other.setup), + sandbox: self.sandbox.combine(other.sandbox), + vars: self.vars.combine(other.vars), + checkpoint: self.checkpoint.combine(other.checkpoint), + pull_request: self.pull_request.combine(other.pull_request), + assets: self.assets.combine(other.assets), + hooks, + mcp_servers: self.mcp_servers.combine(other.mcp_servers), + github: self.github.combine(other.github), + mode: self.mode.combine(other.mode), + server: self.server.combine(other.server), + exec: self.exec.combine(other.exec), + prevent_idle_sleep: self.prevent_idle_sleep.combine(other.prevent_idle_sleep), + verbose: self.verbose.combine(other.verbose), + upgrade_check: self.upgrade_check.combine(other.upgrade_check), + dry_run: self.dry_run.combine(other.dry_run), + auto_approve: self.auto_approve.combine(other.auto_approve), + no_retro: self.no_retro.combine(other.no_retro), + storage_dir: self.storage_dir.combine(other.storage_dir), + max_concurrent_runs: self.max_concurrent_runs.combine(other.max_concurrent_runs), + web: self.web.combine(other.web), + api: self.api.combine(other.api), + features: self.features.combine(other.features), + log: self.log.combine(other.log), + git: self.git.combine(other.git), + fabro: self.fabro.combine(other.fabro), } } } + +impl FabroConfig { + pub fn combine(self, other: Self) -> Self { + Combine::combine(self, other) + } + + pub fn try_into_settings(self) -> anyhow::Result { + self.try_into() + } +} diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 259710bfc..dba127594 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -1,4 +1,7 @@ +extern crate self as fabro_config; + pub mod cli; +pub mod combine; pub mod config; pub mod dotenv; pub mod hook; @@ -7,9 +10,12 @@ pub mod project; pub mod run; pub mod sandbox; pub mod server; +pub mod settings; pub use config::FabroConfig; +pub use fabro_config_derive::Combine; pub use fabro_util::path::expand_tilde; +pub use settings::FabroSettings; use std::path::Path; diff --git a/lib/crates/fabro-config/src/mcp.rs b/lib/crates/fabro-config/src/mcp.rs index 5be45bfaa..7623443a8 100644 --- a/lib/crates/fabro-config/src/mcp.rs +++ b/lib/crates/fabro-config/src/mcp.rs @@ -3,6 +3,8 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; +use crate::combine::Combine; + pub fn default_startup_timeout_secs() -> u64 { 10 } @@ -57,6 +59,12 @@ pub enum McpTransport { }, } +impl Combine for McpTransport { + fn combine(self, _other: Self) -> Self { + self + } +} + /// MCP server entry as it appears in TOML config files (without a `name` field). /// /// Converted to [`McpServerConfig`] via [`McpServerEntry::into_config`]. @@ -81,6 +89,12 @@ impl McpServerEntry { } } +impl Combine for McpServerEntry { + fn combine(self, _other: Self) -> Self { + self + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index 0b494b396..86a313bd3 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -8,17 +8,22 @@ use crate::config::FabroConfig; const CONFIG_FILENAME: &str = "fabro.toml"; const SUPPORTED_VERSION: u32 = 1; -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct ProjectFabroConfig { - #[serde(default = "default_root")] - pub root: String, + pub root: Option, } fn default_root() -> String { ".".to_string() } -impl Default for ProjectFabroConfig { +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct ProjectFabroSettings { + #[serde(default = "default_root")] + pub root: String, +} + +impl Default for ProjectFabroSettings { fn default() -> Self { Self { root: default_root(), @@ -26,6 +31,14 @@ impl Default for ProjectFabroConfig { } } +impl From for ProjectFabroSettings { + fn from(value: ProjectFabroConfig) -> Self { + Self { + root: value.root.unwrap_or_else(default_root), + } + } +} + /// Parse a project config from a TOML string. pub fn parse_project_config(content: &str) -> anyhow::Result { let config: FabroConfig = toml::from_str(content).context("Failed to parse project config")?; @@ -46,7 +59,7 @@ pub fn load_project_config(path: &Path) -> anyhow::Result { let root = config .fabro .as_ref() - .map(|f| f.root.as_str()) + .and_then(|f| f.root.as_deref()) .unwrap_or("."); tracing::debug!(path = %path.display(), root = %root, "Loaded project config"); Ok(config) @@ -295,7 +308,11 @@ fn resolve_workflow_from( pub fn is_retro_enabled() -> bool { let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); match discover_project_config(&start) { - Ok(Some((_path, config))) => config.features.as_ref().map(|f| f.retros).unwrap_or(false), + Ok(Some((_path, config))) => config + .features + .as_ref() + .and_then(|f| f.retros) + .unwrap_or(false), _ => false, } } @@ -309,7 +326,7 @@ pub fn resolve_fabro_root(config_path: &Path, config: &FabroConfig) -> PathBuf { let root = config .fabro .as_ref() - .map(|f| f.root.as_str()) + .and_then(|f| f.root.as_deref()) .unwrap_or("."); project_dir.join(root) } @@ -317,7 +334,6 @@ pub fn resolve_fabro_root(config_path: &Path, config: &FabroConfig) -> PathBuf { #[cfg(test)] mod tests { use super::*; - use crate::run::MergeStrategy; use std::fs; use tempfile::TempDir; @@ -331,22 +347,26 @@ mod tests { #[test] fn parse_full_config() { let config = parse_project_config("version = 1\n[fabro]\nroot = \"fabro/\"\n").unwrap(); - assert_eq!(config.fabro.unwrap().root, "fabro/"); + assert_eq!(config.fabro.unwrap().root.as_deref(), Some("fabro/")); } #[test] fn parse_retros_default_false() { let config = parse_project_config("version = 1\n").unwrap(); assert_eq!( - config.features.as_ref().map(|f| f.retros).unwrap_or(false), - false + config + .features + .as_ref() + .and_then(|f| f.retros) + .unwrap_or(false), + false, ); } #[test] fn parse_retros_enabled() { let config = parse_project_config("version = 1\n[features]\nretros = true\n").unwrap(); - assert!(config.features.unwrap().retros); + assert_eq!(config.features.unwrap().retros, Some(true)); } #[test] @@ -366,10 +386,10 @@ mod tests { assert_eq!( config.pull_request, Some(crate::run::PullRequestConfig { - enabled: true, - draft: false, - auto_merge: false, - merge_strategy: MergeStrategy::Squash, + enabled: Some(true), + draft: Some(false), + auto_merge: None, + merge_strategy: None, }) ); } @@ -389,7 +409,7 @@ memory = 8 let sandbox = config.sandbox.unwrap(); assert_eq!(sandbox.provider.as_deref(), Some("daytona")); let snap = sandbox.daytona.unwrap().snapshot.unwrap(); - assert_eq!(snap.name, "my-snapshot"); + assert_eq!(snap.name.as_deref(), Some("my-snapshot")); assert_eq!(snap.cpu, Some(4)); assert_eq!(snap.memory, Some(8)); } @@ -461,7 +481,7 @@ model = "claude-sonnet-4-6" let config = FabroConfig { version: Some(1), fabro: Some(ProjectFabroConfig { - root: "fabro/".to_string(), + root: Some("fabro/".to_string()), ..Default::default() }), ..Default::default() @@ -478,7 +498,7 @@ model = "claude-sonnet-4-6" let config = FabroConfig { version: Some(1), fabro: Some(ProjectFabroConfig { - root: ".".to_string(), + root: Some(".".to_string()), ..Default::default() }), ..Default::default() diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index d1d7411c9..f257b6ffe 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -3,11 +3,10 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context}; use serde::{Deserialize, Serialize}; - use tracing::debug; +use crate::combine::Combine; use crate::config::FabroConfig; -use crate::sandbox::DockerfileSource; const SUPPORTED_VERSION: u32 = 1; @@ -17,12 +16,44 @@ pub struct CheckpointConfig { pub exclude_globs: Vec, } +impl Combine for CheckpointConfig { + fn combine(mut self, other: Self) -> Self { + self.exclude_globs.extend(other.exclude_globs); + self.exclude_globs.sort(); + self.exclude_globs.dedup(); + self + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct CheckpointSettings { + #[serde(default)] + pub exclude_globs: Vec, +} + +impl From for CheckpointSettings { + fn from(value: CheckpointConfig) -> Self { + let mut exclude_globs = value.exclude_globs; + exclude_globs.sort(); + exclude_globs.dedup(); + Self { exclude_globs } + } +} + fn default_true() -> bool { true } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct PullRequestConfig { + pub enabled: Option, + pub draft: Option, + pub auto_merge: Option, + pub merge_strategy: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct PullRequestSettings { #[serde(default)] pub enabled: bool, #[serde(default = "default_true")] @@ -33,7 +64,18 @@ pub struct PullRequestConfig { pub merge_strategy: MergeStrategy, } -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)] +impl From for PullRequestSettings { + fn from(value: PullRequestConfig) -> Self { + Self { + enabled: value.enabled.unwrap_or(false), + draft: value.draft.unwrap_or(true), + auto_merge: value.auto_merge.unwrap_or(false), + merge_strategy: value.merge_strategy.unwrap_or_default(), + } + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] #[serde(rename_all = "lowercase")] pub enum MergeStrategy { #[default] @@ -42,19 +84,47 @@ pub enum MergeStrategy { Rebase, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct AssetsConfig { #[serde(default)] pub include: Vec, } #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct AssetsSettings { + #[serde(default)] + pub include: Vec, +} + +impl From for AssetsSettings { + fn from(value: AssetsConfig) -> Self { + Self { + include: value.include, + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct GitHubConfig { #[serde(default)] pub permissions: HashMap, } #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct GitHubSettings { + #[serde(default)] + pub permissions: HashMap, +} + +impl From for GitHubSettings { + fn from(value: GitHubConfig) -> Self { + Self { + permissions: value.permissions, + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct LlmConfig { pub model: Option, pub provider: Option, @@ -62,12 +132,47 @@ pub struct LlmConfig { pub fallbacks: Option>>, } -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct LlmSettings { + pub model: Option, + pub provider: Option, + #[serde(default)] + pub fallbacks: Option>>, +} + +impl From for LlmSettings { + fn from(value: LlmConfig) -> Self { + Self { + model: value.model, + provider: value.provider, + fallbacks: value.fallbacks, + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct SetupConfig { + #[serde(default)] pub commands: Vec, pub timeout_ms: Option, } +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct SetupSettings { + #[serde(default)] + pub commands: Vec, + pub timeout_ms: Option, +} + +impl From for SetupSettings { + fn from(value: SetupConfig) -> Self { + Self { + commands: value.commands, + timeout_ms: value.timeout_ms, + } + } +} + /// Load and validate a run config from a TOML file. /// /// The `graph` path in the returned config is resolved relative to the @@ -128,12 +233,12 @@ fn resolve_dockerfile(config: &mut FabroConfig, config_dir: &Path) -> anyhow::Re .and_then(|d| d.snapshot.as_mut()) .and_then(|snap| snap.dockerfile.as_mut()); - if let Some(DockerfileSource::Path { path: ref rel }) = source { + if let Some(crate::sandbox::DockerfileSource::Path { path: ref rel }) = source { let path = config_dir.join(rel); let contents = std::fs::read_to_string(&path) .with_context(|| format!("Failed to read dockerfile at {}", path.display()))?; debug!(path = %path.display(), "Resolved dockerfile from path"); - *source.unwrap() = DockerfileSource::Inline(contents); + *source.unwrap() = crate::sandbox::DockerfileSource::Inline(contents); } Ok(()) @@ -156,7 +261,6 @@ pub fn parse_run_config(contents: &str) -> anyhow::Result { let mut config: FabroConfig = toml::from_str(contents).context("Failed to parse run config TOML")?; - // Apply default graph if not specified if config.graph.is_none() { config.graph = Some("workflow.fabro".to_string()); } diff --git a/lib/crates/fabro-config/src/sandbox.rs b/lib/crates/fabro-config/src/sandbox.rs index c16e562ff..827f9540b 100644 --- a/lib/crates/fabro-config/src/sandbox.rs +++ b/lib/crates/fabro-config/src/sandbox.rs @@ -1,22 +1,44 @@ use std::collections::HashMap; +use anyhow::anyhow; use serde::de::{self, MapAccess, Visitor}; use serde::{Deserialize, Serialize}; -/// Configuration for a Daytona cloud sandbox. -/// -/// Doubles as the TOML deserialization target for `[sandbox.daytona]`. -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct DaytonaConfig { pub auto_stop_interval: Option, pub labels: Option>, pub snapshot: Option, pub network: Option, /// Skip git repo detection and cloning during initialization. + pub skip_clone: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct DaytonaSettings { + pub auto_stop_interval: Option, + pub labels: Option>, + pub snapshot: Option, + pub network: Option, + /// Skip git repo detection and cloning during initialization. #[serde(default)] pub skip_clone: bool, } +impl TryFrom for DaytonaSettings { + type Error = anyhow::Error; + + fn try_from(value: DaytonaConfig) -> Result { + Ok(Self { + auto_stop_interval: value.auto_stop_interval, + labels: value.labels, + snapshot: value.snapshot.map(TryInto::try_into).transpose()?, + network: value.network, + skip_clone: value.skip_clone.unwrap_or(false), + }) + } +} + /// Network access mode for a Daytona sandbox. /// /// TOML syntax: @@ -25,7 +47,7 @@ pub struct DaytonaConfig { /// network = "allow_all" # full access (default) /// network = { allow_list = ["208.80.154.232/32"] } # CIDR allowlist /// ``` -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, crate::Combine)] pub enum DaytonaNetwork { Block, AllowAll, @@ -121,7 +143,7 @@ impl<'de> Deserialize<'de> for DaytonaNetwork { /// `Path` variants are resolved to `Inline` during config loading /// (see `run_config::resolve_dockerfile`), so downstream consumers /// should only ever see `Inline`. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, crate::Combine)] #[serde(untagged)] pub enum DockerfileSource { Inline(String), @@ -130,8 +152,17 @@ pub enum DockerfileSource { /// Snapshot configuration: when present, the sandbox is created from a snapshot /// instead of a bare Docker image. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct DaytonaSnapshotConfig { + pub name: Option, + pub cpu: Option, + pub memory: Option, + pub disk: Option, + pub dockerfile: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct DaytonaSnapshotSettings { pub name: String, pub cpu: Option, pub memory: Option, @@ -139,16 +170,58 @@ pub struct DaytonaSnapshotConfig { pub dockerfile: Option, } +impl TryFrom for DaytonaSnapshotSettings { + type Error = anyhow::Error; + + fn try_from(value: DaytonaSnapshotConfig) -> Result { + Ok(Self { + name: value + .name + .ok_or_else(|| anyhow!("sandbox.daytona.snapshot.name is required"))?, + cpu: value.cpu, + memory: value.memory, + disk: value.disk, + dockerfile: value.dockerfile, + }) + } +} + /// Configuration for an exe.dev sandbox (TOML target for `[sandbox.exe]`). #[cfg(feature = "exedev")] -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct ExeConfig { pub image: Option, } +#[cfg(feature = "exedev")] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct ExeSettings { + pub image: Option, +} + +#[cfg(feature = "exedev")] +impl From for ExeSettings { + fn from(value: ExeConfig) -> Self { + Self { image: value.image } + } +} + /// Configuration for an SSH sandbox (TOML target for `[sandbox.ssh]`). -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct SshConfig { + /// SSH destination (e.g. `user@host` or an SSH alias). + pub destination: Option, + /// Remote working directory. + pub working_directory: Option, + /// Optional path to a custom SSH config file. + pub config_file: Option, + /// Base URL for port previews (e.g. `"http://beast"`). + /// When set, `get_preview_url(port)` returns `"{preview_url_base}:{port}"`. + pub preview_url_base: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct SshSettings { /// SSH destination (e.g. `user@host` or an SSH alias). pub destination: String, /// Remote working directory. @@ -160,7 +233,24 @@ pub struct SshConfig { pub preview_url_base: Option, } -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)] +impl TryFrom for SshSettings { + type Error = anyhow::Error; + + fn try_from(value: SshConfig) -> Result { + Ok(Self { + destination: value + .destination + .ok_or_else(|| anyhow!("sandbox.ssh.destination is required"))?, + working_directory: value + .working_directory + .ok_or_else(|| anyhow!("sandbox.ssh.working_directory is required"))?, + config_file: value.config_file, + preview_url_base: value.preview_url_base, + }) + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] #[serde(rename_all = "snake_case")] pub enum WorktreeMode { Always, @@ -170,17 +260,29 @@ pub enum WorktreeMode { Never, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct LocalSandboxConfig { + pub worktree_mode: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct LocalSandboxSettings { #[serde(default)] pub worktree_mode: WorktreeMode, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +impl From for LocalSandboxSettings { + fn from(value: LocalSandboxConfig) -> Self { + Self { + worktree_mode: value.worktree_mode.unwrap_or_default(), + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct SandboxConfig { pub provider: Option, pub preserve: Option, - #[serde(default)] pub devcontainer: Option, pub local: Option, pub daytona: Option, @@ -189,3 +291,34 @@ pub struct SandboxConfig { pub ssh: Option, pub env: Option>, } + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct SandboxSettings { + pub provider: Option, + pub preserve: Option, + pub devcontainer: Option, + pub local: Option, + pub daytona: Option, + #[cfg(feature = "exedev")] + pub exe: Option, + pub ssh: Option, + pub env: Option>, +} + +impl TryFrom for SandboxSettings { + type Error = anyhow::Error; + + fn try_from(value: SandboxConfig) -> Result { + Ok(Self { + provider: value.provider, + preserve: value.preserve, + devcontainer: value.devcontainer, + local: value.local.map(Into::into), + daytona: value.daytona.map(TryInto::try_into).transpose()?, + #[cfg(feature = "exedev")] + exe: value.exe.map(Into::into), + ssh: value.ssh.map(TryInto::try_into).transpose()?, + env: value.env, + }) + } +} diff --git a/lib/crates/fabro-config/src/server.rs b/lib/crates/fabro-config/src/server.rs index 0efc598af..21b527793 100644 --- a/lib/crates/fabro-config/src/server.rs +++ b/lib/crates/fabro-config/src/server.rs @@ -1,10 +1,12 @@ use std::path::{Path, PathBuf}; +use anyhow::anyhow; use serde::{Deserialize, Serialize}; use crate::config::FabroConfig; +use crate::settings::FabroSettings; -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] #[serde(rename_all = "snake_case")] pub enum AuthProvider { #[default] @@ -12,42 +14,91 @@ pub enum AuthProvider { InsecureDisabled, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct AuthConfig { + pub provider: Option, + #[serde(default)] + pub allowed_usernames: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] +pub struct AuthSettings { #[serde(default)] pub provider: AuthProvider, #[serde(default)] pub allowed_usernames: Vec, } -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +impl From for AuthSettings { + fn from(value: AuthConfig) -> Self { + Self { + provider: value.provider.unwrap_or_default(), + allowed_usernames: value.allowed_usernames, + } + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, crate::Combine)] #[serde(rename_all = "snake_case")] pub enum ApiAuthStrategy { Jwt, Mtls, } -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct TlsConfig { + pub cert: Option, + pub key: Option, + pub ca: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +pub struct TlsSettings { pub cert: PathBuf, pub key: PathBuf, pub ca: PathBuf, } -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +impl TryFrom for TlsSettings { + type Error = anyhow::Error; + + fn try_from(value: TlsConfig) -> Result { + Ok(Self { + cert: value + .cert + .ok_or_else(|| anyhow!("tls.cert is required when tls is configured"))?, + key: value + .key + .ok_or_else(|| anyhow!("tls.key is required when tls is configured"))?, + ca: value + .ca + .ok_or_else(|| anyhow!("tls.ca is required when tls is configured"))?, + }) + } +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct ApiConfig { + pub base_url: Option, + #[serde(default)] + pub authentication_strategies: Vec, + pub tls: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +pub struct ApiSettings { #[serde(default = "default_base_url")] pub base_url: String, #[serde(default)] pub authentication_strategies: Vec, - pub tls: Option, + pub tls: Option, } fn default_base_url() -> String { "http://localhost:3000".to_string() } -impl Default for ApiConfig { +impl Default for ApiSettings { fn default() -> Self { Self { base_url: default_base_url(), @@ -57,65 +108,156 @@ impl Default for ApiConfig { } } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] +impl TryFrom for ApiSettings { + type Error = anyhow::Error; + + fn try_from(value: ApiConfig) -> Result { + Ok(Self { + base_url: value.base_url.unwrap_or_else(default_base_url), + authentication_strategies: value.authentication_strategies, + tls: value.tls.map(TryInto::try_into).transpose()?, + }) + } +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] #[serde(rename_all = "snake_case")] pub enum GitProvider { #[default] Github, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct GitAuthorConfig { pub name: Option, pub email: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] +pub struct GitAuthorSettings { + pub name: Option, + pub email: Option, +} + +impl From for GitAuthorSettings { + fn from(value: GitAuthorConfig) -> Self { + Self { + name: value.name, + email: value.email, + } + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, crate::Combine)] #[serde(rename_all = "snake_case")] pub enum WebhookStrategy { TailscaleFunnel, } -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct WebhookConfig { + pub strategy: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +pub struct WebhookSettings { pub strategy: WebhookStrategy, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] +impl TryFrom for WebhookSettings { + type Error = anyhow::Error; + + fn try_from(value: WebhookConfig) -> Result { + Ok(Self { + strategy: value + .strategy + .ok_or_else(|| anyhow!("git.webhooks.strategy is required"))?, + }) + } +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct GitConfig { + pub provider: Option, + pub app_id: Option, + pub client_id: Option, + pub slug: Option, + pub author: Option, + pub webhooks: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] +pub struct GitSettings { #[serde(default)] pub provider: GitProvider, pub app_id: Option, pub client_id: Option, pub slug: Option, #[serde(default)] - pub author: GitAuthorConfig, - pub webhooks: Option, + pub author: GitAuthorSettings, + pub webhooks: Option, +} + +impl TryFrom for GitSettings { + type Error = anyhow::Error; + + fn try_from(value: GitConfig) -> Result { + Ok(Self { + provider: value.provider.unwrap_or_default(), + app_id: value.app_id, + client_id: value.client_id, + slug: value.slug, + author: value.author.map(Into::into).unwrap_or_default(), + webhooks: value.webhooks.map(TryInto::try_into).transpose()?, + }) + } +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] +pub struct WebConfig { + pub url: Option, + pub auth: Option, } #[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct WebConfig { +pub struct WebSettings { #[serde(default = "default_web_url")] pub url: String, #[serde(default)] - pub auth: AuthConfig, + pub auth: AuthSettings, } fn default_web_url() -> String { "http://localhost:5173".to_string() } -impl Default for WebConfig { +impl Default for WebSettings { fn default() -> Self { Self { url: default_web_url(), - auth: AuthConfig::default(), + auth: AuthSettings::default(), } } } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] +impl From for WebSettings { + fn from(value: WebConfig) -> Self { + Self { + url: value.url.unwrap_or_else(default_web_url), + auth: value.auth.map(Into::into).unwrap_or_default(), + } + } +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct Features { + pub session_sandboxes: Option, + /// Experimental: enable automatic retro generation after workflow runs. + pub retros: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] +pub struct FeaturesSettings { #[serde(default)] pub session_sandboxes: bool, /// Experimental: enable automatic retro generation after workflow runs. @@ -123,11 +265,31 @@ pub struct Features { pub retros: bool, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +impl From for FeaturesSettings { + fn from(value: Features) -> Self { + Self { + session_sandboxes: value.session_sandboxes.unwrap_or(false), + retros: value.retros.unwrap_or(false), + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct LogConfig { pub level: Option, } +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct LogSettings { + pub level: Option, +} + +impl From for LogSettings { + fn from(value: LogConfig) -> Self { + Self { level: value.level } + } +} + /// Load server config from an explicit path or `~/.fabro/server.toml`, returning defaults if the /// default file doesn't exist. An explicit path that doesn't exist is an error. pub fn load_server_config(path: Option<&Path>) -> anyhow::Result { @@ -135,392 +297,6 @@ pub fn load_server_config(path: Option<&Path>) -> anyhow::Result { } /// Resolve the storage directory: config value > default `~/.fabro`. -pub fn resolve_storage_dir(config: &FabroConfig) -> PathBuf { +pub fn resolve_storage_dir(config: &FabroSettings) -> PathBuf { config.storage_dir() } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_config_with_storage_dir() { - let toml = r#"storage_dir = "/custom/path""#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.storage_dir, Some(PathBuf::from("/custom/path"))); - } - - #[test] - fn parse_config_with_data_dir_alias() { - let toml = r#"data_dir = "/custom/path""#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.storage_dir, Some(PathBuf::from("/custom/path"))); - } - - #[test] - fn parse_empty_config_defaults() { - let toml = ""; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.storage_dir, None); - } - - #[test] - fn resolve_storage_dir_uses_config_value() { - let config = FabroConfig { - storage_dir: Some(PathBuf::from("/my/data")), - ..FabroConfig::default() - }; - assert_eq!(resolve_storage_dir(&config), PathBuf::from("/my/data")); - } - - #[test] - fn resolve_storage_dir_defaults_to_home() { - let config = FabroConfig::default(); - let dir = resolve_storage_dir(&config); - // Should end with .fabro - assert!( - dir.ends_with(".fabro"), - "expected path ending with .fabro, got: {}", - dir.display() - ); - } - - #[test] - fn parse_full_config() { - let toml = r#" -[web] -url = "https://arc.example.com" - -[web.auth] -provider = "github" -allowed_usernames = ["brynary", "alice"] - -[api] -base_url = "http://example.com:8080" -authentication_strategies = ["jwt"] - -[git] -provider = "github" -app_id = "12345" -client_id = "Iv1.abc123" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let web = config.web.unwrap(); - assert_eq!(web.url, "https://arc.example.com"); - assert_eq!(web.auth.provider, AuthProvider::Github); - assert_eq!(web.auth.allowed_usernames, vec!["brynary", "alice"]); - let api = config.api.unwrap(); - assert_eq!(api.base_url, "http://example.com:8080"); - assert_eq!(api.authentication_strategies, vec![ApiAuthStrategy::Jwt]); - let git = config.git.unwrap(); - assert_eq!(git.provider, GitProvider::Github); - assert_eq!(git.app_id.as_deref(), Some("12345")); - assert_eq!(git.client_id.as_deref(), Some("Iv1.abc123")); - } - - #[test] - fn parse_web_defaults() { - let toml = "[web]\n"; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let web = config.web.unwrap(); - assert_eq!(web.url, "http://localhost:5173"); - assert_eq!(web.auth.provider, AuthProvider::Github); - assert!(web.auth.allowed_usernames.is_empty()); - } - - #[test] - fn parse_api_defaults() { - let toml = "[api]\n"; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let api = config.api.unwrap(); - assert_eq!(api.base_url, "http://localhost:3000"); - assert!(api.authentication_strategies.is_empty()); - assert!(api.tls.is_none()); - } - - #[test] - fn parse_git_config() { - let toml = r#" -[git] -provider = "github" -app_id = "12345" -client_id = "Iv1.abc123" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let git = config.git.unwrap(); - assert_eq!(git.provider, GitProvider::Github); - assert_eq!(git.app_id.as_deref(), Some("12345")); - assert_eq!(git.client_id.as_deref(), Some("Iv1.abc123")); - } - - #[test] - fn parse_git_defaults() { - let toml = "[git]\n"; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let git = config.git.unwrap(); - assert_eq!(git.provider, GitProvider::Github); - assert_eq!(git.app_id, None); - assert_eq!(git.client_id, None); - assert_eq!(git.author.name, None); - assert_eq!(git.author.email, None); - } - - #[test] - fn parse_git_author_config() { - let toml = r#" -[git.author] -name = "fabro-bot" -email = "fabro-bot@company.com" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let git = config.git.unwrap(); - assert_eq!(git.author.name.as_deref(), Some("fabro-bot")); - assert_eq!(git.author.email.as_deref(), Some("fabro-bot@company.com")); - } - - #[test] - fn parse_git_author_partial() { - let toml = r#" -[git.author] -name = "custom-name" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let git = config.git.unwrap(); - assert_eq!(git.author.name.as_deref(), Some("custom-name")); - assert_eq!(git.author.email, None); - } - - #[test] - fn parse_config_with_run_defaults() { - let toml = r#" -[llm] -model = "claude-haiku" -provider = "anthropic" - -[sandbox] -provider = "daytona" - -[vars] -repo_url = "https://github.com/org/repo" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let llm = config.llm.unwrap(); - assert_eq!(llm.model.as_deref(), Some("claude-haiku")); - assert_eq!(llm.provider.as_deref(), Some("anthropic")); - let sandbox = config.sandbox.unwrap(); - assert_eq!(sandbox.provider.as_deref(), Some("daytona")); - let vars = config.vars.unwrap(); - assert_eq!(vars["repo_url"], "https://github.com/org/repo"); - } - - #[test] - fn parse_config_server_and_run_defaults_together() { - let toml = r#" -[web.auth] -provider = "github" - -[git] -provider = "github" -app_id = "123" - -[llm] -model = "gpt-4" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let web = config.web.unwrap(); - assert_eq!(web.auth.provider, AuthProvider::Github); - let git = config.git.unwrap(); - assert_eq!(git.app_id.as_deref(), Some("123")); - let llm = config.llm.unwrap(); - assert_eq!(llm.model.as_deref(), Some("gpt-4")); - } - - #[test] - fn parse_insecure_disabled_auth_provider() { - let toml = r#" -[web.auth] -provider = "insecure_disabled" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let web = config.web.unwrap(); - assert_eq!(web.auth.provider, AuthProvider::InsecureDisabled); - } - - #[test] - fn parse_jwt_and_mtls_strategies() { - let toml = r#" -[api] -authentication_strategies = ["jwt", "mtls"] - -[api.tls] -cert = "~/.fabro/certs/server.crt" -key = "~/.fabro/certs/server.key" -ca = "~/.fabro/certs/ca.crt" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let api = config.api.unwrap(); - assert_eq!( - api.authentication_strategies, - vec![ApiAuthStrategy::Jwt, ApiAuthStrategy::Mtls] - ); - let tls = api.tls.unwrap(); - assert_eq!(tls.cert, PathBuf::from("~/.fabro/certs/server.crt")); - assert_eq!(tls.key, PathBuf::from("~/.fabro/certs/server.key")); - assert_eq!(tls.ca, PathBuf::from("~/.fabro/certs/ca.crt")); - } - - #[test] - fn parse_max_concurrent_runs() { - let toml = r#"max_concurrent_runs = 8"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.max_concurrent_runs, Some(8)); - } - - #[test] - fn parse_max_concurrent_runs_defaults_to_none() { - let toml = ""; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.max_concurrent_runs, None); - } - - #[test] - fn parse_empty_strategies() { - let toml = r#" -[api] -authentication_strategies = [] -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let api = config.api.unwrap(); - assert!(api.authentication_strategies.is_empty()); - } - - #[test] - fn parse_jwt_only_strategy() { - let toml = r#" -[api] -authentication_strategies = ["jwt"] -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let api = config.api.unwrap(); - assert_eq!(api.authentication_strategies, vec![ApiAuthStrategy::Jwt]); - assert!(api.tls.is_none()); - } - - #[test] - fn load_server_config_from_explicit_path() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("custom.toml"); - std::fs::write(&path, r#"max_concurrent_runs = 42"#).unwrap(); - let config = load_server_config(Some(&path)).unwrap(); - assert_eq!(config.max_concurrent_runs, Some(42)); - } - - #[test] - fn load_server_config_explicit_path_missing_is_error() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("nonexistent.toml"); - let result = load_server_config(Some(&path)); - assert!(result.is_err()); - } - - #[test] - fn parse_config_with_hooks() { - let toml = r#" -[[hooks]] -event = "run_start" -command = "echo 'run starting'" - -[[hooks]] -event = "stage_complete" -command = "echo 'stage done'" -matcher = "agent_loop" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.hooks.len(), 2); - assert_eq!(config.hooks[0].event, crate::hook::HookEvent::RunStart); - assert_eq!( - config.hooks[0].command.as_deref(), - Some("echo 'run starting'") - ); - assert_eq!(config.hooks[1].event, crate::hook::HookEvent::StageComplete); - assert_eq!(config.hooks[1].matcher.as_deref(), Some("agent_loop")); - } - - #[test] - fn parse_features() { - let toml = "[features]\nsession_sandboxes = true"; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let features = config.features.unwrap(); - assert!(features.session_sandboxes); - } - - #[test] - fn parse_features_defaults() { - let toml = ""; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.features, None); - } - - #[test] - fn parse_config_without_hooks_defaults_empty() { - let toml = ""; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert!(config.hooks.is_empty()); - } - - #[test] - fn parse_config_with_checkpoint_exclude_globs() { - let toml = r#" -[checkpoint] -exclude_globs = ["**/node_modules/**", "**/.cache/**"] -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!( - config.checkpoint.exclude_globs, - vec!["**/node_modules/**", "**/.cache/**"] - ); - } - - #[test] - fn parse_config_checkpoint_defaults_empty() { - let toml = ""; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert!(config.checkpoint.exclude_globs.is_empty()); - } - - #[test] - fn parse_git_webhooks_config() { - let toml = r#" -[git] -provider = "github" -app_id = "2993730" - -[git.webhooks] -strategy = "tailscale_funnel" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - let webhooks = config.git.unwrap().webhooks.unwrap(); - assert_eq!(webhooks.strategy, WebhookStrategy::TailscaleFunnel); - } - - #[test] - fn parse_git_webhooks_missing_is_none() { - let toml = r#" -[git] -provider = "github" -app_id = "123" -"#; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert!(config.git.unwrap().webhooks.is_none()); - } - - #[test] - fn parse_log_config() { - let toml = "[log]\nlevel = \"trace\""; - let config: FabroConfig = toml::from_str(toml).unwrap(); - assert_eq!( - config.log.as_ref().and_then(|l| l.level.as_deref()), - Some("trace") - ); - } -} diff --git a/lib/crates/fabro-config/src/settings.rs b/lib/crates/fabro-config/src/settings.rs new file mode 100644 index 000000000..c180757fe --- /dev/null +++ b/lib/crates/fabro-config/src/settings.rs @@ -0,0 +1,225 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::cli::{ExecSettings, ExecutionMode, ServerSettings}; +use crate::config::FabroConfig; +use crate::hook::HookDefinition; +use crate::mcp::McpServerEntry; +use crate::project::ProjectFabroSettings; +use crate::run::{ + AssetsSettings, CheckpointSettings, GitHubSettings, LlmSettings, PullRequestSettings, + SetupSettings, +}; +use crate::sandbox::SandboxSettings; +use crate::server::{ + ApiSettings, FeaturesSettings, GitAuthorSettings, GitSettings, LogSettings, WebSettings, +}; + +fn is_default_checkpoint(c: &CheckpointSettings) -> bool { + c.exclude_globs.is_empty() +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct FabroSettings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goal: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goal_file: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graph: Option, + + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub labels: HashMap, + + #[serde(default, alias = "directory", skip_serializing_if = "Option::is_none")] + pub work_dir: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub llm: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub setup: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vars: Option>, + + #[serde(default, skip_serializing_if = "is_default_checkpoint")] + pub checkpoint: CheckpointSettings, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pull_request: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assets: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hooks: Vec, + + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub mcp_servers: HashMap, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exec: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prevent_idle_sleep: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verbose: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upgrade_check: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dry_run: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_approve: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub no_retro: Option, + + #[serde(default, alias = "data_dir", skip_serializing_if = "Option::is_none")] + pub storage_dir: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_concurrent_runs: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub features: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fabro: Option, +} + +impl TryFrom for FabroSettings { + type Error = anyhow::Error; + + fn try_from(value: FabroConfig) -> Result { + Ok(Self { + version: value.version, + goal: value.goal, + goal_file: value.goal_file, + graph: value.graph, + labels: value.labels, + work_dir: value.work_dir, + llm: value.llm.map(Into::into), + setup: value.setup.map(Into::into), + sandbox: value.sandbox.map(TryInto::try_into).transpose()?, + vars: value.vars, + checkpoint: value.checkpoint.into(), + pull_request: value.pull_request.map(Into::into), + assets: value.assets.map(Into::into), + hooks: value.hooks, + mcp_servers: value.mcp_servers, + github: value.github.map(Into::into), + mode: value.mode, + server: value.server.map(TryInto::try_into).transpose()?, + exec: value.exec.map(Into::into), + prevent_idle_sleep: value.prevent_idle_sleep, + verbose: value.verbose, + upgrade_check: value.upgrade_check, + dry_run: value.dry_run, + auto_approve: value.auto_approve, + no_retro: value.no_retro, + storage_dir: value.storage_dir, + max_concurrent_runs: value.max_concurrent_runs, + web: value.web.map(Into::into), + api: value.api.map(TryInto::try_into).transpose()?, + features: value.features.map(Into::into), + log: value.log.map(Into::into), + git: value.git.map(TryInto::try_into).transpose()?, + fabro: value.fabro.map(Into::into), + }) + } +} + +impl TryFrom<&FabroConfig> for FabroSettings { + type Error = anyhow::Error; + + fn try_from(value: &FabroConfig) -> Result { + value.clone().try_into() + } +} + +impl FabroSettings { + /// Resolve the storage directory: config value > default `~/.fabro`. + pub fn storage_dir(&self) -> PathBuf { + self.storage_dir.clone().unwrap_or_else(|| { + dirs::home_dir() + .expect("could not determine home directory") + .join(".fabro") + }) + } + + pub fn app_id(&self) -> Option<&str> { + self.git.as_ref().and_then(|g| g.app_id.as_deref()) + } + + pub fn slug(&self) -> Option<&str> { + self.git.as_ref().and_then(|g| g.slug.as_deref()) + } + + pub fn client_id(&self) -> Option<&str> { + self.git.as_ref().and_then(|g| g.client_id.as_deref()) + } + + pub fn git_author(&self) -> Option<&GitAuthorSettings> { + self.git.as_ref().map(|g| &g.author) + } + + pub fn verbose_enabled(&self) -> bool { + self.verbose.unwrap_or(false) + } + + pub fn prevent_idle_sleep_enabled(&self) -> bool { + self.prevent_idle_sleep.unwrap_or(false) + } + + pub fn upgrade_check_enabled(&self) -> bool { + self.upgrade_check.unwrap_or(true) + } + + pub fn dry_run_enabled(&self) -> bool { + self.dry_run.unwrap_or(false) + } + + pub fn auto_approve_enabled(&self) -> bool { + self.auto_approve.unwrap_or(false) + } + + pub fn no_retro_enabled(&self) -> bool { + self.no_retro.unwrap_or(false) + } +} diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index 3ee5c4bf2..3b9d16a88 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -15,7 +15,8 @@ const WORKING_DIRECTORY: &str = "/home/daytona/workspace"; const DEFAULT_SNAPSHOT: &str = "daytona-medium"; pub use fabro_config::sandbox::{ - DaytonaConfig, DaytonaNetwork, DaytonaSnapshotConfig, DockerfileSource, + DaytonaNetwork, DaytonaSettings as DaytonaConfig, + DaytonaSnapshotSettings as DaytonaSnapshotConfig, DockerfileSource, }; /// Sandbox that runs all operations inside a Daytona cloud sandbox. diff --git a/lib/crates/fabro-sandbox/src/exe/mod.rs b/lib/crates/fabro-sandbox/src/exe/mod.rs index da1a96b5b..d10282979 100644 --- a/lib/crates/fabro-sandbox/src/exe/mod.rs +++ b/lib/crates/fabro-sandbox/src/exe/mod.rs @@ -16,7 +16,7 @@ use tokio_util::sync::CancellationToken; pub use crate::ssh_common::{GitCloneParams, SshOutput, SshRunner}; pub use openssh_runner::OpensshRunner; -pub use fabro_config::sandbox::ExeConfig; +pub use fabro_config::sandbox::ExeSettings as ExeConfig; const WORKING_DIRECTORY: &str = "/home/exedev"; const PROVIDER: &str = "exe"; diff --git a/lib/crates/fabro-sandbox/src/ssh/mod.rs b/lib/crates/fabro-sandbox/src/ssh/mod.rs index 31ca93a95..1e03081eb 100644 --- a/lib/crates/fabro-sandbox/src/ssh/mod.rs +++ b/lib/crates/fabro-sandbox/src/ssh/mod.rs @@ -16,7 +16,7 @@ use tokio_util::sync::CancellationToken; pub use crate::ssh_common::{GitCloneParams, SshOutput, SshRunner}; pub use openssh_runner::OpensshRunner; -pub use fabro_config::sandbox::SshConfig; +pub use fabro_config::sandbox::SshSettings as SshConfig; const PROVIDER: &str = "ssh"; diff --git a/lib/crates/fabro-workflows/src/handler/manager_loop.rs b/lib/crates/fabro-workflows/src/handler/manager_loop.rs index d62346913..8afd28d22 100644 --- a/lib/crates/fabro-workflows/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflows/src/handler/manager_loop.rs @@ -144,7 +144,7 @@ impl Handler for SubWorkflowHandler { let git_state = services.git_state(); let child_run_options = RunOptions { - config: fabro_config::FabroConfig::default(), + config: fabro_config::FabroSettings::default(), run_dir: child_logs, cancel_token: Some(cancel_token), dry_run: services.dry_run, diff --git a/lib/crates/fabro-workflows/src/operations/create.rs b/lib/crates/fabro-workflows/src/operations/create.rs index b65430a5f..25b6ac422 100644 --- a/lib/crates/fabro-workflows/src/operations/create.rs +++ b/lib/crates/fabro-workflows/src/operations/create.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use chrono::{Local, Utc}; -use fabro_config::config::FabroConfig; +use fabro_config::FabroSettings; use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_model::{Catalog, Provider}; @@ -16,12 +16,12 @@ use crate::transforms::{expand_vars, Transform}; pub struct ValidateOptions { pub base_dir: Option, pub custom_transforms: Vec>, - pub config: Option, + pub config: Option, pub goal_override: Option, } pub struct RunCreateOptions { - pub config: FabroConfig, + pub config: FabroSettings, pub run_dir: Option, pub run_id: Option, pub workflow_slug: Option, @@ -96,7 +96,7 @@ fn preprocess_and_validate( dot_source: &str, base_dir: Option, custom_transforms: Vec>, - config: Option<&FabroConfig>, + config: Option<&FabroSettings>, goal_override: Option<&str>, ) -> Result { let source = match config.and_then(|cfg| cfg.vars.as_ref()) { @@ -149,7 +149,7 @@ fn persist_validated( base_dir: _, } = options; - finalize_config(&mut config, validated.graph()); + finalize_settings(&mut config, validated.graph()); let run_id = run_id.unwrap_or_else(|| ulid::Ulid::new().to_string()); let run_dir = run_dir.unwrap_or_else(|| default_run_dir(&run_id, config.dry_run_enabled())); @@ -177,7 +177,7 @@ fn persist_validated( ) } -pub(crate) fn finalize_config(config: &mut FabroConfig, graph: &Graph) { +pub(crate) fn finalize_settings(config: &mut FabroSettings, graph: &Graph) { let llm_config = config.llm.as_ref(); let configured_model = llm_config.and_then(|l| l.model.as_deref()); let configured_provider = llm_config.and_then(|l| l.provider.as_deref()); @@ -308,7 +308,7 @@ mod tests { let validated = validate( dot, ValidateOptions { - config: Some(FabroConfig { + config: Some(FabroSettings { vars: Some(HashMap::from([("who".to_string(), "agent".to_string())])), ..Default::default() }), @@ -407,7 +407,7 @@ mod tests { let err = create( dot, RunCreateOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: None, run_id: None, workflow_slug: None, @@ -435,13 +435,13 @@ mod tests { let persisted = create( MINIMAL_DOT, RunCreateOptions { - config: FabroConfig { - llm: Some(fabro_config::run::LlmConfig { + config: FabroSettings { + llm: Some(fabro_config::run::LlmSettings { model: Some("sonnet".to_string()), provider: None, fallbacks: None, }), - pull_request: Some(fabro_config::run::PullRequestConfig { + pull_request: Some(fabro_config::run::PullRequestSettings { enabled: false, ..Default::default() }), diff --git a/lib/crates/fabro-workflows/src/operations/start.rs b/lib/crates/fabro-workflows/src/operations/start.rs index c6e32cb36..d483f59ba 100644 --- a/lib/crates/fabro-workflows/src/operations/start.rs +++ b/lib/crates/fabro-workflows/src/operations/start.rs @@ -23,7 +23,7 @@ pub struct StartFinalizeOptions { } pub struct StartPullRequestConfig { - pub pr_config: Option, + pub pr_config: Option, pub github_app: Option, pub origin_url: Option, pub model: String, @@ -252,7 +252,7 @@ mod tests { use chrono::Utc; use fabro_agent::{LocalSandbox, Sandbox}; - use fabro_config::config::FabroConfig; + use fabro_config::FabroSettings; use super::*; use crate::context::Context; @@ -274,7 +274,7 @@ mod tests { crate::operations::create( dot, crate::operations::RunCreateOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: Some(run_dir.to_path_buf()), run_id: Some("run-test".to_string()), workflow_slug: Some("test".to_string()), diff --git a/lib/crates/fabro-workflows/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflows/src/pipeline/execute/tests.rs index 2047aeca5..05e26bb1f 100644 --- a/lib/crates/fabro-workflows/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflows/src/pipeline/execute/tests.rs @@ -7,7 +7,7 @@ use std::time::Duration; use async_trait::async_trait; use chrono::Utc; use fabro_agent::Sandbox; -use fabro_config::config::FabroConfig; +use fabro_config::FabroSettings; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_hooks::HookConfig; use fabro_interview::AutoApproveInterviewer; @@ -71,7 +71,7 @@ fn test_run_options(run_dir: &Path, run_id: &str) -> RunOptions { cancel_token: None, dry_run: false, run_id: run_id.into(), - config: FabroConfig::default(), + config: FabroSettings::default(), git: None, host_repo_path: None, labels: HashMap::new(), @@ -115,7 +115,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: &str RunRecord { run_id: run_id.to_string(), created_at: Utc::now(), - config: FabroConfig::default(), + config: FabroSettings::default(), graph, workflow_slug: Some("test".to_string()), working_directory: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), diff --git a/lib/crates/fabro-workflows/src/pipeline/finalize.rs b/lib/crates/fabro-workflows/src/pipeline/finalize.rs index 647eccead..1510bb102 100644 --- a/lib/crates/fabro-workflows/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflows/src/pipeline/finalize.rs @@ -302,7 +302,7 @@ mod tests { use std::collections::HashMap; use std::sync::Arc; - use fabro_config::config::FabroConfig; + use fabro_config::FabroSettings; use fabro_graphviz::graph::Graph; use super::*; @@ -311,7 +311,7 @@ mod tests { fn test_run_options(run_dir: &std::path::Path) -> RunOptions { RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: run_dir.to_path_buf(), cancel_token: None, dry_run: true, diff --git a/lib/crates/fabro-workflows/src/pipeline/initialize.rs b/lib/crates/fabro-workflows/src/pipeline/initialize.rs index e60928af7..3859c4016 100644 --- a/lib/crates/fabro-workflows/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflows/src/pipeline/initialize.rs @@ -865,7 +865,7 @@ mod tests { use std::sync::Arc; use chrono::Utc; - use fabro_config::config::FabroConfig; + use fabro_config::FabroSettings; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_interview::AutoApproveInterviewer; @@ -900,7 +900,7 @@ mod tests { fn test_settings(run_dir: &std::path::Path) -> RunOptions { RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: run_dir.to_path_buf(), cancel_token: None, dry_run: false, @@ -925,7 +925,7 @@ mod tests { RunRecord { run_id: "run-test".to_string(), created_at: Utc::now(), - config: FabroConfig::default(), + config: FabroSettings::default(), graph, workflow_slug: Some("test".to_string()), working_directory: std::env::current_dir().unwrap(), diff --git a/lib/crates/fabro-workflows/src/pipeline/persist.rs b/lib/crates/fabro-workflows/src/pipeline/persist.rs index 0098e0d11..2630da7e6 100644 --- a/lib/crates/fabro-workflows/src/pipeline/persist.rs +++ b/lib/crates/fabro-workflows/src/pipeline/persist.rs @@ -56,7 +56,7 @@ mod tests { use std::path::PathBuf; use chrono::Utc; - use fabro_config::config::FabroConfig; + use fabro_config::FabroSettings; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use super::*; @@ -110,7 +110,7 @@ mod tests { RunRecord { run_id: "run-123".to_string(), created_at: Utc::now(), - config: FabroConfig { + config: FabroSettings { dry_run: Some(true), verbose: Some(true), ..Default::default() diff --git a/lib/crates/fabro-workflows/src/pipeline/retro.rs b/lib/crates/fabro-workflows/src/pipeline/retro.rs index 8f157d1a6..2c078e67a 100644 --- a/lib/crates/fabro-workflows/src/pipeline/retro.rs +++ b/lib/crates/fabro-workflows/src/pipeline/retro.rs @@ -152,7 +152,7 @@ mod tests { use std::collections::HashMap; use std::sync::{Arc, Mutex}; - use fabro_config::config::FabroConfig; + use fabro_config::FabroSettings; use fabro_graphviz::graph::Graph; use super::*; @@ -183,7 +183,7 @@ mod tests { fn test_run_options(run_dir: &std::path::Path) -> RunOptions { RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: run_dir.to_path_buf(), cancel_token: None, dry_run: true, diff --git a/lib/crates/fabro-workflows/src/pipeline/types.rs b/lib/crates/fabro-workflows/src/pipeline/types.rs index 95ad7ffc7..e07a2a8f1 100644 --- a/lib/crates/fabro-workflows/src/pipeline/types.rs +++ b/lib/crates/fabro-workflows/src/pipeline/types.rs @@ -374,7 +374,7 @@ pub struct FinalizeOptions { /// Options for the PULL_REQUEST phase. pub struct PullRequestOptions { pub run_dir: PathBuf, - pub pr_config: Option, + pub pr_config: Option, pub github_app: Option, pub origin_url: Option, pub model: String, diff --git a/lib/crates/fabro-workflows/src/records/run.rs b/lib/crates/fabro-workflows/src/records/run.rs index 2de3acb6f..0e5235316 100644 --- a/lib/crates/fabro-workflows/src/records/run.rs +++ b/lib/crates/fabro-workflows/src/records/run.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; -use fabro_config::config::FabroConfig; +use fabro_config::FabroSettings; use fabro_graphviz::graph::Graph; use serde::{Deserialize, Serialize}; @@ -12,7 +12,7 @@ const FILE_NAME: &str = "run.json"; pub struct RunRecord { pub run_id: String, pub created_at: DateTime, - pub config: FabroConfig, + pub config: FabroSettings, pub graph: Graph, #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow_slug: Option, @@ -73,7 +73,7 @@ mod tests { RunRecord { run_id: "run-abc123".to_string(), created_at: Utc::now(), - config: FabroConfig::default(), + config: FabroSettings::default(), graph, workflow_slug: Some("smoke".to_string()), working_directory: PathBuf::from("/home/user/project"), diff --git a/lib/crates/fabro-workflows/src/run_options.rs b/lib/crates/fabro-workflows/src/run_options.rs index 204285527..98fd0f87f 100644 --- a/lib/crates/fabro-workflows/src/run_options.rs +++ b/lib/crates/fabro-workflows/src/run_options.rs @@ -3,8 +3,8 @@ use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::Arc; -use fabro_config::config::FabroConfig; -use fabro_config::run::PullRequestConfig; +use fabro_config::run::PullRequestSettings; +use fabro_config::FabroSettings; use crate::git::GitAuthor; @@ -19,7 +19,7 @@ pub struct GitCheckpointOptions { /// Options for a workflow run. #[derive(Clone)] pub struct RunOptions { - pub config: FabroConfig, + pub config: FabroSettings, pub run_dir: PathBuf, pub cancel_token: Option>, pub dry_run: bool, @@ -49,7 +49,7 @@ impl RunOptions { } /// PR config (already normalized — disabled entries stripped at construction). - pub fn pull_request(&self) -> Option<&PullRequestConfig> { + pub fn pull_request(&self) -> Option<&PullRequestSettings> { self.config.pull_request.as_ref() } diff --git a/lib/crates/fabro-workflows/tests/daytona_integration.rs b/lib/crates/fabro-workflows/tests/daytona_integration.rs index 980540b5b..05710fe60 100644 --- a/lib/crates/fabro-workflows/tests/daytona_integration.rs +++ b/lib/crates/fabro-workflows/tests/daytona_integration.rs @@ -8,7 +8,7 @@ use std::path::Path; use std::sync::Arc; use fabro_agent::Sandbox; -use fabro_config::FabroConfig; +use fabro_config::FabroSettings; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_llm::provider::Provider; use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig}; @@ -389,7 +389,7 @@ async fn daytona_pipeline_artifact_offload_and_sync() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -581,7 +581,7 @@ async fn daytona_git_checkpoint_remote_emits_events() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env.clone()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -768,7 +768,7 @@ async fn daytona_parallel_git_branching_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), Arc::clone(&env)); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: run_tmp.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -1145,7 +1145,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() { let meta_branch = MetadataStore::branch_name(&run_id); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -1286,11 +1286,11 @@ async fn daytona_asset_collection() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - config: FabroConfig { - assets: Some(fabro_config::run::AssetsConfig { + config: FabroSettings { + assets: Some(fabro_config::run::AssetsSettings { include: vec!["test-results/**".to_string()], }), - ..FabroConfig::default() + ..FabroSettings::default() }, run_dir: dir.path().to_path_buf(), cancel_token: None, @@ -1543,7 +1543,7 @@ async fn daytona_git_push_run_branch_to_origin() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, diff --git a/lib/crates/fabro-workflows/tests/integration.rs b/lib/crates/fabro-workflows/tests/integration.rs index 63b0e2ad9..6a5430e8b 100644 --- a/lib/crates/fabro-workflows/tests/integration.rs +++ b/lib/crates/fabro-workflows/tests/integration.rs @@ -3,7 +3,7 @@ use std::path::Path; use std::sync::Arc; use std::time::Duration; -use fabro_config::FabroConfig; +use fabro_config::FabroSettings; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_graphviz::parser::parse; use fabro_interview::{ @@ -194,7 +194,7 @@ async fn end_to_end_linear_pipeline() { local_env(), ); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -334,7 +334,7 @@ async fn end_to_end_branching_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -455,7 +455,7 @@ async fn end_to_end_human_gate_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -552,7 +552,7 @@ async fn human_gate_aborted_input_fails_closed_without_fail_route() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -664,7 +664,7 @@ async fn human_gate_aborted_input_routes_via_outcome_fail_condition() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -778,7 +778,7 @@ async fn goal_gate_routes_to_retry_target_on_failure() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -900,7 +900,7 @@ async fn goal_gate_routes_to_retry_target_when_present() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -1213,7 +1213,7 @@ async fn retry_on_failure_then_succeed() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -1289,7 +1289,7 @@ async fn pipeline_with_many_nodes() { local_env(), ); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -1612,7 +1612,7 @@ async fn smoke_test_with_mock_codergen_backend() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -1714,7 +1714,7 @@ async fn end_to_end_parallel_fan_out_fan_in() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -1828,7 +1828,7 @@ async fn resume_from_checkpoint_completes_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -1928,7 +1928,7 @@ async fn resume_from_checkpoint_preserves_goal_gate_outcomes() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -1972,7 +1972,7 @@ async fn graph_goal_in_context() { local_env(), ); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -2009,7 +2009,7 @@ async fn event_streaming_lifecycle() { let events = collect_events(&emitter); let engine = WorkflowRunner::new(make_linear_registry(), Arc::new(emitter), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -2090,7 +2090,7 @@ async fn context_flow_between_stages() { local_env(), ); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -2144,7 +2144,7 @@ async fn tool_handler_e2e() { local_env(), ); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -2215,7 +2215,7 @@ async fn auto_approve_interviewer_e2e() { local_env(), ); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -2253,7 +2253,7 @@ async fn codergen_without_backend_simulated() { local_env(), ); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -2359,7 +2359,7 @@ async fn branching_loop_back_on_failure() { ); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -2443,7 +2443,7 @@ async fn human_gate_loops_back() { registry.register("human", Box::new(HumanHandler::new(interviewer))); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -2502,7 +2502,7 @@ async fn scenario_ship_a_feature() { local_env(), ); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -2589,7 +2589,7 @@ async fn scenario_parallel_expert_review() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -2674,7 +2674,7 @@ async fn scenario_node_retries_on_retry_status() { ); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -2737,7 +2737,7 @@ async fn scenario_loop_restart_resets_context() { ); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -2806,7 +2806,7 @@ async fn scenario_bug_triage_router() { registry.register("conditional", Box::new(ConditionalHandler)); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -2866,7 +2866,7 @@ async fn scenario_crash_recovery() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -2976,7 +2976,7 @@ async fn manager_loop_stop_condition_satisfied_e2e() { registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -3054,7 +3054,7 @@ async fn manager_loop_max_cycles_exceeded_e2e() { registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -3191,7 +3191,7 @@ async fn conditional_branching_success_fail_paths() { registry.register("always_fail", Box::new(AlwaysFailHandler)); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -3245,7 +3245,7 @@ async fn edge_selection_condition_match_wins_over_weight() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -3293,7 +3293,7 @@ async fn edge_selection_weight_breaks_ties() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -3333,7 +3333,7 @@ async fn edge_selection_lexical_tiebreak() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -3392,7 +3392,7 @@ async fn context_updates_visible_across_nodes() { registry.register("context_setter", Box::new(ContextSetterHandler)); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -3437,7 +3437,7 @@ async fn stylesheet_applies_model_override() { local_env(), ); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -3494,7 +3494,7 @@ async fn custom_handler_registration_and_execution() { registry.register("my_custom", Box::new(CustomHandler)); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -3566,7 +3566,7 @@ async fn integration_smoke_plan_implement_review_done() { local_env(), ); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -3671,7 +3671,7 @@ async fn manager_loop_runs_child_engine_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -3806,7 +3806,7 @@ async fn manager_loop_context_flows_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -3880,7 +3880,7 @@ async fn manager_loop_child_dotfile_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -3994,7 +3994,7 @@ async fn graph_merge_e2e_through_engine() { local_env(), ); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -4145,7 +4145,7 @@ async fn fidelity_default_is_compact() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -4203,7 +4203,7 @@ async fn fidelity_graph_default_applied() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -4257,7 +4257,7 @@ async fn fidelity_node_overrides_graph_default() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -4317,7 +4317,7 @@ async fn fidelity_edge_overrides_node_and_graph() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -4367,7 +4367,7 @@ async fn fidelity_full_produces_empty_preamble() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -4427,7 +4427,7 @@ async fn fidelity_truncate_preamble_minimal() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -4500,7 +4500,7 @@ async fn fidelity_summary_low_mode() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -4568,7 +4568,7 @@ async fn fidelity_summary_medium_mode() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -4636,7 +4636,7 @@ async fn fidelity_summary_high_mode() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -4697,7 +4697,7 @@ async fn fidelity_full_sets_thread_id_in_context() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -4769,7 +4769,7 @@ async fn fidelity_full_nodes_share_thread_id() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -4851,7 +4851,7 @@ async fn fidelity_resume_degrades_full_to_summary_high() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -4949,7 +4949,7 @@ async fn fidelity_resume_degrade_only_affects_first_hop() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5034,7 +5034,7 @@ async fn fidelity_resume_no_degrade_when_not_full() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5077,7 +5077,7 @@ async fn fidelity_stored_in_checkpoint_context() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5164,7 +5164,7 @@ async fn fidelity_precedence_multi_node_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5233,7 +5233,7 @@ async fn fidelity_compact_preamble_includes_completed_stages_and_context() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5309,7 +5309,7 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { ); let engine_low = WorkflowRunner::new(registry_low, Arc::new(EventEmitter::new()), local_env()); let run_options_low = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir_low.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5377,7 +5377,7 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { ); let engine_med = WorkflowRunner::new(registry_med, Arc::new(EventEmitter::new()), local_env()); let run_options_med = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir_med.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5449,7 +5449,7 @@ async fn fidelity_thread_id_fallback_to_previous_node_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5504,7 +5504,7 @@ async fn fidelity_thread_id_from_node_class_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5562,7 +5562,7 @@ async fn fidelity_edge_thread_id_override_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5621,7 +5621,7 @@ async fn fidelity_full_without_explicit_thread_id_uses_previous_node() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5690,7 +5690,7 @@ async fn fidelity_from_parsed_dot_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5739,7 +5739,7 @@ async fn fidelity_checkpoint_roundtrip_preserves_fidelity() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5810,7 +5810,7 @@ async fn fidelity_node_thread_id_overrides_edge_thread_id_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5898,7 +5898,7 @@ async fn fidelity_resume_preserves_context_values_across_checkpoint() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -5941,7 +5941,7 @@ mod real_llm { use async_trait::async_trait; - use fabro_config::FabroConfig; + use fabro_config::FabroSettings; use fabro_graphviz::graph::Node; use fabro_workflows::context::Context; use fabro_workflows::error::FabroError; @@ -6114,7 +6114,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -6229,7 +6229,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -6369,7 +6369,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -6477,7 +6477,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -6574,7 +6574,7 @@ async fn human_gate_freeform_only_routes_text() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -6705,7 +6705,7 @@ async fn human_gate_freeform_with_fixed_choice_match() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -6821,7 +6821,7 @@ async fn human_gate_freeform_fallback_on_unmatched_text() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -6950,7 +6950,7 @@ async fn human_gate_freeform_sets_allow_freeform_on_question() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -7059,7 +7059,7 @@ async fn human_gate_without_freeform_sets_allow_freeform_false() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -7341,7 +7341,7 @@ fn engine_with_hooks_and_events( fn make_run_options(dir: &std::path::Path) -> RunOptions { RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.to_path_buf(), cancel_token: None, dry_run: false, @@ -8025,7 +8025,7 @@ event = "run_complete" command = "echo done" "#; - let cfg: FabroConfig = toml::from_str(toml).unwrap(); + let cfg: FabroSettings = 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")); @@ -8205,7 +8205,7 @@ max_tool_rounds = 10 timeout_ms = 120000 "#; - let cfg: FabroConfig = toml::from_str(toml).unwrap(); + let cfg: FabroSettings = toml::from_str(toml).unwrap(); assert_eq!(cfg.hooks.len(), 2); // Prompt hook @@ -8446,7 +8446,7 @@ async fn arc_e2e_with_real_llm() { let run_dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -8575,7 +8575,7 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -8775,7 +8775,7 @@ async fn large_context_values_are_offloaded_to_artifact_store() { let events = collect_events(&emitter); let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -8995,7 +8995,7 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() { let remote_env = Arc::new(RemoteMockEnv::new("/sandbox")); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), remote_env.clone()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -9126,7 +9126,7 @@ async fn node_dir_uses_visit_count_on_revisit() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -10099,7 +10099,7 @@ async fn full_pipeline_with_cli_backend_node() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -10231,7 +10231,7 @@ async fn stylesheet_backend_property_routes_to_cli() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -10511,7 +10511,7 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -10715,7 +10715,7 @@ async fn git_checkpoint_host_writes_shadow_branch() { let meta_branch = MetadataStore::branch_name(run_id); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -10914,7 +10914,7 @@ async fn parallel_git_branching_host_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -11178,7 +11178,7 @@ async fn git_checkpoint_host_skips_empty_diff_patch() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -11561,7 +11561,7 @@ async fn e2e_circuit_breaker_deterministic_self_loop() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -11609,7 +11609,7 @@ async fn e2e_circuit_breaker_custom_limit() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -11650,7 +11650,7 @@ async fn e2e_circuit_breaker_ignores_transient_failures() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -11698,7 +11698,7 @@ async fn e2e_circuit_breaker_different_reasons_separate_counters() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -11739,7 +11739,7 @@ async fn e2e_circuit_breaker_loop_restart() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -11802,7 +11802,7 @@ async fn e2e_failure_signature_persisted_in_context() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -11867,7 +11867,7 @@ async fn e2e_failure_signature_hint_overrides_reason_in_context() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -11924,7 +11924,7 @@ async fn e2e_signature_maps_persist_in_checkpoint() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -12052,7 +12052,7 @@ async fn e2e_circuit_breaker_emits_events_before_abort() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -12120,7 +12120,7 @@ async fn e2e_circuit_breaker_does_not_fire_below_limit() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -12217,7 +12217,7 @@ async fn e2e_circuit_breaker_multi_stage_impl_verify_cycle() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -12315,7 +12315,7 @@ async fn e2e_loop_restart_blocked_for_deterministic_failure() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -12356,7 +12356,7 @@ async fn e2e_loop_restart_blocked_for_structural_failure() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -12397,7 +12397,7 @@ async fn e2e_loop_restart_blocked_for_budget_exhausted_failure() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -12438,7 +12438,7 @@ async fn e2e_loop_restart_blocked_for_canceled_failure() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -12476,7 +12476,7 @@ async fn e2e_loop_restart_blocked_for_compilation_loop_failure() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -12518,7 +12518,7 @@ async fn e2e_loop_restart_allowed_for_transient_infra() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -12623,7 +12623,7 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -12680,7 +12680,7 @@ async fn e2e_stall_watchdog_kept_alive_by_handler_events() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -12727,7 +12727,7 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -12793,7 +12793,7 @@ async fn e2e_stall_watchdog_with_explicit_timeout_override() { let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false, @@ -12925,11 +12925,11 @@ async fn asset_collection_local_sandbox_success() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - config: FabroConfig { - assets: Some(fabro_config::run::AssetsConfig { + config: FabroSettings { + assets: Some(fabro_config::run::AssetsSettings { include: vec!["test-results/**".to_string()], }), - ..FabroConfig::default() + ..FabroSettings::default() }, run_dir: run_dir.path().to_path_buf(), cancel_token: None, @@ -13040,11 +13040,11 @@ async fn asset_collection_local_sandbox_on_failure() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - config: FabroConfig { - assets: Some(fabro_config::run::AssetsConfig { + config: FabroSettings { + assets: Some(fabro_config::run::AssetsSettings { include: vec!["test-results/**".to_string()], }), - ..FabroConfig::default() + ..FabroSettings::default() }, run_dir: run_dir.path().to_path_buf(), cancel_token: None, @@ -13138,11 +13138,11 @@ async fn asset_collection_docker_sandbox() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - config: FabroConfig { - assets: Some(fabro_config::run::AssetsConfig { + config: FabroSettings { + assets: Some(fabro_config::run::AssetsSettings { include: vec!["test-results/**".to_string()], }), - ..FabroConfig::default() + ..FabroSettings::default() }, run_dir: run_dir.path().to_path_buf(), cancel_token: None, @@ -13213,7 +13213,7 @@ async fn wait_timer_e2e() { local_env(), ); let run_options = RunOptions { - config: FabroConfig::default(), + config: FabroSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, dry_run: false,