Add run defaults to server config for workflow run inheritance

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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-03 01:26:41 -05:00
parent c8c1ec6916
commit e3fa99eba1
9 changed files with 569 additions and 331 deletions

View file

@ -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<String>,
}
#[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<String>,
pub client_id: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct AppConfig {
pub data_dir: Option<PathBuf>,
#[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<AppConfig> {
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
);
}
}

View file

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

View file

@ -1,4 +1,3 @@
pub mod app_config;
mod demo;
pub mod jwt_auth;
pub mod serve;

View file

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

View file

@ -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<ServerConfig> {
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<ServerConfig> {
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<String>,
}
#[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<String>,
pub client_id: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct ServerConfig {
pub data_dir: Option<PathBuf>,
#[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<ServerConfig> {
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
);
}
}

View file

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

View file

@ -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<String>) {
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<String> = 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::<SandboxProvider>())
.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::<SandboxProvider>())
.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::<SandboxProvider>())
.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<run_config::WorkflowRunConfig>,
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!({

View file

@ -21,27 +21,64 @@ pub struct WorkflowRunConfig {
pub vars: Option<HashMap<String, String>>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
#[derive(Clone, Debug, Deserialize)]
pub struct LlmConfig {
pub model: Option<String>,
pub provider: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
#[derive(Clone, Debug, Deserialize)]
pub struct SetupConfig {
pub commands: Vec<String>,
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
#[derive(Clone, Debug, Deserialize)]
pub struct SandboxConfig {
pub provider: Option<String>,
pub daytona: Option<DaytonaConfig>,
}
/// 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<String>,
pub llm: Option<LlmConfig>,
pub setup: Option<SetupConfig>,
pub sandbox: Option<SandboxConfig>,
pub vars: Option<HashMap<String, String>>,
}
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();

View file

@ -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<i32>,
pub labels: Option<HashMap<String, String>>,
@ -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<i32>,