diff --git a/lib/crates/fabro-api/src/demo/mod.rs b/lib/crates/fabro-api/src/demo/mod.rs index 3b879b6da..f7d418fa5 100644 --- a/lib/crates/fabro-api/src/demo/mod.rs +++ b/lib/crates/fabro-api/src/demo/mod.rs @@ -1277,10 +1277,10 @@ mod runs { } pub fn configuration() -> serde_json::Value { - serde_json::to_value(fabro_config::run::WorkflowRunConfig { - version: 1, + serde_json::to_value(fabro_config::FabroConfig { + version: Some(1), goal: Some("Add rate limiting to auth endpoints".into()), - graph: "implement.fabro".into(), + graph: Some("implement.fabro".into()), work_dir: Some("/workspace/api-server".into()), llm: Some(fabro_config::run::LlmConfig { model: Some("claude-opus-4-6".into()), @@ -1329,6 +1329,7 @@ mod runs { assets: None, mcp_servers: Default::default(), github: None, + ..Default::default() }) .unwrap() } @@ -1430,7 +1431,7 @@ mod workflows { ] } - fn run_config_to_api(cfg: fabro_config::run::WorkflowRunConfig) -> RunConfiguration { + fn run_config_to_api(cfg: fabro_config::FabroConfig) -> RunConfiguration { fn strip_nulls(val: serde_json::Value) -> serde_json::Value { match val { serde_json::Value::Object(map) => serde_json::Value::Object( @@ -1454,10 +1455,10 @@ 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::run::WorkflowRunConfig { - version: 1, + config: run_config_to_api(fabro_config::FabroConfig { + version: Some(1), goal: Some("Diagnose and fix CI build failures".into()), - graph: "fix_build.fabro".into(), + graph: Some("fix_build.fabro".into()), work_dir: None, llm: Some(fabro_config::run::LlmConfig { model: Some("claude-sonnet".into()), @@ -1499,6 +1500,7 @@ mod workflows { assets: None, mcp_servers: Default::default(), github: None, + ..Default::default() }), graph: r#"digraph fix_build { graph [ @@ -1524,10 +1526,10 @@ 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::run::WorkflowRunConfig { - version: 1, + config: run_config_to_api(fabro_config::FabroConfig { + version: Some(1), goal: Some("Implement feature from technical blueprint".into()), - graph: "implement.fabro".into(), + graph: Some("implement.fabro".into()), work_dir: None, llm: Some(fabro_config::run::LlmConfig { model: Some("claude-sonnet".into()), @@ -1573,6 +1575,7 @@ mod workflows { assets: None, mcp_servers: Default::default(), github: None, + ..Default::default() }), graph: r#"digraph implement { graph [ @@ -1612,10 +1615,10 @@ 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::run::WorkflowRunConfig { - version: 1, + config: run_config_to_api(fabro_config::FabroConfig { + version: Some(1), goal: Some("Detect and reconcile configuration drift across environments".into()), - graph: "sync_drift.fabro".into(), + graph: Some("sync_drift.fabro".into()), work_dir: None, llm: Some(fabro_config::run::LlmConfig { model: Some("claude-sonnet".into()), @@ -1659,6 +1662,7 @@ mod workflows { assets: None, mcp_servers: Default::default(), github: None, + ..Default::default() }), graph: r#"digraph sync { graph [ @@ -1688,10 +1692,10 @@ 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::run::WorkflowRunConfig { - version: 1, + config: run_config_to_api(fabro_config::FabroConfig { + version: Some(1), goal: Some("Propose and implement incremental product improvements".into()), - graph: "expand.fabro".into(), + graph: Some("expand.fabro".into()), work_dir: None, llm: Some(fabro_config::run::LlmConfig { model: Some("claude-sonnet".into()), @@ -1734,6 +1738,7 @@ mod workflows { assets: None, mcp_servers: Default::default(), github: None, + ..Default::default() }), graph: r#"digraph expand { graph [ @@ -3237,68 +3242,67 @@ mod insights { mod settings { use fabro_config::server::*; + use fabro_config::FabroConfig; pub fn server_config() -> serde_json::Value { - serde_json::to_value(ServerConfig { + serde_json::to_value(FabroConfig { data_dir: Some("/home/fabro/.fabro".into()), max_concurrent_runs: Some(10), - web: WebConfig { + web: Some(WebConfig { url: "https://arc.example.com".into(), auth: AuthConfig { provider: AuthProvider::Github, allowed_usernames: vec!["brynary".into(), "alice".into()], }, - }, - api: ApiConfig { + }), + api: Some(ApiConfig { base_url: "https://api.fabro.example.com".into(), authentication_strategies: vec![ApiAuthStrategy::Jwt], tls: None, - }, - git: GitConfig { + }), + git: Some(GitConfig { provider: GitProvider::Github, app_id: Some("12345".into()), client_id: Some("Iv1.abc123".into()), slug: Some("fabro-dev".into()), author: Default::default(), webhooks: None, - }, - features: Features { + }), + features: Some(Features { session_sandboxes: false, retros: false, - }, + }), log: Default::default(), - run_defaults: fabro_config::run::RunDefaults { - work_dir: None, - llm: Some(fabro_config::run::LlmConfig { - model: Some("claude-sonnet".into()), - provider: Some("anthropic".into()), - fallbacks: None, + llm: Some(fabro_config::run::LlmConfig { + model: Some("claude-sonnet".into()), + provider: Some("anthropic".into()), + fallbacks: None, + }), + setup: None, + sandbox: Some(fabro_config::sandbox::SandboxConfig { + provider: Some("daytona".into()), + preserve: None, + devcontainer: None, + local: None, + daytona: Some(fabro_sandbox::daytona::DaytonaConfig { + auto_stop_interval: Some(60), + labels: None, + snapshot: None, + network: Some(fabro_sandbox::daytona::DaytonaNetwork::Block), + skip_clone: false, }), - setup: None, - sandbox: Some(fabro_config::sandbox::SandboxConfig { - provider: Some("daytona".into()), - preserve: None, - devcontainer: None, - local: None, - daytona: Some(fabro_sandbox::daytona::DaytonaConfig { - auto_stop_interval: Some(60), - labels: None, - snapshot: None, - network: Some(fabro_sandbox::daytona::DaytonaNetwork::Block), - skip_clone: false, - }), - exe: None, - ssh: None, - env: None, - }), - vars: None, - checkpoint: Default::default(), - pull_request: None, - assets: None, - hooks: vec![], - mcp_servers: Default::default(), - github: None, - }, + exe: None, + ssh: None, + env: None, + }), + vars: None, + checkpoint: Default::default(), + pull_request: None, + assets: None, + hooks: vec![], + mcp_servers: Default::default(), + github: None, + ..Default::default() }) .unwrap() } diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 9beabb5da..ffb78c120 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -6,6 +6,7 @@ pub mod serve; pub mod server; pub mod server_config { pub use fabro_config::server::*; + pub use fabro_config::FabroConfig; } 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 33b944446..678eac8a2 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::server::ServerConfig; +use fabro_config::FabroConfig; use crate::jwt_auth::{AuthMode, AuthStrategy}; use crate::server::build_router; @@ -121,13 +121,14 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: let (auth_mode, client_auth, max_concurrent_runs) = { let cfg = shared_config.read().expect("config lock poisoned"); - let auth_mode = - crate::jwt_auth::resolve_auth_mode(&cfg.api, cfg.web.auth.allowed_usernames.clone()); - let client_auth = cfg - .api - .tls + let api = cfg.api.clone().unwrap_or_default(); + let allowed_usernames = cfg + .web .as_ref() - .map(|_| client_auth_from_mode(&auth_mode)); + .map(|w| w.auth.allowed_usernames.clone()) + .unwrap_or_default(); + let auth_mode = crate::jwt_auth::resolve_auth_mode(&api, allowed_usernames); + let client_auth = api.tls.as_ref().map(|_| client_auth_from_mode(&auth_mode)); let max_concurrent_runs = args .max_concurrent_runs .or(cfg.max_concurrent_runs) @@ -137,14 +138,15 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: let git_author = { let cfg = shared_config.read().expect("config lock poisoned"); + let author = cfg.git_author(); fabro_workflows::git::GitAuthor::from_options( - cfg.git.author.name.clone(), - cfg.git.author.email.clone(), + author.and_then(|a| a.name.clone()), + author.and_then(|a| a.email.clone()), ) }; let hooks = { let cfg = shared_config.read().expect("config lock poisoned"); - cfg.run_defaults.hooks.clone() + cfg.hooks.clone() }; let state = crate::server::create_app_state_with_options( db, @@ -176,10 +178,10 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: // Optionally start webhook listener let webhook_app_id = { let cfg = shared_config.read().expect("config lock poisoned"); - match (&cfg.git.webhooks, &cfg.git.app_id) { - (Some(_), Some(app_id)) => Some(app_id.clone()), - _ => None, - } + cfg.git + .as_ref() + .and_then(|g| g.webhooks.as_ref().and(g.app_id.as_ref())) + .cloned() }; let webhook_manager = match webhook_app_id { Some(app_id) => { @@ -242,8 +244,8 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: .read() .expect("config lock poisoned") .api - .tls - .clone(); + .as_ref() + .and_then(|a| a.tls.clone()); if let Some(ref tls_config) = tls_config { let client_auth = client_auth.unwrap(); @@ -267,21 +269,13 @@ 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) { let cfg = shared_config.read().expect("config lock poisoned"); - let config_provider = cfg - .run_defaults - .llm - .as_ref() - .and_then(|l| l.provider.as_deref()); - let config_model = cfg - .run_defaults - .llm - .as_ref() - .and_then(|l| l.model.as_deref()); + let config_provider = cfg.llm.as_ref().and_then(|l| l.provider.as_deref()); + let config_model = cfg.llm.as_ref().and_then(|l| l.model.as_deref()); let provider_str = cli_provider.or(config_provider); let model = cli_model diff --git a/lib/crates/fabro-api/tests/openapi_conformance.rs b/lib/crates/fabro-api/tests/openapi_conformance.rs index 289912d81..2e42f09b3 100644 --- a/lib/crates/fabro-api/tests/openapi_conformance.rs +++ b/lib/crates/fabro-api/tests/openapi_conformance.rs @@ -236,20 +236,20 @@ fn compare_schema( } } -/// Build a ServerConfig with every Option set to Some so all keys appear +/// Build a FabroConfig with every Option set to Some so all keys appear /// in the serialized JSON. -fn fully_populated_server_config() -> ServerConfig { - ServerConfig { +fn fully_populated_server_config() -> FabroConfig { + FabroConfig { data_dir: Some("/data".into()), max_concurrent_runs: Some(10), - web: WebConfig { + web: Some(WebConfig { url: "https://example.com".into(), auth: AuthConfig { provider: AuthProvider::Github, allowed_usernames: vec!["user".into()], }, - }, - api: ApiConfig { + }), + api: Some(ApiConfig { base_url: "https://api.example.com".into(), authentication_strategies: vec![ApiAuthStrategy::Jwt], tls: Some(TlsConfig { @@ -257,8 +257,8 @@ fn fully_populated_server_config() -> ServerConfig { key: "k".into(), ca: "ca".into(), }), - }, - git: GitConfig { + }), + git: Some(GitConfig { provider: GitProvider::Github, app_id: Some("123".into()), client_id: Some("456".into()), @@ -270,118 +270,129 @@ fn fully_populated_server_config() -> ServerConfig { webhooks: Some(WebhookConfig { strategy: WebhookStrategy::TailscaleFunnel, }), - }, - features: Features { + }), + features: Some(Features { session_sandboxes: true, retros: false, - }, - log: LogConfig { + }), + log: Some(LogConfig { level: Some("debug".into()), - }, - run_defaults: RunDefaults { - work_dir: Some("/work".into()), - llm: Some(LlmConfig { - model: Some("m".into()), - provider: Some("p".into()), - fallbacks: Some(Default::default()), - }), - setup: Some(SetupConfig { - commands: vec!["echo hi".into()], - timeout_ms: Some(5000), - }), - sandbox: Some(SandboxConfig { - provider: Some("daytona".into()), - preserve: Some(true), - devcontainer: None, - local: None, - daytona: Some(DaytonaConfig { - auto_stop_interval: Some(60), - labels: Some(Default::default()), - snapshot: Some(DaytonaSnapshotConfig { - name: "snap".into(), - cpu: Some(2), - memory: Some(4), - disk: Some(10), - dockerfile: Some(DockerfileSource::Inline("FROM x".into())), - }), - network: Some(DaytonaNetwork::Block), - skip_clone: false, + }), + work_dir: Some("/work".into()), + llm: Some(LlmConfig { + model: Some("m".into()), + provider: Some("p".into()), + fallbacks: Some(Default::default()), + }), + setup: Some(SetupConfig { + commands: vec!["echo hi".into()], + timeout_ms: Some(5000), + }), + sandbox: Some(SandboxConfig { + provider: Some("daytona".into()), + preserve: Some(true), + devcontainer: None, + local: None, + daytona: Some(DaytonaConfig { + auto_stop_interval: Some(60), + labels: Some(Default::default()), + snapshot: Some(DaytonaSnapshotConfig { + name: "snap".into(), + cpu: Some(2), + memory: Some(4), + disk: Some(10), + dockerfile: Some(DockerfileSource::Inline("FROM x".into())), }), - exe: Some(fabro_sandbox::exe::ExeConfig { image: None }), - ssh: None, - env: Some(Default::default()), + network: Some(DaytonaNetwork::Block), + skip_clone: false, }), - vars: Some(Default::default()), - checkpoint: CheckpointConfig { - exclude_globs: vec![], - }, - pull_request: Some(PullRequestConfig { - enabled: true, - draft: false, - auto_merge: false, - merge_strategy: MergeStrategy::Squash, - }), - assets: Some(AssetsConfig { - include: vec!["test-results/**".into()], - }), - // One hook per HookType variant so the key union covers all fields. - hooks: vec![ - HookDefinition { - name: Some("cmd".into()), - event: HookEvent::RunStart, - command: Some("echo".into()), - hook_type: None, - matcher: Some("*".into()), - blocking: Some(true), - timeout_ms: Some(5000), - sandbox: Some(true), - }, - HookDefinition { - name: Some("http".into()), - event: HookEvent::RunStart, - command: None, - hook_type: Some(HookType::Http { - url: "http://x".into(), - headers: Some(Default::default()), - allowed_env_vars: vec!["X".into()], - tls: TlsMode::Verify, - }), - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }, - HookDefinition { - name: Some("prompt".into()), - event: HookEvent::RunStart, - command: None, - hook_type: Some(HookType::Prompt { - prompt: "hi".into(), - model: Some("m".into()), - }), - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }, - HookDefinition { - name: Some("agent".into()), - event: HookEvent::RunStart, - command: None, - hook_type: Some(HookType::Agent { - prompt: "hi".into(), - model: Some("m".into()), - max_tool_rounds: Some(5), - }), - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }, - ], - mcp_servers: Default::default(), - github: None, + exe: Some(fabro_sandbox::exe::ExeConfig { image: None }), + ssh: None, + env: Some(Default::default()), + }), + vars: Some(Default::default()), + checkpoint: CheckpointConfig { + exclude_globs: vec!["**/node_modules/**".into()], }, + pull_request: Some(PullRequestConfig { + enabled: true, + draft: false, + auto_merge: false, + merge_strategy: MergeStrategy::Squash, + }), + assets: Some(AssetsConfig { + include: vec!["test-results/**".into()], + }), + // One hook per HookType variant so the key union covers all fields. + hooks: vec![ + HookDefinition { + name: Some("cmd".into()), + event: HookEvent::RunStart, + command: Some("echo".into()), + hook_type: None, + matcher: Some("*".into()), + blocking: Some(true), + timeout_ms: Some(5000), + sandbox: Some(true), + }, + HookDefinition { + name: Some("http".into()), + event: HookEvent::RunStart, + command: None, + hook_type: Some(HookType::Http { + url: "http://x".into(), + headers: Some(Default::default()), + allowed_env_vars: vec!["X".into()], + tls: TlsMode::Verify, + }), + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }, + HookDefinition { + name: Some("prompt".into()), + event: HookEvent::RunStart, + command: None, + hook_type: Some(HookType::Prompt { + prompt: "hi".into(), + model: Some("m".into()), + }), + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }, + HookDefinition { + name: Some("agent".into()), + event: HookEvent::RunStart, + command: None, + hook_type: Some(HookType::Agent { + prompt: "hi".into(), + model: Some("m".into()), + max_tool_rounds: Some(5), + }), + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }, + ], + mcp_servers: std::collections::HashMap::from([( + "test".into(), + fabro_config::mcp::McpServerEntry { + transport: fabro_config::mcp::McpTransport::Stdio { + command: vec!["echo".into()], + env: Default::default(), + }, + startup_timeout_secs: fabro_config::mcp::default_startup_timeout_secs(), + tool_timeout_secs: fabro_config::mcp::default_tool_timeout_secs(), + }, + )]), + github: Some(GitHubConfig { + permissions: std::collections::HashMap::from([("contents".into(), "read".into())]), + }), + ..Default::default() } } diff --git a/lib/crates/fabro-cli/src/cli_config.rs b/lib/crates/fabro-cli/src/cli_config.rs index 6812a1a95..9c1bc47b0 100644 --- a/lib/crates/fabro-cli/src/cli_config.rs +++ b/lib/crates/fabro-cli/src/cli_config.rs @@ -18,7 +18,7 @@ const DEFAULT_SERVER_URL: &str = "http://localhost:3000"; pub fn resolve_mode( cli_mode: Option, cli_server_url: Option<&str>, - config: &CliConfig, + config: &FabroConfig, ) -> ResolvedMode { let mode = cli_mode.or_else(|| config.mode.clone()).unwrap_or_default(); @@ -80,7 +80,7 @@ mod tests { #[test] fn resolve_mode_defaults_to_standalone() { - let config = CliConfig::default(); + let config = FabroConfig::default(); let resolved = resolve_mode(None, None, &config); assert_eq!(resolved.mode, ExecutionMode::Standalone); assert_eq!(resolved.server_base_url, DEFAULT_SERVER_URL); @@ -89,13 +89,13 @@ mod tests { #[test] fn resolve_mode_config_overrides_default() { - let config = CliConfig { + let config = FabroConfig { mode: Some(ExecutionMode::Server), server: Some(ServerDefaults { base_url: Some("https://config.example.com".to_string()), tls: None, }), - ..CliConfig::default() + ..FabroConfig::default() }; let resolved = resolve_mode(None, None, &config); assert_eq!(resolved.mode, ExecutionMode::Server); @@ -104,13 +104,13 @@ mod tests { #[test] fn resolve_mode_cli_overrides_config() { - let config = CliConfig { + let config = FabroConfig { mode: Some(ExecutionMode::Standalone), server: Some(ServerDefaults { base_url: Some("https://config.example.com".to_string()), tls: None, }), - ..CliConfig::default() + ..FabroConfig::default() }; let resolved = resolve_mode( Some(ExecutionMode::Server), @@ -123,12 +123,12 @@ mod tests { #[test] fn resolve_mode_cli_url_overrides_config_url() { - let config = CliConfig { + let config = FabroConfig { server: Some(ServerDefaults { base_url: Some("https://config.example.com".to_string()), tls: None, }), - ..CliConfig::default() + ..FabroConfig::default() }; let resolved = resolve_mode(None, Some("https://cli.example.com"), &config); assert_eq!(resolved.server_base_url, "https://cli.example.com"); @@ -141,12 +141,12 @@ mod tests { key: PathBuf::from("key.pem"), ca: PathBuf::from("ca.pem"), }; - let config = CliConfig { + let config = FabroConfig { server: Some(ServerDefaults { base_url: None, tls: Some(tls.clone()), }), - ..CliConfig::default() + ..FabroConfig::default() }; let resolved = resolve_mode(None, None, &config); assert_eq!(resolved.tls, Some(tls)); diff --git a/lib/crates/fabro-cli/src/commands/create.rs b/lib/crates/fabro-cli/src/commands/create.rs index c7b8b460d..5476198b0 100644 --- a/lib/crates/fabro-cli/src/commands/create.rs +++ b/lib/crates/fabro-cli/src/commands/create.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use chrono::Utc; -use fabro_config::run::RunDefaults; +use fabro_config::config::FabroConfig; use fabro_workflows::manifest::Manifest; use fabro_workflows::run_spec::RunSpec; @@ -15,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: RunDefaults, + run_defaults: FabroConfig, styles: &Styles, quiet: bool, ) -> anyhow::Result<(String, PathBuf)> { diff --git a/lib/crates/fabro-cli/src/commands/resume.rs b/lib/crates/fabro-cli/src/commands/resume.rs index 7527799c6..ce8724d6a 100644 --- a/lib/crates/fabro-cli/src/commands/resume.rs +++ b/lib/crates/fabro-cli/src/commands/resume.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use anyhow::{bail, Context}; use clap::Args; use fabro_agent::{DockerSandbox, DockerSandboxConfig, Sandbox, WorktreeConfig, WorktreeSandbox}; -use fabro_config::run::{RunDefaults, WorkflowRunConfig}; +use fabro_config::config::FabroConfig; use fabro_graphviz::graph::Graph; use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, Interviewer}; use fabro_model::{Catalog, Provider}; @@ -98,7 +98,7 @@ struct ResumeContext { graph: Graph, run_id: String, run_dir: PathBuf, - run_cfg: Option, + run_cfg: Option, sandbox: Arc, /// Kept as Arc so the sandbox event callbacks can emit through it. Listeners /// that need to be added later (e.g. ProgressUI) are registered separately. @@ -158,7 +158,7 @@ fn preferred_resume_repo_path( /// or the workflow cannot be resumed. pub async fn resume_command( args: ResumeArgs, - mut run_defaults: RunDefaults, + mut run_defaults: FabroConfig, styles: &'static Styles, github_app: Option, git_author: fabro_workflows::git::GitAuthor, @@ -168,7 +168,7 @@ pub async fn resume_command( project_config::discover_project_config(&std::env::current_dir().unwrap_or_default()) { tracing::debug!("Applying run defaults from fabro.toml"); - run_defaults.merge_overlay(project_config.into_run_defaults()); + run_defaults.merge_overlay(project_config); } let ctx = if args.checkpoint.is_some() { @@ -183,7 +183,7 @@ pub async fn resume_command( /// Checkpoint-file path: load checkpoint and graph from files, resolve sandbox from flags/config. async fn prepare_from_checkpoint( args: &ResumeArgs, - run_defaults: &RunDefaults, + run_defaults: &FabroConfig, styles: &Styles, github_app: &Option, git_author: fabro_workflows::git::GitAuthor, @@ -225,7 +225,7 @@ async fn prepare_from_checkpoint( .context("Failed to activate per-run log")?; let status_guard = DetachedRunBootstrapGuard::arm(&run_dir)?; tokio::fs::write(run_dir.join("graph.fabro"), &source).await?; - let mut run_cfg = run_cfg; + let mut run_cfg: Option = run_cfg; write_run_config_snapshot(&run_dir, run_cfg.as_mut()).await?; let original_cwd = std::env::current_dir()?; @@ -479,7 +479,7 @@ async fn prepare_from_checkpoint( async fn prepare_from_branch( args: &ResumeArgs, styles: &Styles, - run_defaults: &RunDefaults, + run_defaults: &FabroConfig, github_app: &Option, git_author: fabro_workflows::git::GitAuthor, ) -> anyhow::Result { @@ -584,7 +584,7 @@ async fn prepare_from_branch( .context("Failed to activate per-run log")?; let status_guard = DetachedRunBootstrapGuard::arm(&run_dir)?; tokio::fs::write(run_dir.join("graph.fabro"), &graph_source).await?; - let mut run_cfg = run_cfg; + let mut run_cfg: Option = run_cfg; write_run_config_snapshot(&run_dir, run_cfg.as_mut()).await?; let emitter = Arc::new(EventEmitter::new()); @@ -867,7 +867,7 @@ async fn prepare_from_branch( async fn run_resumed( ctx: ResumeContext, args: ResumeArgs, - run_defaults: RunDefaults, + run_defaults: FabroConfig, styles: &'static Styles, ) -> anyhow::Result<()> { let ResumeContext { @@ -1158,7 +1158,7 @@ async fn run_resumed( .unwrap_or_else(|| run_defaults.mcp_servers.clone()) .clone() .into_iter() - .map(|(name, entry)| entry.into_config(name)) + .map(|(name, entry): (String, fabro_config::mcp::McpServerEntry)| entry.into_config(name)) .collect(); let registry = fabro_workflows::handler::default_registry(interviewer.clone(), { diff --git a/lib/crates/fabro-cli/src/commands/run.rs b/lib/crates/fabro-cli/src/commands/run.rs index db8ebb41c..2351c93c8 100644 --- a/lib/crates/fabro-cli/src/commands/run.rs +++ b/lib/crates/fabro-cli/src/commands/run.rs @@ -10,7 +10,7 @@ use clap::{Args, ValueEnum}; use fabro_agent::{ DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox, WorktreeConfig, WorktreeSandbox, }; -use fabro_config::run::{RunDefaults, WorkflowRunConfig}; +use fabro_config::config::FabroConfig; use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config}; use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, FileInterviewer, Interviewer}; use fabro_model::{Catalog, FallbackTarget, Provider}; @@ -223,8 +223,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<&WorkflowRunConfig>, - run_defaults: &RunDefaults, + run_cfg: Option<&FabroConfig>, + run_defaults: &FabroConfig, graph: &fabro_graphviz::graph::Graph, ) -> (String, Option) { let toml_model = run_cfg @@ -285,8 +285,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<&WorkflowRunConfig>, - run_defaults: &RunDefaults, + run_cfg: Option<&FabroConfig>, + run_defaults: &FabroConfig, ) -> 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())?; @@ -296,8 +296,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<&WorkflowRunConfig>, - run_defaults: &RunDefaults, + run_cfg: Option<&FabroConfig>, + run_defaults: &FabroConfig, ) -> bool { if cli { return true; @@ -311,8 +311,8 @@ pub(crate) fn resolve_preserve_sandbox( /// Resolve worktree mode: TOML config > run defaults > Clean. fn resolve_worktree_mode( - run_cfg: Option<&WorkflowRunConfig>, - run_defaults: &RunDefaults, + run_cfg: Option<&FabroConfig>, + run_defaults: &FabroConfig, ) -> sandbox_config::WorktreeMode { run_cfg .and_then(|c| c.sandbox.as_ref()) @@ -330,8 +330,8 @@ fn resolve_worktree_mode( /// Resolve daytona config: TOML config > run defaults. pub(crate) fn resolve_daytona_config( - run_cfg: Option<&WorkflowRunConfig>, - run_defaults: &RunDefaults, + run_cfg: Option<&FabroConfig>, + run_defaults: &FabroConfig, ) -> Option { run_cfg .and_then(|c| c.sandbox.as_ref()) @@ -347,8 +347,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<&WorkflowRunConfig>, - run_defaults: &RunDefaults, + run_cfg: Option<&FabroConfig>, + run_defaults: &FabroConfig, ) -> Option { run_cfg .and_then(|c| c.sandbox.as_ref()) @@ -377,8 +377,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<&WorkflowRunConfig>, - run_defaults: &RunDefaults, + run_cfg: Option<&FabroConfig>, + run_defaults: &FabroConfig, ) -> Option { run_cfg .and_then(|c| c.sandbox.as_ref()) @@ -411,7 +411,7 @@ pub(crate) fn resolve_ssh_clone_params( pub(crate) fn resolve_fallback_chain( provider: Provider, model: &str, - run_cfg: Option<&WorkflowRunConfig>, + run_cfg: Option<&FabroConfig>, ) -> Vec { let fallbacks = run_cfg .and_then(|c| c.llm.as_ref()) @@ -498,14 +498,14 @@ pub(crate) fn cached_run_config_path(run_dir: &Path) -> PathBuf { run_dir.join(RUN_CONFIG_FILE) } -fn serialize_run_config_snapshot(run_cfg: &mut WorkflowRunConfig) -> anyhow::Result { - run_cfg.graph = RUN_GRAPH_FILE.to_string(); +fn serialize_run_config_snapshot(run_cfg: &mut FabroConfig) -> anyhow::Result { + run_cfg.graph = Some(RUN_GRAPH_FILE.to_string()); toml::to_string_pretty(run_cfg).context("Failed to serialize run config") } pub(crate) async fn write_run_config_snapshot( run_dir: &Path, - run_cfg: Option<&mut WorkflowRunConfig>, + run_cfg: Option<&mut FabroConfig>, ) -> anyhow::Result<()> { if let Some(cfg) = run_cfg { let toml_str = serialize_run_config_snapshot(cfg)?; @@ -516,12 +516,15 @@ pub(crate) async fn write_run_config_snapshot( pub(crate) fn resolve_workflow_source( workflow_path: &Path, -) -> anyhow::Result<(PathBuf, PathBuf, Option)> { +) -> anyhow::Result<(PathBuf, PathBuf, Option)> { let path = project_config::resolve_workflow_arg(workflow_path)?; if path.extension().is_some_and(|ext| ext == "toml") { match run_config::load_run_config(&path) { Ok(cfg) => { - let dot = run_config::resolve_graph_path(&path, &cfg.graph); + let dot = run_config::resolve_graph_path( + &path, + cfg.graph.as_deref().unwrap_or("workflow.fabro"), + ); Ok((path, dot, Some(cfg))) } // Backward compatibility for detached runs created before run.toml existed. @@ -544,12 +547,12 @@ pub(crate) fn resolve_workflow_source( pub(crate) struct PreparedWorkflow { pub source: String, pub graph: fabro_graphviz::graph::Graph, - pub run_cfg: Option, + pub run_cfg: Option, pub sandbox_provider: SandboxProvider, pub model: String, pub provider: Option, pub workflow_slug: Option, - pub run_defaults: RunDefaults, + pub run_defaults: FabroConfig, } /// Resolve config, parse/validate the workflow graph, and resolve sandbox + model. @@ -558,7 +561,7 @@ pub(crate) struct PreparedWorkflow { /// `run_command` (which goes on to execute the workflow). pub(crate) fn prepare_workflow( args: &RunArgs, - run_defaults: RunDefaults, + run_defaults: FabroConfig, styles: &Styles, quiet: bool, ) -> anyhow::Result { @@ -567,7 +570,7 @@ pub(crate) fn prepare_workflow( pub(crate) fn prepare_workflow_with_project_config( args: &RunArgs, - mut run_defaults: RunDefaults, + mut run_defaults: FabroConfig, styles: &Styles, quiet: bool, apply_project_config: bool, @@ -583,17 +586,19 @@ pub(crate) fn prepare_workflow_with_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.into_run_defaults()); + run_defaults.merge_overlay(project_config); } } - // Resolve workflow arg, load run config if TOML, apply defaults + // Resolve workflow arg, load run config if TOML, merge with defaults let (resolved_workflow_path, dot_path, run_cfg) = { let (resolved, dot, cfg) = resolve_workflow_source(workflow_path)?; match cfg { - Some(mut cfg) => { - cfg.apply_defaults(&run_defaults); - (resolved, dot, Some(cfg)) + Some(cfg) => { + // run_defaults is the base; cfg (from workflow.toml) is the overlay that wins + let mut merged = run_defaults.clone(); + merged.merge_overlay(cfg); + (resolved, dot, Some(merged)) } None => (resolved, dot, None), } @@ -708,7 +713,7 @@ pub(crate) fn prepare_workflow_with_project_config( /// Returns an error if the workflow cannot be read, parsed, validated, or executed. pub async fn run_command( args: RunArgs, - run_defaults: RunDefaults, + run_defaults: FabroConfig, styles: &'static Styles, github_app: Option, git_author: fabro_workflows::git::GitAuthor, @@ -1435,7 +1440,11 @@ pub async fn run_command( servers .clone() .into_iter() - .map(|(name, entry)| entry.into_config(name)) + .map( + |(name, entry): (String, fabro_config::mcp::McpServerEntry)| { + entry.into_config(name) + }, + ) .collect() }; let registry = default_registry(interviewer.clone(), { @@ -2122,9 +2131,9 @@ pub(crate) fn print_assets(run_dir: &std::path::Path, styles: &Styles) { #[allow(clippy::too_many_arguments)] async fn run_preflight( graph: &fabro_graphviz::graph::Graph, - run_cfg: &Option, + run_cfg: &Option, args: &RunArgs, - run_defaults: &RunDefaults, + run_defaults: &FabroConfig, git_status: GitSyncStatus, sandbox_provider: SandboxProvider, styles: &'static Styles, @@ -2712,24 +2721,15 @@ mod tests { #[test] fn serialize_run_config_snapshot_rewrites_graph_path() { - let cfg = run_config::WorkflowRunConfig { - version: 1, + let cfg = FabroConfig { + version: Some(1), goal: Some("test".to_string()), - graph: "workflow.fabro".to_string(), - work_dir: None, - llm: None, - setup: None, - sandbox: None, - vars: None, - hooks: Vec::new(), - checkpoint: Default::default(), + graph: Some("workflow.fabro".to_string()), pull_request: Some(run_config::PullRequestConfig { enabled: true, ..Default::default() }), - assets: None, - mcp_servers: Default::default(), - github: None, + ..Default::default() }; let pr = cfg.pull_request.clone(); @@ -2737,7 +2737,7 @@ mod tests { let serialized = serialize_run_config_snapshot(&mut cfg).unwrap(); let reparsed = run_config::parse_run_config(&serialized).unwrap(); - assert_eq!(reparsed.graph, RUN_GRAPH_FILE); + assert_eq!(reparsed.graph.as_deref(), Some(RUN_GRAPH_FILE)); assert_eq!(reparsed.pull_request, pr); } @@ -2862,7 +2862,7 @@ include = ["*.md"] let styles = Styles::new(false); let prepared = prepare_workflow_with_project_config( &args, - RunDefaults::default(), + FabroConfig::default(), &styles, true, false, @@ -2964,7 +2964,7 @@ include = ["*.md"] #[test] fn resolve_model_provider_defaults() { let graph = fabro_graphviz::graph::Graph::new("test"); - let defaults = RunDefaults::default(); + let defaults = FabroConfig::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 @@ -2974,26 +2974,17 @@ include = ["*.md"] #[test] fn resolve_model_provider_cli_overrides_toml() { let graph = fabro_graphviz::graph::Graph::new("test"); - let defaults = RunDefaults::default(); - let cfg = run_config::WorkflowRunConfig { - version: 1, + let defaults = FabroConfig::default(); + let cfg = FabroConfig { + version: Some(1), goal: Some("test".to_string()), - graph: "test.fabro".to_string(), - work_dir: None, + graph: Some("test.fabro".to_string()), llm: Some(run_config::LlmConfig { model: Some("toml-model".to_string()), provider: Some("openai".to_string()), fallbacks: None, }), - setup: None, - sandbox: None, - vars: None, - hooks: Vec::new(), - checkpoint: Default::default(), - pull_request: None, - assets: None, - mcp_servers: Default::default(), - github: None, + ..Default::default() }; let (model, provider) = resolve_model_provider( Some("gpt-5.2"), @@ -3019,26 +3010,17 @@ include = ["*.md"] AttrValue::String("gemini".to_string()), ); - let defaults = RunDefaults::default(); - let cfg = run_config::WorkflowRunConfig { - version: 1, + let defaults = FabroConfig::default(); + let cfg = FabroConfig { + version: Some(1), goal: Some("test".to_string()), - graph: "test.fabro".to_string(), - work_dir: None, + graph: Some("test.fabro".to_string()), llm: Some(run_config::LlmConfig { model: Some("toml-model".to_string()), provider: Some("openai".to_string()), fallbacks: None, }), - setup: None, - sandbox: None, - vars: None, - hooks: Vec::new(), - checkpoint: Default::default(), - pull_request: None, - assets: None, - mcp_servers: Default::default(), - github: None, + ..Default::default() }; let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph); assert_eq!(model, "toml-model"); @@ -3058,7 +3040,7 @@ include = ["*.md"] AttrValue::String("openai".to_string()), ); - let defaults = RunDefaults::default(); + let defaults = FabroConfig::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())); @@ -3067,7 +3049,7 @@ include = ["*.md"] #[test] fn resolve_model_provider_alias_expansion() { let graph = fabro_graphviz::graph::Graph::new("test"); - let defaults = RunDefaults::default(); + let defaults = FabroConfig::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())); @@ -3076,13 +3058,13 @@ include = ["*.md"] #[test] fn resolve_model_provider_run_defaults_used() { let graph = fabro_graphviz::graph::Graph::new("test"); - let defaults = RunDefaults { + let defaults = FabroConfig { llm: Some(run_config::LlmConfig { model: Some("default-model".to_string()), provider: Some("openai".to_string()), fallbacks: None, }), - ..RunDefaults::default() + ..FabroConfig::default() }; let (model, provider) = resolve_model_provider(None, None, None, &defaults, &graph); assert_eq!(model, "default-model"); @@ -3092,33 +3074,24 @@ include = ["*.md"] #[test] fn resolve_model_provider_toml_overrides_run_defaults() { let graph = fabro_graphviz::graph::Graph::new("test"); - let defaults = RunDefaults { + let defaults = FabroConfig { llm: Some(run_config::LlmConfig { model: Some("default-model".to_string()), provider: Some("anthropic".to_string()), fallbacks: None, }), - ..RunDefaults::default() + ..FabroConfig::default() }; - let cfg = run_config::WorkflowRunConfig { - version: 1, + let cfg = FabroConfig { + version: Some(1), goal: Some("test".to_string()), - graph: "test.fabro".to_string(), - work_dir: None, + graph: Some("test.fabro".to_string()), llm: Some(run_config::LlmConfig { model: Some("toml-model".to_string()), provider: Some("openai".to_string()), fallbacks: None, }), - setup: None, - sandbox: None, - vars: None, - hooks: Vec::new(), - checkpoint: Default::default(), - pull_request: None, - assets: None, - mcp_servers: Default::default(), - github: None, + ..Default::default() }; let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph); assert_eq!(model, "toml-model"); @@ -3127,85 +3100,61 @@ include = ["*.md"] #[test] fn resolve_preserve_sandbox_cli_wins() { - let cfg = run_config::WorkflowRunConfig { - version: 1, - goal: Some("test".to_string()), - graph: "w.fabro".into(), - work_dir: None, - llm: None, - setup: None, + let cfg = FabroConfig { sandbox: Some(sandbox_config::SandboxConfig { provider: None, preserve: Some(false), ..Default::default() }), - vars: None, - hooks: Vec::new(), - checkpoint: Default::default(), - pull_request: None, - assets: None, - mcp_servers: Default::default(), - github: None, + ..Default::default() }; - let defaults = RunDefaults::default(); + let defaults = FabroConfig::default(); assert!(resolve_preserve_sandbox(true, Some(&cfg), &defaults)); } #[test] fn resolve_preserve_sandbox_toml_wins_over_defaults() { - let cfg = run_config::WorkflowRunConfig { - version: 1, - goal: Some("test".to_string()), - graph: "w.fabro".into(), - work_dir: None, - llm: None, - setup: None, + let cfg = FabroConfig { sandbox: Some(sandbox_config::SandboxConfig { provider: None, preserve: Some(true), ..Default::default() }), - vars: None, - hooks: Vec::new(), - checkpoint: Default::default(), - pull_request: None, - assets: None, - mcp_servers: Default::default(), - github: None, + ..Default::default() }; - let defaults = RunDefaults { + let defaults = FabroConfig { sandbox: Some(sandbox_config::SandboxConfig { provider: None, preserve: Some(false), ..Default::default() }), - ..RunDefaults::default() + ..FabroConfig::default() }; assert!(resolve_preserve_sandbox(false, Some(&cfg), &defaults)); } #[test] fn resolve_preserve_sandbox_defaults_used() { - let defaults = RunDefaults { + let defaults = FabroConfig { sandbox: Some(sandbox_config::SandboxConfig { provider: None, preserve: Some(true), ..Default::default() }), - ..RunDefaults::default() + ..FabroConfig::default() }; assert!(resolve_preserve_sandbox(false, None, &defaults)); } #[test] fn resolve_preserve_sandbox_defaults_to_false() { - let defaults = RunDefaults::default(); + let defaults = FabroConfig::default(); assert!(!resolve_preserve_sandbox(false, None, &defaults)); } #[test] fn resolve_worktree_mode_defaults_to_clean() { - let defaults = RunDefaults::default(); + let defaults = FabroConfig::default(); assert_eq!( resolve_worktree_mode(None, &defaults), sandbox_config::WorktreeMode::Clean @@ -3214,31 +3163,16 @@ include = ["*.md"] #[test] fn resolve_worktree_mode_from_toml() { - let cfg = run_config::WorkflowRunConfig { - version: 1, - goal: Some("test".into()), - graph: "w.fabro".into(), - work_dir: None, - llm: None, - setup: None, + let cfg = FabroConfig { sandbox: Some(sandbox_config::SandboxConfig { - provider: None, - preserve: None, - devcontainer: None, local: Some(sandbox_config::LocalSandboxConfig { worktree_mode: sandbox_config::WorktreeMode::Always, }), ..Default::default() }), - vars: None, - hooks: Vec::new(), - checkpoint: Default::default(), - pull_request: None, - assets: None, - mcp_servers: Default::default(), - github: None, + ..Default::default() }; - let defaults = RunDefaults::default(); + let defaults = FabroConfig::default(); assert_eq!( resolve_worktree_mode(Some(&cfg), &defaults), sandbox_config::WorktreeMode::Always @@ -3247,7 +3181,7 @@ include = ["*.md"] #[test] fn resolve_worktree_mode_from_defaults() { - let defaults = RunDefaults { + let defaults = FabroConfig { sandbox: Some(sandbox_config::SandboxConfig { provider: None, preserve: None, @@ -3257,7 +3191,7 @@ include = ["*.md"] }), ..Default::default() }), - ..RunDefaults::default() + ..FabroConfig::default() }; assert_eq!( resolve_worktree_mode(None, &defaults), @@ -3267,31 +3201,16 @@ include = ["*.md"] #[test] fn resolve_worktree_mode_toml_overrides_defaults() { - let cfg = run_config::WorkflowRunConfig { - version: 1, - goal: Some("test".into()), - graph: "w.fabro".into(), - work_dir: None, - llm: None, - setup: None, + let cfg = FabroConfig { sandbox: Some(sandbox_config::SandboxConfig { - provider: None, - preserve: None, - devcontainer: None, local: Some(sandbox_config::LocalSandboxConfig { worktree_mode: sandbox_config::WorktreeMode::Never, }), ..Default::default() }), - vars: None, - hooks: Vec::new(), - checkpoint: Default::default(), - pull_request: None, - assets: None, - mcp_servers: Default::default(), - github: None, + ..Default::default() }; - let defaults = RunDefaults { + let defaults = FabroConfig { sandbox: Some(sandbox_config::SandboxConfig { provider: None, preserve: None, @@ -3301,7 +3220,7 @@ include = ["*.md"] }), ..Default::default() }), - ..RunDefaults::default() + ..FabroConfig::default() }; assert_eq!( resolve_worktree_mode(Some(&cfg), &defaults), diff --git a/lib/crates/fabro-cli/src/doctor.rs b/lib/crates/fabro-cli/src/doctor.rs index 45c002ab9..9720190e6 100644 --- a/lib/crates/fabro-cli/src/doctor.rs +++ b/lib/crates/fabro-cli/src/doctor.rs @@ -946,16 +946,22 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 { let server_config = fabro_config::server::load_server_config(None).unwrap_or_default(); #[cfg(feature = "server")] - let api_status = ApiStatus { - base_url: server_config.api.base_url.clone(), - authentication_strategies: server_config.api.authentication_strategies.clone(), + let api_status = { + let api = server_config.api.unwrap_or_default(); + ApiStatus { + base_url: api.base_url.clone(), + authentication_strategies: api.authentication_strategies.clone(), + } }; #[cfg(feature = "server")] - let web_status = WebStatus { - url: server_config.web.url.clone(), - auth_provider: server_config.web.auth.provider.clone(), - allowed_usernames_count: server_config.web.auth.allowed_usernames.len(), + let web_status = { + let web = server_config.web.unwrap_or_default(); + WebStatus { + url: web.url.clone(), + auth_provider: web.auth.provider.clone(), + allowed_usernames_count: web.auth.allowed_usernames.len(), + } }; let git_app_id = cli_config.app_id().map(str::to_owned); diff --git a/lib/crates/fabro-cli/src/install.rs b/lib/crates/fabro-cli/src/install.rs index 55b21b020..589081db9 100644 --- a/lib/crates/fabro-cli/src/install.rs +++ b/lib/crates/fabro-cli/src/install.rs @@ -931,18 +931,18 @@ mod tests { #[cfg(feature = "server")] fn config_toml_roundtrips() { let toml_str = format_config_toml("brynary"); - let config: fabro_config::server::ServerConfig = + let config: fabro_config::FabroConfig = toml::from_str(&toml_str).expect("config should parse"); - assert_eq!(config.web.auth.allowed_usernames, vec!["brynary"]); + assert_eq!(config.web.unwrap().auth.allowed_usernames, vec!["brynary"]); } #[test] #[cfg(feature = "server")] fn config_toml_has_auth_strategies() { let toml_str = format_config_toml("alice"); - let config: fabro_config::server::ServerConfig = toml::from_str(&toml_str).unwrap(); + let config: fabro_config::FabroConfig = toml::from_str(&toml_str).unwrap(); assert_eq!( - config.api.authentication_strategies, + config.api.unwrap().authentication_strategies, vec![ fabro_config::server::ApiAuthStrategy::Jwt, fabro_config::server::ApiAuthStrategy::Mtls, @@ -955,8 +955,8 @@ mod tests { fn config_toml_has_tls_paths() { use std::path::PathBuf; let toml_str = format_config_toml("bob"); - let config: fabro_config::server::ServerConfig = toml::from_str(&toml_str).unwrap(); - let tls = config.api.tls.expect("tls should be set"); + let config: fabro_config::FabroConfig = 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")); assert_eq!(tls.ca, PathBuf::from("~/.fabro/certs/ca.crt")); diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index fa6844aa9..06ee75016 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -388,15 +388,7 @@ async fn run_engine_entrypoint( run_id: Some(spec.run_id), }; - match commands::run::run_command( - run_args, - cli_config.run_defaults, - styles, - github_app, - git_author, - ) - .await - { + match commands::run::run_command(run_args, cli_config, styles, github_app, git_author).await { Ok(()) => Ok(()), Err(err) => { let _ = commands::detached_support::persist_detached_failure( @@ -566,12 +558,18 @@ async fn main_inner() -> (String, Result<()>) { { if let Command::Serve(ref args) = cli.command { match fabro_config::server::load_server_config(args.config.as_deref()) { - Ok(server_config) => (server_config.log.level, false), + Ok(server_config) => ( + server_config.log.as_ref().and_then(|l| l.level.clone()), + false, + ), Err(err) => return (command_name, Err(err)), } } else { match fabro_config::cli::load_cli_config(None) { - Ok(cli_config) => (cli_config.log.level, cli_config.upgrade_check), + Ok(cli_config) => ( + cli_config.log.as_ref().and_then(|l| l.level.clone()), + cli_config.upgrade_check.unwrap_or(true), + ), Err(err) => return (command_name, Err(err)), } } @@ -579,7 +577,10 @@ async fn main_inner() -> (String, Result<()>) { #[cfg(not(feature = "server"))] { match fabro_config::cli::load_cli_config(None) { - Ok(cli_config) => (cli_config.log.level, cli_config.upgrade_check), + Ok(cli_config) => ( + cli_config.log.as_ref().and_then(|l| l.level.clone()), + cli_config.upgrade_check.unwrap_or(true), + ), Err(err) => return (command_name, Err(err)), } } @@ -614,7 +615,7 @@ async fn main_inner() -> (String, Result<()>) { match cli.command { Command::Llm { command } => { let cli_config = cli_config::load_cli_config(None)?; - let llm_defaults = cli_config.run_defaults.llm.as_ref(); + let llm_defaults = cli_config.llm.as_ref(); match command { LlmCommand::Prompt(mut args) => { if args.model.is_none() { @@ -683,7 +684,8 @@ async fn main_inner() -> (String, Result<()>) { Command::Exec(mut args) => { let cli_config = cli_config::load_cli_config(None)?; #[cfg(feature = "sleep_inhibitor")] - let _sleep_guard = fabro_beastie::guard(cli_config.prevent_idle_sleep); + let _sleep_guard = + fabro_beastie::guard(cli_config.prevent_idle_sleep.unwrap_or(false)); let exec_defaults = cli_config.exec.as_ref(); args.apply_cli_defaults( exec_defaults.and_then(|a| a.provider.as_deref()), @@ -695,10 +697,13 @@ async fn main_inner() -> (String, Result<()>) { let resolved = cli_config::resolve_mode(cli.mode, cli.server_url.as_deref(), &cli_config); let mcp_servers: Vec = cli_config - .run_defaults .mcp_servers .into_iter() - .map(|(name, entry)| entry.into_config(name)) + .map( + |(name, entry): (String, fabro_config::mcp::McpServerEntry)| { + entry.into_config(name) + }, + ) .collect(); #[cfg(feature = "server")] { @@ -748,7 +753,7 @@ async fn main_inner() -> (String, 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)?; - args.verbose = args.verbose || cli_config.verbose; + args.verbose = args.verbose || cli_config.verbose.unwrap_or(false); if args.preflight { // Preflight validates config without creating a run dir. @@ -758,23 +763,17 @@ async fn main_inner() -> (String, Result<()>) { cli_config.git_author().and_then(|a| a.name.clone()), cli_config.git_author().and_then(|a| a.email.clone()), ); - commands::run::run_command( - args, - cli_config.run_defaults, - styles, - github_app, - git_author, - ) - .await?; + commands::run::run_command(args, cli_config, styles, github_app, git_author) + .await?; } else { // Unified path: create + start (+ attach for foreground) let quiet = args.detach; + let _prevent_idle_sleep = cli_config.prevent_idle_sleep; let (run_id, run_dir) = - commands::create::create_run(&args, cli_config.run_defaults, styles, quiet) - .await?; + commands::create::create_run(&args, cli_config, styles, quiet).await?; #[cfg(feature = "sleep_inhibitor")] - let _sleep_guard = fabro_beastie::guard(cli_config.prevent_idle_sleep); + let _sleep_guard = fabro_beastie::guard(_prevent_idle_sleep.unwrap_or(false)); let child = commands::start::start_run(&run_dir)?; @@ -796,8 +795,7 @@ async fn main_inner() -> (String, Result<()>) { Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr())); let cli_config = cli_config::load_cli_config(None)?; let (run_id, _run_dir) = - commands::create::create_run(&args, cli_config.run_defaults, styles, true) - .await?; + commands::create::create_run(&args, cli_config, styles, true).await?; println!("{run_id}"); } Command::Start { run } => { @@ -897,7 +895,7 @@ async fn main_inner() -> (String, Result<()>) { } Command::Doctor { verbose, dry_run } => { let cli_config = cli_config::load_cli_config(None)?; - let verbose = verbose || cli_config.verbose; + let verbose = verbose || cli_config.verbose.unwrap_or(false); let exit_code = doctor::run_doctor(verbose, !dry_run).await; std::process::exit(exit_code); } @@ -975,22 +973,17 @@ async fn main_inner() -> (String, 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)?; - args.verbose = args.verbose || cli_config.verbose; + args.verbose = args.verbose || cli_config.verbose.unwrap_or(false); #[cfg(feature = "sleep_inhibitor")] - let _sleep_guard = fabro_beastie::guard(cli_config.prevent_idle_sleep); + let _sleep_guard = + fabro_beastie::guard(cli_config.prevent_idle_sleep.unwrap_or(false)); let github_app = build_github_app_credentials(cli_config.app_id()); let git_author = fabro_workflows::git::GitAuthor::from_options( cli_config.git_author().and_then(|a| a.name.clone()), cli_config.git_author().and_then(|a| a.email.clone()), ); - commands::resume::resume_command( - args, - cli_config.run_defaults, - styles, - github_app, - git_author, - ) - .await?; + commands::resume::resume_command(args, cli_config, styles, github_app, git_author) + .await?; } Command::Rewind(args) => { let styles = fabro_util::terminal::Styles::detect_stderr(); diff --git a/lib/crates/fabro-config/src/cli.rs b/lib/crates/fabro-config/src/cli.rs index 4c58f1c59..4f88e8bc9 100644 --- a/lib/crates/fabro-config/src/cli.rs +++ b/lib/crates/fabro-config/src/cli.rs @@ -1,10 +1,8 @@ use std::path::{Path, PathBuf}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -use crate::run::RunDefaults; - -#[derive(Clone, Copy, Debug, PartialEq, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[serde(rename_all = "kebab-case")] pub enum OutputFormat { @@ -12,7 +10,7 @@ pub enum OutputFormat { Json, } -#[derive(Clone, Copy, Debug, PartialEq, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[serde(rename_all = "kebab-case")] pub enum PermissionLevel { @@ -21,7 +19,7 @@ pub enum PermissionLevel { Full, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "lowercase")] pub enum ExecutionMode { #[default] @@ -29,20 +27,20 @@ pub enum ExecutionMode { Server, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] pub struct ClientTlsConfig { pub cert: PathBuf, pub key: PathBuf, pub ca: PathBuf, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] pub struct ServerDefaults { pub base_url: Option, pub tls: Option, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] pub struct ExecDefaults { pub provider: Option, pub model: Option, @@ -50,87 +48,23 @@ pub struct ExecDefaults { pub output_format: Option, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq)] -pub struct CliGitConfig { - pub app_id: Option, - pub slug: Option, - pub client_id: Option, - #[serde(default)] - pub author: crate::server::GitAuthorConfig, -} - -fn default_upgrade_check() -> bool { - true -} - -#[derive(Clone, Debug, Deserialize, PartialEq)] -pub struct CliConfig { - pub mode: Option, - pub server: Option, - pub exec: Option, - pub git: Option, - #[serde(default)] - pub prevent_idle_sleep: bool, - #[serde(default)] - pub verbose: bool, - #[serde(default = "default_upgrade_check")] - pub upgrade_check: bool, - #[serde(default)] - pub log: crate::server::LogConfig, - #[serde(flatten)] - pub run_defaults: RunDefaults, -} - -impl Default for CliConfig { - fn default() -> Self { - Self { - mode: Default::default(), - server: Default::default(), - exec: Default::default(), - git: Default::default(), - prevent_idle_sleep: false, - verbose: false, - upgrade_check: true, - log: Default::default(), - run_defaults: Default::default(), - } - } -} - -impl CliConfig { - 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) - } -} - /// 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 { +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: CliConfig = toml::from_str("").unwrap(); - assert_eq!(config, CliConfig::default()); + let config: FabroConfig = toml::from_str("").unwrap(); + assert_eq!(config, FabroConfig::default()); } #[test] @@ -145,13 +79,13 @@ output_format = "text" [llm] model = "claude-sonnet-4-5" "#; - let config: CliConfig = toml::from_str(toml).unwrap(); + 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.run_defaults.llm.unwrap(); + let llm = config.llm.unwrap(); assert_eq!(llm.model.as_deref(), Some("claude-sonnet-4-5")); } @@ -161,13 +95,13 @@ model = "claude-sonnet-4-5" [exec] provider = "openai" "#; - let config: CliConfig = toml::from_str(toml).unwrap(); + 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.run_defaults.llm, None); + assert_eq!(config.llm, None); } #[test] @@ -200,20 +134,20 @@ model = "gemini-pro" #[test] fn parse_mode_server() { let toml = r#"mode = "server""#; - let config: CliConfig = toml::from_str(toml).unwrap(); + 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: CliConfig = toml::from_str(toml).unwrap(); + let config: FabroConfig = toml::from_str(toml).unwrap(); assert_eq!(config.mode, Some(ExecutionMode::Standalone)); } #[test] fn parse_mode_absent() { - let config: CliConfig = toml::from_str("").unwrap(); + let config: FabroConfig = toml::from_str("").unwrap(); assert_eq!(config.mode, None); } @@ -223,7 +157,7 @@ model = "gemini-pro" [server] base_url = "https://arc.example.com:3000" "#; - let config: CliConfig = toml::from_str(toml).unwrap(); + let config: FabroConfig = toml::from_str(toml).unwrap(); let server = config.server.unwrap(); assert_eq!( server.base_url.as_deref(), @@ -243,7 +177,7 @@ cert = "~/.fabro/tls/client.crt" key = "~/.fabro/tls/client.key" ca = "~/.fabro/tls/ca.crt" "#; - let config: CliConfig = toml::from_str(toml).unwrap(); + 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")); @@ -257,7 +191,7 @@ ca = "~/.fabro/tls/ca.crt" name = "my-arc" email = "me@local" "#; - let config: CliConfig = toml::from_str(toml).unwrap(); + 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")); @@ -265,33 +199,36 @@ email = "me@local" #[test] fn parse_git_author_absent() { - let config: CliConfig = toml::from_str("").unwrap(); + let config: FabroConfig = toml::from_str("").unwrap(); assert_eq!(config.git, None); } #[test] fn parse_prevent_idle_sleep_true() { - let config: CliConfig = toml::from_str("prevent_idle_sleep = true").unwrap(); - assert!(config.prevent_idle_sleep); + 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_false() { - let config: CliConfig = toml::from_str("").unwrap(); - assert!(!config.prevent_idle_sleep); + 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: CliConfig = toml::from_str("verbose = true").unwrap(); - assert!(config.verbose); + 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: CliConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.log.level.as_deref(), Some("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] @@ -300,15 +237,15 @@ email = "me@local" [pull_request] enabled = true "#; - let config: CliConfig = toml::from_str(toml).unwrap(); - let pr = config.run_defaults.pull_request.unwrap(); + 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: CliConfig = toml::from_str("").unwrap(); - assert_eq!(config.run_defaults.pull_request, None); + let config: FabroConfig = toml::from_str("").unwrap(); + assert_eq!(config.pull_request, None); } #[test] @@ -322,7 +259,7 @@ slug = "my-app" name = "fabro-bot" email = "arc@test.com" "#; - let config: CliConfig = toml::from_str(toml).unwrap(); + 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")); @@ -337,7 +274,7 @@ app_id = "12345" slug = "my-app" client_id = "Iv1.abc123" "#; - let config: CliConfig = toml::from_str(toml).unwrap(); + 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")); @@ -350,8 +287,8 @@ client_id = "Iv1.abc123" model = "claude-sonnet-4-5" provider = "anthropic" "#; - let config: CliConfig = toml::from_str(toml).unwrap(); - let llm = config.run_defaults.llm.unwrap(); + 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")); } @@ -362,8 +299,8 @@ provider = "anthropic" [sandbox] provider = "daytona" "#; - let config: CliConfig = toml::from_str(toml).unwrap(); - let sandbox = config.run_defaults.sandbox.unwrap(); + let config: FabroConfig = toml::from_str(toml).unwrap(); + let sandbox = config.sandbox.unwrap(); assert_eq!(sandbox.provider.as_deref(), Some("daytona")); } @@ -379,9 +316,9 @@ tool_timeout_secs = 90 [mcp_servers.filesystem.env] NODE_ENV = "production" "#; - let config: CliConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.run_defaults.mcp_servers.len(), 1); - let entry = &config.run_defaults.mcp_servers["filesystem"]; + 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 { @@ -411,9 +348,9 @@ url = "https://mcp.sentry.dev/mcp" [mcp_servers.sentry.headers] Authorization = "Bearer sk-xxx" "#; - let config: CliConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.run_defaults.mcp_servers.len(), 1); - let entry = &config.run_defaults.mcp_servers["sentry"]; + 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"); @@ -425,8 +362,8 @@ Authorization = "Bearer sk-xxx" #[test] fn parse_mcp_empty_backward_compat() { - let config: CliConfig = toml::from_str("").unwrap(); - assert!(config.run_defaults.mcp_servers.is_empty()); + let config: FabroConfig = toml::from_str("").unwrap(); + assert!(config.mcp_servers.is_empty()); } #[test] @@ -440,14 +377,14 @@ command = ["python3", "server.py"] type = "http" url = "https://mcp.example.com" "#; - let config: CliConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.run_defaults.mcp_servers.len(), 2); + let config: FabroConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.mcp_servers.len(), 2); assert!(matches!( - config.run_defaults.mcp_servers["local"].transport, + config.mcp_servers["local"].transport, McpTransport::Stdio { .. } )); assert!(matches!( - config.run_defaults.mcp_servers["remote"].transport, + config.mcp_servers["remote"].transport, McpTransport::Http { .. } )); } @@ -459,8 +396,8 @@ url = "https://mcp.example.com" type = "stdio" command = ["echo"] "#; - let config: CliConfig = toml::from_str(toml).unwrap(); - let entry = &config.run_defaults.mcp_servers["minimal"]; + 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); } @@ -483,13 +420,13 @@ command = ["echo"] #[test] fn parse_upgrade_check_false() { - let config: CliConfig = toml::from_str("upgrade_check = false").unwrap(); - assert!(!config.upgrade_check); + let config: FabroConfig = toml::from_str("upgrade_check = false").unwrap(); + assert_eq!(config.upgrade_check, Some(false)); } #[test] - fn parse_upgrade_check_default_true() { - let config: CliConfig = toml::from_str("").unwrap(); - assert!(config.upgrade_check); + 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/config.rs b/lib/crates/fabro-config/src/config.rs new file mode 100644 index 000000000..1ea3abe93 --- /dev/null +++ b/lib/crates/fabro-config/src/config.rs @@ -0,0 +1,332 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::cli::{ExecDefaults, ExecutionMode, ServerDefaults}; +use crate::hook::{HookConfig, HookDefinition}; +use crate::mcp::McpServerEntry; +use crate::project::ProjectFabroConfig; +use crate::run::{ + AssetsConfig, CheckpointConfig, GitHubConfig, LlmConfig, PullRequestConfig, SetupConfig, +}; +use crate::sandbox::SandboxConfig; +use crate::server::{ApiConfig, Features, GitConfig, LogConfig, WebConfig}; + +fn is_default_checkpoint(c: &CheckpointConfig) -> bool { + c.exclude_globs.is_empty() +} + +/// Unified 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). +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct FabroConfig { + // --- Workflow run config fields --- + #[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 graph: Option, + + // --- Run defaults fields (inlined) --- + #[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: CheckpointConfig, + + #[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, + + // --- CLI config fields --- + #[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, + + // --- Server config fields --- + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_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, + + // --- Shared fields --- + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, + + // --- Project config fields --- + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fabro: Option, +} + +impl FabroConfig { + // --- Convenience methods (ported from CliConfig) --- + + 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) + } + + /// 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.graph.is_some() { + self.graph = overlay.graph; + } + + // --- 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 + .append(&mut overlay.checkpoint.exclude_globs.clone()); + 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; + } + + // --- Server config fields --- + if overlay.data_dir.is_some() { + self.data_dir = overlay.data_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; + } + } +} diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index a26798d7b..259710bfc 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -1,4 +1,5 @@ pub mod cli; +pub mod config; pub mod dotenv; pub mod hook; pub mod mcp; @@ -7,6 +8,7 @@ pub mod run; pub mod sandbox; pub mod server; +pub use config::FabroConfig; pub use fabro_util::path::expand_tilde; use std::path::Path; diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index f25e964fb..0b494b396 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -1,67 +1,14 @@ -use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::{bail, Context}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -use crate::hook::HookDefinition; -use crate::mcp::McpServerEntry; -use crate::run::{ - AssetsConfig, CheckpointConfig, GitHubConfig, LlmConfig, PullRequestConfig, RunDefaults, - SetupConfig, -}; -use crate::sandbox::SandboxConfig; +use crate::config::FabroConfig; const CONFIG_FILENAME: &str = "fabro.toml"; const SUPPORTED_VERSION: u32 = 1; -#[derive(Debug, Default, Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct ProjectConfig { - #[serde(default)] - pub version: u32, - #[serde(default)] - pub fabro: ProjectFabroConfig, - #[serde(default)] - pub features: ProjectFeatures, - #[serde(alias = "directory")] - pub work_dir: Option, - pub llm: Option, - pub setup: Option, - pub sandbox: Option, - pub vars: Option>, - #[serde(default)] - pub checkpoint: CheckpointConfig, - pub pull_request: Option, - pub assets: Option, - #[serde(default)] - pub hooks: Vec, - #[serde(default)] - pub mcp_servers: HashMap, - pub github: Option, -} - -impl ProjectConfig { - /// Convert project config fields into `RunDefaults`. - pub fn into_run_defaults(self) -> RunDefaults { - RunDefaults { - work_dir: self.work_dir, - llm: self.llm, - setup: self.setup, - sandbox: self.sandbox, - vars: self.vars, - checkpoint: self.checkpoint, - pull_request: self.pull_request, - assets: self.assets, - hooks: self.hooks, - mcp_servers: self.mcp_servers, - github: self.github, - } - } -} - -#[derive(Debug, Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct ProjectFabroConfig { #[serde(default = "default_root")] pub root: String, @@ -79,40 +26,35 @@ impl Default for ProjectFabroConfig { } } -/// Feature flags for the project. All features default to `false` (opt-in). -#[derive(Debug, Default, Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct ProjectFeatures { - /// Experimental: enable automatic retro generation after workflow runs. - #[serde(default)] - pub retros: bool, -} - /// Parse a project config from a TOML string. -pub fn parse_project_config(content: &str) -> anyhow::Result { - let config: ProjectConfig = - toml::from_str(content).context("Failed to parse project config")?; - if config.version != SUPPORTED_VERSION { +pub fn parse_project_config(content: &str) -> anyhow::Result { + let config: FabroConfig = toml::from_str(content).context("Failed to parse project config")?; + let version = config.version.unwrap_or(0); + if version != SUPPORTED_VERSION { bail!( - "Unsupported project config version: {}. Only version {SUPPORTED_VERSION} is supported.", - config.version, + "Unsupported project config version: {version}. Only version {SUPPORTED_VERSION} is supported.", ); } Ok(config) } /// Load a project config from a file path. -pub fn load_project_config(path: &Path) -> anyhow::Result { +pub fn load_project_config(path: &Path) -> anyhow::Result { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; let config = parse_project_config(&content)?; - tracing::debug!(path = %path.display(), root = %config.fabro.root, "Loaded project config"); + let root = config + .fabro + .as_ref() + .map(|f| f.root.as_str()) + .unwrap_or("."); + tracing::debug!(path = %path.display(), root = %root, "Loaded project config"); Ok(config) } /// Walk ancestor directories from `start` looking for `fabro.toml`. /// Returns the config file path and parsed config, or `None` if not found. -pub fn discover_project_config(start: &Path) -> anyhow::Result> { +pub fn discover_project_config(start: &Path) -> anyhow::Result> { for ancestor in start.ancestors() { let candidate = ancestor.join(CONFIG_FILENAME); if candidate.is_file() { @@ -327,9 +269,7 @@ fn find_closest_match(input: &str, candidates: &[String]) -> Option { /// /// Calls `resolve_workflow_arg` first, then if the result is a `.toml` file, /// loads the run config and resolves the graph path within it. -pub fn resolve_workflow( - arg: &Path, -) -> anyhow::Result<(PathBuf, Option)> { +pub fn resolve_workflow(arg: &Path) -> anyhow::Result<(PathBuf, Option)> { let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); resolve_workflow_from(arg, &start) } @@ -337,11 +277,12 @@ pub fn resolve_workflow( fn resolve_workflow_from( arg: &Path, start_dir: &Path, -) -> anyhow::Result<(PathBuf, Option)> { +) -> anyhow::Result<(PathBuf, Option)> { let path = resolve_workflow_arg_from(arg, start_dir)?; if path.extension().is_some_and(|ext| ext == "toml") { let cfg = crate::run::load_run_config(&path)?; - let dot = crate::run::resolve_graph_path(&path, &cfg.graph); + let dot = + crate::run::resolve_graph_path(&path, cfg.graph.as_deref().unwrap_or("workflow.fabro")); Ok((dot, Some(cfg))) } else { Ok((path, None)) @@ -354,18 +295,23 @@ 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.retros, + Ok(Some((_path, config))) => config.features.as_ref().map(|f| f.retros).unwrap_or(false), _ => false, } } /// Resolve the fabro root directory from a config file path and its config. /// The returned path is the directory containing `fabro.toml` joined with the `root` value. -pub fn resolve_fabro_root(config_path: &Path, config: &ProjectConfig) -> PathBuf { +pub fn resolve_fabro_root(config_path: &Path, config: &FabroConfig) -> PathBuf { let project_dir = config_path .parent() .expect("config_path should have a parent directory"); - project_dir.join(&config.fabro.root) + let root = config + .fabro + .as_ref() + .map(|f| f.root.as_str()) + .unwrap_or("."); + project_dir.join(root) } #[cfg(test)] @@ -378,34 +324,29 @@ mod tests { #[test] fn parse_minimal_config() { let config = parse_project_config("version = 1\n").unwrap(); - assert_eq!( - config, - ProjectConfig { - version: 1, - fabro: ProjectFabroConfig { - root: ".".to_string(), - }, - ..Default::default() - } - ); + assert_eq!(config.version, Some(1)); + assert_eq!(config.fabro, None,); } #[test] fn parse_full_config() { let config = parse_project_config("version = 1\n[fabro]\nroot = \"fabro/\"\n").unwrap(); - assert_eq!(config.fabro.root, "fabro/"); + assert_eq!(config.fabro.unwrap().root, "fabro/"); } #[test] fn parse_retros_default_false() { let config = parse_project_config("version = 1\n").unwrap(); - assert!(!config.features.retros); + assert_eq!( + config.features.as_ref().map(|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.retros); + assert!(config.features.unwrap().retros); } #[test] @@ -424,7 +365,7 @@ mod tests { .unwrap(); assert_eq!( config.pull_request, - Some(PullRequestConfig { + Some(crate::run::PullRequestConfig { enabled: true, draft: false, auto_merge: false, @@ -486,41 +427,13 @@ model = "claude-sonnet-4-6" ); } - #[test] - fn into_run_defaults_preserves_fields() { - let toml = r#" -version = 1 -work_dir = "/ws" -[llm] -model = "m" -[sandbox] -provider = "daytona" -"#; - let config = parse_project_config(toml).unwrap(); - let defaults = config.into_run_defaults(); - assert_eq!(defaults.work_dir.as_deref(), Some("/ws")); - assert_eq!(defaults.llm.unwrap().model.as_deref(), Some("m")); - assert_eq!( - defaults.sandbox.unwrap().provider.as_deref(), - Some("daytona") - ); - } - - #[test] - fn parse_unknown_field_rejected() { - let err = parse_project_config("version = 1\nfoo = \"bar\"\n").unwrap_err(); - let chain = format!("{err:#}"); - assert!(chain.contains("unknown field"), "got: {chain}"); - } - #[test] fn load_from_disk() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("fabro.toml"); fs::write(&path, "version = 1\n").unwrap(); let config = load_project_config(&path).unwrap(); - assert_eq!(config.version, 1); - assert_eq!(config.fabro.root, "."); + assert_eq!(config.version, Some(1)); } #[test] @@ -532,7 +445,7 @@ provider = "daytona" let (found_path, config) = discover_project_config(&sub).unwrap().unwrap(); assert_eq!(found_path, tmp.path().join("fabro.toml")); - assert_eq!(config.version, 1); + assert_eq!(config.version, Some(1)); } #[test] @@ -545,12 +458,12 @@ provider = "daytona" #[test] fn resolve_fabro_root_with_subdirectory() { let config_path = Path::new("/repo/fabro.toml"); - let config = ProjectConfig { - version: 1, - fabro: ProjectFabroConfig { + let config = FabroConfig { + version: Some(1), + fabro: Some(ProjectFabroConfig { root: "fabro/".to_string(), ..Default::default() - }, + }), ..Default::default() }; assert_eq!( @@ -562,12 +475,12 @@ provider = "daytona" #[test] fn resolve_fabro_root_with_dot() { let config_path = Path::new("/repo/fabro.toml"); - let config = ProjectConfig { - version: 1, - fabro: ProjectFabroConfig { + let config = FabroConfig { + version: Some(1), + fabro: Some(ProjectFabroConfig { root: ".".to_string(), ..Default::default() - }, + }), ..Default::default() }; assert_eq!( @@ -576,6 +489,16 @@ provider = "daytona" ); } + #[test] + fn resolve_fabro_root_without_fabro_section() { + let config_path = Path::new("/repo/fabro.toml"); + let config = FabroConfig::default(); + assert_eq!( + resolve_fabro_root(config_path, &config), + Path::new("/repo/.") + ); + } + #[test] fn resolve_workflow_arg_toml_extension_returned_as_is() { let tmp = TempDir::new().unwrap(); @@ -643,19 +566,4 @@ permissions = { contents = "read" } let github = config.github.unwrap(); assert_eq!(github.permissions["contents"], "read"); } - - #[test] - fn into_run_defaults_preserves_github() { - let toml = r#" -version = 1 - -[github] -permissions = { contents = "read", issues = "write" } -"#; - let config = parse_project_config(toml).unwrap(); - let defaults = config.into_run_defaults(); - let github = defaults.github.unwrap(); - assert_eq!(github.permissions["contents"], "read"); - assert_eq!(github.permissions["issues"], "write"); - } } diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index 04bc39ee0..9ca6764e7 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -6,9 +6,8 @@ use serde::{Deserialize, Serialize}; use tracing::debug; -use crate::hook::{HookConfig, HookDefinition}; -use crate::mcp::McpServerEntry; -use crate::sandbox::{DockerfileSource, SandboxConfig}; +use crate::config::FabroConfig; +use crate::sandbox::DockerfileSource; const SUPPORTED_VERSION: u32 = 1; @@ -22,10 +21,6 @@ fn default_true() -> bool { true } -fn default_graph() -> String { - "workflow.fabro".to_string() -} - #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] pub struct PullRequestConfig { #[serde(default)] @@ -59,30 +54,6 @@ pub struct GitHubConfig { pub permissions: HashMap, } -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct WorkflowRunConfig { - pub version: u32, - pub goal: Option, - #[serde(default = "default_graph")] - pub graph: String, - #[serde(alias = "directory")] - pub work_dir: Option, - pub llm: Option, - pub setup: Option, - pub sandbox: Option, - pub vars: Option>, - #[serde(default)] - pub hooks: Vec, - #[serde(default)] - pub checkpoint: CheckpointConfig, - pub pull_request: Option, - pub assets: Option, - #[serde(default)] - pub mcp_servers: HashMap, - pub github: Option, -} - #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct LlmConfig { pub model: Option, @@ -97,203 +68,6 @@ pub struct SetupConfig { pub timeout_ms: Option, } -/// Defaults for workflow runs, loaded from the server config. -/// -/// Fields mirror `WorkflowRunConfig` but are all optional. -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct RunDefaults { - #[serde(alias = "directory")] - pub work_dir: Option, - pub llm: Option, - pub setup: Option, - pub sandbox: Option, - pub vars: Option>, - #[serde(default)] - pub checkpoint: CheckpointConfig, - pub pull_request: Option, - pub assets: Option, - #[serde(default)] - pub hooks: Vec, - #[serde(default)] - pub mcp_servers: HashMap, - pub github: Option, -} - -impl WorkflowRunConfig { - /// Apply server-level run defaults to this config. - /// - /// Each field uses the first non-`None` value (task config wins). - /// Vars are merged: defaults first, then task config overwrites. - pub fn apply_defaults(&mut self, defaults: &RunDefaults) { - // Build a RunDefaults from the task's current fields (the "overlay"), - // then merge it on top of the server defaults (the "base"). - // This gives "task wins" semantics via merge_overlay's "overlay wins". - let task_overlay = RunDefaults { - work_dir: self.work_dir.take(), - llm: self.llm.take(), - setup: self.setup.take(), - sandbox: self.sandbox.take(), - vars: self.vars.take(), - checkpoint: std::mem::take(&mut self.checkpoint), - pull_request: self.pull_request.take(), - assets: self.assets.take(), - hooks: std::mem::take(&mut self.hooks), - mcp_servers: std::mem::take(&mut self.mcp_servers), - github: self.github.take(), - }; - let mut merged = defaults.clone(); - merged.merge_overlay(task_overlay); - - self.work_dir = merged.work_dir; - self.llm = merged.llm; - self.setup = merged.setup; - self.sandbox = merged.sandbox; - self.vars = merged.vars; - self.checkpoint = merged.checkpoint; - self.pull_request = merged.pull_request; - self.assets = merged.assets; - self.hooks = merged.hooks; - self.mcp_servers = merged.mcp_servers; - self.github = merged.github; - } -} - -impl RunDefaults { - /// 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: RunDefaults) { - 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 - .append(&mut overlay.checkpoint.exclude_globs.clone()); - 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; - } - } -} - /// Load and validate a run config from a TOML file. /// /// The `graph` path in the returned config is resolved relative to the @@ -303,7 +77,7 @@ impl RunDefaults { /// `${env.VARNAME}` references in `[sandbox.env]` are NOT resolved here — /// call [`resolve_sandbox_env`] separately after snapshotting, so that /// plaintext secrets are never written to disk. -pub fn load_run_config(path: &Path) -> anyhow::Result { +pub fn load_run_config(path: &Path) -> anyhow::Result { let contents = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; let mut config = parse_run_config(&contents)?; @@ -318,7 +92,7 @@ pub fn load_run_config(path: &Path) -> anyhow::Result { /// /// Only whole-value references are supported (no partial interpolation). /// Missing host env vars produce a hard error. -pub fn resolve_sandbox_env(config: &mut WorkflowRunConfig) -> anyhow::Result<()> { +pub fn resolve_sandbox_env(config: &mut FabroConfig) -> anyhow::Result<()> { if let Some(env) = config.sandbox.as_mut().and_then(|s| s.env.as_mut()) { resolve_env_refs(env)?; } @@ -346,7 +120,7 @@ pub fn resolve_env_refs(env: &mut HashMap) -> anyhow::Result<()> /// If the config contains a `dockerfile = { path = "..." }`, read the file /// and replace it with `DockerfileSource::Inline(contents)`. -fn resolve_dockerfile(config: &mut WorkflowRunConfig, config_dir: &Path) -> anyhow::Result<()> { +fn resolve_dockerfile(config: &mut FabroConfig, config_dir: &Path) -> anyhow::Result<()> { let source = config .sandbox .as_mut() @@ -378,14 +152,19 @@ pub fn resolve_graph_path(toml_path: &Path, graph: &str) -> PathBuf { } } -pub fn parse_run_config(contents: &str) -> anyhow::Result { - let config: WorkflowRunConfig = +pub fn parse_run_config(contents: &str) -> anyhow::Result { + let mut config: FabroConfig = toml::from_str(contents).context("Failed to parse run config TOML")?; - if config.version != SUPPORTED_VERSION { + // Apply default graph if not specified + if config.graph.is_none() { + config.graph = Some("workflow.fabro".to_string()); + } + + let version = config.version.unwrap_or(0); + if version != SUPPORTED_VERSION { bail!( - "Unsupported run config version {}. Only version {SUPPORTED_VERSION} is supported.", - config.version + "Unsupported run config version {version}. Only version {SUPPORTED_VERSION} is supported.", ); } diff --git a/lib/crates/fabro-config/src/server.rs b/lib/crates/fabro-config/src/server.rs index 029f814b2..310f986f8 100644 --- a/lib/crates/fabro-config/src/server.rs +++ b/lib/crates/fabro-config/src/server.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; -use crate::run::RunDefaults; +use crate::config::FabroConfig; #[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] @@ -128,32 +128,14 @@ pub struct LogConfig { pub level: Option, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ServerConfig { - pub data_dir: Option, - pub max_concurrent_runs: Option, - #[serde(default)] - pub web: WebConfig, - #[serde(default)] - pub api: ApiConfig, - #[serde(default)] - pub git: GitConfig, - #[serde(default)] - pub features: Features, - #[serde(default)] - pub log: LogConfig, - #[serde(flatten)] - pub run_defaults: RunDefaults, -} - /// 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 { +pub fn load_server_config(path: Option<&Path>) -> anyhow::Result { crate::load_config_file(path, "server.toml") } /// Resolve the data directory: config value > default `~/.fabro`. -pub fn resolve_data_dir(config: &ServerConfig) -> PathBuf { +pub fn resolve_data_dir(config: &FabroConfig) -> PathBuf { if let Some(ref dir) = config.data_dir { return dir.clone(); } @@ -169,29 +151,29 @@ mod tests { #[test] fn parse_config_with_data_dir() { let toml = r#"data_dir = "/custom/path""#; - let config: ServerConfig = toml::from_str(toml).unwrap(); + let config: FabroConfig = toml::from_str(toml).unwrap(); assert_eq!(config.data_dir, Some(PathBuf::from("/custom/path"))); } #[test] fn parse_empty_config_defaults() { let toml = ""; - let config: ServerConfig = toml::from_str(toml).unwrap(); + let config: FabroConfig = toml::from_str(toml).unwrap(); assert_eq!(config.data_dir, None); } #[test] fn resolve_data_dir_uses_config_value() { - let config = ServerConfig { + let config = FabroConfig { data_dir: Some(PathBuf::from("/my/data")), - ..ServerConfig::default() + ..FabroConfig::default() }; assert_eq!(resolve_data_dir(&config), PathBuf::from("/my/data")); } #[test] fn resolve_data_dir_defaults_to_home_arc() { - let config = ServerConfig::default(); + let config = FabroConfig::default(); let dir = resolve_data_dir(&config); // Should end with .fabro assert!( @@ -220,36 +202,38 @@ provider = "github" app_id = "12345" client_id = "Iv1.abc123" "#; - let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.web.url, "https://arc.example.com"); - assert_eq!(config.web.auth.provider, AuthProvider::Github); - assert_eq!(config.web.auth.allowed_usernames, vec!["brynary", "alice"]); - assert_eq!(config.api.base_url, "http://example.com:8080"); - assert_eq!( - config.api.authentication_strategies, - vec![ApiAuthStrategy::Jwt] - ); - assert_eq!(config.git.provider, GitProvider::Github); - assert_eq!(config.git.app_id.as_deref(), Some("12345")); - assert_eq!(config.git.client_id.as_deref(), Some("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 = ""; - let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.web.url, "http://localhost:5173"); - assert_eq!(config.web.auth.provider, AuthProvider::Github); - assert!(config.web.auth.allowed_usernames.is_empty()); + 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 = ""; - let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.api.base_url, "http://localhost:3000"); - assert!(config.api.authentication_strategies.is_empty()); - assert!(config.api.tls.is_none()); + 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] @@ -260,21 +244,23 @@ provider = "github" app_id = "12345" client_id = "Iv1.abc123" "#; - let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.git.provider, GitProvider::Github); - assert_eq!(config.git.app_id.as_deref(), Some("12345")); - assert_eq!(config.git.client_id.as_deref(), Some("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 = ""; - let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.git.provider, GitProvider::Github); - assert_eq!(config.git.app_id, None); - assert_eq!(config.git.client_id, None); - assert_eq!(config.git.author.name, None); - assert_eq!(config.git.author.email, None); + 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] @@ -284,12 +270,10 @@ client_id = "Iv1.abc123" name = "fabro-bot" email = "fabro-bot@company.com" "#; - let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.git.author.name.as_deref(), Some("fabro-bot")); - assert_eq!( - config.git.author.email.as_deref(), - Some("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] @@ -298,9 +282,10 @@ email = "fabro-bot@company.com" [git.author] name = "custom-name" "#; - let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.git.author.name.as_deref(), Some("custom-name")); - assert_eq!(config.git.author.email, None); + 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] @@ -316,13 +301,13 @@ provider = "daytona" [vars] repo_url = "https://github.com/org/repo" "#; - let config: ServerConfig = toml::from_str(toml).unwrap(); - let llm = config.run_defaults.llm.unwrap(); + 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.run_defaults.sandbox.unwrap(); + let sandbox = config.sandbox.unwrap(); assert_eq!(sandbox.provider.as_deref(), Some("daytona")); - let vars = config.run_defaults.vars.unwrap(); + let vars = config.vars.unwrap(); assert_eq!(vars["repo_url"], "https://github.com/org/repo"); } @@ -339,10 +324,12 @@ app_id = "123" [llm] model = "gpt-4" "#; - let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.web.auth.provider, AuthProvider::Github); - assert_eq!(config.git.app_id.as_deref(), Some("123")); - let llm = config.run_defaults.llm.unwrap(); + 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")); } @@ -352,8 +339,9 @@ model = "gpt-4" [web.auth] provider = "insecure_disabled" "#; - let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.web.auth.provider, AuthProvider::InsecureDisabled); + let config: FabroConfig = toml::from_str(toml).unwrap(); + let web = config.web.unwrap(); + assert_eq!(web.auth.provider, AuthProvider::InsecureDisabled); } #[test] @@ -367,12 +355,13 @@ cert = "~/.fabro/certs/server.crt" key = "~/.fabro/certs/server.key" ca = "~/.fabro/certs/ca.crt" "#; - let config: ServerConfig = toml::from_str(toml).unwrap(); + let config: FabroConfig = toml::from_str(toml).unwrap(); + let api = config.api.unwrap(); assert_eq!( - config.api.authentication_strategies, + api.authentication_strategies, vec![ApiAuthStrategy::Jwt, ApiAuthStrategy::Mtls] ); - let tls = config.api.tls.unwrap(); + 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")); @@ -381,14 +370,14 @@ ca = "~/.fabro/certs/ca.crt" #[test] fn parse_max_concurrent_runs() { let toml = r#"max_concurrent_runs = 8"#; - let config: ServerConfig = toml::from_str(toml).unwrap(); + 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: ServerConfig = toml::from_str(toml).unwrap(); + let config: FabroConfig = toml::from_str(toml).unwrap(); assert_eq!(config.max_concurrent_runs, None); } @@ -398,8 +387,9 @@ ca = "~/.fabro/certs/ca.crt" [api] authentication_strategies = [] "#; - let config: ServerConfig = toml::from_str(toml).unwrap(); - assert!(config.api.authentication_strategies.is_empty()); + let config: FabroConfig = toml::from_str(toml).unwrap(); + let api = config.api.unwrap(); + assert!(api.authentication_strategies.is_empty()); } #[test] @@ -408,12 +398,10 @@ authentication_strategies = [] [api] authentication_strategies = ["jwt"] "#; - let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!( - config.api.authentication_strategies, - vec![ApiAuthStrategy::Jwt] - ); - assert!(config.api.tls.is_none()); + 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] @@ -445,45 +433,37 @@ event = "stage_complete" command = "echo 'stage done'" matcher = "agent_loop" "#; - let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.run_defaults.hooks.len(), 2); + 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.run_defaults.hooks[0].event, - crate::hook::HookEvent::RunStart - ); - assert_eq!( - config.run_defaults.hooks[0].command.as_deref(), + config.hooks[0].command.as_deref(), Some("echo 'run starting'") ); - assert_eq!( - config.run_defaults.hooks[1].event, - crate::hook::HookEvent::StageComplete - ); - assert_eq!( - config.run_defaults.hooks[1].matcher.as_deref(), - Some("agent_loop") - ); + 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: ServerConfig = toml::from_str(toml).unwrap(); - assert!(config.features.session_sandboxes); + 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: ServerConfig = toml::from_str(toml).unwrap(); - assert!(!config.features.session_sandboxes); + 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: ServerConfig = toml::from_str(toml).unwrap(); - assert!(config.run_defaults.hooks.is_empty()); + let config: FabroConfig = toml::from_str(toml).unwrap(); + assert!(config.hooks.is_empty()); } #[test] @@ -492,9 +472,9 @@ matcher = "agent_loop" [checkpoint] exclude_globs = ["**/node_modules/**", "**/.cache/**"] "#; - let config: ServerConfig = toml::from_str(toml).unwrap(); + let config: FabroConfig = toml::from_str(toml).unwrap(); assert_eq!( - config.run_defaults.checkpoint.exclude_globs, + config.checkpoint.exclude_globs, vec!["**/node_modules/**", "**/.cache/**"] ); } @@ -502,8 +482,8 @@ exclude_globs = ["**/node_modules/**", "**/.cache/**"] #[test] fn parse_config_checkpoint_defaults_empty() { let toml = ""; - let config: ServerConfig = toml::from_str(toml).unwrap(); - assert!(config.run_defaults.checkpoint.exclude_globs.is_empty()); + let config: FabroConfig = toml::from_str(toml).unwrap(); + assert!(config.checkpoint.exclude_globs.is_empty()); } #[test] @@ -516,8 +496,8 @@ app_id = "2993730" [git.webhooks] strategy = "tailscale_funnel" "#; - let config: ServerConfig = toml::from_str(toml).unwrap(); - let webhooks = config.git.webhooks.unwrap(); + let config: FabroConfig = toml::from_str(toml).unwrap(); + let webhooks = config.git.unwrap().webhooks.unwrap(); assert_eq!(webhooks.strategy, WebhookStrategy::TailscaleFunnel); } @@ -528,14 +508,17 @@ strategy = "tailscale_funnel" provider = "github" app_id = "123" "#; - let config: ServerConfig = toml::from_str(toml).unwrap(); - assert!(config.git.webhooks.is_none()); + 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: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.log.level.as_deref(), Some("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-workflows/tests/integration.rs b/lib/crates/fabro-workflows/tests/integration.rs index bbe6d3ca5..dd7bbd32a 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::run::WorkflowRunConfig; +use fabro_config::FabroConfig; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_graphviz::parser::parse; use fabro_interview::{ @@ -8345,7 +8345,7 @@ event = "run_complete" command = "echo done" "#; - let cfg: WorkflowRunConfig = toml::from_str(toml).unwrap(); + let cfg: FabroConfig = 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")); @@ -8525,7 +8525,7 @@ max_tool_rounds = 10 timeout_ms = 120000 "#; - let cfg: WorkflowRunConfig = toml::from_str(toml).unwrap(); + let cfg: FabroConfig = toml::from_str(toml).unwrap(); assert_eq!(cfg.hooks.len(), 2); // Prompt hook