From 918847af91a46c352d3e2dbbe22af9ec12c120b4 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 4 Mar 2026 07:58:56 -0500 Subject: [PATCH] Field-level merging for run config defaults apply_defaults() previously did whole-struct replacement for llm, setup, and sandbox sections. If run.toml specified only [llm] model, the entire server.toml [llm] block (including provider) was lost. Now each field merges individually: task values win per-field, with map-merge for labels (like vars) and whole-struct semantics for snapshot. Co-Authored-By: Claude Opus 4.6 --- crates/arc-workflows/src/cli/run_config.rs | 283 ++++++++++++++++++++- 1 file changed, 277 insertions(+), 6 deletions(-) diff --git a/crates/arc-workflows/src/cli/run_config.rs b/crates/arc-workflows/src/cli/run_config.rs index fe412eed0..d5ce139a8 100644 --- a/crates/arc-workflows/src/cli/run_config.rs +++ b/crates/arc-workflows/src/cli/run_config.rs @@ -60,15 +60,59 @@ impl WorkflowRunConfig { if self.directory.is_none() { self.directory = defaults.directory.clone(); } - if self.llm.is_none() { - self.llm = defaults.llm.clone(); + + match (&mut self.llm, &defaults.llm) { + (Some(task), Some(default)) => { + if task.model.is_none() { + task.model = default.model.clone(); + } + if task.provider.is_none() { + task.provider = default.provider.clone(); + } + } + (None, Some(_)) => self.llm = defaults.llm.clone(), + _ => {} } - if self.setup.is_none() { - self.setup = defaults.setup.clone(); + + match (&mut self.setup, &defaults.setup) { + (Some(task), Some(default)) => { + if task.timeout_ms.is_none() { + task.timeout_ms = default.timeout_ms; + } + } + (None, Some(_)) => self.setup = defaults.setup.clone(), + _ => {} } - if self.sandbox.is_none() { - self.sandbox = defaults.sandbox.clone(); + + match (&mut self.sandbox, &defaults.sandbox) { + (Some(task), Some(default)) => { + if task.provider.is_none() { + task.provider = default.provider.clone(); + } + match (&mut task.daytona, &default.daytona) { + (Some(task_d), Some(default_d)) => { + if task_d.auto_stop_interval.is_none() { + task_d.auto_stop_interval = default_d.auto_stop_interval; + } + if task_d.snapshot.is_none() { + task_d.snapshot = default_d.snapshot.clone(); + } + if let Some(ref default_labels) = default_d.labels { + let mut merged = default_labels.clone(); + if let Some(ref task_labels) = task_d.labels { + merged.extend(task_labels.clone()); + } + task_d.labels = Some(merged); + } + } + (None, Some(_)) => task.daytona = default.daytona.clone(), + _ => {} + } + } + (None, Some(_)) => 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 { @@ -195,6 +239,7 @@ pub async fn run_setup(setup: &SetupConfig, directory: &Path) -> anyhow::Result< #[cfg(test)] mod tests { use super::*; + use crate::daytona_sandbox::DaytonaSnapshotConfig; #[test] fn parse_toml_with_vars() { @@ -567,6 +612,232 @@ graph = "w.dot" assert_eq!(vars["key"], "val"); } + #[test] + fn apply_defaults_merges_llm_fields() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" + +[llm] +model = "haiku" +"#, + ) + .unwrap(); + let defaults = RunDefaults { + llm: Some(LlmConfig { + model: None, + provider: Some("anthropic".into()), + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let llm = cfg.llm.unwrap(); + assert_eq!(llm.model.as_deref(), Some("haiku")); + assert_eq!(llm.provider.as_deref(), Some("anthropic")); + } + + #[test] + fn apply_defaults_merges_setup_timeout() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" + +[setup] +commands = ["make test"] +"#, + ) + .unwrap(); + let defaults = RunDefaults { + setup: Some(SetupConfig { + commands: vec!["make build".into()], + timeout_ms: Some(60000), + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let setup = cfg.setup.unwrap(); + assert_eq!(setup.commands, vec!["make test"]); + assert_eq!(setup.timeout_ms, Some(60000)); + } + + #[test] + fn apply_defaults_merges_sandbox_fields() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" + +[sandbox] +provider = "daytona" +"#, + ) + .unwrap(); + let defaults = RunDefaults { + sandbox: Some(SandboxConfig { + provider: None, + daytona: Some(DaytonaConfig { + auto_stop_interval: Some(30), + labels: None, + snapshot: None, + }), + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let sandbox = cfg.sandbox.unwrap(); + assert_eq!(sandbox.provider.as_deref(), Some("daytona")); + let daytona = sandbox.daytona.unwrap(); + assert_eq!(daytona.auto_stop_interval, Some(30)); + } + + #[test] + fn apply_defaults_merges_daytona_fields() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" + +[sandbox] +provider = "daytona" + +[sandbox.daytona] +auto_stop_interval = 60 +"#, + ) + .unwrap(); + let defaults = RunDefaults { + sandbox: Some(SandboxConfig { + provider: Some("daytona".into()), + daytona: Some(DaytonaConfig { + auto_stop_interval: Some(30), + labels: Some(HashMap::from([("env".into(), "prod".into())])), + snapshot: None, + }), + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let daytona = cfg.sandbox.unwrap().daytona.unwrap(); + assert_eq!(daytona.auto_stop_interval, Some(60)); + assert_eq!(daytona.labels.as_ref().unwrap()["env"], "prod"); + } + + #[test] + fn apply_defaults_merges_daytona_labels() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" + +[sandbox.daytona.labels] +project = "arc" +env = "from_task" +"#, + ) + .unwrap(); + let defaults = RunDefaults { + sandbox: Some(SandboxConfig { + provider: None, + daytona: Some(DaytonaConfig { + auto_stop_interval: None, + labels: Some(HashMap::from([ + ("env".into(), "from_default".into()), + ("team".into(), "platform".into()), + ])), + snapshot: None, + }), + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let labels = cfg.sandbox.unwrap().daytona.unwrap().labels.unwrap(); + assert_eq!(labels["project"], "arc"); + assert_eq!(labels["team"], "platform"); + assert_eq!(labels["env"], "from_task"); + } + + #[test] + fn apply_defaults_daytona_snapshot_whole_struct() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" + +[sandbox.daytona.snapshot] +name = "task-snap" +cpu = 2 +"#, + ) + .unwrap(); + let defaults = RunDefaults { + sandbox: Some(SandboxConfig { + provider: None, + daytona: Some(DaytonaConfig { + auto_stop_interval: None, + labels: None, + snapshot: Some(DaytonaSnapshotConfig { + name: "default-snap".into(), + cpu: Some(8), + memory: Some(16), + disk: Some(100), + dockerfile: Some("FROM ubuntu".into()), + }), + }), + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let snapshot = cfg.sandbox.unwrap().daytona.unwrap().snapshot.unwrap(); + assert_eq!(snapshot.name, "task-snap"); + assert_eq!(snapshot.cpu, Some(2)); + assert!(snapshot.memory.is_none()); + } + + #[test] + fn apply_defaults_daytona_snapshot_from_default() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" + +[sandbox.daytona] +auto_stop_interval = 60 +"#, + ) + .unwrap(); + let defaults = RunDefaults { + sandbox: Some(SandboxConfig { + provider: None, + daytona: Some(DaytonaConfig { + auto_stop_interval: None, + labels: None, + snapshot: Some(DaytonaSnapshotConfig { + name: "default-snap".into(), + cpu: Some(4), + memory: Some(8), + disk: None, + dockerfile: None, + }), + }), + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let snapshot = cfg.sandbox.unwrap().daytona.unwrap().snapshot.unwrap(); + assert_eq!(snapshot.name, "default-snap"); + assert_eq!(snapshot.cpu, Some(4)); + assert_eq!(snapshot.memory, Some(8)); + } + #[tokio::test] async fn run_setup_succeeds() { let dir = tempfile::tempdir().unwrap();