Unify CliConfig, ServerConfig, ProjectConfig, WorkflowRunConfig, RunDefaults into FabroConfig

Replace five config types with a single FabroConfig superset type. All
loading functions (load_cli_config, load_server_config, load_run_config,
parse_project_config) now return FabroConfig. This eliminates the
run_defaults indirection, into_run_defaults() conversion, and
apply_defaults() bridging method in favor of a single merge_overlay()
that works across all config layers (CLI → project → workflow).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-23 17:07:27 -04:00
parent f22aa63350
commit 790d8f27e2
No known key found for this signature in database
18 changed files with 949 additions and 1080 deletions

View file

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

View file

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

View file

@ -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<ServerConfig>,
shared_config: &RwLock<FabroConfig>,
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

View file

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

View file

@ -18,7 +18,7 @@ const DEFAULT_SERVER_URL: &str = "http://localhost:3000";
pub fn resolve_mode(
cli_mode: Option<ExecutionMode>,
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));

View file

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

View file

@ -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<WorkflowRunConfig>,
run_cfg: Option<FabroConfig>,
sandbox: Arc<dyn Sandbox>,
/// 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<fabro_github::GitHubAppCredentials>,
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<fabro_github::GitHubAppCredentials>,
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<FabroConfig> = 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<fabro_github::GitHubAppCredentials>,
git_author: fabro_workflows::git::GitAuthor,
) -> anyhow::Result<ResumeContext> {
@ -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<FabroConfig> = 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(), {

View file

@ -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<String>) {
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<SandboxProvider>,
run_cfg: Option<&WorkflowRunConfig>,
run_defaults: &RunDefaults,
run_cfg: Option<&FabroConfig>,
run_defaults: &FabroConfig,
) -> anyhow::Result<SandboxProvider> {
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<fabro_sandbox::daytona::DaytonaConfig> {
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<fabro_sandbox::exe::ExeConfig> {
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<fabro_sandbox::ssh::SshConfig> {
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<FallbackTarget> {
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<String> {
run_cfg.graph = RUN_GRAPH_FILE.to_string();
fn serialize_run_config_snapshot(run_cfg: &mut FabroConfig) -> anyhow::Result<String> {
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<WorkflowRunConfig>)> {
) -> anyhow::Result<(PathBuf, PathBuf, Option<FabroConfig>)> {
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<WorkflowRunConfig>,
pub run_cfg: Option<FabroConfig>,
pub sandbox_provider: SandboxProvider,
pub model: String,
pub provider: Option<String>,
pub workflow_slug: Option<String>,
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<PreparedWorkflow> {
@ -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<fabro_github::GitHubAppCredentials>,
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_config::WorkflowRunConfig>,
run_cfg: &Option<FabroConfig>,
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),

View file

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

View file

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

View file

@ -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<fabro_mcp::config::McpServerConfig> = 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();

View file

@ -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<String>,
pub tls: Option<ClientTlsConfig>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ExecDefaults {
pub provider: Option<String>,
pub model: Option<String>,
@ -50,87 +48,23 @@ pub struct ExecDefaults {
pub output_format: Option<OutputFormat>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub struct CliGitConfig {
pub app_id: Option<String>,
pub slug: Option<String>,
pub client_id: Option<String>,
#[serde(default)]
pub author: crate::server::GitAuthorConfig,
}
fn default_upgrade_check() -> bool {
true
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct CliConfig {
pub mode: Option<ExecutionMode>,
pub server: Option<ServerDefaults>,
pub exec: Option<ExecDefaults>,
pub git: Option<CliGitConfig>,
#[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<CliConfig> {
pub fn load_cli_config(path: Option<&Path>) -> anyhow::Result<crate::config::FabroConfig> {
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);
}
}

View file

@ -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<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<String>,
// --- Run defaults fields (inlined) ---
#[serde(default, alias = "directory", skip_serializing_if = "Option::is_none")]
pub work_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm: Option<LlmConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub setup: Option<SetupConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<SandboxConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vars: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "is_default_checkpoint")]
pub checkpoint: CheckpointConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull_request: Option<PullRequestConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assets: Option<AssetsConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<HookDefinition>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub mcp_servers: HashMap<String, McpServerEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GitHubConfig>,
// --- CLI config fields ---
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<ExecutionMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<ServerDefaults>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec: Option<ExecDefaults>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prevent_idle_sleep: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verbose: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upgrade_check: Option<bool>,
// --- Server config fields ---
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_dir: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent_runs: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web: Option<WebConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<ApiConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub features: Option<Features>,
// --- Shared fields ---
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log: Option<LogConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<GitConfig>,
// --- Project config fields ---
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fabro: Option<ProjectFabroConfig>,
}
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;
}
}
}

View file

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

View file

@ -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<String>,
pub llm: Option<LlmConfig>,
pub setup: Option<SetupConfig>,
pub sandbox: Option<SandboxConfig>,
pub vars: Option<HashMap<String, String>>,
#[serde(default)]
pub checkpoint: CheckpointConfig,
pub pull_request: Option<PullRequestConfig>,
pub assets: Option<AssetsConfig>,
#[serde(default)]
pub hooks: Vec<HookDefinition>,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
pub github: Option<GitHubConfig>,
}
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<ProjectConfig> {
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<FabroConfig> {
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<ProjectConfig> {
pub fn load_project_config(path: &Path) -> anyhow::Result<FabroConfig> {
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<Option<(PathBuf, ProjectConfig)>> {
pub fn discover_project_config(start: &Path) -> anyhow::Result<Option<(PathBuf, FabroConfig)>> {
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<String> {
///
/// 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<crate::run::WorkflowRunConfig>)> {
pub fn resolve_workflow(arg: &Path) -> anyhow::Result<(PathBuf, Option<FabroConfig>)> {
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<crate::run::WorkflowRunConfig>)> {
) -> anyhow::Result<(PathBuf, Option<FabroConfig>)> {
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");
}
}

View file

@ -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<String, String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkflowRunConfig {
pub version: u32,
pub goal: Option<String>,
#[serde(default = "default_graph")]
pub graph: String,
#[serde(alias = "directory")]
pub work_dir: Option<String>,
pub llm: Option<LlmConfig>,
pub setup: Option<SetupConfig>,
pub sandbox: Option<SandboxConfig>,
pub vars: Option<HashMap<String, String>>,
#[serde(default)]
pub hooks: Vec<HookDefinition>,
#[serde(default)]
pub checkpoint: CheckpointConfig,
pub pull_request: Option<PullRequestConfig>,
pub assets: Option<AssetsConfig>,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
pub github: Option<GitHubConfig>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct LlmConfig {
pub model: Option<String>,
@ -97,203 +68,6 @@ pub struct SetupConfig {
pub timeout_ms: Option<u64>,
}
/// 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<String>,
pub llm: Option<LlmConfig>,
pub setup: Option<SetupConfig>,
pub sandbox: Option<SandboxConfig>,
pub vars: Option<HashMap<String, String>>,
#[serde(default)]
pub checkpoint: CheckpointConfig,
pub pull_request: Option<PullRequestConfig>,
pub assets: Option<AssetsConfig>,
#[serde(default)]
pub hooks: Vec<HookDefinition>,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
pub github: Option<GitHubConfig>,
}
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<WorkflowRunConfig> {
pub fn load_run_config(path: &Path) -> anyhow::Result<FabroConfig> {
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<WorkflowRunConfig> {
///
/// 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<String, String>) -> 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<WorkflowRunConfig> {
let config: WorkflowRunConfig =
pub fn parse_run_config(contents: &str) -> anyhow::Result<FabroConfig> {
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.",
);
}

View file

@ -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<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ServerConfig {
pub data_dir: Option<PathBuf>,
pub max_concurrent_runs: Option<usize>,
#[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<ServerConfig> {
pub fn load_server_config(path: Option<&Path>) -> anyhow::Result<FabroConfig> {
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")
);
}
}

View file

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