mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
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:
parent
f22aa63350
commit
790d8f27e2
18 changed files with 949 additions and 1080 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)> {
|
||||
|
|
|
|||
|
|
@ -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(), {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
332
lib/crates/fabro-config/src/config.rs
Normal file
332
lib/crates/fabro-config/src/config.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue