From 7400a9d5a105708676c99a685c98d149f24777bd Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 8 Mar 2026 23:39:04 -0400 Subject: [PATCH] Make asset collection opt-in via [assets] config globs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asset collection previously ran a full `find` scan before and after every stage (~30s per scan), even when no assets were needed. This makes it opt-in: only workflows with an `[assets] include = [...]` section in their TOML config will run asset collection, and only with user-specified globs. - Add AssetsConfig struct and wire through WorkflowRunConfig, RunDefaults, and RunConfig - Remove baseline snapshot approach; single post-execution scan using user globs filtered by mtime and size budgets - Delete hardcoded pattern constants, is_asset_candidate, matches_simple_glob, FileFingerprint, and snapshot() — replaced by configurable globs - Skip all asset work when asset_globs is empty (zero overhead default) Co-Authored-By: Claude Opus 4.6 --- crates/arc-api/src/demo/mod.rs | 6 + crates/arc-api/src/server.rs | 1 + crates/arc-workflows/src/asset_snapshot.rs | 329 ++++-------------- crates/arc-workflows/src/cli/run.rs | 11 + crates/arc-workflows/src/cli/run_config.rs | 85 +++++ crates/arc-workflows/src/engine.rs | 59 +++- .../arc-workflows/src/handler/manager_loop.rs | 1 + .../tests/daytona_integration.rs | 6 + crates/arc-workflows/tests/integration.rs | 112 ++++++ 9 files changed, 334 insertions(+), 276 deletions(-) diff --git a/crates/arc-api/src/demo/mod.rs b/crates/arc-api/src/demo/mod.rs index 98e63fb1e..7d6eea3f2 100644 --- a/crates/arc-api/src/demo/mod.rs +++ b/crates/arc-api/src/demo/mod.rs @@ -1293,6 +1293,7 @@ mod runs { hooks: vec![], checkpoint: Default::default(), pull_request: None, + assets: None, }) .unwrap() } @@ -1443,6 +1444,7 @@ mod workflows { hooks: vec![], checkpoint: Default::default(), pull_request: None, + assets: None, }), graph: r#"digraph fix_build { graph [ @@ -1510,6 +1512,7 @@ mod workflows { hooks: vec![], checkpoint: Default::default(), pull_request: None, + assets: None, }), graph: r#"digraph implement { graph [ @@ -1589,6 +1592,7 @@ mod workflows { hooks: vec![], checkpoint: Default::default(), pull_request: None, + assets: None, }), graph: r#"digraph sync { graph [ @@ -1657,6 +1661,7 @@ mod workflows { hooks: vec![], checkpoint: Default::default(), pull_request: None, + assets: None, }), graph: r#"digraph expand { graph [ @@ -3097,6 +3102,7 @@ mod settings { vars: None, checkpoint: Default::default(), pull_request: None, + assets: None, }, hook_config: arc_workflows::hook::HookConfig { hooks: vec![] }, }) diff --git a/crates/arc-api/src/server.rs b/crates/arc-api/src/server.rs index 821a80ebe..b331fc230 100644 --- a/crates/arc-api/src/server.rs +++ b/crates/arc-api/src/server.rs @@ -608,6 +608,7 @@ async fn execute_run(state: Arc, run_id: String) { git_author: state.git_author.clone(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = tokio::select! { diff --git a/crates/arc-workflows/src/asset_snapshot.rs b/crates/arc-workflows/src/asset_snapshot.rs index 310c1b670..a00d4749e 100644 --- a/crates/arc-workflows/src/asset_snapshot.rs +++ b/crates/arc-workflows/src/asset_snapshot.rs @@ -1,16 +1,8 @@ use arc_agent::Sandbox; use serde::Serialize; -use std::collections::HashMap; use std::path::Path; use tracing::{debug, warn}; -/// Fingerprint of a file for change detection between snapshots. -#[derive(Debug, Clone, PartialEq)] -pub struct FileFingerprint { - pub size: u64, - pub mtime_epoch_secs: f64, -} - /// A file discovered by the find command. #[derive(Debug, Clone)] pub struct DiscoveredFile { @@ -29,17 +21,6 @@ pub struct AssetCollectionSummary { pub copied_paths: Vec, } -/// Directory path segments that identify asset directories. -const DIRECTORY_SEGMENTS: &[&str] = &[ - "playwright-report", - "test-results", - "cypress/videos", - "cypress/screenshots", -]; - -/// Filename glob patterns for individual asset files. -const FILENAME_GLOBS: &[&str] = &["junit*.xml", "*.trace.zip"]; - /// Directories to exclude from the find search. const EXCLUDE_DIRS: &[&str] = &[ ".git", @@ -51,17 +32,18 @@ const EXCLUDE_DIRS: &[&str] = &[ "__pycache__", ]; -/// Path segments that indicate excluded tool cache directories. -const EXCLUDE_SEGMENTS: &[&str] = &[".cache/ms-playwright", "playwright/.cache", ".yarn/cache"]; - /// Maximum size for a single file (10 MB). const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; /// Maximum total size for all collected files (50 MB). const MAX_TOTAL_SIZE: u64 = 50 * 1024 * 1024; -/// Build a platform-aware find command to discover asset files. -pub fn build_find_command(root: &str, platform: &str) -> String { +/// Build a platform-aware find command to discover asset files matching the given globs. +/// +/// Globs without `/` are treated as filename patterns (`-name`). +/// Globs with `/` are treated as directory patterns: the trailing `/**` (if any) is stripped +/// and the remainder is matched via `-path '*/{dir}/*'`. +pub fn build_find_command(root: &str, platform: &str, globs: &[String]) -> String { let mut cmd = format!("find {root}"); // Prune excluded directories @@ -73,17 +55,21 @@ pub fn build_find_command(root: &str, platform: &str) -> String { cmd.push_str(&prune_parts.join(" -o ")); cmd.push_str(" \\) -prune -o"); - // Match conditions: not a symlink, is a file, matches asset patterns + // Match conditions: not a symlink, is a file, matches user globs cmd.push_str(" -not -type l -type f \\("); - let mut path_conditions: Vec = Vec::new(); - for segment in DIRECTORY_SEGMENTS { - path_conditions.push(format!(" -path '*/{segment}/*'")); + let mut conditions: Vec = Vec::new(); + for glob in globs { + if glob.contains('/') { + // Directory-style glob: strip trailing /** and match as path + let dir = glob.trim_end_matches("/**").trim_end_matches("/*"); + conditions.push(format!(" -path '*/{dir}/*'")); + } else { + // Filename glob + conditions.push(format!(" -name '{glob}'")); + } } - for glob in FILENAME_GLOBS { - path_conditions.push(format!(" -name '{glob}'")); - } - cmd.push_str(&path_conditions.join(" -o")); + cmd.push_str(&conditions.join(" -o")); cmd.push_str(" \\)"); // Platform-specific output format @@ -177,94 +163,14 @@ fn parse_find_output_darwin(output: &str) -> Vec { files } -/// Check whether a path matches known asset patterns. -pub fn is_asset_candidate(path: &str) -> bool { - // Check excluded segments first - for seg in EXCLUDE_SEGMENTS { - if path.contains(seg) { - return false; - } - } - - // Check directory segments — must appear as a complete path segment - for segment in DIRECTORY_SEGMENTS { - // segment may contain a slash (e.g., "cypress/videos"), so check - // that it appears bounded by / or start/end of string - if let Some(pos) = path.find(segment) { - let before_ok = pos == 0 || path.as_bytes()[pos - 1] == b'/'; - let after_pos = pos + segment.len(); - let after_ok = after_pos >= path.len() || path.as_bytes()[after_pos] == b'/'; - if before_ok && after_ok { - return true; - } - } - } - - // Check filename globs against the last path component - if let Some(filename) = path.rsplit('/').next() { - for glob_pattern in FILENAME_GLOBS { - if matches_simple_glob(glob_pattern, filename) { - return true; - } - } - // Also check at root level (no slash in path) - if !path.contains('/') { - for glob_pattern in FILENAME_GLOBS { - if matches_simple_glob(glob_pattern, path) { - return true; - } - } - } - } - - false -} - -/// Simple glob matching supporting only `*` wildcard. -fn matches_simple_glob(pattern: &str, text: &str) -> bool { - let parts: Vec<&str> = pattern.split('*').collect(); - if parts.len() == 1 { - return pattern == text; - } - - // Check prefix - if !text.starts_with(parts[0]) { - return false; - } - // Check suffix - if !text.ends_with(parts[parts.len() - 1]) { - return false; - } - - // For patterns like "junit*.xml", verify the middle parts appear in order - let mut pos = parts[0].len(); - for part in &parts[1..parts.len() - 1] { - if let Some(found) = text[pos..].find(part) { - pos += found + part.len(); - } else { - return false; - } - } - - true -} - -/// Select which files should be collected based on fingerprint changes, timing, and size budgets. +/// Select which files should be collected based on timing and size budgets. pub fn select_files_to_collect( discovered: &[DiscoveredFile], - baseline: &HashMap, command_start_epoch: f64, ) -> Vec { let mut candidates: Vec = discovered .iter() .filter(|f| { - // Skip files that haven't changed since baseline - if let Some(fp) = baseline.get(&f.relative_path) { - if fp.size == f.size && (fp.mtime_epoch_secs - f.mtime_epoch_secs).abs() < 0.01 { - return false; - } - } - // Skip files older than command start if f.mtime_epoch_secs < command_start_epoch { return false; @@ -326,62 +232,27 @@ fn normalize_paths(discovered: Vec, root: &str) -> Vec Result, String> { - let root = sandbox.working_directory(); - let platform = sandbox.platform(); - let cmd = build_find_command(root, platform); - - debug!("Taking asset snapshot"); - let result = sandbox - .exec_command(&cmd, FIND_TIMEOUT_MS, None, None, None) - .await?; - - // Ignore non-zero exit codes — find may return 1 if some dirs are unreadable - let discovered = parse_find_output(&result.stdout, platform); - let discovered = normalize_paths(discovered, root); - - let mut fingerprints = HashMap::new(); - for f in discovered { - if is_asset_candidate(&f.relative_path) { - fingerprints.insert( - f.relative_path, - FileFingerprint { - size: f.size, - mtime_epoch_secs: f.mtime_epoch_secs, - }, - ); - } - } - - Ok(fingerprints) -} - -/// Collect asset files that changed since the baseline snapshot. +/// Collect asset files matching the configured globs that were created during this stage. pub async fn collect_assets( sandbox: &dyn Sandbox, stage_dir: &Path, - baseline: &HashMap, + globs: &[String], command_start_epoch: f64, ) -> Result { let root = sandbox.working_directory(); let platform = sandbox.platform(); - let cmd = build_find_command(root, platform); + let cmd = build_find_command(root, platform, globs); + debug!(cmd = cmd.as_str(), "Collecting assets"); let result = sandbox .exec_command(&cmd, FIND_TIMEOUT_MS, None, None, None) .await?; let discovered = parse_find_output(&result.stdout, platform); let discovered = normalize_paths(discovered, root); - let candidates: Vec = discovered - .into_iter() - .filter(|f| is_asset_candidate(&f.relative_path)) - .collect(); - let total_discovered = candidates.len(); - let to_collect = select_files_to_collect(&candidates, baseline, command_start_epoch); + let total_discovered = discovered.len(); + let to_collect = select_files_to_collect(&discovered, command_start_epoch); let files_skipped = total_discovered - to_collect.len(); let mut files_copied: usize = 0; @@ -437,6 +308,7 @@ pub async fn collect_assets( mod tests { use super::*; use arc_agent::sandbox::ExecResult; + use std::collections::HashMap; /// Minimal mock sandbox for asset_snapshot tests. struct AssetMockSandbox { @@ -546,50 +418,6 @@ mod tests { } } - #[test] - fn is_asset_candidate_matches_directory_segments() { - assert!(is_asset_candidate("playwright-report/index.html")); - } - - #[test] - fn is_asset_candidate_matches_nested_segments() { - assert!(is_asset_candidate("frontend/playwright-report/index.html")); - } - - #[test] - fn is_asset_candidate_rejects_partial_segments() { - assert!(!is_asset_candidate("playwright-reporter/index.html")); - } - - #[test] - fn is_asset_candidate_matches_filename_globs() { - assert!(is_asset_candidate("junit-report.xml")); - assert!(is_asset_candidate("some/dir/junit.xml")); - assert!(is_asset_candidate("output/results.trace.zip")); - } - - #[test] - fn is_asset_candidate_rejects_excluded_paths() { - assert!(!is_asset_candidate( - ".cache/ms-playwright/chromium/file.txt" - )); - assert!(!is_asset_candidate("playwright/.cache/some-file")); - assert!(!is_asset_candidate(".yarn/cache/something.zip")); - } - - #[test] - fn is_asset_candidate_matches_cypress_directories() { - assert!(is_asset_candidate("cypress/videos/test.mp4")); - assert!(is_asset_candidate("cypress/screenshots/fail.png")); - } - - #[test] - fn is_asset_candidate_rejects_unrelated_paths() { - assert!(!is_asset_candidate("src/main.rs")); - assert!(!is_asset_candidate("package.json")); - assert!(!is_asset_candidate("report.xml")); - } - #[test] fn parse_find_output_linux() { let output = "1024\t1709312400.0\ttest-results/r.xml\n"; @@ -620,25 +448,6 @@ mod tests { assert_eq!(files[0].relative_path, "test-results/good.xml"); } - #[test] - fn select_files_skips_unchanged() { - let discovered = vec![DiscoveredFile { - relative_path: "test-results/r.xml".to_string(), - size: 1024, - mtime_epoch_secs: 1000.0, - }]; - let mut baseline = HashMap::new(); - baseline.insert( - "test-results/r.xml".to_string(), - FileFingerprint { - size: 1024, - mtime_epoch_secs: 1000.0, - }, - ); - let selected = select_files_to_collect(&discovered, &baseline, 500.0); - assert_eq!(selected.len(), 0); - } - #[test] fn select_files_skips_old_mtime() { let discovered = vec![DiscoveredFile { @@ -646,8 +455,7 @@ mod tests { size: 1024, mtime_epoch_secs: 500.0, }]; - let baseline = HashMap::new(); - let selected = select_files_to_collect(&discovered, &baseline, 1000.0); + let selected = select_files_to_collect(&discovered, 1000.0); assert_eq!(selected.len(), 0); } @@ -658,8 +466,7 @@ mod tests { size: MAX_FILE_SIZE + 1, mtime_epoch_secs: 2000.0, }]; - let baseline = HashMap::new(); - let selected = select_files_to_collect(&discovered, &baseline, 1000.0); + let selected = select_files_to_collect(&discovered, 1000.0); assert_eq!(selected.len(), 0); } @@ -682,8 +489,7 @@ mod tests { mtime_epoch_secs: 2000.0, }, ]; - let baseline = HashMap::new(); - let selected = select_files_to_collect(&discovered, &baseline, 1000.0); + let selected = select_files_to_collect(&discovered, 1000.0); assert_eq!(selected.len(), 3); assert_eq!(selected[0].size, 1000); assert_eq!(selected[1].size, 2000); @@ -699,28 +505,46 @@ mod tests { mtime_epoch_secs: 2000.0, }) .collect(); - let baseline = HashMap::new(); - let selected = select_files_to_collect(&discovered, &baseline, 1000.0); + let selected = select_files_to_collect(&discovered, 1000.0); // 50 MB budget / 9 MB each = 5 fit (45 MB), 6th would be 54 MB assert_eq!(selected.len(), 5); } #[test] - fn build_find_command_linux() { - let cmd = build_find_command("/workspace", "linux"); + fn build_find_command_filename_glob() { + let globs = vec!["*.trace.zip".to_string()]; + let cmd = build_find_command("/workspace", "linux", &globs); + assert!(cmd.contains("-name '*.trace.zip'")); assert!(cmd.contains("-printf")); - assert!(cmd.contains("playwright-report")); - assert!(cmd.contains("junit*.xml")); assert!(cmd.contains("-prune")); assert!(cmd.contains("node_modules")); } + #[test] + fn build_find_command_directory_glob() { + let globs = vec!["test-results/**".to_string()]; + let cmd = build_find_command("/workspace", "linux", &globs); + assert!(cmd.contains("-path '*/test-results/*'")); + } + + #[test] + fn build_find_command_mixed_globs() { + let globs = vec![ + "test-results/**".to_string(), + "playwright-report/**".to_string(), + "*.trace.zip".to_string(), + ]; + let cmd = build_find_command("/workspace", "linux", &globs); + assert!(cmd.contains("-path '*/test-results/*'")); + assert!(cmd.contains("-path '*/playwright-report/*'")); + assert!(cmd.contains("-name '*.trace.zip'")); + } + #[test] fn build_find_command_darwin() { - let cmd = build_find_command("/workspace", "darwin"); + let globs = vec!["test-results/**".to_string()]; + let cmd = build_find_command("/workspace", "darwin", &globs); assert!(cmd.contains("-exec stat -f")); - assert!(cmd.contains("playwright-report")); - assert!(cmd.contains("junit*.xml")); assert!(!cmd.contains("-printf")); } @@ -749,21 +573,6 @@ mod tests { assert_eq!(normalized[2].relative_path, "test-results/t.xml"); } - #[tokio::test] - async fn snapshot_uses_exec_command_and_parses() { - let mock = AssetMockSandbox::new( - HashMap::new(), - "1024\t2000.0\ttest-results/r.xml\n512\t2000.0\tsrc/main.rs\n", - "linux", - ); - - let fingerprints = snapshot(&mock).await.unwrap(); - // Only test-results/r.xml is an asset candidate, src/main.rs is not - assert_eq!(fingerprints.len(), 1); - assert!(fingerprints.contains_key("test-results/r.xml")); - assert_eq!(fingerprints["test-results/r.xml"].size, 1024); - } - #[tokio::test] async fn collect_assets_downloads_and_writes_manifest() { let stage_dir = tempfile::tempdir().unwrap(); @@ -773,8 +582,8 @@ mod tests { let mock = AssetMockSandbox::new(files, "1024\t2000.0\ttest-results/r.xml\n", "linux"); - let baseline = HashMap::new(); - let summary = collect_assets(&mock, stage_dir.path(), &baseline, 1000.0) + let globs = vec!["test-results/**".to_string()]; + let summary = collect_assets(&mock, stage_dir.path(), &globs, 1000.0) .await .unwrap(); @@ -795,25 +604,17 @@ mod tests { } #[tokio::test] - async fn collect_assets_skips_unchanged_files() { + async fn collect_assets_skips_old_files() { let stage_dir = tempfile::tempdir().unwrap(); let mut files = HashMap::new(); files.insert("test-results/r.xml".to_string(), "".to_string()); - let mock = AssetMockSandbox::new(files, "1024\t2000.0\ttest-results/r.xml\n", "linux"); + // File mtime (500.0) is before command_start_epoch (1000.0) + let mock = AssetMockSandbox::new(files, "1024\t500.0\ttest-results/r.xml\n", "linux"); - // Provide a baseline with the same fingerprint - let mut baseline = HashMap::new(); - baseline.insert( - "test-results/r.xml".to_string(), - FileFingerprint { - size: 1024, - mtime_epoch_secs: 2000.0, - }, - ); - - let summary = collect_assets(&mock, stage_dir.path(), &baseline, 1000.0) + let globs = vec!["test-results/**".to_string()]; + let summary = collect_assets(&mock, stage_dir.path(), &globs, 1000.0) .await .unwrap(); @@ -831,8 +632,8 @@ mod tests { "linux", ); - let baseline = HashMap::new(); - let summary = collect_assets(&mock, stage_dir.path(), &baseline, 1000.0) + let globs = vec!["test-results/**".to_string()]; + let summary = collect_assets(&mock, stage_dir.path(), &globs, 1000.0) .await .unwrap(); diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index 44dd5b056..c19bb7e86 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -770,6 +770,11 @@ pub async fn run_command( .as_ref() .and_then(|c| c.pull_request.as_ref()) .is_some_and(|p| p.enabled), + asset_globs: run_cfg + .as_ref() + .and_then(|c| c.assets.as_ref()) + .map(|a| a.include.clone()) + .unwrap_or_default(), }; let run_start = Instant::now(); @@ -1204,6 +1209,7 @@ async fn run_from_branch( git_author, base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let run_start = Instant::now(); @@ -1764,6 +1770,7 @@ mod tests { hooks: Vec::new(), checkpoint: Default::default(), pull_request: None, + assets: None, }; let (model, provider) = resolve_model_provider( Some("gpt-5.2"), @@ -1806,6 +1813,7 @@ mod tests { hooks: Vec::new(), checkpoint: Default::default(), pull_request: None, + assets: None, }; let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph); assert_eq!(model, "toml-model"); @@ -1883,6 +1891,7 @@ mod tests { hooks: Vec::new(), checkpoint: Default::default(), pull_request: None, + assets: None, }; let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph); assert_eq!(model, "toml-model"); @@ -1909,6 +1918,7 @@ mod tests { hooks: Vec::new(), checkpoint: Default::default(), pull_request: None, + assets: None, }; let defaults = RunDefaults::default(); assert!(resolve_preserve_sandbox(true, Some(&cfg), &defaults)); @@ -1934,6 +1944,7 @@ mod tests { hooks: Vec::new(), checkpoint: Default::default(), pull_request: None, + assets: None, }; let defaults = RunDefaults { sandbox: Some(run_config::SandboxConfig { diff --git a/crates/arc-workflows/src/cli/run_config.rs b/crates/arc-workflows/src/cli/run_config.rs index 6a61b8ef6..e5ebfcdf6 100644 --- a/crates/arc-workflows/src/cli/run_config.rs +++ b/crates/arc-workflows/src/cli/run_config.rs @@ -22,6 +22,12 @@ pub struct PullRequestConfig { pub enabled: bool, } +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct AssetsConfig { + #[serde(default)] + pub include: Vec, +} + #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct WorkflowRunConfig { @@ -38,6 +44,7 @@ pub struct WorkflowRunConfig { #[serde(default)] pub checkpoint: CheckpointConfig, pub pull_request: Option, + pub assets: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -76,6 +83,7 @@ pub struct RunDefaults { #[serde(default)] pub checkpoint: CheckpointConfig, pub pull_request: Option, + pub assets: Option, } impl WorkflowRunConfig { @@ -176,6 +184,10 @@ impl WorkflowRunConfig { if self.pull_request.is_none() { self.pull_request = defaults.pull_request.clone(); } + + if self.assets.is_none() { + self.assets = defaults.assets.clone(); + } } } @@ -1790,4 +1802,77 @@ enabled = true let defaults: RunDefaults = toml::from_str(toml).unwrap(); assert!(defaults.pull_request.unwrap().enabled); } + + #[test] + fn parse_toml_with_assets() { + let toml = r#" +version = 1 +goal = "Run tests" +graph = "workflow.dot" + +[assets] +include = ["test-results/**", "playwright-report/**", "*.trace.zip"] +"#; + let config = parse_run_config(toml).unwrap(); + let assets = config.assets.unwrap(); + assert_eq!( + assets.include, + vec!["test-results/**", "playwright-report/**", "*.trace.zip"] + ); + } + + #[test] + fn parse_toml_without_assets() { + let toml = r#" +version = 1 +graph = "workflow.dot" +"#; + let config = parse_run_config(toml).unwrap(); + assert!(config.assets.is_none()); + } + + #[test] + fn apply_defaults_inherits_assets() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" +"#, + ) + .unwrap(); + let defaults = RunDefaults { + assets: Some(AssetsConfig { + include: vec!["test-results/**".into()], + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let assets = cfg.assets.unwrap(); + assert_eq!(assets.include, vec!["test-results/**"]); + } + + #[test] + fn apply_defaults_task_assets_wins() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" + +[assets] +include = ["playwright-report/**"] +"#, + ) + .unwrap(); + let defaults = RunDefaults { + assets: Some(AssetsConfig { + include: vec!["test-results/**".into()], + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let assets = cfg.assets.unwrap(); + assert_eq!(assets.include, vec!["playwright-report/**"]); + } } diff --git a/crates/arc-workflows/src/engine.rs b/crates/arc-workflows/src/engine.rs index 6115ecbd5..3db6cfc39 100644 --- a/crates/arc-workflows/src/engine.rs +++ b/crates/arc-workflows/src/engine.rs @@ -855,6 +855,8 @@ pub struct RunConfig { pub base_branch: Option, /// Whether to auto-create a PR on successful completion. pub pull_request_enabled: bool, + /// Glob patterns for asset collection. Empty = no asset collection. + pub asset_globs: Vec, } /// The workflow run execution engine. @@ -983,20 +985,13 @@ impl WorkflowRunEngine { policy: &RetryPolicy, stage_index: usize, visit: usize, + asset_globs: &[String], ) -> Result<(Outcome, u32)> { let handler = self.services.registry.resolve(node); let node_timeout = node.timeout(); for attempt in 1..=policy.max_attempts { - // Take baseline asset snapshot before handler execution - let baseline = match asset_snapshot::snapshot(self.services.sandbox.as_ref()).await { - Ok(fp) => fp, - Err(e) => { - tracing::warn!(node = %node.id, error = %e, "Asset baseline snapshot failed"); - std::collections::HashMap::new() - } - }; // Floor to integer seconds: macOS stat reports mtime as integer seconds, // so a fractional epoch would reject files created in the same second. let command_start_epoch = std::time::SystemTime::now() @@ -1038,8 +1033,8 @@ impl WorkflowRunEngine { } }; - // Collect assets after handler completes (both success and error) - { + // Collect assets after handler completes (only when globs are configured) + if !asset_globs.is_empty() { let node_slug = if visit <= 1 { node.id.clone() } else { @@ -1053,7 +1048,7 @@ impl WorkflowRunEngine { match asset_snapshot::collect_assets( self.services.sandbox.as_ref(), &assets_dir, - &baseline, + asset_globs, command_start_epoch, ) .await @@ -1636,7 +1631,7 @@ impl WorkflowRunEngine { let (mut outcome, attempts_used) = if let Some((ref token, _)) = stall_token { tokio::select! { result = self.execute_with_retry( - node, &context, graph, &config.logs_root, &retry_policy, stage_index, visit, + node, &context, graph, &config.logs_root, &retry_policy, stage_index, visit, &config.asset_globs, ) => result?, () = token.cancelled() => { let idle_secs = graph.stall_timeout().map_or(0, |d| d.as_secs()); @@ -1659,6 +1654,7 @@ impl WorkflowRunEngine { &retry_policy, stage_index, visit, + &config.asset_globs, ) .await? }; @@ -2878,6 +2874,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -2904,6 +2901,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&g, &config).await.unwrap(); let checkpoint_path = dir.path().join("checkpoint.json"); @@ -2938,6 +2936,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&g, &config).await.unwrap(); @@ -2968,6 +2967,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -2994,6 +2994,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&g, &config).await.unwrap(); @@ -3033,6 +3034,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -3096,6 +3098,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&g, &config).await.unwrap(); @@ -3186,6 +3189,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&g, &config).await.unwrap(); @@ -3219,6 +3223,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&g, &config).await.unwrap(); @@ -3247,6 +3252,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&g, &config).await.unwrap(); @@ -3275,6 +3281,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&g, &config).await.unwrap(); @@ -3307,6 +3314,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&g, &config).await.unwrap(); @@ -3467,6 +3475,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&g, &config).await.unwrap(); @@ -3510,6 +3519,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&g, &config).await.unwrap(); @@ -3571,6 +3581,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&g, &config).await.unwrap(); @@ -3635,6 +3646,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; @@ -3703,6 +3715,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; assert!(result.is_ok()); @@ -3760,6 +3773,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -3818,6 +3832,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&g, &config).await.unwrap(); @@ -3851,6 +3866,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -3880,6 +3896,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -3908,6 +3925,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -3949,6 +3967,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; // Set cancel after a short delay (while the slow handler is running) @@ -4027,6 +4046,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4058,6 +4078,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4091,6 +4112,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4129,6 +4151,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4165,6 +4188,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4198,6 +4222,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4292,6 +4317,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; // The engine returns Err because the Fail outcome has no outgoing fail edge, @@ -4501,6 +4527,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4537,6 +4564,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4580,6 +4608,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4663,6 +4692,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4757,6 +4787,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4828,6 +4859,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -4886,6 +4918,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -4945,6 +4978,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let _outcome = engine.run(&g, &config).await.unwrap(); @@ -5031,6 +5065,7 @@ mod tests { git_author: crate::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&g, &config).await.unwrap(); diff --git a/crates/arc-workflows/src/handler/manager_loop.rs b/crates/arc-workflows/src/handler/manager_loop.rs index 9f2427184..4bfd550d0 100644 --- a/crates/arc-workflows/src/handler/manager_loop.rs +++ b/crates/arc-workflows/src/handler/manager_loop.rs @@ -153,6 +153,7 @@ impl Handler for SubWorkflowHandler { .unwrap_or_default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; // Clone parent context for child; inject parent preamble diff --git a/crates/arc-workflows/tests/daytona_integration.rs b/crates/arc-workflows/tests/daytona_integration.rs index 212d174a5..d599f9b55 100644 --- a/crates/arc-workflows/tests/daytona_integration.rs +++ b/crates/arc-workflows/tests/daytona_integration.rs @@ -400,6 +400,7 @@ async fn daytona_pipeline_artifact_offload_and_sync() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -596,6 +597,7 @@ async fn daytona_git_checkpoint_remote_emits_events() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -784,6 +786,7 @@ async fn daytona_parallel_git_branching_e2e() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -1160,6 +1163,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -1302,6 +1306,7 @@ async fn daytona_asset_collection() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -1558,6 +1563,7 @@ async fn daytona_git_push_run_branch_to_origin() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine diff --git a/crates/arc-workflows/tests/integration.rs b/crates/arc-workflows/tests/integration.rs index 5e815f8a6..76aab0bd8 100644 --- a/crates/arc-workflows/tests/integration.rs +++ b/crates/arc-workflows/tests/integration.rs @@ -204,6 +204,7 @@ async fn end_to_end_linear_pipeline() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -346,6 +347,7 @@ async fn end_to_end_branching_pipeline() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -470,6 +472,7 @@ async fn end_to_end_human_gate_pipeline() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -584,6 +587,7 @@ async fn goal_gate_routes_to_retry_target_on_failure() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -708,6 +712,7 @@ async fn goal_gate_routes_to_retry_target_when_present() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -1019,6 +1024,7 @@ async fn retry_on_failure_then_succeed() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -1097,6 +1103,7 @@ async fn pipeline_with_many_nodes() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -1425,6 +1432,7 @@ async fn smoke_test_with_mock_codergen_backend() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -1529,6 +1537,7 @@ async fn end_to_end_parallel_fan_out_fan_in() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -1645,6 +1654,7 @@ async fn resume_from_checkpoint_completes_pipeline() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -1747,6 +1757,7 @@ async fn resume_from_checkpoint_preserves_goal_gate_outcomes() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; // This should succeed because goal gate for gated_work is satisfied @@ -1793,6 +1804,7 @@ async fn graph_goal_in_context() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -1831,6 +1843,7 @@ async fn event_streaming_lifecycle() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -1913,6 +1926,7 @@ async fn context_flow_between_stages() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -1968,6 +1982,7 @@ async fn tool_handler_e2e() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2040,6 +2055,7 @@ async fn auto_approve_interviewer_e2e() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2079,6 +2095,7 @@ async fn codergen_without_backend_simulated() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -2186,6 +2203,7 @@ async fn branching_loop_back_on_failure() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2273,6 +2291,7 @@ async fn human_gate_loops_back() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2333,6 +2352,7 @@ async fn scenario_ship_a_feature() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2421,6 +2441,7 @@ async fn scenario_parallel_expert_review() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2503,6 +2524,7 @@ async fn scenario_node_retries_on_retry_status() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2567,6 +2589,7 @@ async fn scenario_loop_restart_resets_context() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2637,6 +2660,7 @@ async fn scenario_bug_triage_router() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2698,6 +2722,7 @@ async fn scenario_crash_recovery() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine .run_from_checkpoint(&graph, &config, &checkpoint) @@ -2809,6 +2834,7 @@ async fn manager_loop_stop_condition_satisfied_e2e() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); @@ -2888,6 +2914,7 @@ async fn manager_loop_max_cycles_exceeded_e2e() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); @@ -3026,6 +3053,7 @@ async fn conditional_branching_success_fail_paths() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -3081,6 +3109,7 @@ async fn edge_selection_condition_match_wins_over_weight() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -3130,6 +3159,7 @@ async fn edge_selection_weight_breaks_ties() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -3171,6 +3201,7 @@ async fn edge_selection_lexical_tiebreak() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -3231,6 +3262,7 @@ async fn context_updates_visible_across_nodes() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -3277,6 +3309,7 @@ async fn stylesheet_applies_model_override() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -3335,6 +3368,7 @@ async fn custom_handler_registration_and_execution() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -3408,6 +3442,7 @@ async fn integration_smoke_plan_implement_review_done() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -3514,6 +3549,7 @@ async fn manager_loop_runs_child_engine_e2e() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -3651,6 +3687,7 @@ async fn manager_loop_context_flows_e2e() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); @@ -3727,6 +3764,7 @@ async fn manager_loop_child_dotfile_e2e() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); @@ -3843,6 +3881,7 @@ async fn graph_merge_e2e_through_engine() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -3996,6 +4035,7 @@ async fn fidelity_default_is_compact() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -4055,6 +4095,7 @@ async fn fidelity_graph_default_applied() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -4110,6 +4151,7 @@ async fn fidelity_node_overrides_graph_default() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -4171,6 +4213,7 @@ async fn fidelity_edge_overrides_node_and_graph() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -4222,6 +4265,7 @@ async fn fidelity_full_produces_empty_preamble() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -4283,6 +4327,7 @@ async fn fidelity_truncate_preamble_minimal() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -4357,6 +4402,7 @@ async fn fidelity_summary_low_mode() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -4426,6 +4472,7 @@ async fn fidelity_summary_medium_mode() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -4495,6 +4542,7 @@ async fn fidelity_summary_high_mode() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -4557,6 +4605,7 @@ async fn fidelity_full_sets_thread_id_in_context() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -4630,6 +4679,7 @@ async fn fidelity_full_nodes_share_thread_id() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -4713,6 +4763,7 @@ async fn fidelity_resume_degrades_full_to_summary_high() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine .run_from_checkpoint(&graph, &config, &checkpoint) @@ -4812,6 +4863,7 @@ async fn fidelity_resume_degrade_only_affects_first_hop() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine .run_from_checkpoint(&graph, &config, &checkpoint) @@ -4898,6 +4950,7 @@ async fn fidelity_resume_no_degrade_when_not_full() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine .run_from_checkpoint(&graph, &config, &checkpoint) @@ -4942,6 +4995,7 @@ async fn fidelity_stored_in_checkpoint_context() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -5030,6 +5084,7 @@ async fn fidelity_precedence_multi_node_pipeline() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -5100,6 +5155,7 @@ async fn fidelity_compact_preamble_includes_completed_stages_and_context() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -5178,6 +5234,7 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine_low .run(&graph_low, &config_low) @@ -5248,6 +5305,7 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine_med .run(&graph_med, &config_med) @@ -5321,6 +5379,7 @@ async fn fidelity_thread_id_fallback_to_previous_node_in_pipeline() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -5377,6 +5436,7 @@ async fn fidelity_thread_id_from_node_class_in_pipeline() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -5436,6 +5496,7 @@ async fn fidelity_edge_thread_id_override_in_pipeline() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -5496,6 +5557,7 @@ async fn fidelity_full_without_explicit_thread_id_uses_previous_node() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -5566,6 +5628,7 @@ async fn fidelity_from_parsed_dot_pipeline() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -5616,6 +5679,7 @@ async fn fidelity_checkpoint_roundtrip_preserves_fidelity() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -5688,6 +5752,7 @@ async fn fidelity_node_thread_id_overrides_edge_thread_id_in_pipeline() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine.run(&graph, &config).await.expect("run"); @@ -5777,6 +5842,7 @@ async fn fidelity_resume_preserves_context_values_across_checkpoint() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine .run_from_checkpoint(&graph, &config, &checkpoint) @@ -5990,6 +6056,7 @@ mod real_llm { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = tokio::time::timeout( @@ -6107,6 +6174,7 @@ mod real_llm { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = tokio::time::timeout( @@ -6249,6 +6317,7 @@ mod real_llm { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = tokio::time::timeout( @@ -6359,6 +6428,7 @@ mod real_llm { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = tokio::time::timeout( @@ -6458,6 +6528,7 @@ async fn human_gate_freeform_only_routes_text() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -6592,6 +6663,7 @@ async fn human_gate_freeform_with_fixed_choice_match() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -6710,6 +6782,7 @@ async fn human_gate_freeform_fallback_on_unmatched_text() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -6842,6 +6915,7 @@ async fn human_gate_freeform_sets_allow_freeform_on_question() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -6954,6 +7028,7 @@ async fn human_gate_without_freeform_sets_allow_freeform_false() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -7217,6 +7292,7 @@ fn make_run_config(dir: &std::path::Path) -> RunConfig { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), } } @@ -8356,6 +8432,7 @@ async fn arc_e2e_with_real_llm() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -8487,6 +8564,7 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; engine @@ -8689,6 +8767,7 @@ async fn large_context_values_are_offloaded_to_artifact_store() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -8904,6 +8983,7 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -9037,6 +9117,7 @@ async fn node_dir_uses_visit_count_on_revisit() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -9993,6 +10074,7 @@ async fn full_pipeline_with_cli_backend_node() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -10125,6 +10207,7 @@ async fn stylesheet_backend_property_routes_to_cli() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -10410,6 +10493,7 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; // 5. Run pipeline @@ -10602,6 +10686,7 @@ async fn git_checkpoint_host_writes_shadow_branch() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; // 5. Run pipeline @@ -10799,6 +10884,7 @@ async fn parallel_git_branching_host_e2e() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; // 5. Run pipeline @@ -11064,6 +11150,7 @@ async fn git_checkpoint_host_skips_empty_diff_patch() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -11449,6 +11536,7 @@ async fn e2e_circuit_breaker_deterministic_self_loop() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -11499,6 +11587,7 @@ async fn e2e_circuit_breaker_custom_limit() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -11542,6 +11631,7 @@ async fn e2e_circuit_breaker_ignores_transient_failures() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -11592,6 +11682,7 @@ async fn e2e_circuit_breaker_different_reasons_separate_counters() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -11635,6 +11726,7 @@ async fn e2e_circuit_breaker_loop_restart() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -11700,6 +11792,7 @@ async fn e2e_failure_signature_persisted_in_context() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.unwrap(); @@ -11767,6 +11860,7 @@ async fn e2e_failure_signature_hint_overrides_reason_in_context() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let _outcome = engine.run(&graph, &config).await.unwrap(); @@ -11826,6 +11920,7 @@ async fn e2e_signature_maps_persist_in_checkpoint() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.unwrap(); @@ -11956,6 +12051,7 @@ async fn e2e_circuit_breaker_emits_events_before_abort() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -12026,6 +12122,7 @@ async fn e2e_circuit_breaker_does_not_fire_below_limit() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.unwrap(); @@ -12125,6 +12222,7 @@ async fn e2e_circuit_breaker_multi_stage_impl_verify_cycle() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -12224,6 +12322,7 @@ async fn e2e_loop_restart_blocked_for_deterministic_failure() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -12267,6 +12366,7 @@ async fn e2e_loop_restart_blocked_for_structural_failure() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -12310,6 +12410,7 @@ async fn e2e_loop_restart_blocked_for_budget_exhausted_failure() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -12353,6 +12454,7 @@ async fn e2e_loop_restart_blocked_for_canceled_failure() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -12393,6 +12495,7 @@ async fn e2e_loop_restart_blocked_for_compilation_loop_failure() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -12437,6 +12540,7 @@ async fn e2e_loop_restart_allowed_for_transient_infra() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -12544,6 +12648,7 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let result = engine.run(&graph, &config).await; @@ -12603,6 +12708,7 @@ async fn e2e_stall_watchdog_kept_alive_by_handler_events() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -12652,6 +12758,7 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -12720,6 +12827,7 @@ async fn e2e_stall_watchdog_with_explicit_timeout_override() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let start = std::time::Instant::now(); @@ -12853,6 +12961,7 @@ async fn asset_collection_local_sandbox_success() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -12964,6 +13073,7 @@ async fn asset_collection_local_sandbox_on_failure() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -13058,6 +13168,7 @@ async fn asset_collection_docker_sandbox() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine @@ -13130,6 +13241,7 @@ async fn wait_timer_e2e() { git_author: arc_workflows::git::GitAuthor::default(), base_branch: None, pull_request_enabled: false, + asset_globs: Vec::new(), }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success);