From e3fa99eba14d67a314c1608c8622c81b9aee0a5b Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 3 Mar 2026 01:26:41 -0500 Subject: [PATCH] Add run defaults to server config for workflow run inheritance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename AppConfig → ServerConfig with flattened RunDefaults so users can set default llm, sandbox, setup, directory and vars in ~/.arc/arc.toml. Precedence: CLI flags > workflow TOML > server config defaults > DOT graph attrs > hardcoded defaults. Vars merge (defaults first, task config overwrites collisions). Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/arc-api/src/app_config.rs | 240 --------------- crates/arc-api/src/jwt_auth.rs | 6 +- crates/arc-api/src/lib.rs | 1 - crates/arc-api/src/serve.rs | 6 +- crates/arc-api/src/server_config.rs | 310 ++++++++++++++++---- crates/arc-cli/src/main.rs | 4 +- crates/arc-workflows/src/cli/run.rs | 137 +++++++-- crates/arc-workflows/src/cli/run_config.rs | 194 +++++++++++- crates/arc-workflows/src/daytona_sandbox.rs | 2 - 9 files changed, 569 insertions(+), 331 deletions(-) delete mode 100644 crates/arc-api/src/app_config.rs diff --git a/crates/arc-api/src/app_config.rs b/crates/arc-api/src/app_config.rs deleted file mode 100644 index da21ce8c6..000000000 --- a/crates/arc-api/src/app_config.rs +++ /dev/null @@ -1,240 +0,0 @@ -use std::path::PathBuf; - -use serde::Deserialize; - -#[derive(Debug, Clone, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum AuthProvider { - Github, - InsecureDisabled, -} - -impl Default for AuthProvider { - fn default() -> Self { - Self::Github - } -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] -pub struct AuthConfig { - #[serde(default)] - pub provider: AuthProvider, - #[serde(default)] - pub allowed_usernames: Vec, -} - -#[derive(Debug, Clone, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum ApiAuthenticationStrategy { - Jwt, - InsecureDisabled, -} - -impl Default for ApiAuthenticationStrategy { - fn default() -> Self { - Self::Jwt - } -} - -#[derive(Debug, Clone, Deserialize, PartialEq)] -pub struct ApiConfig { - #[serde(default = "default_base_url")] - pub base_url: String, - #[serde(default)] - pub authentication_strategy: ApiAuthenticationStrategy, -} - -fn default_base_url() -> String { - "http://localhost:3000".to_string() -} - -impl Default for ApiConfig { - fn default() -> Self { - Self { - base_url: default_base_url(), - authentication_strategy: ApiAuthenticationStrategy::default(), - } - } -} - -#[derive(Debug, Clone, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum GitProvider { - Github, -} - -impl Default for GitProvider { - fn default() -> Self { - Self::Github - } -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] -pub struct GitConfig { - #[serde(default)] - pub provider: GitProvider, - pub app_id: Option, - pub client_id: Option, -} - -#[derive(Debug, Default, Deserialize)] -pub struct AppConfig { - pub data_dir: Option, - #[serde(default)] - pub auth: AuthConfig, - #[serde(default)] - pub api: ApiConfig, - #[serde(default)] - pub git: GitConfig, -} - -/// Load app config from `~/.arc/arc.toml`, returning defaults if the file doesn't exist. -pub fn load_app_config() -> anyhow::Result { - let Some(home) = dirs::home_dir() else { - return Ok(AppConfig::default()); - }; - let path = home.join(".arc").join("arc.toml"); - match std::fs::read_to_string(&path) { - Ok(contents) => Ok(toml::from_str(&contents)?), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AppConfig::default()), - Err(e) => Err(e.into()), - } -} - -/// Resolve the data directory: config value > default `~/.arc`. -pub fn resolve_data_dir(config: &AppConfig) -> PathBuf { - if let Some(ref dir) = config.data_dir { - return dir.clone(); - } - dirs::home_dir() - .map(|h| h.join(".arc")) - .unwrap_or_else(|| PathBuf::from(".arc")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_config_with_data_dir() { - let toml = r#"data_dir = "/custom/path""#; - let config: AppConfig = 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: AppConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.data_dir, None); - } - - #[test] - fn resolve_data_dir_uses_config_value() { - let config = AppConfig { - data_dir: Some(PathBuf::from("/my/data")), - ..AppConfig::default() - }; - assert_eq!(resolve_data_dir(&config), PathBuf::from("/my/data")); - } - - #[test] - fn resolve_data_dir_defaults_to_home_arc() { - let config = AppConfig::default(); - let dir = resolve_data_dir(&config); - // Should end with .arc - assert!( - dir.ends_with(".arc"), - "expected path ending with .arc, got: {}", - dir.display() - ); - } - - #[test] - fn parse_full_config() { - let toml = r#" -[auth] -provider = "github" -allowed_usernames = ["brynary", "alice"] - -[api] -base_url = "http://example.com:8080" -authentication_strategy = "jwt" - -[git] -provider = "github" -app_id = "12345" -client_id = "Iv1.abc123" -"#; - let config: AppConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.auth.provider, AuthProvider::Github); - assert_eq!(config.auth.allowed_usernames, vec!["brynary", "alice"]); - assert_eq!(config.api.base_url, "http://example.com:8080"); - assert_eq!( - config.api.authentication_strategy, - ApiAuthenticationStrategy::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")); - } - - #[test] - fn parse_auth_defaults() { - let toml = ""; - let config: AppConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.auth.provider, AuthProvider::Github); - assert!(config.auth.allowed_usernames.is_empty()); - } - - #[test] - fn parse_api_defaults() { - let toml = ""; - let config: AppConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.api.base_url, "http://localhost:3000"); - assert_eq!( - config.api.authentication_strategy, - ApiAuthenticationStrategy::Jwt - ); - } - - #[test] - fn parse_git_config() { - let toml = r#" -[git] -provider = "github" -app_id = "12345" -client_id = "Iv1.abc123" -"#; - let config: AppConfig = 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")); - } - - #[test] - fn parse_git_defaults() { - let toml = ""; - let config: AppConfig = 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); - } - - #[test] - fn parse_insecure_disabled_values() { - let toml = r#" -[auth] -provider = "insecure_disabled" - -[api] -authentication_strategy = "insecure_disabled" -"#; - let config: AppConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.auth.provider, AuthProvider::InsecureDisabled); - assert_eq!( - config.api.authentication_strategy, - ApiAuthenticationStrategy::InsecureDisabled - ); - } -} diff --git a/crates/arc-api/src/jwt_auth.rs b/crates/arc-api/src/jwt_auth.rs index ae1b649f3..7e891c21f 100644 --- a/crates/arc-api/src/jwt_auth.rs +++ b/crates/arc-api/src/jwt_auth.rs @@ -30,8 +30,8 @@ pub enum AuthMode { /// /// Call this once at startup before serving requests. Panics if the /// configuration is invalid (JWT strategy but no public key). -pub fn resolve_auth_mode(api_config: &crate::app_config::ApiConfig) -> AuthMode { - use crate::app_config::ApiAuthenticationStrategy; +pub fn resolve_auth_mode(api_config: &crate::server_config::ApiConfig) -> AuthMode { + use crate::server_config::ApiAuthenticationStrategy; match api_config.authentication_strategy { ApiAuthenticationStrategy::InsecureDisabled => { @@ -248,7 +248,7 @@ mod tests { #[test] fn resolve_auth_mode_insecure_disabled() { - use crate::app_config::{ApiAuthenticationStrategy, ApiConfig}; + use crate::server_config::{ApiAuthenticationStrategy, ApiConfig}; let config = ApiConfig { authentication_strategy: ApiAuthenticationStrategy::InsecureDisabled, diff --git a/crates/arc-api/src/lib.rs b/crates/arc-api/src/lib.rs index 5a6f2eda9..a0678e802 100644 --- a/crates/arc-api/src/lib.rs +++ b/crates/arc-api/src/lib.rs @@ -1,4 +1,3 @@ -pub mod app_config; mod demo; pub mod jwt_auth; pub mod serve; diff --git a/crates/arc-api/src/serve.rs b/crates/arc-api/src/serve.rs index 4c21ac38f..8c0b3835d 100644 --- a/crates/arc-api/src/serve.rs +++ b/crates/arc-api/src/serve.rs @@ -114,8 +114,8 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: }; // Initialize data directory and SQLite database - let app_config = crate::app_config::load_app_config()?; - let data_dir = crate::app_config::resolve_data_dir(&app_config); + let server_config = crate::server_config::load_server_config()?; + let data_dir = crate::server_config::resolve_data_dir(&server_config); std::fs::create_dir_all(&data_dir)?; let db = arc_db::connect(&data_dir.join("arc.db")).await?; arc_db::initialize_db(&db).await?; @@ -123,7 +123,7 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: let auth_mode = if args.demo { crate::jwt_auth::AuthMode::Disabled } else { - crate::jwt_auth::resolve_auth_mode(&app_config.api) + crate::jwt_auth::resolve_auth_mode(&server_config.api) }; let state = create_app_state_with_options(db, factory, dry_run_mode, args.demo); diff --git a/crates/arc-api/src/server_config.rs b/crates/arc-api/src/server_config.rs index 9931c4cb0..52f0709f2 100644 --- a/crates/arc-api/src/server_config.rs +++ b/crates/arc-api/src/server_config.rs @@ -1,35 +1,117 @@ -use std::path::Path; +use std::path::PathBuf; -use anyhow::{bail, Context}; +use arc_workflows::cli::run_config::RunDefaults; use serde::Deserialize; -const SUPPORTED_VERSION: &str = "1"; - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerConfig { - pub version: String, - pub url: String, +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum AuthProvider { + Github, + InsecureDisabled, } -pub fn load_server_config(path: &Path) -> anyhow::Result { - let contents = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read {}", path.display()))?; - parse_server_config(&contents) -} - -fn parse_server_config(contents: &str) -> anyhow::Result { - let config: ServerConfig = - toml::from_str(contents).context("Failed to parse server config TOML")?; - - if config.version != SUPPORTED_VERSION { - bail!( - "Unsupported server config version {}. Only version {SUPPORTED_VERSION} is supported.", - config.version - ); +impl Default for AuthProvider { + fn default() -> Self { + Self::Github } +} - Ok(config) +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +pub struct AuthConfig { + #[serde(default)] + pub provider: AuthProvider, + #[serde(default)] + pub allowed_usernames: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ApiAuthenticationStrategy { + Jwt, + InsecureDisabled, +} + +impl Default for ApiAuthenticationStrategy { + fn default() -> Self { + Self::Jwt + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct ApiConfig { + #[serde(default = "default_base_url")] + pub base_url: String, + #[serde(default)] + pub authentication_strategy: ApiAuthenticationStrategy, +} + +fn default_base_url() -> String { + "http://localhost:3000".to_string() +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + base_url: default_base_url(), + authentication_strategy: ApiAuthenticationStrategy::default(), + } + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum GitProvider { + Github, +} + +impl Default for GitProvider { + fn default() -> Self { + Self::Github + } +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +pub struct GitConfig { + #[serde(default)] + pub provider: GitProvider, + pub app_id: Option, + pub client_id: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub struct ServerConfig { + pub data_dir: Option, + #[serde(default)] + pub auth: AuthConfig, + #[serde(default)] + pub api: ApiConfig, + #[serde(default)] + pub git: GitConfig, + #[serde(flatten)] + pub run_defaults: RunDefaults, +} + +/// Load server config from `~/.arc/arc.toml`, returning defaults if the file doesn't exist. +pub fn load_server_config() -> anyhow::Result { + let Some(home) = dirs::home_dir() else { + return Ok(ServerConfig::default()); + }; + let path = home.join(".arc").join("arc.toml"); + match std::fs::read_to_string(&path) { + Ok(contents) => Ok(toml::from_str(&contents)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ServerConfig::default()), + Err(e) => Err(e.into()), + } +} + +/// Resolve the data directory: config value > default `~/.arc`. +pub fn resolve_data_dir(config: &ServerConfig) -> PathBuf { + if let Some(ref dir) = config.data_dir { + return dir.clone(); + } + dirs::home_dir() + .map(|h| h.join(".arc")) + .unwrap_or_else(|| PathBuf::from(".arc")) } #[cfg(test)] @@ -37,50 +119,168 @@ mod tests { use super::*; #[test] - fn parse_valid_config() { - let toml = r#" -version = "1" -url = "http://localhost:3000" -"#; - let config = parse_server_config(toml).unwrap(); - assert_eq!(config.version, "1"); - assert_eq!(config.url, "http://localhost:3000"); + fn parse_config_with_data_dir() { + let toml = r#"data_dir = "/custom/path""#; + let config: ServerConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.data_dir, Some(PathBuf::from("/custom/path"))); } #[test] - fn unsupported_version_rejected() { - let toml = r#" -version = "2" -url = "http://localhost:3000" -"#; - let err = parse_server_config(toml).unwrap_err(); + fn parse_empty_config_defaults() { + let toml = ""; + let config: ServerConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.data_dir, None); + } + + #[test] + fn resolve_data_dir_uses_config_value() { + let config = ServerConfig { + data_dir: Some(PathBuf::from("/my/data")), + ..ServerConfig::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 dir = resolve_data_dir(&config); + // Should end with .arc assert!( - err.to_string() - .contains("Unsupported server config version 2"), - "unexpected error: {err}" + dir.ends_with(".arc"), + "expected path ending with .arc, got: {}", + dir.display() ); } #[test] - fn unknown_fields_rejected() { + fn parse_full_config() { let toml = r#" -version = "1" -url = "http://localhost:3000" -extra = "nope" +[auth] +provider = "github" +allowed_usernames = ["brynary", "alice"] + +[api] +base_url = "http://example.com:8080" +authentication_strategy = "jwt" + +[git] +provider = "github" +app_id = "12345" +client_id = "Iv1.abc123" "#; - assert!(parse_server_config(toml).is_err()); + let config: ServerConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.auth.provider, AuthProvider::Github); + assert_eq!(config.auth.allowed_usernames, vec!["brynary", "alice"]); + assert_eq!(config.api.base_url, "http://example.com:8080"); + assert_eq!( + config.api.authentication_strategy, + ApiAuthenticationStrategy::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")); } #[test] - fn missing_fields_rejected() { - let no_url = r#" -version = "1" -"#; - assert!(parse_server_config(no_url).is_err()); + fn parse_auth_defaults() { + let toml = ""; + let config: ServerConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.auth.provider, AuthProvider::Github); + assert!(config.auth.allowed_usernames.is_empty()); + } - let no_version = r#" -url = "http://localhost:3000" + #[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_eq!( + config.api.authentication_strategy, + ApiAuthenticationStrategy::Jwt + ); + } + + #[test] + fn parse_git_config() { + let toml = r#" +[git] +provider = "github" +app_id = "12345" +client_id = "Iv1.abc123" "#; - assert!(parse_server_config(no_version).is_err()); + 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")); + } + + #[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); + } + + #[test] + fn parse_config_with_run_defaults() { + let toml = r#" +[llm] +model = "claude-haiku" +provider = "anthropic" + +[sandbox] +provider = "daytona" + +[vars] +repo_url = "https://github.com/org/repo" +"#; + let config: ServerConfig = toml::from_str(toml).unwrap(); + let llm = config.run_defaults.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(); + assert_eq!(sandbox.provider.as_deref(), Some("daytona")); + let vars = config.run_defaults.vars.unwrap(); + assert_eq!(vars["repo_url"], "https://github.com/org/repo"); + } + + #[test] + fn parse_config_server_and_run_defaults_together() { + let toml = r#" +[auth] +provider = "github" + +[git] +provider = "github" +app_id = "123" + +[llm] +model = "gpt-4" +"#; + let config: ServerConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.auth.provider, AuthProvider::Github); + assert_eq!(config.git.app_id.as_deref(), Some("123")); + let llm = config.run_defaults.llm.unwrap(); + assert_eq!(llm.model.as_deref(), Some("gpt-4")); + } + + #[test] + fn parse_insecure_disabled_values() { + let toml = r#" +[auth] +provider = "insecure_disabled" + +[api] +authentication_strategy = "insecure_disabled" +"#; + let config: ServerConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.auth.provider, AuthProvider::InsecureDisabled); + assert_eq!( + config.api.authentication_strategy, + ApiAuthenticationStrategy::InsecureDisabled + ); } } diff --git a/crates/arc-cli/src/main.rs b/crates/arc-cli/src/main.rs index acca195ae..5b89c545c 100644 --- a/crates/arc-cli/src/main.rs +++ b/crates/arc-cli/src/main.rs @@ -93,7 +93,9 @@ async fn main() -> Result<()> { RunCommand::Start(args) => { let styles: &'static arc_util::terminal::Styles = Box::leak(Box::new(arc_util::terminal::Styles::detect_stderr())); - arc_workflows::cli::run::run_command(args, styles).await?; + let server_config = arc_api::server_config::load_server_config()?; + arc_workflows::cli::run::run_command(args, server_config.run_defaults, styles) + .await?; } RunCommand::List(args) => { arc_workflows::cli::runs::list_command(&args)?; diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index 0c4fc2451..188b3f35f 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -26,7 +26,7 @@ use arc_llm::provider::Provider; use super::backend::AgentApiBackend; use super::cli_backend::{BackendRouter, AgentCliBackend}; use super::run_config; -use super::run_config::WorkflowRunConfig; +use super::run_config::{RunDefaults, WorkflowRunConfig}; use super::{ compute_stage_cost, format_cost, format_duration_human, format_event_summary, format_tokens_human, print_diagnostics, read_dot_file, @@ -47,12 +47,13 @@ fn default_model_for_provider(provider: Provider) -> &'static str { } /// Resolve model and provider through the full precedence chain: -/// CLI flag > TOML config > DOT graph attrs > provider-specific defaults. +/// CLI flag > TOML config > run defaults > DOT graph attrs > provider-specific defaults. /// Then resolve through the catalog for alias expansion. fn resolve_model_provider( cli_model: Option<&str>, cli_provider: Option<&str>, run_cfg: Option<&WorkflowRunConfig>, + run_defaults: &RunDefaults, graph: &crate::graph::types::Graph, ) -> (String, Option) { let toml_model = run_cfg @@ -61,10 +62,19 @@ fn resolve_model_provider( let toml_provider = run_cfg .and_then(|c| c.llm.as_ref()) .and_then(|l| l.provider.as_deref()); + let defaults_model = run_defaults + .llm + .as_ref() + .and_then(|l| l.model.as_deref()); + let defaults_provider = run_defaults + .llm + .as_ref() + .and_then(|l| l.provider.as_deref()); - // Precedence: CLI flag > TOML > DOT graph attrs > defaults + // Precedence: CLI flag > TOML > run defaults > DOT graph attrs > defaults let provider = cli_provider .or(toml_provider) + .or(defaults_provider) .or_else(|| { graph .attrs @@ -75,6 +85,7 @@ fn resolve_model_provider( let model = cli_model .or(toml_model) + .or(defaults_model) .or_else(|| { graph .attrs @@ -114,7 +125,11 @@ struct CostAccumulator { /// # Errors /// /// Returns an error if the workflow cannot be read, parsed, validated, or executed. -pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Result<()> { +pub async fn run_command( + args: RunArgs, + run_defaults: RunDefaults, + styles: &'static Styles, +) -> anyhow::Result<()> { // Handle --run-branch resume: read everything from git metadata if let Some(branch) = args.run_branch.clone() { return run_from_branch(args, &branch, styles).await; @@ -125,32 +140,40 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu .as_ref() .ok_or_else(|| anyhow::anyhow!("--workflow is required unless --run-branch is provided"))?; - // 0. Load run config if TOML, resolve DOT path, run setup + // 0. Load run config if TOML, resolve DOT path, apply defaults let (dot_path, run_cfg) = if workflow_path.extension().is_some_and(|ext| ext == "toml") { - let cfg = run_config::load_run_config(workflow_path)?; + let mut cfg = run_config::load_run_config(workflow_path)?; + cfg.apply_defaults(&run_defaults); let dot = run_config::resolve_graph_path(workflow_path, &cfg.graph); (dot, Some(cfg)) } else { (workflow_path.clone(), None) }; - if let Some(ref cfg) = run_cfg { - if let Some(ref dir) = cfg.directory { - std::env::set_current_dir(dir) - .map_err(|e| anyhow::anyhow!("Failed to set working directory to {dir}: {e}"))?; - } + let directory = run_cfg + .as_ref() + .and_then(|c| c.directory.as_deref()) + .or(run_defaults.directory.as_deref()); + if let Some(dir) = directory { + std::env::set_current_dir(dir) + .map_err(|e| anyhow::anyhow!("Failed to set working directory to {dir}: {e}"))?; } // Collect setup commands — they'll be run inside the sandbox let setup_commands: Vec = run_cfg .as_ref() .and_then(|c| c.setup.as_ref()) + .or(run_defaults.setup.as_ref()) .map(|s| s.commands.clone()) .unwrap_or_default(); // 1. Parse and validate workflow let source = read_dot_file(&dot_path)?; - let source = match run_cfg.as_ref().and_then(|c| c.vars.as_ref()) { + let vars = run_cfg + .as_ref() + .and_then(|c| c.vars.as_ref()) + .or(run_defaults.vars.as_ref()); + let source = match vars { Some(vars) => run_config::expand_vars(&source, vars)?, None => source, }; @@ -192,7 +215,15 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu .transpose() .ok() .flatten(); - args.sandbox.or(toml_exec).unwrap_or_default() + let defaults_exec = run_defaults + .sandbox + .as_ref() + .and_then(|s| s.provider.as_deref()) + .map(|s| s.parse::()) + .transpose() + .ok() + .flatten(); + args.sandbox.or(toml_exec).or(defaults_exec).unwrap_or_default() }; let original_cwd = std::env::current_dir()?; let git_clean = match sandbox_provider_preview { @@ -203,7 +234,7 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu }; if args.preflight { - return run_preflight(&graph, &run_cfg, &args, git_clean, sandbox_provider_preview, styles).await; + return run_preflight(&graph, &run_cfg, &args, &run_defaults, git_clean, sandbox_provider_preview, styles).await; } // 3. Create logs directory @@ -367,7 +398,7 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu Arc::new(ConsoleInterviewer::new(styles)) }; - // 5. Resolve sandbox: CLI flag > TOML > default + // 5. Resolve sandbox: CLI flag > TOML (with defaults applied) > run defaults > default let toml_sandbox = run_cfg .as_ref() .and_then(|c| c.sandbox.as_ref()) @@ -375,9 +406,17 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu .map(|s| s.parse::()) .transpose() .map_err(|e| anyhow::anyhow!("Invalid sandbox in TOML: {e}"))?; + let defaults_sandbox = run_defaults + .sandbox + .as_ref() + .and_then(|s| s.provider.as_deref()) + .map(|s| s.parse::()) + .transpose() + .map_err(|e| anyhow::anyhow!("Invalid sandbox in server config: {e}"))?; let sandbox_provider = args .sandbox .or(toml_sandbox) + .or(defaults_sandbox) .unwrap_or_default(); // Set up git worktree for local execution (must happen before cwd is captured) @@ -403,7 +442,13 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu let daytona_config = run_cfg .as_ref() .and_then(|c| c.sandbox.as_ref()) - .and_then(|e| e.daytona.clone()); + .and_then(|e| e.daytona.clone()) + .or_else(|| { + run_defaults + .sandbox + .as_ref() + .and_then(|s| s.daytona.clone()) + }); // Wrap emitter in Arc now so we can share it with exec env callbacks let emitter = Arc::new(emitter); @@ -555,6 +600,7 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu args.model.as_deref(), args.provider.as_deref(), run_cfg.as_ref(), + &run_defaults, &graph, ); @@ -1047,6 +1093,7 @@ async fn run_preflight( graph: &crate::graph::types::Graph, run_cfg: &Option, args: &RunArgs, + run_defaults: &RunDefaults, git_clean: bool, sandbox_provider: SandboxProvider, styles: &'static Styles, @@ -1125,6 +1172,7 @@ async fn run_preflight( args.model.as_deref(), args.provider.as_deref(), run_cfg.as_ref(), + run_defaults, graph, ); @@ -1328,7 +1376,8 @@ mod tests { #[test] fn resolve_model_provider_defaults() { let graph = crate::graph::types::Graph::new("test"); - let (model, provider) = resolve_model_provider(None, None, None, &graph); + let defaults = RunDefaults::default(); + let (model, provider) = resolve_model_provider(None, None, None, &defaults, &graph); assert_eq!(model, "claude-opus-4-6"); // Catalog resolves anthropic as the provider for claude-opus-4-6 assert_eq!(provider, Some("anthropic".to_string())); @@ -1337,6 +1386,7 @@ mod tests { #[test] fn resolve_model_provider_cli_overrides_toml() { let graph = crate::graph::types::Graph::new("test"); + let defaults = RunDefaults::default(); let cfg = run_config::WorkflowRunConfig { version: 1, goal: "test".to_string(), @@ -1354,6 +1404,7 @@ mod tests { Some("gpt-5.2"), Some("openai"), Some(&cfg), + &defaults, &graph, ); assert_eq!(model, "gpt-5.2"); @@ -1367,6 +1418,7 @@ mod tests { graph.attrs.insert("default_model".to_string(), AttrValue::String("graph-model".to_string())); graph.attrs.insert("default_provider".to_string(), AttrValue::String("gemini".to_string())); + let defaults = RunDefaults::default(); let cfg = run_config::WorkflowRunConfig { version: 1, goal: "test".to_string(), @@ -1380,7 +1432,7 @@ mod tests { sandbox: None, vars: None, }; - let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &graph); + let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph); assert_eq!(model, "toml-model"); assert_eq!(provider, Some("openai".to_string())); } @@ -1392,7 +1444,8 @@ mod tests { graph.attrs.insert("default_model".to_string(), AttrValue::String("gpt-5.2".to_string())); graph.attrs.insert("default_provider".to_string(), AttrValue::String("openai".to_string())); - let (model, provider) = resolve_model_provider(None, None, None, &graph); + let defaults = RunDefaults::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())); } @@ -1400,11 +1453,55 @@ mod tests { #[test] fn resolve_model_provider_alias_expansion() { let graph = crate::graph::types::Graph::new("test"); - let (model, provider) = resolve_model_provider(Some("opus"), None, None, &graph); + let defaults = RunDefaults::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())); } + #[test] + fn resolve_model_provider_run_defaults_used() { + let graph = crate::graph::types::Graph::new("test"); + let defaults = RunDefaults { + llm: Some(run_config::LlmConfig { + model: Some("default-model".to_string()), + provider: Some("openai".to_string()), + }), + ..RunDefaults::default() + }; + let (model, provider) = resolve_model_provider(None, None, None, &defaults, &graph); + assert_eq!(model, "default-model"); + assert_eq!(provider, Some("openai".to_string())); + } + + #[test] + fn resolve_model_provider_toml_overrides_run_defaults() { + let graph = crate::graph::types::Graph::new("test"); + let defaults = RunDefaults { + llm: Some(run_config::LlmConfig { + model: Some("default-model".to_string()), + provider: Some("anthropic".to_string()), + }), + ..RunDefaults::default() + }; + let cfg = run_config::WorkflowRunConfig { + version: 1, + goal: "test".to_string(), + graph: "test.dot".to_string(), + directory: None, + llm: Some(run_config::LlmConfig { + model: Some("toml-model".to_string()), + provider: Some("openai".to_string()), + }), + setup: None, + sandbox: None, + vars: None, + }; + let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph); + assert_eq!(model, "toml-model"); + assert_eq!(provider, Some("openai".to_string())); + } + #[test] fn redact_removes_aws_key_from_compact_json() { let envelope = serde_json::json!({ diff --git a/crates/arc-workflows/src/cli/run_config.rs b/crates/arc-workflows/src/cli/run_config.rs index 4d793ba69..bafe9378d 100644 --- a/crates/arc-workflows/src/cli/run_config.rs +++ b/crates/arc-workflows/src/cli/run_config.rs @@ -21,27 +21,64 @@ pub struct WorkflowRunConfig { pub vars: Option>, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Clone, Debug, Deserialize)] pub struct LlmConfig { pub model: Option, pub provider: Option, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Clone, Debug, Deserialize)] pub struct SetupConfig { pub commands: Vec, pub timeout_ms: Option, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Clone, Debug, Deserialize)] pub struct SandboxConfig { pub provider: Option, pub daytona: Option, } +/// Defaults for workflow runs, loaded from the server config. +/// +/// Fields mirror `WorkflowRunConfig` but are all optional. +#[derive(Clone, Debug, Default, Deserialize)] +pub struct RunDefaults { + pub directory: Option, + pub llm: Option, + pub setup: Option, + pub sandbox: Option, + pub vars: Option>, +} + +impl WorkflowRunConfig { + /// Apply server-level run defaults to this config. + /// + /// Each field uses the first non-`None` value (task config wins). + /// Vars are merged: defaults first, then task config overwrites. + pub fn apply_defaults(&mut self, defaults: &RunDefaults) { + if self.directory.is_none() { + self.directory = defaults.directory.clone(); + } + if self.llm.is_none() { + self.llm = defaults.llm.clone(); + } + if self.setup.is_none() { + self.setup = defaults.setup.clone(); + } + if self.sandbox.is_none() { + self.sandbox = defaults.sandbox.clone(); + } + if let Some(ref default_vars) = defaults.vars { + let mut merged = default_vars.clone(); + if let Some(ref task_vars) = self.vars { + merged.extend(task_vars.clone()); + } + self.vars = Some(merged); + } + } +} + /// Load and validate a run config from a TOML file. /// /// The `graph` path in the returned config is resolved relative to the @@ -386,6 +423,151 @@ goal = "x" assert!(parse_run_config(no_graph).is_err()); } + #[test] + fn parse_run_defaults_with_llm() { + let toml = r#" +[llm] +model = "claude-haiku" +provider = "anthropic" +"#; + let defaults: RunDefaults = toml::from_str(toml).unwrap(); + let llm = defaults.llm.unwrap(); + assert_eq!(llm.model.as_deref(), Some("claude-haiku")); + assert_eq!(llm.provider.as_deref(), Some("anthropic")); + } + + #[test] + fn parse_run_defaults_empty() { + let defaults: RunDefaults = toml::from_str("").unwrap(); + assert!(defaults.directory.is_none()); + assert!(defaults.llm.is_none()); + assert!(defaults.setup.is_none()); + assert!(defaults.sandbox.is_none()); + assert!(defaults.vars.is_none()); + } + + #[test] + fn parse_run_defaults_full() { + let toml = r#" +directory = "/work" + +[llm] +model = "gpt-4" +provider = "openai" + +[setup] +commands = ["make build"] +timeout_ms = 5000 + +[sandbox] +provider = "daytona" + +[vars] +key = "value" +"#; + let defaults: RunDefaults = toml::from_str(toml).unwrap(); + assert_eq!(defaults.directory.as_deref(), Some("/work")); + assert!(defaults.llm.is_some()); + assert!(defaults.setup.is_some()); + assert!(defaults.sandbox.is_some()); + assert_eq!(defaults.vars.as_ref().unwrap()["key"], "value"); + } + + #[test] + fn apply_defaults_fills_missing_llm() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" +"#, + ) + .unwrap(); + let defaults = RunDefaults { + llm: Some(LlmConfig { + model: Some("default-model".into()), + provider: Some("anthropic".into()), + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let llm = cfg.llm.unwrap(); + assert_eq!(llm.model.as_deref(), Some("default-model")); + } + + #[test] + fn apply_defaults_task_config_wins() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" + +[llm] +model = "task-model" +"#, + ) + .unwrap(); + let defaults = RunDefaults { + llm: Some(LlmConfig { + model: Some("default-model".into()), + provider: None, + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let llm = cfg.llm.unwrap(); + assert_eq!(llm.model.as_deref(), Some("task-model")); + } + + #[test] + fn apply_defaults_merges_vars() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" + +[vars] +task_key = "task_val" +shared = "from_task" +"#, + ) + .unwrap(); + let defaults = RunDefaults { + vars: Some(HashMap::from([ + ("default_key".into(), "default_val".into()), + ("shared".into(), "from_default".into()), + ])), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let vars = cfg.vars.unwrap(); + assert_eq!(vars["default_key"], "default_val"); + assert_eq!(vars["task_key"], "task_val"); + // Task config wins on collision + assert_eq!(vars["shared"], "from_task"); + } + + #[test] + fn apply_defaults_vars_default_only() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" +"#, + ) + .unwrap(); + let defaults = RunDefaults { + vars: Some(HashMap::from([("key".into(), "val".into())])), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let vars = cfg.vars.unwrap(); + assert_eq!(vars["key"], "val"); + } + #[tokio::test] async fn run_setup_succeeds() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/arc-workflows/src/daytona_sandbox.rs b/crates/arc-workflows/src/daytona_sandbox.rs index ed72746c0..2789d0284 100644 --- a/crates/arc-workflows/src/daytona_sandbox.rs +++ b/crates/arc-workflows/src/daytona_sandbox.rs @@ -17,7 +17,6 @@ const DEFAULT_IMAGE: &str = "ubuntu:22.04"; /// /// Doubles as the TOML deserialization target for `[sandbox.daytona]`. #[derive(Clone, Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] pub struct DaytonaConfig { pub auto_stop_interval: Option, pub labels: Option>, @@ -27,7 +26,6 @@ pub struct DaytonaConfig { /// Snapshot configuration: when present, the sandbox is created from a snapshot /// instead of a bare Docker image. #[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] pub struct DaytonaSnapshotConfig { pub name: String, pub cpu: Option,