mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Add [sandbox.local] worktree_mode config for local sandbox runs
Allow customizing when git worktrees are created via a new `worktree_mode` enum (always/clean/dirty/never) in `[sandbox.local]`. Previously, worktree creation was hardcoded to only occur on clean repos. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e9ff7f3274
commit
4f99eb435b
4 changed files with 264 additions and 13 deletions
|
|
@ -1265,6 +1265,7 @@ mod runs {
|
|||
sandbox: Some(arc_workflows::cli::run_config::SandboxConfig {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig {
|
||||
auto_stop_interval: Some(60),
|
||||
labels: Some(std::collections::HashMap::from([(
|
||||
|
|
@ -1420,6 +1421,7 @@ mod workflows {
|
|||
sandbox: Some(arc_workflows::cli::run_config::SandboxConfig {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig {
|
||||
auto_stop_interval: Some(60),
|
||||
labels: Some(std::collections::HashMap::from([
|
||||
|
|
@ -1487,6 +1489,7 @@ mod workflows {
|
|||
sandbox: Some(arc_workflows::cli::run_config::SandboxConfig {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig {
|
||||
auto_stop_interval: Some(120),
|
||||
labels: Some(std::collections::HashMap::from([
|
||||
|
|
@ -1566,6 +1569,7 @@ mod workflows {
|
|||
sandbox: Some(arc_workflows::cli::run_config::SandboxConfig {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig {
|
||||
auto_stop_interval: Some(120),
|
||||
labels: Some(std::collections::HashMap::from([
|
||||
|
|
@ -1636,6 +1640,7 @@ mod workflows {
|
|||
sandbox: Some(arc_workflows::cli::run_config::SandboxConfig {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig {
|
||||
auto_stop_interval: Some(180),
|
||||
labels: Some(std::collections::HashMap::from([
|
||||
|
|
@ -3090,6 +3095,7 @@ mod settings {
|
|||
sandbox: Some(arc_workflows::cli::run_config::SandboxConfig {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig {
|
||||
auto_stop_interval: Some(60),
|
||||
labels: None,
|
||||
|
|
|
|||
|
|
@ -681,6 +681,20 @@ impl ProgressUI {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn show_worktree(&mut self, path: &Path) {
|
||||
let path_str = super::tilde_path(path);
|
||||
match &self.renderer {
|
||||
ProgressRenderer::Tty(tty) => {
|
||||
let bar = tty.multi.add(ProgressBar::new_spinner());
|
||||
bar.set_style(style_static_dim());
|
||||
bar.finish_with_message(format!("Worktree: {path_str}"));
|
||||
}
|
||||
ProgressRenderer::Plain => {
|
||||
eprintln!(" Worktree: {path_str}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stages ──────────────────────────────────────────────────────────
|
||||
|
||||
fn on_stage_started(&mut self, node_id: &str, name: &str, script: Option<&str>) {
|
||||
|
|
|
|||
|
|
@ -159,6 +159,25 @@ fn resolve_preserve_sandbox(
|
|||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Resolve worktree mode: TOML config > run defaults > Clean.
|
||||
fn resolve_worktree_mode(
|
||||
run_cfg: Option<&WorkflowRunConfig>,
|
||||
run_defaults: &RunDefaults,
|
||||
) -> run_config::WorktreeMode {
|
||||
run_cfg
|
||||
.and_then(|c| c.sandbox.as_ref())
|
||||
.and_then(|s| s.local.as_ref())
|
||||
.map(|l| l.worktree_mode)
|
||||
.unwrap_or_else(|| {
|
||||
run_defaults
|
||||
.sandbox
|
||||
.as_ref()
|
||||
.and_then(|s| s.local.as_ref())
|
||||
.map(|l| l.worktree_mode)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve daytona config: TOML config > run defaults.
|
||||
fn resolve_daytona_config(
|
||||
run_cfg: Option<&WorkflowRunConfig>,
|
||||
|
|
@ -519,20 +538,47 @@ pub async fn run_command(
|
|||
};
|
||||
|
||||
// Set up git worktree for local execution (must happen before cwd is captured)
|
||||
let (worktree_work_dir, _worktree_path, worktree_branch, worktree_base_sha) = if git_clean {
|
||||
match setup_worktree(&original_cwd, &logs_dir, &run_id) {
|
||||
Ok((wd, wt, branch, base)) => (Some(wd), Some(wt), Some(branch), Some(base)),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Git worktree setup failed ({e}), running without worktree.",
|
||||
styles.yellow.apply_to("Warning:"),
|
||||
);
|
||||
(None, None, None, None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(None, None, None, None)
|
||||
let worktree_mode = resolve_worktree_mode(run_cfg.as_ref(), &run_defaults);
|
||||
let should_create_worktree = match worktree_mode {
|
||||
run_config::WorktreeMode::Always => true,
|
||||
run_config::WorktreeMode::Clean => git_clean,
|
||||
run_config::WorktreeMode::Dirty => !git_clean,
|
||||
run_config::WorktreeMode::Never => false,
|
||||
};
|
||||
debug!(
|
||||
?worktree_mode,
|
||||
git_clean, should_create_worktree, "Resolved worktree mode"
|
||||
);
|
||||
|
||||
if should_create_worktree && !git_clean {
|
||||
eprintln!(
|
||||
"{} Uncommitted changes will not be included in the worktree.",
|
||||
styles.yellow.apply_to("Warning:"),
|
||||
);
|
||||
}
|
||||
|
||||
let (worktree_work_dir, _worktree_path, worktree_branch, worktree_base_sha) =
|
||||
if should_create_worktree {
|
||||
match setup_worktree(&original_cwd, &logs_dir, &run_id) {
|
||||
Ok((wd, wt, branch, base)) => (Some(wd), Some(wt), Some(branch), Some(base)),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Git worktree setup failed ({e}), running without worktree.",
|
||||
styles.yellow.apply_to("Warning:"),
|
||||
);
|
||||
(None, None, None, None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(None, None, None, None)
|
||||
};
|
||||
|
||||
if let Some(ref wt) = _worktree_path {
|
||||
progress_ui
|
||||
.lock()
|
||||
.expect("progress lock poisoned")
|
||||
.show_worktree(wt);
|
||||
}
|
||||
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let daytona_config = resolve_daytona_config(run_cfg.as_ref(), &run_defaults);
|
||||
|
|
@ -2146,6 +2192,7 @@ mod tests {
|
|||
sandbox: Some(run_config::SandboxConfig {
|
||||
provider: None,
|
||||
preserve: Some(false),
|
||||
local: None,
|
||||
daytona: None,
|
||||
exe: None,
|
||||
env: None,
|
||||
|
|
@ -2172,6 +2219,7 @@ mod tests {
|
|||
sandbox: Some(run_config::SandboxConfig {
|
||||
provider: None,
|
||||
preserve: Some(true),
|
||||
local: None,
|
||||
daytona: None,
|
||||
exe: None,
|
||||
env: None,
|
||||
|
|
@ -2186,6 +2234,7 @@ mod tests {
|
|||
sandbox: Some(run_config::SandboxConfig {
|
||||
provider: None,
|
||||
preserve: Some(false),
|
||||
local: None,
|
||||
daytona: None,
|
||||
exe: None,
|
||||
env: None,
|
||||
|
|
@ -2201,6 +2250,7 @@ mod tests {
|
|||
sandbox: Some(run_config::SandboxConfig {
|
||||
provider: None,
|
||||
preserve: Some(true),
|
||||
local: None,
|
||||
daytona: None,
|
||||
exe: None,
|
||||
env: None,
|
||||
|
|
@ -2216,6 +2266,112 @@ mod tests {
|
|||
assert!(!resolve_preserve_sandbox(false, None, &defaults));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_worktree_mode_defaults_to_clean() {
|
||||
let defaults = RunDefaults::default();
|
||||
assert_eq!(
|
||||
resolve_worktree_mode(None, &defaults),
|
||||
run_config::WorktreeMode::Clean
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_worktree_mode_from_toml() {
|
||||
let cfg = run_config::WorkflowRunConfig {
|
||||
version: 1,
|
||||
goal: Some("test".into()),
|
||||
graph: "w.dot".into(),
|
||||
directory: None,
|
||||
llm: None,
|
||||
setup: None,
|
||||
sandbox: Some(run_config::SandboxConfig {
|
||||
provider: None,
|
||||
preserve: None,
|
||||
local: Some(run_config::LocalSandboxConfig {
|
||||
worktree_mode: run_config::WorktreeMode::Always,
|
||||
}),
|
||||
daytona: None,
|
||||
exe: None,
|
||||
env: None,
|
||||
}),
|
||||
vars: None,
|
||||
hooks: Vec::new(),
|
||||
checkpoint: Default::default(),
|
||||
pull_request: None,
|
||||
assets: None,
|
||||
};
|
||||
let defaults = RunDefaults::default();
|
||||
assert_eq!(
|
||||
resolve_worktree_mode(Some(&cfg), &defaults),
|
||||
run_config::WorktreeMode::Always
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_worktree_mode_from_defaults() {
|
||||
let defaults = RunDefaults {
|
||||
sandbox: Some(run_config::SandboxConfig {
|
||||
provider: None,
|
||||
preserve: None,
|
||||
local: Some(run_config::LocalSandboxConfig {
|
||||
worktree_mode: run_config::WorktreeMode::Dirty,
|
||||
}),
|
||||
daytona: None,
|
||||
exe: None,
|
||||
env: None,
|
||||
}),
|
||||
..RunDefaults::default()
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_worktree_mode(None, &defaults),
|
||||
run_config::WorktreeMode::Dirty
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_worktree_mode_toml_overrides_defaults() {
|
||||
let cfg = run_config::WorkflowRunConfig {
|
||||
version: 1,
|
||||
goal: Some("test".into()),
|
||||
graph: "w.dot".into(),
|
||||
directory: None,
|
||||
llm: None,
|
||||
setup: None,
|
||||
sandbox: Some(run_config::SandboxConfig {
|
||||
provider: None,
|
||||
preserve: None,
|
||||
local: Some(run_config::LocalSandboxConfig {
|
||||
worktree_mode: run_config::WorktreeMode::Never,
|
||||
}),
|
||||
daytona: None,
|
||||
exe: None,
|
||||
env: None,
|
||||
}),
|
||||
vars: None,
|
||||
hooks: Vec::new(),
|
||||
checkpoint: Default::default(),
|
||||
pull_request: None,
|
||||
assets: None,
|
||||
};
|
||||
let defaults = RunDefaults {
|
||||
sandbox: Some(run_config::SandboxConfig {
|
||||
provider: None,
|
||||
preserve: None,
|
||||
local: Some(run_config::LocalSandboxConfig {
|
||||
worktree_mode: run_config::WorktreeMode::Dirty,
|
||||
}),
|
||||
daytona: None,
|
||||
exe: None,
|
||||
env: None,
|
||||
}),
|
||||
..RunDefaults::default()
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_worktree_mode(Some(&cfg), &defaults),
|
||||
run_config::WorktreeMode::Never
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_removes_aws_key_from_compact_json() {
|
||||
let envelope = serde_json::json!({
|
||||
|
|
|
|||
|
|
@ -61,10 +61,27 @@ pub struct SetupConfig {
|
|||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorktreeMode {
|
||||
Always,
|
||||
#[default]
|
||||
Clean,
|
||||
Dirty,
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct LocalSandboxConfig {
|
||||
#[serde(default)]
|
||||
pub worktree_mode: WorktreeMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct SandboxConfig {
|
||||
pub provider: Option<String>,
|
||||
pub preserve: Option<bool>,
|
||||
pub local: Option<LocalSandboxConfig>,
|
||||
pub daytona: Option<DaytonaConfig>,
|
||||
pub exe: Option<arc_exe::ExeConfig>,
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
|
|
@ -130,6 +147,9 @@ impl WorkflowRunConfig {
|
|||
if task.preserve.is_none() {
|
||||
task.preserve = default.preserve;
|
||||
}
|
||||
if task.local.is_none() {
|
||||
task.local = default.local.clone();
|
||||
}
|
||||
match (&mut task.daytona, &default.daytona) {
|
||||
(Some(task_d), Some(default_d)) => {
|
||||
if task_d.auto_stop_interval.is_none() {
|
||||
|
|
@ -952,6 +972,50 @@ provider = "docker"
|
|||
assert_eq!(sandbox.preserve, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_worktree_mode_always() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "test"
|
||||
graph = "w.dot"
|
||||
|
||||
[sandbox.local]
|
||||
worktree_mode = "always"
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
let local = config.sandbox.unwrap().local.unwrap();
|
||||
assert_eq!(local.worktree_mode, WorktreeMode::Always);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_worktree_mode_defaults_to_clean() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "test"
|
||||
graph = "w.dot"
|
||||
|
||||
[sandbox]
|
||||
provider = "local"
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
let sandbox = config.sandbox.unwrap();
|
||||
assert_eq!(sandbox.local, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_worktree_mode_empty_local_defaults_to_clean() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
goal = "test"
|
||||
graph = "w.dot"
|
||||
|
||||
[sandbox.local]
|
||||
"#;
|
||||
let config = parse_run_config(toml).unwrap();
|
||||
let local = config.sandbox.unwrap().local.unwrap();
|
||||
assert_eq!(local.worktree_mode, WorktreeMode::Clean);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_defaults_merges_sandbox_preserve_task_wins() {
|
||||
let mut cfg = parse_run_config(
|
||||
|
|
@ -969,6 +1033,7 @@ preserve = true
|
|||
sandbox: Some(SandboxConfig {
|
||||
provider: None,
|
||||
preserve: Some(false),
|
||||
local: None,
|
||||
daytona: None,
|
||||
exe: None,
|
||||
env: None,
|
||||
|
|
@ -996,6 +1061,7 @@ provider = "docker"
|
|||
sandbox: Some(SandboxConfig {
|
||||
provider: None,
|
||||
preserve: Some(true),
|
||||
local: None,
|
||||
daytona: None,
|
||||
exe: None,
|
||||
env: None,
|
||||
|
|
@ -1023,6 +1089,7 @@ provider = "daytona"
|
|||
sandbox: Some(SandboxConfig {
|
||||
provider: None,
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: Some(DaytonaConfig {
|
||||
auto_stop_interval: Some(30),
|
||||
..DaytonaConfig::default()
|
||||
|
|
@ -1059,6 +1126,7 @@ auto_stop_interval = 60
|
|||
sandbox: Some(SandboxConfig {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: Some(DaytonaConfig {
|
||||
auto_stop_interval: Some(30),
|
||||
labels: Some(HashMap::from([("env".into(), "prod".into())])),
|
||||
|
|
@ -1093,6 +1161,7 @@ env = "from_task"
|
|||
sandbox: Some(SandboxConfig {
|
||||
provider: None,
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: Some(DaytonaConfig {
|
||||
labels: Some(HashMap::from([
|
||||
("env".into(), "from_default".into()),
|
||||
|
|
@ -1130,6 +1199,7 @@ cpu = 2
|
|||
sandbox: Some(SandboxConfig {
|
||||
provider: None,
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: Some(DaytonaConfig {
|
||||
snapshot: Some(DaytonaSnapshotConfig {
|
||||
name: "default-snap".into(),
|
||||
|
|
@ -1169,6 +1239,7 @@ auto_stop_interval = 60
|
|||
sandbox: Some(SandboxConfig {
|
||||
provider: None,
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: Some(DaytonaConfig {
|
||||
snapshot: Some(DaytonaSnapshotConfig {
|
||||
name: "default-snap".into(),
|
||||
|
|
@ -1397,6 +1468,7 @@ network = "block"
|
|||
sandbox: Some(SandboxConfig {
|
||||
provider: None,
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: Some(DaytonaConfig {
|
||||
network: Some(crate::daytona_sandbox::DaytonaNetwork::AllowAll),
|
||||
..DaytonaConfig::default()
|
||||
|
|
@ -1430,6 +1502,7 @@ auto_stop_interval = 60
|
|||
sandbox: Some(SandboxConfig {
|
||||
provider: None,
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: Some(DaytonaConfig {
|
||||
network: Some(crate::daytona_sandbox::DaytonaNetwork::AllowList(vec![
|
||||
"10.0.0.0/8".into(),
|
||||
|
|
@ -1642,6 +1715,7 @@ SHARED = "from_task"
|
|||
sandbox: Some(SandboxConfig {
|
||||
provider: None,
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: None,
|
||||
exe: None,
|
||||
env: Some(HashMap::from([
|
||||
|
|
@ -1675,6 +1749,7 @@ provider = "daytona"
|
|||
sandbox: Some(SandboxConfig {
|
||||
provider: None,
|
||||
preserve: None,
|
||||
local: None,
|
||||
daytona: None,
|
||||
exe: None,
|
||||
env: Some(HashMap::from([("KEY".into(), "val".into())])),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue