mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
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:
parent
c8c1ec6916
commit
e3fa99eba1
9 changed files with 569 additions and 331 deletions
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
pub mod app_config;
|
||||
mod demo;
|
||||
pub mod jwt_auth;
|
||||
pub mod serve;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue