diff --git a/crates/arc-agent/src/cli.rs b/crates/arc-agent/src/cli.rs index b12497268..9e0b77799 100644 --- a/crates/arc-agent/src/cli.rs +++ b/crates/arc-agent/src/cli.rs @@ -1,7 +1,7 @@ use crate::{ subagent::{SessionFactory, SubAgentManager}, - AgentEvent, AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile, - ProviderProfile, Session, SessionConfig, ToolApprovalFn, Turn, + AgentEvent, AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile, ProviderProfile, + Session, SessionConfig, ToolApprovalFn, Turn, }; use arc_llm::client::Client; use arc_llm::provider::{ModelId, Provider}; @@ -497,10 +497,7 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> { ); } AgentEvent::Error { error } => { - eprintln!( - " {}", - s.red.apply_to(format!("\u{2717} {error}")), - ); + eprintln!(" {}", s.red.apply_to(format!("\u{2717} {error}")),); } AgentEvent::SubAgentSpawned { agent_id, @@ -561,9 +558,8 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> { let short_id = &agent_id[..8.min(agent_id.len())]; eprintln!( " {}", - s.dim.apply_to(format!( - "[subagent {short_id}] {child_event:?}" - )), + s.dim + .apply_to(format!("[subagent {short_id}] {child_event:?}")), ); } _ => {} diff --git a/crates/arc-agent/src/compaction.rs b/crates/arc-agent/src/compaction.rs index 63f2f197b..2a476ef65 100644 --- a/crates/arc-agent/src/compaction.rs +++ b/crates/arc-agent/src/compaction.rs @@ -120,7 +120,10 @@ and conversational filler.{file_ops_section}" .map_err(AgentError::Llm)?; let summary_text = response.text(); - debug!(summary_len = summary_text.len(), "Compaction summary generated"); + debug!( + summary_len = summary_text.len(), + "Compaction summary generated" + ); let summary_content = format!("[Context Summary]\n{summary_text}"); let summary_token_estimate = summary_content.len() / 4; diff --git a/crates/arc-agent/src/docker_sandbox.rs b/crates/arc-agent/src/docker_sandbox.rs index d0335a0f5..a5fcffa7f 100644 --- a/crates/arc-agent/src/docker_sandbox.rs +++ b/crates/arc-agent/src/docker_sandbox.rs @@ -1,6 +1,6 @@ use crate::sandbox::{ - format_lines_numbered, DirEntry, SandboxEventCallback, ExecResult, SandboxEvent, - Sandbox, GrepOptions, + format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, + SandboxEventCallback, }; use async_trait::async_trait; use bollard::container::{ @@ -294,15 +294,13 @@ impl Sandbox for DockerSandbox { .await .map_err(|e| format!("Failed to create parent dirs: {e}"))?; } - tokio::fs::copy(&host_path, local_path) - .await - .map_err(|e| { - format!( - "Failed to copy {} to {}: {e}", - host_path.display(), - local_path.display() - ) - })?; + tokio::fs::copy(&host_path, local_path).await.map_err(|e| { + format!( + "Failed to copy {} to {}: {e}", + host_path.display(), + local_path.display() + ) + })?; Ok(()) } @@ -773,8 +771,7 @@ mod tests { std::fs::create_dir_all(&host_dir).unwrap(); let config = test_config(host_dir.to_str().unwrap()); - let env: Arc = - Arc::new(DockerSandbox::new(config).unwrap()); + let env: Arc = Arc::new(DockerSandbox::new(config).unwrap()); // Initialize env.initialize().await.unwrap(); diff --git a/crates/arc-agent/src/lib.rs b/crates/arc-agent/src/lib.rs index b092dc249..3b1b2014d 100644 --- a/crates/arc-agent/src/lib.rs +++ b/crates/arc-agent/src/lib.rs @@ -29,13 +29,9 @@ pub mod v4a_patch; pub use arc_mcp::config::McpServerConfig; pub use config::{SessionConfig, ToolApprovalFn}; #[cfg(feature = "docker")] -pub use docker_sandbox::{DockerSandboxConfig, DockerSandbox}; +pub use docker_sandbox::{DockerSandbox, DockerSandboxConfig}; pub use error::AgentError; pub use event::EventEmitter; -pub use sandbox::{ - format_lines_numbered, DirEntry, SandboxEventCallback, ExecResult, SandboxEvent, - Sandbox, GrepOptions, -}; pub use history::History; pub use local_sandbox::LocalSandbox; pub use loop_detection::detect_loop; @@ -43,6 +39,10 @@ pub use profiles::{AnthropicProfile, EnvContext, GeminiProfile, OpenAiProfile}; pub use project_docs::discover_project_docs; pub use provider_profile::{ProfileCapabilities, ProviderProfile}; pub use read_before_write_sandbox::ReadBeforeWriteSandbox; +pub use sandbox::{ + format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, + SandboxEventCallback, +}; pub use session::Session; pub use skills::Skill; pub use subagent::{SubAgent, SubAgentEventCallback, SubAgentManager, SubAgentResult}; diff --git a/crates/arc-agent/src/local_sandbox.rs b/crates/arc-agent/src/local_sandbox.rs index 36c6e957c..ba4ea55f3 100644 --- a/crates/arc-agent/src/local_sandbox.rs +++ b/crates/arc-agent/src/local_sandbox.rs @@ -1,6 +1,6 @@ use crate::sandbox::{ - format_lines_numbered, DirEntry, SandboxEventCallback, ExecResult, SandboxEvent, - Sandbox, GrepOptions, + format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, + SandboxEventCallback, }; use async_trait::async_trait; use std::path::{Path, PathBuf}; @@ -356,9 +356,13 @@ impl Sandbox for LocalSandbox { .await .map_err(|e| format!("Failed to create parent dirs: {e}"))?; } - tokio::fs::copy(&full_path, local_path) - .await - .map_err(|e| format!("Failed to copy {} to {}: {e}", full_path.display(), local_path.display()))?; + tokio::fs::copy(&full_path, local_path).await.map_err(|e| { + format!( + "Failed to copy {} to {}: {e}", + full_path.display(), + local_path.display() + ) + })?; Ok(()) } @@ -613,38 +617,20 @@ mod tests { #[test] fn env_var_filtering() { - assert!(LocalSandbox::should_filter_env_var( - "OPENAI_API_KEY" - )); - assert!(LocalSandbox::should_filter_env_var( - "ANTHROPIC_API_KEY" - )); - assert!(LocalSandbox::should_filter_env_var( - "DB_PASSWORD" - )); - assert!(LocalSandbox::should_filter_env_var( - "AWS_SECRET" - )); - assert!(LocalSandbox::should_filter_env_var( - "AUTH_TOKEN" - )); - assert!(LocalSandbox::should_filter_env_var( - "MY_CREDENTIAL" - )); + assert!(LocalSandbox::should_filter_env_var("OPENAI_API_KEY")); + assert!(LocalSandbox::should_filter_env_var("ANTHROPIC_API_KEY")); + assert!(LocalSandbox::should_filter_env_var("DB_PASSWORD")); + assert!(LocalSandbox::should_filter_env_var("AWS_SECRET")); + assert!(LocalSandbox::should_filter_env_var("AUTH_TOKEN")); + assert!(LocalSandbox::should_filter_env_var("MY_CREDENTIAL")); // Case insensitive - assert!(LocalSandbox::should_filter_env_var( - "my_api_key" - )); - assert!(LocalSandbox::should_filter_env_var( - "Some_Secret" - )); + assert!(LocalSandbox::should_filter_env_var("my_api_key")); + assert!(LocalSandbox::should_filter_env_var("Some_Secret")); // Should not filter assert!(!LocalSandbox::should_filter_env_var("PATH")); assert!(!LocalSandbox::should_filter_env_var("HOME")); assert!(!LocalSandbox::should_filter_env_var("EDITOR")); - assert!(!LocalSandbox::should_filter_env_var( - "SECRET_PATH" - )); + assert!(!LocalSandbox::should_filter_env_var("SECRET_PATH")); } #[test] @@ -838,9 +824,7 @@ mod tests { let env = LocalSandbox::new(dir.clone()); let dest = dir.join("deep/nested/dir/data.bin"); - env.download_file_to_local("data.bin", &dest) - .await - .unwrap(); + env.download_file_to_local("data.bin", &dest).await.unwrap(); assert_eq!(std::fs::read_to_string(&dest).unwrap(), "binary-ish"); std::fs::remove_dir_all(&dir).unwrap(); diff --git a/crates/arc-agent/src/profiles/anthropic.rs b/crates/arc-agent/src/profiles/anthropic.rs index 839d4f1e3..bd1d67216 100644 --- a/crates/arc-agent/src/profiles/anthropic.rs +++ b/crates/arc-agent/src/profiles/anthropic.rs @@ -1,8 +1,8 @@ use crate::config::SessionConfig; -use crate::sandbox::Sandbox; use crate::profiles::assemble_system_prompt; use crate::profiles::BaseProfile; use crate::provider_profile::{ProfileCapabilities, ProviderProfile}; +use crate::sandbox::Sandbox; use crate::skills::Skill; use crate::tool_registry::ToolRegistry; use crate::tools::{make_edit_file_tool, register_core_tools, WebFetchSummarizer}; diff --git a/crates/arc-agent/src/profiles/gemini.rs b/crates/arc-agent/src/profiles/gemini.rs index ba8864d7f..70852af7e 100644 --- a/crates/arc-agent/src/profiles/gemini.rs +++ b/crates/arc-agent/src/profiles/gemini.rs @@ -1,8 +1,8 @@ use crate::config::SessionConfig; -use crate::sandbox::Sandbox; use crate::profiles::assemble_system_prompt; use crate::profiles::BaseProfile; use crate::provider_profile::{ProfileCapabilities, ProviderProfile}; +use crate::sandbox::Sandbox; use crate::skills::Skill; use crate::tool_registry::ToolRegistry; use crate::tools::{ diff --git a/crates/arc-agent/src/profiles/openai.rs b/crates/arc-agent/src/profiles/openai.rs index 489fed590..239893c26 100644 --- a/crates/arc-agent/src/profiles/openai.rs +++ b/crates/arc-agent/src/profiles/openai.rs @@ -1,8 +1,8 @@ use crate::config::SessionConfig; -use crate::sandbox::Sandbox; use crate::profiles::assemble_system_prompt; use crate::profiles::BaseProfile; use crate::provider_profile::{ProfileCapabilities, ProviderProfile}; +use crate::sandbox::Sandbox; use crate::skills::Skill; use crate::tool_registry::ToolRegistry; use crate::tools::{register_core_tools, WebFetchSummarizer}; diff --git a/crates/arc-agent/src/provider_profile.rs b/crates/arc-agent/src/provider_profile.rs index 2b4f61619..f0463f54a 100644 --- a/crates/arc-agent/src/provider_profile.rs +++ b/crates/arc-agent/src/provider_profile.rs @@ -1,5 +1,5 @@ -use crate::sandbox::Sandbox; use crate::profiles::EnvContext; +use crate::sandbox::Sandbox; use crate::skills::Skill; use crate::subagent::{ make_close_agent_tool, make_send_input_tool, make_spawn_agent_tool, SessionFactory, diff --git a/crates/arc-agent/src/sandbox.rs b/crates/arc-agent/src/sandbox.rs index 7b8bd77e0..a96ecc71e 100644 --- a/crates/arc-agent/src/sandbox.rs +++ b/crates/arc-agent/src/sandbox.rs @@ -211,7 +211,11 @@ impl SandboxEvent { error!(name, error, "Snapshot failed"); } Self::GitCloneStarted { url, branch } => { - debug!(url, branch = branch.as_deref().unwrap_or(""), "Git clone started"); + debug!( + url, + branch = branch.as_deref().unwrap_or(""), + "Git clone started" + ); } Self::GitCloneCompleted { url, duration_ms } => { debug!(url, duration_ms, "Git clone completed"); diff --git a/crates/arc-agent/src/session.rs b/crates/arc-agent/src/session.rs index 3e0462512..6fa211a11 100644 --- a/crates/arc-agent/src/session.rs +++ b/crates/arc-agent/src/session.rs @@ -1,13 +1,13 @@ use crate::config::SessionConfig; use crate::error::AgentError; use crate::event::EventEmitter; -use crate::sandbox::Sandbox; use crate::file_tracker::FileTracker; use crate::history::History; use crate::loop_detection::detect_loop; use crate::profiles::EnvContext; use crate::project_docs::discover_project_docs; use crate::provider_profile::ProviderProfile; +use crate::sandbox::Sandbox; use crate::skills::{ default_skill_dirs, discover_skills, expand_skill, make_use_skill_tool, Skill, }; @@ -567,7 +567,9 @@ impl Session { self.event_emitter.emit( self.id.clone(), AgentEvent::Error { - error: AgentError::InvalidState(format!("Context compaction failed: {e}")), + error: AgentError::InvalidState(format!( + "Context compaction failed: {e}" + )), }, ); } @@ -1978,8 +1980,7 @@ mod tests { let client = make_client(provider).await; let profile: Arc = Arc::new(TestProfile::new()); - let env: Arc = - Arc::new(MockSandbox::default()); + let env: Arc = Arc::new(MockSandbox::default()); let mut session = Session::new(client, profile, env, config); // Subscribe to events before initialize diff --git a/crates/arc-agent/src/skills.rs b/crates/arc-agent/src/skills.rs index fb730f595..d8993a7ff 100644 --- a/crates/arc-agent/src/skills.rs +++ b/crates/arc-agent/src/skills.rs @@ -543,8 +543,7 @@ name: trimmed let skills = Arc::new(test_skills()); let tool = make_use_skill_tool(skills); - let env: Arc = - Arc::new(MockSandbox::default()); + let env: Arc = Arc::new(MockSandbox::default()); let args = serde_json::json!({"skill_name": "commit"}); let ctx = crate::tool_registry::ToolContext { env, @@ -562,8 +561,7 @@ name: trimmed let skills = Arc::new(test_skills()); let tool = make_use_skill_tool(skills); - let env: Arc = - Arc::new(MockSandbox::default()); + let env: Arc = Arc::new(MockSandbox::default()); let args = serde_json::json!({"skill_name": "nonexistent"}); let ctx = crate::tool_registry::ToolContext { env, @@ -579,8 +577,7 @@ name: trimmed let skills = Arc::new(test_skills()); let tool = make_use_skill_tool(skills); - let env: Arc = - Arc::new(MockSandbox::default()); + let env: Arc = Arc::new(MockSandbox::default()); let args = serde_json::json!({}); let ctx = crate::tool_registry::ToolContext { env, diff --git a/crates/arc-agent/src/test_support.rs b/crates/arc-agent/src/test_support.rs index 1a5b03f55..7f3ac576a 100644 --- a/crates/arc-agent/src/test_support.rs +++ b/crates/arc-agent/src/test_support.rs @@ -1,7 +1,7 @@ use crate::config::SessionConfig; -use crate::sandbox::*; use crate::profiles::EnvContext; use crate::provider_profile::{ProfileCapabilities, ProviderProfile}; +use crate::sandbox::*; use crate::session::Session; use crate::skills::Skill; use crate::tool_registry::ToolRegistry; diff --git a/crates/arc-agent/src/types.rs b/crates/arc-agent/src/types.rs index dcb27f393..fad2688ef 100644 --- a/crates/arc-agent/src/types.rs +++ b/crates/arc-agent/src/types.rs @@ -280,9 +280,7 @@ impl AgentEvent { } => { info!( session_id, - estimated_tokens, - context_window_size, - "Context compaction started" + estimated_tokens, context_window_size, "Context compaction started" ); } Self::CompactionCompleted { @@ -322,13 +320,7 @@ impl AgentEvent { depth, task, } => { - debug!( - session_id, - agent_id, - depth, - task, - "Sub-agent spawned" - ); + debug!(session_id, agent_id, depth, task, "Sub-agent spawned"); } Self::SubAgentCompleted { agent_id, @@ -338,11 +330,7 @@ impl AgentEvent { } => { debug!( session_id, - agent_id, - depth, - success, - turns_used, - "Sub-agent completed" + agent_id, depth, success, turns_used, "Sub-agent completed" ); } Self::SubAgentFailed { diff --git a/crates/arc-agent/tests/parity_matrix.rs b/crates/arc-agent/tests/parity_matrix.rs index c9c35e53a..936157dac 100644 --- a/crates/arc-agent/tests/parity_matrix.rs +++ b/crates/arc-agent/tests/parity_matrix.rs @@ -2,8 +2,8 @@ use std::path::Path; use std::sync::Arc; use arc_agent::{ - AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile, ProviderProfile, - Session, SessionConfig, SubAgentManager, WebFetchSummarizer, + AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile, ProviderProfile, Session, + SessionConfig, SubAgentManager, WebFetchSummarizer, }; use arc_llm::client::Client; use arc_llm::provider::{ModelId, Provider}; diff --git a/crates/arc-api/src/demo/mod.rs b/crates/arc-api/src/demo/mod.rs index 469fc6c71..fa541948a 100644 --- a/crates/arc-api/src/demo/mod.rs +++ b/crates/arc-api/src/demo/mod.rs @@ -104,7 +104,11 @@ pub async fn generate_preview_url_stub( State(_state): State>, Path(_id): Path, ) -> Response { - (StatusCode::OK, Json(serde_json::json!({"url": "https://google.com"}))).into_response() + ( + StatusCode::OK, + Json(serde_json::json!({"url": "https://google.com"})), + ) + .into_response() } pub async fn get_run_status( @@ -506,11 +510,7 @@ pub async fn send_message_stub( State(_state): State>, Path(_id): Path, ) -> Response { - ( - StatusCode::OK, - Json(serde_json::json!({"accepted": true})), - ) - .into_response() + (StatusCode::OK, Json(serde_json::json!({"accepted": true}))).into_response() } pub async fn session_events_stub( @@ -619,150 +619,383 @@ mod runs { pub fn list_items() -> Vec { vec![ RunListItem { - id: "run-1".into(), repo: "api-server".into(), + id: "run-1".into(), + repo: "api-server".into(), title: "Add rate limiting to auth endpoints".into(), - workflow: "implement".into(), status: RunListItemStatus::Working, - number: None, additions: None, deletions: None, checks: vec![], - elapsed_secs: Some(420.0), elapsed_warning: Some(false), - resources: Some("4 CPU / 8 GB".into()), comments: Some(0), - question: None, sandbox_id: Some("sb-a1b2c3d4".into()), + workflow: "implement".into(), + status: RunListItemStatus::Working, + number: None, + additions: None, + deletions: None, + checks: vec![], + elapsed_secs: Some(420.0), + elapsed_warning: Some(false), + resources: Some("4 CPU / 8 GB".into()), + comments: Some(0), + question: None, + sandbox_id: Some("sb-a1b2c3d4".into()), }, RunListItem { - id: "run-2".into(), repo: "web-dashboard".into(), + id: "run-2".into(), + repo: "web-dashboard".into(), title: "Migrate to React Router v7".into(), - workflow: "implement".into(), status: RunListItemStatus::Working, - number: None, additions: None, deletions: None, checks: vec![], - elapsed_secs: Some(8100.0), elapsed_warning: Some(false), - resources: Some("8 CPU / 16 GB".into()), comments: Some(0), - question: None, sandbox_id: Some("sb-e5f6g7h8".into()), + workflow: "implement".into(), + status: RunListItemStatus::Working, + number: None, + additions: None, + deletions: None, + checks: vec![], + elapsed_secs: Some(8100.0), + elapsed_warning: Some(false), + resources: Some("8 CPU / 16 GB".into()), + comments: Some(0), + question: None, + sandbox_id: Some("sb-e5f6g7h8".into()), }, RunListItem { - id: "run-3".into(), repo: "cli-tools".into(), + id: "run-3".into(), + repo: "cli-tools".into(), title: "Fix config parsing for nested values".into(), - workflow: "fix_build".into(), status: RunListItemStatus::Working, - number: None, additions: None, deletions: None, checks: vec![], - elapsed_secs: Some(2700.0), elapsed_warning: Some(false), - resources: Some("2 CPU / 4 GB".into()), comments: Some(0), - question: None, sandbox_id: Some("sb-i9j0k1l2".into()), + workflow: "fix_build".into(), + status: RunListItemStatus::Working, + number: None, + additions: None, + deletions: None, + checks: vec![], + elapsed_secs: Some(2700.0), + elapsed_warning: Some(false), + resources: Some("2 CPU / 4 GB".into()), + comments: Some(0), + question: None, + sandbox_id: Some("sb-i9j0k1l2".into()), }, RunListItem { - id: "run-4".into(), repo: "api-server".into(), + id: "run-4".into(), + repo: "api-server".into(), title: "Update OpenAPI spec for v3".into(), - workflow: "expand".into(), status: RunListItemStatus::Pending, - number: None, additions: Some(567), deletions: Some(234), checks: vec![], - elapsed_secs: Some(4320.0), elapsed_warning: Some(false), - resources: None, comments: Some(0), + workflow: "expand".into(), + status: RunListItemStatus::Pending, + number: None, + additions: Some(567), + deletions: Some(234), + checks: vec![], + elapsed_secs: Some(4320.0), + elapsed_warning: Some(false), + resources: None, + comments: Some(0), question: Some("Accept or push for another round?".into()), sandbox_id: Some("sb-q7r8s9t0".into()), }, RunListItem { - id: "run-5".into(), repo: "shared-types".into(), + id: "run-5".into(), + repo: "shared-types".into(), title: "Add pipeline event types".into(), - workflow: "implement".into(), status: RunListItemStatus::Pending, - number: None, additions: Some(145), deletions: Some(23), checks: vec![], - elapsed_secs: Some(1680.0), elapsed_warning: Some(false), - resources: None, comments: Some(0), + workflow: "implement".into(), + status: RunListItemStatus::Pending, + number: None, + additions: Some(145), + deletions: Some(23), + checks: vec![], + elapsed_secs: Some(1680.0), + elapsed_warning: Some(false), + resources: None, + comments: Some(0), question: Some("Proceed from investigation to fix?".into()), sandbox_id: Some("sb-u1v2w3x4".into()), }, RunListItem { - id: "run-6".into(), repo: "web-dashboard".into(), + id: "run-6".into(), + repo: "web-dashboard".into(), title: "Add dark mode toggle".into(), - workflow: "implement".into(), status: RunListItemStatus::Review, - number: Some(889), additions: Some(234), deletions: Some(67), + workflow: "implement".into(), + status: RunListItemStatus::Review, + number: Some(889), + additions: Some(234), + deletions: Some(67), checks: vec![ - CheckRun { name: "lint".into(), status: CheckRunStatus::Success, duration_secs: Some(23.0) }, - CheckRun { name: "typecheck".into(), status: CheckRunStatus::Success, duration_secs: Some(72.0) }, - CheckRun { name: "unit-tests".into(), status: CheckRunStatus::Success, duration_secs: Some(154.0) }, - CheckRun { name: "integration-tests".into(), status: CheckRunStatus::Failure, duration_secs: Some(296.0) }, - CheckRun { name: "e2e / chrome".into(), status: CheckRunStatus::Failure, duration_secs: Some(182.0) }, - CheckRun { name: "build".into(), status: CheckRunStatus::Success, duration_secs: Some(105.0) }, - CheckRun { name: "coverage".into(), status: CheckRunStatus::Skipped, duration_secs: None }, + CheckRun { + name: "lint".into(), + status: CheckRunStatus::Success, + duration_secs: Some(23.0), + }, + CheckRun { + name: "typecheck".into(), + status: CheckRunStatus::Success, + duration_secs: Some(72.0), + }, + CheckRun { + name: "unit-tests".into(), + status: CheckRunStatus::Success, + duration_secs: Some(154.0), + }, + CheckRun { + name: "integration-tests".into(), + status: CheckRunStatus::Failure, + duration_secs: Some(296.0), + }, + CheckRun { + name: "e2e / chrome".into(), + status: CheckRunStatus::Failure, + duration_secs: Some(182.0), + }, + CheckRun { + name: "build".into(), + status: CheckRunStatus::Success, + duration_secs: Some(105.0), + }, + CheckRun { + name: "coverage".into(), + status: CheckRunStatus::Skipped, + duration_secs: None, + }, ], - elapsed_secs: Some(2100.0), elapsed_warning: Some(false), - resources: None, comments: Some(4), - question: None, sandbox_id: Some("sb-m3n4o5p6".into()), + elapsed_secs: Some(2100.0), + elapsed_warning: Some(false), + resources: None, + comments: Some(4), + question: None, + sandbox_id: Some("sb-m3n4o5p6".into()), }, RunListItem { - id: "run-7".into(), repo: "infrastructure".into(), + id: "run-7".into(), + repo: "infrastructure".into(), title: "Terraform module for Redis cluster".into(), - workflow: "implement".into(), status: RunListItemStatus::Review, - number: Some(156), additions: Some(412), deletions: Some(0), + workflow: "implement".into(), + status: RunListItemStatus::Review, + number: Some(156), + additions: Some(412), + deletions: Some(0), checks: vec![ - CheckRun { name: "lint".into(), status: CheckRunStatus::Success, duration_secs: Some(18.0) }, - CheckRun { name: "typecheck".into(), status: CheckRunStatus::Success, duration_secs: Some(56.0) }, - CheckRun { name: "unit-tests".into(), status: CheckRunStatus::Pending, duration_secs: None }, - CheckRun { name: "integration-tests".into(), status: CheckRunStatus::Queued, duration_secs: None }, - CheckRun { name: "build".into(), status: CheckRunStatus::Pending, duration_secs: None }, + CheckRun { + name: "lint".into(), + status: CheckRunStatus::Success, + duration_secs: Some(18.0), + }, + CheckRun { + name: "typecheck".into(), + status: CheckRunStatus::Success, + duration_secs: Some(56.0), + }, + CheckRun { + name: "unit-tests".into(), + status: CheckRunStatus::Pending, + duration_secs: None, + }, + CheckRun { + name: "integration-tests".into(), + status: CheckRunStatus::Queued, + duration_secs: None, + }, + CheckRun { + name: "build".into(), + status: CheckRunStatus::Pending, + duration_secs: None, + }, ], - elapsed_secs: Some(720.0), elapsed_warning: Some(false), - resources: None, comments: Some(1), - question: None, sandbox_id: Some("sb-y5z6a7b8".into()), + elapsed_secs: Some(720.0), + elapsed_warning: Some(false), + resources: None, + comments: Some(1), + question: None, + sandbox_id: Some("sb-y5z6a7b8".into()), }, RunListItem { - id: "run-8".into(), repo: "api-server".into(), + id: "run-8".into(), + repo: "api-server".into(), title: "Implement webhook retry logic".into(), - workflow: "implement".into(), status: RunListItemStatus::Merge, - number: Some(1249), additions: Some(189), deletions: Some(45), + workflow: "implement".into(), + status: RunListItemStatus::Merge, + number: Some(1249), + additions: Some(189), + deletions: Some(45), checks: vec![ - CheckRun { name: "lint".into(), status: CheckRunStatus::Success, duration_secs: Some(21.0) }, - CheckRun { name: "typecheck".into(), status: CheckRunStatus::Success, duration_secs: Some(68.0) }, - CheckRun { name: "unit-tests".into(), status: CheckRunStatus::Success, duration_secs: Some(192.0) }, - CheckRun { name: "integration-tests".into(), status: CheckRunStatus::Success, duration_secs: Some(334.0) }, - CheckRun { name: "e2e / chrome".into(), status: CheckRunStatus::Success, duration_secs: Some(262.0) }, - CheckRun { name: "e2e / firefox".into(), status: CheckRunStatus::Success, duration_secs: Some(285.0) }, - CheckRun { name: "build".into(), status: CheckRunStatus::Success, duration_secs: Some(121.0) }, - CheckRun { name: "deploy-preview".into(), status: CheckRunStatus::Success, duration_secs: Some(93.0) }, - CheckRun { name: "security-scan".into(), status: CheckRunStatus::Skipped, duration_secs: None }, - CheckRun { name: "performance".into(), status: CheckRunStatus::Success, duration_secs: Some(138.0) }, - CheckRun { name: "bundle-size".into(), status: CheckRunStatus::Success, duration_secs: Some(34.0) }, - CheckRun { name: "accessibility".into(), status: CheckRunStatus::Success, duration_secs: Some(72.0) }, + CheckRun { + name: "lint".into(), + status: CheckRunStatus::Success, + duration_secs: Some(21.0), + }, + CheckRun { + name: "typecheck".into(), + status: CheckRunStatus::Success, + duration_secs: Some(68.0), + }, + CheckRun { + name: "unit-tests".into(), + status: CheckRunStatus::Success, + duration_secs: Some(192.0), + }, + CheckRun { + name: "integration-tests".into(), + status: CheckRunStatus::Success, + duration_secs: Some(334.0), + }, + CheckRun { + name: "e2e / chrome".into(), + status: CheckRunStatus::Success, + duration_secs: Some(262.0), + }, + CheckRun { + name: "e2e / firefox".into(), + status: CheckRunStatus::Success, + duration_secs: Some(285.0), + }, + CheckRun { + name: "build".into(), + status: CheckRunStatus::Success, + duration_secs: Some(121.0), + }, + CheckRun { + name: "deploy-preview".into(), + status: CheckRunStatus::Success, + duration_secs: Some(93.0), + }, + CheckRun { + name: "security-scan".into(), + status: CheckRunStatus::Skipped, + duration_secs: None, + }, + CheckRun { + name: "performance".into(), + status: CheckRunStatus::Success, + duration_secs: Some(138.0), + }, + CheckRun { + name: "bundle-size".into(), + status: CheckRunStatus::Success, + duration_secs: Some(34.0), + }, + CheckRun { + name: "accessibility".into(), + status: CheckRunStatus::Success, + duration_secs: Some(72.0), + }, ], - elapsed_secs: Some(259200.0), elapsed_warning: Some(true), - resources: None, comments: Some(7), - question: None, sandbox_id: Some("sb-c9d0e1f2".into()), + elapsed_secs: Some(259200.0), + elapsed_warning: Some(true), + resources: None, + comments: Some(7), + question: None, + sandbox_id: Some("sb-c9d0e1f2".into()), }, RunListItem { - id: "run-9".into(), repo: "cli-tools".into(), + id: "run-9".into(), + repo: "cli-tools".into(), title: "Add --verbose flag to run command".into(), - workflow: "expand".into(), status: RunListItemStatus::Merge, - number: Some(430), additions: Some(56), deletions: Some(12), + workflow: "expand".into(), + status: RunListItemStatus::Merge, + number: Some(430), + additions: Some(56), + deletions: Some(12), checks: vec![ - CheckRun { name: "lint".into(), status: CheckRunStatus::Success, duration_secs: Some(15.0) }, - CheckRun { name: "typecheck".into(), status: CheckRunStatus::Success, duration_secs: Some(48.0) }, - CheckRun { name: "unit-tests".into(), status: CheckRunStatus::Success, duration_secs: Some(116.0) }, - CheckRun { name: "build".into(), status: CheckRunStatus::Success, duration_secs: Some(82.0) }, - CheckRun { name: "coverage".into(), status: CheckRunStatus::Success, duration_secs: Some(124.0) }, - CheckRun { name: "bundle-size".into(), status: CheckRunStatus::Skipped, duration_secs: None }, + CheckRun { + name: "lint".into(), + status: CheckRunStatus::Success, + duration_secs: Some(15.0), + }, + CheckRun { + name: "typecheck".into(), + status: CheckRunStatus::Success, + duration_secs: Some(48.0), + }, + CheckRun { + name: "unit-tests".into(), + status: CheckRunStatus::Success, + duration_secs: Some(116.0), + }, + CheckRun { + name: "build".into(), + status: CheckRunStatus::Success, + duration_secs: Some(82.0), + }, + CheckRun { + name: "coverage".into(), + status: CheckRunStatus::Success, + duration_secs: Some(124.0), + }, + CheckRun { + name: "bundle-size".into(), + status: CheckRunStatus::Skipped, + duration_secs: None, + }, ], - elapsed_secs: Some(3900.0), elapsed_warning: Some(false), - resources: None, comments: Some(2), - question: None, sandbox_id: Some("sb-g3h4i5j6".into()), + elapsed_secs: Some(3900.0), + elapsed_warning: Some(false), + resources: None, + comments: Some(2), + question: None, + sandbox_id: Some("sb-g3h4i5j6".into()), }, RunListItem { - id: "run-10".into(), repo: "shared-types".into(), + id: "run-10".into(), + repo: "shared-types".into(), title: "Export utility type helpers".into(), - workflow: "sync_drift".into(), status: RunListItemStatus::Merge, - number: Some(76), additions: Some(34), deletions: Some(8), + workflow: "sync_drift".into(), + status: RunListItemStatus::Merge, + number: Some(76), + additions: Some(34), + deletions: Some(8), checks: vec![ - CheckRun { name: "lint".into(), status: CheckRunStatus::Success, duration_secs: Some(12.0) }, - CheckRun { name: "typecheck".into(), status: CheckRunStatus::Success, duration_secs: Some(34.0) }, - CheckRun { name: "unit-tests".into(), status: CheckRunStatus::Success, duration_secs: Some(75.0) }, - CheckRun { name: "build".into(), status: CheckRunStatus::Success, duration_secs: Some(58.0) }, + CheckRun { + name: "lint".into(), + status: CheckRunStatus::Success, + duration_secs: Some(12.0), + }, + CheckRun { + name: "typecheck".into(), + status: CheckRunStatus::Success, + duration_secs: Some(34.0), + }, + CheckRun { + name: "unit-tests".into(), + status: CheckRunStatus::Success, + duration_secs: Some(75.0), + }, + CheckRun { + name: "build".into(), + status: CheckRunStatus::Success, + duration_secs: Some(58.0), + }, ], - elapsed_secs: Some(2880.0), elapsed_warning: Some(false), - resources: None, comments: Some(0), - question: None, sandbox_id: Some("sb-k7l8m9n0".into()), + elapsed_secs: Some(2880.0), + elapsed_warning: Some(false), + resources: None, + comments: Some(0), + question: None, + sandbox_id: Some("sb-k7l8m9n0".into()), }, ] } pub fn stages() -> Vec { vec![ - RunStage { id: "detect-drift".into(), name: "Detect Drift".into(), status: StageStatus::Completed, duration_secs: Some(72.0), dot_id: Some("detect".into()) }, - RunStage { id: "propose-changes".into(), name: "Propose Changes".into(), status: StageStatus::Completed, duration_secs: Some(154.0), dot_id: Some("propose".into()) }, - RunStage { id: "review-changes".into(), name: "Review Changes".into(), status: StageStatus::Completed, duration_secs: Some(45.0), dot_id: Some("review".into()) }, - RunStage { id: "apply-changes".into(), name: "Apply Changes".into(), status: StageStatus::Running, duration_secs: Some(118.0), dot_id: Some("apply".into()) }, + RunStage { + id: "detect-drift".into(), + name: "Detect Drift".into(), + status: StageStatus::Completed, + duration_secs: Some(72.0), + dot_id: Some("detect".into()), + }, + RunStage { + id: "propose-changes".into(), + name: "Propose Changes".into(), + status: StageStatus::Completed, + duration_secs: Some(154.0), + dot_id: Some("propose".into()), + }, + RunStage { + id: "review-changes".into(), + name: "Review Changes".into(), + status: StageStatus::Completed, + duration_secs: Some(45.0), + dot_id: Some("review".into()), + }, + RunStage { + id: "apply-changes".into(), + name: "Apply Changes".into(), + status: StageStatus::Running, + duration_secs: Some(118.0), + dot_id: Some("apply".into()), + }, ] } @@ -811,16 +1044,67 @@ mod runs { pub fn usage() -> RunUsage { RunUsage { stages: vec![ - UsageStage { stage: "Detect Drift".into(), model: "Opus 4.6".into(), input_tokens: 12480, output_tokens: 3210, runtime_secs: 72.0, cost: 0.48 }, - UsageStage { stage: "Propose Changes".into(), model: "Gemini 3.1".into(), input_tokens: 28640, output_tokens: 8750, runtime_secs: 154.0, cost: 0.72 }, - UsageStage { stage: "Review Changes".into(), model: "Codex 5.3".into(), input_tokens: 9120, output_tokens: 2640, runtime_secs: 45.0, cost: 0.19 }, - UsageStage { stage: "Apply Changes".into(), model: "Opus 4.6".into(), input_tokens: 21300, output_tokens: 6480, runtime_secs: 118.0, cost: 0.87 }, + UsageStage { + stage: "Detect Drift".into(), + model: "Opus 4.6".into(), + input_tokens: 12480, + output_tokens: 3210, + runtime_secs: 72.0, + cost: 0.48, + }, + UsageStage { + stage: "Propose Changes".into(), + model: "Gemini 3.1".into(), + input_tokens: 28640, + output_tokens: 8750, + runtime_secs: 154.0, + cost: 0.72, + }, + UsageStage { + stage: "Review Changes".into(), + model: "Codex 5.3".into(), + input_tokens: 9120, + output_tokens: 2640, + runtime_secs: 45.0, + cost: 0.19, + }, + UsageStage { + stage: "Apply Changes".into(), + model: "Opus 4.6".into(), + input_tokens: 21300, + output_tokens: 6480, + runtime_secs: 118.0, + cost: 0.87, + }, ], - totals: UsageTotals { runtime_secs: 389.0, input_tokens: 71540, output_tokens: 21080, cost: 2.26 }, + totals: UsageTotals { + runtime_secs: 389.0, + input_tokens: 71540, + output_tokens: 21080, + cost: 2.26, + }, by_model: vec![ - UsageByModel { model: "Opus 4.6".into(), stages: 2, input_tokens: 33780, output_tokens: 9690, cost: 1.35 }, - UsageByModel { model: "Gemini 3.1".into(), stages: 1, input_tokens: 28640, output_tokens: 8750, cost: 0.72 }, - UsageByModel { model: "Codex 5.3".into(), stages: 1, input_tokens: 9120, output_tokens: 2640, cost: 0.19 }, + UsageByModel { + model: "Opus 4.6".into(), + stages: 2, + input_tokens: 33780, + output_tokens: 9690, + cost: 1.35, + }, + UsageByModel { + model: "Gemini 3.1".into(), + stages: 1, + input_tokens: 28640, + output_tokens: 8750, + cost: 0.72, + }, + UsageByModel { + model: "Codex 5.3".into(), + stages: 1, + input_tokens: 9120, + output_tokens: 2640, + cost: 0.19, + }, ], } } @@ -854,10 +1138,38 @@ mod workflows { pub fn list_items() -> Vec { vec![ - WorkflowListItem { name: "Fix Build".into(), slug: "fix_build".into(), filename: "fix_build.dot".into(), last_run: Some("2 hours ago".into()), schedule: None, next_run: None }, - WorkflowListItem { name: "Implement Feature".into(), slug: "implement".into(), filename: "implement.dot".into(), last_run: Some("4 days ago".into()), schedule: None, next_run: None }, - WorkflowListItem { name: "Sync Drift".into(), slug: "sync_drift".into(), filename: "sync_drift.dot".into(), last_run: Some("1 day ago".into()), schedule: None, next_run: None }, - WorkflowListItem { name: "Expand Product".into(), slug: "expand".into(), filename: "expand.dot".into(), last_run: Some("2 weeks ago".into()), schedule: None, next_run: None }, + WorkflowListItem { + name: "Fix Build".into(), + slug: "fix_build".into(), + filename: "fix_build.dot".into(), + last_run: Some("2 hours ago".into()), + schedule: None, + next_run: None, + }, + WorkflowListItem { + name: "Implement Feature".into(), + slug: "implement".into(), + filename: "implement.dot".into(), + last_run: Some("4 days ago".into()), + schedule: None, + next_run: None, + }, + WorkflowListItem { + name: "Sync Drift".into(), + slug: "sync_drift".into(), + filename: "sync_drift.dot".into(), + last_run: Some("1 day ago".into()), + schedule: None, + next_run: None, + }, + WorkflowListItem { + name: "Expand Product".into(), + slug: "expand".into(), + filename: "expand.dot".into(), + last_run: Some("2 weeks ago".into()), + schedule: None, + next_run: None, + }, ] } @@ -1134,35 +1446,155 @@ mod verifications { } const DEFAULT_RECENT_RESULTS: &[RecentResultDef] = &[ - RecentResultDef { run_id: "run-047", run_title: "PR #312 \u{2014} Add OAuth2 PKCE flow", workflow: "code_review", result: VerificationStatus::Pass, timestamp: "2h ago" }, - RecentResultDef { run_id: "run-046", run_title: "PR #311 \u{2014} Update rate limiter config", workflow: "code_review", result: VerificationStatus::Pass, timestamp: "5h ago" }, - RecentResultDef { run_id: "run-044", run_title: "PR #309 \u{2014} Migrate to pnpm", workflow: "code_review", result: VerificationStatus::Pass, timestamp: "1d ago" }, - RecentResultDef { run_id: "run-042", run_title: "PR #307 \u{2014} Fix session timeout", workflow: "fix_build", result: VerificationStatus::Pass, timestamp: "2d ago" }, - RecentResultDef { run_id: "run-040", run_title: "PR #305 \u{2014} Add webhook retries", workflow: "code_review", result: VerificationStatus::Pass, timestamp: "3d ago" }, + RecentResultDef { + run_id: "run-047", + run_title: "PR #312 \u{2014} Add OAuth2 PKCE flow", + workflow: "code_review", + result: VerificationStatus::Pass, + timestamp: "2h ago", + }, + RecentResultDef { + run_id: "run-046", + run_title: "PR #311 \u{2014} Update rate limiter config", + workflow: "code_review", + result: VerificationStatus::Pass, + timestamp: "5h ago", + }, + RecentResultDef { + run_id: "run-044", + run_title: "PR #309 \u{2014} Migrate to pnpm", + workflow: "code_review", + result: VerificationStatus::Pass, + timestamp: "1d ago", + }, + RecentResultDef { + run_id: "run-042", + run_title: "PR #307 \u{2014} Fix session timeout", + workflow: "fix_build", + result: VerificationStatus::Pass, + timestamp: "2d ago", + }, + RecentResultDef { + run_id: "run-040", + run_title: "PR #305 \u{2014} Add webhook retries", + workflow: "code_review", + result: VerificationStatus::Pass, + timestamp: "3d ago", + }, ]; const MOTIVATION_RESULTS: &[RecentResultDef] = &[ - RecentResultDef { run_id: "run-047", run_title: "PR #312 \u{2014} Add OAuth2 PKCE flow", workflow: "code_review", result: VerificationStatus::Pass, timestamp: "2h ago" }, - RecentResultDef { run_id: "run-046", run_title: "PR #311 \u{2014} Update rate limiter config", workflow: "code_review", result: VerificationStatus::Pass, timestamp: "5h ago" }, - RecentResultDef { run_id: "run-044", run_title: "PR #309 \u{2014} Migrate to pnpm", workflow: "code_review", result: VerificationStatus::Fail, timestamp: "1d ago" }, - RecentResultDef { run_id: "run-042", run_title: "PR #307 \u{2014} Fix session timeout", workflow: "fix_build", result: VerificationStatus::Pass, timestamp: "2d ago" }, - RecentResultDef { run_id: "run-040", run_title: "PR #305 \u{2014} Add webhook retries", workflow: "code_review", result: VerificationStatus::Pass, timestamp: "3d ago" }, + RecentResultDef { + run_id: "run-047", + run_title: "PR #312 \u{2014} Add OAuth2 PKCE flow", + workflow: "code_review", + result: VerificationStatus::Pass, + timestamp: "2h ago", + }, + RecentResultDef { + run_id: "run-046", + run_title: "PR #311 \u{2014} Update rate limiter config", + workflow: "code_review", + result: VerificationStatus::Pass, + timestamp: "5h ago", + }, + RecentResultDef { + run_id: "run-044", + run_title: "PR #309 \u{2014} Migrate to pnpm", + workflow: "code_review", + result: VerificationStatus::Fail, + timestamp: "1d ago", + }, + RecentResultDef { + run_id: "run-042", + run_title: "PR #307 \u{2014} Fix session timeout", + workflow: "fix_build", + result: VerificationStatus::Pass, + timestamp: "2d ago", + }, + RecentResultDef { + run_id: "run-040", + run_title: "PR #305 \u{2014} Add webhook retries", + workflow: "code_review", + result: VerificationStatus::Pass, + timestamp: "3d ago", + }, ]; const DOCUMENTATION_RESULTS: &[RecentResultDef] = &[ - RecentResultDef { run_id: "run-047", run_title: "PR #312 \u{2014} Add OAuth2 PKCE flow", workflow: "code_review", result: VerificationStatus::Pass, timestamp: "2h ago" }, - RecentResultDef { run_id: "run-046", run_title: "PR #311 \u{2014} Update rate limiter config", workflow: "code_review", result: VerificationStatus::Fail, timestamp: "5h ago" }, - RecentResultDef { run_id: "run-044", run_title: "PR #309 \u{2014} Migrate to pnpm", workflow: "code_review", result: VerificationStatus::Pass, timestamp: "1d ago" }, - RecentResultDef { run_id: "run-042", run_title: "PR #307 \u{2014} Fix session timeout", workflow: "fix_build", result: VerificationStatus::Pass, timestamp: "2d ago" }, - RecentResultDef { run_id: "run-040", run_title: "PR #305 \u{2014} Add webhook retries", workflow: "code_review", result: VerificationStatus::Fail, timestamp: "3d ago" }, + RecentResultDef { + run_id: "run-047", + run_title: "PR #312 \u{2014} Add OAuth2 PKCE flow", + workflow: "code_review", + result: VerificationStatus::Pass, + timestamp: "2h ago", + }, + RecentResultDef { + run_id: "run-046", + run_title: "PR #311 \u{2014} Update rate limiter config", + workflow: "code_review", + result: VerificationStatus::Fail, + timestamp: "5h ago", + }, + RecentResultDef { + run_id: "run-044", + run_title: "PR #309 \u{2014} Migrate to pnpm", + workflow: "code_review", + result: VerificationStatus::Pass, + timestamp: "1d ago", + }, + RecentResultDef { + run_id: "run-042", + run_title: "PR #307 \u{2014} Fix session timeout", + workflow: "fix_build", + result: VerificationStatus::Pass, + timestamp: "2d ago", + }, + RecentResultDef { + run_id: "run-040", + run_title: "PR #305 \u{2014} Add webhook retries", + workflow: "code_review", + result: VerificationStatus::Fail, + timestamp: "3d ago", + }, ]; const ROLLOUT_ROLLBACK_RESULTS: &[RecentResultDef] = &[ - RecentResultDef { run_id: "run-047", run_title: "PR #312 \u{2014} Add OAuth2 PKCE flow", workflow: "code_review", result: VerificationStatus::Fail, timestamp: "2h ago" }, - RecentResultDef { run_id: "run-046", run_title: "PR #311 \u{2014} Update rate limiter config", workflow: "code_review", result: VerificationStatus::Pass, timestamp: "5h ago" }, - RecentResultDef { run_id: "run-044", run_title: "PR #309 \u{2014} Migrate to pnpm", workflow: "code_review", result: VerificationStatus::Fail, timestamp: "1d ago" }, - RecentResultDef { run_id: "run-042", run_title: "PR #307 \u{2014} Fix session timeout", workflow: "fix_build", result: VerificationStatus::Fail, timestamp: "2d ago" }, - RecentResultDef { run_id: "run-040", run_title: "PR #305 \u{2014} Add webhook retries", workflow: "code_review", result: VerificationStatus::Pass, timestamp: "3d ago" }, + RecentResultDef { + run_id: "run-047", + run_title: "PR #312 \u{2014} Add OAuth2 PKCE flow", + workflow: "code_review", + result: VerificationStatus::Fail, + timestamp: "2h ago", + }, + RecentResultDef { + run_id: "run-046", + run_title: "PR #311 \u{2014} Update rate limiter config", + workflow: "code_review", + result: VerificationStatus::Pass, + timestamp: "5h ago", + }, + RecentResultDef { + run_id: "run-044", + run_title: "PR #309 \u{2014} Migrate to pnpm", + workflow: "code_review", + result: VerificationStatus::Fail, + timestamp: "1d ago", + }, + RecentResultDef { + run_id: "run-042", + run_title: "PR #307 \u{2014} Fix session timeout", + workflow: "fix_build", + result: VerificationStatus::Fail, + timestamp: "2d ago", + }, + RecentResultDef { + run_id: "run-040", + run_title: "PR #305 \u{2014} Add webhook retries", + workflow: "code_review", + result: VerificationStatus::Pass, + timestamp: "3d ago", + }, ]; use EvaluationResult::{Fail as F, Pass as P}; @@ -1624,19 +2056,29 @@ mod verifications { // ── Helpers ────────────────────────────────────────────────────────── fn recent_results_from_def(defs: &[RecentResultDef]) -> Vec { - defs.iter().map(|r| RecentControlResult { - run_id: r.run_id.into(), - run_title: r.run_title.into(), - workflow: r.workflow.into(), - result: r.result, - timestamp: r.timestamp.into(), - }).collect() + defs.iter() + .map(|r| RecentControlResult { + run_id: r.run_id.into(), + run_title: r.run_title.into(), + workflow: r.workflow.into(), + result: r.result, + timestamp: r.timestamp.into(), + }) + .collect() } fn category_run_status(cat: &CategoryDef) -> VerificationStatus { - if cat.controls.iter().any(|c| c.run_status == VerificationStatus::Fail) { + if cat + .controls + .iter() + .any(|c| c.run_status == VerificationStatus::Fail) + { VerificationStatus::Fail - } else if cat.controls.iter().all(|c| c.run_status == VerificationStatus::Na) { + } else if cat + .controls + .iter() + .all(|c| c.run_status == VerificationStatus::Na) + { VerificationStatus::Na } else { VerificationStatus::Pass @@ -1646,29 +2088,36 @@ mod verifications { // ── Public API ────────────────────────────────────────────────────── pub fn categories() -> Vec { - ALL_CATEGORIES.iter().map(|cat| { - VerificationCategory { + ALL_CATEGORIES + .iter() + .map(|cat| VerificationCategory { name: cat.name.into(), question: cat.question.into(), - controls: cat.controls.iter().map(|c| VerificationControl { - name: c.name.into(), - slug: c.slug.into(), - description: c.description.into(), - type_: c.type_, - mode: Some(c.mode), - f1: c.f1, - pass_at_1: c.pass_at_1, - evaluations: c.evaluations.to_vec(), - }).collect(), - } - }).collect() + controls: cat + .controls + .iter() + .map(|c| VerificationControl { + name: c.name.into(), + slug: c.slug.into(), + description: c.description.into(), + type_: c.type_, + mode: Some(c.mode), + f1: c.f1, + pass_at_1: c.pass_at_1, + evaluations: c.evaluations.to_vec(), + }) + .collect(), + }) + .collect() } pub fn detail(slug: &str) -> Option { for cat in ALL_CATEGORIES { for (idx, ctrl) in cat.controls.iter().enumerate() { if ctrl.slug == slug { - let siblings: Vec = cat.controls.iter() + let siblings: Vec = cat + .controls + .iter() .enumerate() .filter(|(i, _)| *i != idx) .map(|(_, s)| SiblingControl { @@ -1714,19 +2163,24 @@ mod verifications { } pub fn run_verifications() -> Vec { - ALL_CATEGORIES.iter().map(|cat| { - RunVerification { + ALL_CATEGORIES + .iter() + .map(|cat| RunVerification { name: cat.name.into(), question: cat.question.into(), status: category_run_status(cat), - controls: cat.controls.iter().map(|c| RunVerificationControl { - name: c.name.into(), - description: c.description.into(), - type_: c.type_, - status: c.run_status, - }).collect(), - } - }).collect() + controls: cat + .controls + .iter() + .map(|c| RunVerificationControl { + name: c.name.into(), + description: c.description.into(), + type_: c.type_, + status: c.run_status, + }) + .collect(), + }) + .collect() } } @@ -1736,43 +2190,110 @@ mod retros { pub fn list_items() -> Vec { vec![ RetroListItem { - run_id: "run-1".into(), workflow_name: "implement".into(), + run_id: "run-1".into(), + workflow_name: "implement".into(), goal: "Add rate limiting to auth endpoints".into(), timestamp: "2026-02-28T14:32:00Z".into(), smoothness: Some(SmoothnessRating::Smooth), - stats: RetroStats { total_duration_ms: 389000, total_cost: Some(2.78), total_retries: 0, files_touched: vec!["src/middleware/rate-limit.ts".into(), "src/routes/auth.ts".into(), "src/config.ts".into(), "tests/rate-limit.test.ts".into()], stages_completed: 4, stages_failed: 0 }, + stats: RetroStats { + total_duration_ms: 389000, + total_cost: Some(2.78), + total_retries: 0, + files_touched: vec![ + "src/middleware/rate-limit.ts".into(), + "src/routes/auth.ts".into(), + "src/config.ts".into(), + "tests/rate-limit.test.ts".into(), + ], + stages_completed: 4, + stages_failed: 0, + }, friction_point_count: 0, }, RetroListItem { - run_id: "run-2".into(), workflow_name: "implement".into(), + run_id: "run-2".into(), + workflow_name: "implement".into(), goal: "Migrate to React Router v7".into(), timestamp: "2026-02-28T10:15:00Z".into(), smoothness: Some(SmoothnessRating::Bumpy), - stats: RetroStats { total_duration_ms: 975000, total_cost: Some(6.82), total_retries: 4, files_touched: vec!["package.json".into(), "src/routes.ts".into(), "src/app.tsx".into(), "src/routes/dashboard.tsx".into(), "src/routes/settings.tsx".into(), "src/routes/profile.tsx".into(), "tests/routes.test.tsx".into()], stages_completed: 4, stages_failed: 0 }, + stats: RetroStats { + total_duration_ms: 975000, + total_cost: Some(6.82), + total_retries: 4, + files_touched: vec![ + "package.json".into(), + "src/routes.ts".into(), + "src/app.tsx".into(), + "src/routes/dashboard.tsx".into(), + "src/routes/settings.tsx".into(), + "src/routes/profile.tsx".into(), + "tests/routes.test.tsx".into(), + ], + stages_completed: 4, + stages_failed: 0, + }, friction_point_count: 2, }, RetroListItem { - run_id: "run-6".into(), workflow_name: "implement".into(), + run_id: "run-6".into(), + workflow_name: "implement".into(), goal: "Add dark mode toggle".into(), timestamp: "2026-02-27T16:45:00Z".into(), smoothness: Some(SmoothnessRating::Effortless), - stats: RetroStats { total_duration_ms: 216000, total_cost: Some(1.51), total_retries: 0, files_touched: vec!["src/components/ThemeToggle.tsx".into(), "src/hooks/useTheme.ts".into(), "src/layouts/Header.tsx".into()], stages_completed: 3, stages_failed: 0 }, + stats: RetroStats { + total_duration_ms: 216000, + total_cost: Some(1.51), + total_retries: 0, + files_touched: vec![ + "src/components/ThemeToggle.tsx".into(), + "src/hooks/useTheme.ts".into(), + "src/layouts/Header.tsx".into(), + ], + stages_completed: 3, + stages_failed: 0, + }, friction_point_count: 0, }, RetroListItem { - run_id: "run-3".into(), workflow_name: "fix_build".into(), + run_id: "run-3".into(), + workflow_name: "fix_build".into(), goal: "Fix config parsing for nested values".into(), timestamp: "2026-02-27T09:20:00Z".into(), smoothness: Some(SmoothnessRating::Struggled), - stats: RetroStats { total_duration_ms: 830000, total_cost: Some(4.97), total_retries: 4, files_touched: vec!["src/config/parser.ts".into(), "src/config/defaults.ts".into(), "src/config/merge.ts".into(), "tests/config-parser.test.ts".into()], stages_completed: 4, stages_failed: 0 }, + stats: RetroStats { + total_duration_ms: 830000, + total_cost: Some(4.97), + total_retries: 4, + files_touched: vec![ + "src/config/parser.ts".into(), + "src/config/defaults.ts".into(), + "src/config/merge.ts".into(), + "tests/config-parser.test.ts".into(), + ], + stages_completed: 4, + stages_failed: 0, + }, friction_point_count: 3, }, RetroListItem { - run_id: "run-8".into(), workflow_name: "implement".into(), + run_id: "run-8".into(), + workflow_name: "implement".into(), goal: "Implement webhook retry logic".into(), timestamp: "2026-02-26T11:00:00Z".into(), smoothness: Some(SmoothnessRating::Smooth), - stats: RetroStats { total_duration_ms: 440000, total_cost: Some(3.09), total_retries: 1, files_touched: vec!["src/webhooks/retry.ts".into(), "src/webhooks/dlq.ts".into(), "src/webhooks/dispatcher.ts".into(), "tests/webhook-retry.test.ts".into()], stages_completed: 4, stages_failed: 0 }, + stats: RetroStats { + total_duration_ms: 440000, + total_cost: Some(3.09), + total_retries: 1, + files_touched: vec![ + "src/webhooks/retry.ts".into(), + "src/webhooks/dlq.ts".into(), + "src/webhooks/dispatcher.ts".into(), + "tests/webhook-retry.test.ts".into(), + ], + stages_completed: 4, + stages_failed: 0, + }, friction_point_count: 1, }, ] @@ -1787,24 +2308,64 @@ mod sessions { SessionGroup { label: "Today".into(), sessions: vec![ - SessionListItem { id: "s1".into(), title: "Add rate limiting to auth endpoints".into(), repo: "api-server".into(), time: "2h ago".into() }, - SessionListItem { id: "s2".into(), title: "Fix config parsing for nested values".into(), repo: "cli-tools".into(), time: "4h ago".into() }, + SessionListItem { + id: "s1".into(), + title: "Add rate limiting to auth endpoints".into(), + repo: "api-server".into(), + time: "2h ago".into(), + }, + SessionListItem { + id: "s2".into(), + title: "Fix config parsing for nested values".into(), + repo: "cli-tools".into(), + time: "4h ago".into(), + }, ], }, SessionGroup { label: "Yesterday".into(), sessions: vec![ - SessionListItem { id: "s3".into(), title: "Migrate to React Router v7".into(), repo: "web-dashboard".into(), time: "1d ago".into() }, - SessionListItem { id: "s4".into(), title: "Add dark mode toggle".into(), repo: "web-dashboard".into(), time: "1d ago".into() }, - SessionListItem { id: "s5".into(), title: "Update OpenAPI spec for v3".into(), repo: "api-server".into(), time: "1d ago".into() }, + SessionListItem { + id: "s3".into(), + title: "Migrate to React Router v7".into(), + repo: "web-dashboard".into(), + time: "1d ago".into(), + }, + SessionListItem { + id: "s4".into(), + title: "Add dark mode toggle".into(), + repo: "web-dashboard".into(), + time: "1d ago".into(), + }, + SessionListItem { + id: "s5".into(), + title: "Update OpenAPI spec for v3".into(), + repo: "api-server".into(), + time: "1d ago".into(), + }, ], }, SessionGroup { label: "Previous 7 days".into(), sessions: vec![ - SessionListItem { id: "s6".into(), title: "Terraform module for Redis cluster".into(), repo: "infrastructure".into(), time: "3d ago".into() }, - SessionListItem { id: "s7".into(), title: "Add pipeline event types".into(), repo: "shared-types".into(), time: "5d ago".into() }, - SessionListItem { id: "s8".into(), title: "Implement webhook retry logic".into(), repo: "api-server".into(), time: "6d ago".into() }, + SessionListItem { + id: "s6".into(), + title: "Terraform module for Redis cluster".into(), + repo: "infrastructure".into(), + time: "3d ago".into(), + }, + SessionListItem { + id: "s7".into(), + title: "Add pipeline event types".into(), + repo: "shared-types".into(), + time: "5d ago".into(), + }, + SessionListItem { + id: "s8".into(), + title: "Implement webhook retry logic".into(), + repo: "api-server".into(), + time: "6d ago".into(), + }, ], }, ] @@ -1877,9 +2438,28 @@ mod insights { pub fn history() -> Vec { vec![ - HistoryEntry { id: "h1".into(), sql: "SELECT workflow_name, COUNT(*) FROM runs GROUP BY 1".into(), timestamp: "2 min ago".into(), elapsed: 0.342, row_count: 6 }, - HistoryEntry { id: "h2".into(), sql: "SELECT * FROM runs WHERE status = 'failed' LIMIT 100".into(), timestamp: "8 min ago".into(), elapsed: 0.127, row_count: 23 }, - HistoryEntry { id: "h3".into(), sql: "SELECT date_trunc('day', created_at) as d, COUNT(*) FROM runs GROUP BY 1".into(), timestamp: "15 min ago".into(), elapsed: 0.531, row_count: 30 }, + HistoryEntry { + id: "h1".into(), + sql: "SELECT workflow_name, COUNT(*) FROM runs GROUP BY 1".into(), + timestamp: "2 min ago".into(), + elapsed: 0.342, + row_count: 6, + }, + HistoryEntry { + id: "h2".into(), + sql: "SELECT * FROM runs WHERE status = 'failed' LIMIT 100".into(), + timestamp: "8 min ago".into(), + elapsed: 0.127, + row_count: 23, + }, + HistoryEntry { + id: "h3".into(), + sql: "SELECT date_trunc('day', created_at) as d, COUNT(*) FROM runs GROUP BY 1" + .into(), + timestamp: "15 min ago".into(), + elapsed: 0.531, + row_count: 30, + }, ] } } @@ -1890,52 +2470,253 @@ mod settings { pub fn groups() -> Vec { vec![ SettingGroup { - id: "general".into(), name: "General".into(), description: "Core platform settings and defaults.".into(), + id: "general".into(), + name: "General".into(), + description: "Core platform settings and defaults.".into(), fields: vec![ - SettingField { key: "org_name".into(), label: "Organization name".into(), value: "Acme Corp".into(), type_: SettingFieldType::Text, options: vec![], description: None }, - SettingField { key: "default_branch".into(), label: "Default branch".into(), value: "main".into(), type_: SettingFieldType::Text, options: vec![], description: None }, - SettingField { key: "timezone".into(), label: "Timezone".into(), value: "America/New_York".into(), type_: SettingFieldType::Select, options: vec!["America/New_York".into(), "America/Chicago".into(), "America/Denver".into(), "America/Los_Angeles".into(), "UTC".into(), "Europe/London".into(), "Europe/Berlin".into(), "Asia/Tokyo".into()], description: None }, - SettingField { key: "auto_cancel".into(), label: "Auto-cancel superseded runs".into(), value: "true".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, + SettingField { + key: "org_name".into(), + label: "Organization name".into(), + value: "Acme Corp".into(), + type_: SettingFieldType::Text, + options: vec![], + description: None, + }, + SettingField { + key: "default_branch".into(), + label: "Default branch".into(), + value: "main".into(), + type_: SettingFieldType::Text, + options: vec![], + description: None, + }, + SettingField { + key: "timezone".into(), + label: "Timezone".into(), + value: "America/New_York".into(), + type_: SettingFieldType::Select, + options: vec![ + "America/New_York".into(), + "America/Chicago".into(), + "America/Denver".into(), + "America/Los_Angeles".into(), + "UTC".into(), + "Europe/London".into(), + "Europe/Berlin".into(), + "Asia/Tokyo".into(), + ], + description: None, + }, + SettingField { + key: "auto_cancel".into(), + label: "Auto-cancel superseded runs".into(), + value: "true".into(), + type_: SettingFieldType::Toggle, + options: vec![], + description: None, + }, ], }, SettingGroup { - id: "git".into(), name: "Git & VCS".into(), description: "Version control integration and repository settings.".into(), + id: "git".into(), + name: "Git & VCS".into(), + description: "Version control integration and repository settings.".into(), fields: vec![ - SettingField { key: "github_org".into(), label: "GitHub organization".into(), value: "acme-corp".into(), type_: SettingFieldType::Text, options: vec![], description: None }, - SettingField { key: "clone_protocol".into(), label: "Clone protocol".into(), value: "SSH".into(), type_: SettingFieldType::Select, options: vec!["SSH".into(), "HTTPS".into()], description: None }, - SettingField { key: "auto_merge".into(), label: "Auto-merge when checks pass".into(), value: "false".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, - SettingField { key: "delete_branch".into(), label: "Delete branch after merge".into(), value: "true".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, - SettingField { key: "commit_signing".into(), label: "Require commit signing".into(), value: "false".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, + SettingField { + key: "github_org".into(), + label: "GitHub organization".into(), + value: "acme-corp".into(), + type_: SettingFieldType::Text, + options: vec![], + description: None, + }, + SettingField { + key: "clone_protocol".into(), + label: "Clone protocol".into(), + value: "SSH".into(), + type_: SettingFieldType::Select, + options: vec!["SSH".into(), "HTTPS".into()], + description: None, + }, + SettingField { + key: "auto_merge".into(), + label: "Auto-merge when checks pass".into(), + value: "false".into(), + type_: SettingFieldType::Toggle, + options: vec![], + description: None, + }, + SettingField { + key: "delete_branch".into(), + label: "Delete branch after merge".into(), + value: "true".into(), + type_: SettingFieldType::Toggle, + options: vec![], + description: None, + }, + SettingField { + key: "commit_signing".into(), + label: "Require commit signing".into(), + value: "false".into(), + type_: SettingFieldType::Toggle, + options: vec![], + description: None, + }, ], }, SettingGroup { - id: "compute".into(), name: "Compute".into(), description: "Resource allocation and execution environment.".into(), + id: "compute".into(), + name: "Compute".into(), + description: "Resource allocation and execution environment.".into(), fields: vec![ - SettingField { key: "default_cpu".into(), label: "Default CPU".into(), value: "4".into(), type_: SettingFieldType::Select, options: vec!["2".into(), "4".into(), "8".into(), "16".into()], description: None }, - SettingField { key: "default_memory".into(), label: "Default memory".into(), value: "8 GB".into(), type_: SettingFieldType::Select, options: vec!["4 GB".into(), "8 GB".into(), "16 GB".into(), "32 GB".into()], description: None }, - SettingField { key: "max_parallel".into(), label: "Max parallel runs".into(), value: "10".into(), type_: SettingFieldType::Text, options: vec![], description: None }, - SettingField { key: "timeout_minutes".into(), label: "Run timeout (minutes)".into(), value: "120".into(), type_: SettingFieldType::Text, options: vec![], description: None }, - SettingField { key: "gpu_enabled".into(), label: "GPU acceleration".into(), value: "false".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, + SettingField { + key: "default_cpu".into(), + label: "Default CPU".into(), + value: "4".into(), + type_: SettingFieldType::Select, + options: vec!["2".into(), "4".into(), "8".into(), "16".into()], + description: None, + }, + SettingField { + key: "default_memory".into(), + label: "Default memory".into(), + value: "8 GB".into(), + type_: SettingFieldType::Select, + options: vec!["4 GB".into(), "8 GB".into(), "16 GB".into(), "32 GB".into()], + description: None, + }, + SettingField { + key: "max_parallel".into(), + label: "Max parallel runs".into(), + value: "10".into(), + type_: SettingFieldType::Text, + options: vec![], + description: None, + }, + SettingField { + key: "timeout_minutes".into(), + label: "Run timeout (minutes)".into(), + value: "120".into(), + type_: SettingFieldType::Text, + options: vec![], + description: None, + }, + SettingField { + key: "gpu_enabled".into(), + label: "GPU acceleration".into(), + value: "false".into(), + type_: SettingFieldType::Toggle, + options: vec![], + description: None, + }, ], }, SettingGroup { - id: "notifications".into(), name: "Notifications".into(), description: "Alerts and notification delivery preferences.".into(), + id: "notifications".into(), + name: "Notifications".into(), + description: "Alerts and notification delivery preferences.".into(), fields: vec![ - SettingField { key: "slack_webhook".into(), label: "Slack webhook URL".into(), value: "https://hooks.slack.com/services/T00/B00/xxxx".into(), type_: SettingFieldType::Text, options: vec![], description: None }, - SettingField { key: "notify_on_failure".into(), label: "Notify on failure".into(), value: "true".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, - SettingField { key: "notify_on_success".into(), label: "Notify on success".into(), value: "false".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, - SettingField { key: "notify_on_approval".into(), label: "Notify on approval needed".into(), value: "true".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, - SettingField { key: "email_digest".into(), label: "Daily email digest".into(), value: "false".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, + SettingField { + key: "slack_webhook".into(), + label: "Slack webhook URL".into(), + value: "https://hooks.slack.com/services/T00/B00/xxxx".into(), + type_: SettingFieldType::Text, + options: vec![], + description: None, + }, + SettingField { + key: "notify_on_failure".into(), + label: "Notify on failure".into(), + value: "true".into(), + type_: SettingFieldType::Toggle, + options: vec![], + description: None, + }, + SettingField { + key: "notify_on_success".into(), + label: "Notify on success".into(), + value: "false".into(), + type_: SettingFieldType::Toggle, + options: vec![], + description: None, + }, + SettingField { + key: "notify_on_approval".into(), + label: "Notify on approval needed".into(), + value: "true".into(), + type_: SettingFieldType::Toggle, + options: vec![], + description: None, + }, + SettingField { + key: "email_digest".into(), + label: "Daily email digest".into(), + value: "false".into(), + type_: SettingFieldType::Toggle, + options: vec![], + description: None, + }, ], }, SettingGroup { - id: "security".into(), name: "Security".into(), description: "Access control and security policies.".into(), + id: "security".into(), + name: "Security".into(), + description: "Access control and security policies.".into(), fields: vec![ - SettingField { key: "sso_provider".into(), label: "SSO provider".into(), value: "Okta".into(), type_: SettingFieldType::Select, options: vec!["None".into(), "Okta".into(), "Azure AD".into(), "Google Workspace".into(), "OneLogin".into()], description: None }, - SettingField { key: "mfa_required".into(), label: "Require MFA".into(), value: "true".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, - SettingField { key: "session_timeout".into(), label: "Session timeout".into(), value: "8 hours".into(), type_: SettingFieldType::Select, options: vec!["1 hour".into(), "4 hours".into(), "8 hours".into(), "24 hours".into(), "7 days".into()], description: None }, - SettingField { key: "audit_log".into(), label: "Audit logging".into(), value: "true".into(), type_: SettingFieldType::Toggle, options: vec![], description: None }, - SettingField { key: "ip_allowlist".into(), label: "IP allowlist".into(), value: "".into(), type_: SettingFieldType::Text, options: vec![], description: Some("Comma-separated CIDRs. Leave empty to allow all.".into()) }, + SettingField { + key: "sso_provider".into(), + label: "SSO provider".into(), + value: "Okta".into(), + type_: SettingFieldType::Select, + options: vec![ + "None".into(), + "Okta".into(), + "Azure AD".into(), + "Google Workspace".into(), + "OneLogin".into(), + ], + description: None, + }, + SettingField { + key: "mfa_required".into(), + label: "Require MFA".into(), + value: "true".into(), + type_: SettingFieldType::Toggle, + options: vec![], + description: None, + }, + SettingField { + key: "session_timeout".into(), + label: "Session timeout".into(), + value: "8 hours".into(), + type_: SettingFieldType::Select, + options: vec![ + "1 hour".into(), + "4 hours".into(), + "8 hours".into(), + "24 hours".into(), + "7 days".into(), + ], + description: None, + }, + SettingField { + key: "audit_log".into(), + label: "Audit logging".into(), + value: "true".into(), + type_: SettingFieldType::Toggle, + options: vec![], + description: None, + }, + SettingField { + key: "ip_allowlist".into(), + label: "IP allowlist".into(), + value: "".into(), + type_: SettingFieldType::Text, + options: vec![], + description: Some( + "Comma-separated CIDRs. Leave empty to allow all.".into(), + ), + }, ], }, ] @@ -1947,17 +2728,35 @@ mod projects { pub fn list_items() -> Vec { vec![ - Project { id: "arc-web".into(), name: "arc-web".into() }, - Project { id: "arc-workflows".into(), name: "arc-workflows".into() }, - Project { id: "arc-cli".into(), name: "arc-cli".into() }, + Project { + id: "arc-web".into(), + name: "arc-web".into(), + }, + Project { + id: "arc-workflows".into(), + name: "arc-workflows".into(), + }, + Project { + id: "arc-cli".into(), + name: "arc-cli".into(), + }, ] } pub fn branches() -> Vec { vec![ - Branch { id: "main".into(), name: "main".into() }, - Branch { id: "develop".into(), name: "develop".into() }, - Branch { id: "feature/start-page".into(), name: "feature/start-page".into() }, + Branch { + id: "main".into(), + name: "main".into(), + }, + Branch { + id: "develop".into(), + name: "develop".into(), + }, + Branch { + id: "feature/start-page".into(), + name: "feature/start-page".into(), + }, ] } } diff --git a/crates/arc-api/src/jwt_auth.rs b/crates/arc-api/src/jwt_auth.rs index 5cd608624..e3b014fcf 100644 --- a/crates/arc-api/src/jwt_auth.rs +++ b/crates/arc-api/src/jwt_auth.rs @@ -58,7 +58,8 @@ fn decode_pem_env(name: &str, value: &str) -> String { } let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, value) .unwrap_or_else(|e| panic!("{name} is not valid PEM or base64: {e}")); - String::from_utf8(bytes).unwrap_or_else(|e| panic!("{name} base64 decoded to invalid UTF-8: {e}")) + String::from_utf8(bytes) + .unwrap_or_else(|e| panic!("{name} base64 decoded to invalid UTF-8: {e}")) } /// Resolve the authentication mode from the API config section. @@ -162,8 +163,8 @@ fn try_mtls(parts: &Parts) -> Result<(), StatusCode> { // Verify we can parse the leaf certificate and extract a CN let cert = &peer_certs[0]; - let (_, parsed) = x509_parser::parse_x509_certificate(cert) - .map_err(|_| StatusCode::UNAUTHORIZED)?; + let (_, parsed) = + x509_parser::parse_x509_certificate(cert).map_err(|_| StatusCode::UNAUTHORIZED)?; parsed .subject() @@ -322,8 +323,15 @@ mod tests { let ca_cert = { let mut child = Command::new("openssl") .args([ - "req", "-new", "-x509", "-key", "/dev/stdin", "-days", "1", - "-subj", "/CN=TestCA", + "req", + "-new", + "-x509", + "-key", + "/dev/stdin", + "-days", + "1", + "-subj", + "/CN=TestCA", ]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -344,9 +352,7 @@ mod tests { let subj = format!("/CN={cn}"); let client_csr = { let mut child = Command::new("openssl") - .args([ - "req", "-new", "-key", "/dev/stdin", "-subj", &subj, - ]) + .args(["req", "-new", "-key", "/dev/stdin", "-subj", &subj]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() @@ -366,12 +372,17 @@ mod tests { let client_cert_pem = Command::new("openssl") .args([ - "x509", "-req", - "-in", csr_path.to_str().unwrap(), - "-CA", ca_cert_path.to_str().unwrap(), - "-CAkey", ca_key_path.to_str().unwrap(), + "x509", + "-req", + "-in", + csr_path.to_str().unwrap(), + "-CA", + ca_cert_path.to_str().unwrap(), + "-CAkey", + ca_key_path.to_str().unwrap(), "-CAcreateserial", - "-days", "1", + "-days", + "1", ]) .output() .expect("openssl x509 failed") @@ -425,12 +436,7 @@ mod tests { let (encoding, decoding) = generate_test_keypair(); let app = test_router(jwt_mode(decoding, vec!["brynary"])); - let token = sign_token( - &encoding, - "arc-web", - 60, - Some("https://github.com/brynary"), - ); + let token = sign_token(&encoding, "arc-web", 60, Some("https://github.com/brynary")); let req = Request::builder() .uri("/test") @@ -486,12 +492,7 @@ mod tests { let (encoding, decoding) = generate_test_keypair(); let app = test_router(jwt_mode(decoding, vec![])); - let token = sign_token( - &encoding, - "arc-web", - 60, - Some("https://github.com/brynary"), - ); + let token = sign_token(&encoding, "arc-web", 60, Some("https://github.com/brynary")); let req = Request::builder() .uri("/test") @@ -652,12 +653,7 @@ mod tests { ]); let app = test_router(mode); - let token = sign_token( - &encoding, - "arc-web", - 60, - Some("https://github.com/brynary"), - ); + let token = sign_token(&encoding, "arc-web", 60, Some("https://github.com/brynary")); // No peer certs, but valid JWT let mut req = Request::builder() diff --git a/crates/arc-api/src/server.rs b/crates/arc-api/src/server.rs index d9d813c16..96cf631f1 100644 --- a/crates/arc-api/src/server.rs +++ b/crates/arc-api/src/server.rs @@ -19,7 +19,7 @@ use arc_agent::LocalSandbox; use crate::jwt_auth::{AuthMode, AuthenticatedService}; use arc_workflows::checkpoint::Checkpoint; use arc_workflows::context::Context; -use arc_workflows::engine::{WorkflowRunEngine, RunConfig}; +use arc_workflows::engine::{RunConfig, WorkflowRunEngine}; use arc_workflows::event::{EventEmitter, WorkflowRunEvent}; use arc_workflows::handler::HandlerRegistry; use arc_workflows::interviewer::web::WebInterviewer; @@ -30,7 +30,6 @@ pub use arc_types::{ StartRunResponse, SubmitAnswerRequest, SubmitAnswerResponse, }; - /// Snapshot of a managed run. struct ManagedRun { dot_source: String, @@ -62,10 +61,16 @@ pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { if is_demo { router = router - .route("/runs", get(crate::demo::list_runs).post(crate::demo::start_run_stub)) + .route( + "/runs", + get(crate::demo::list_runs).post(crate::demo::start_run_stub), + ) .route("/runs/{id}", get(crate::demo::get_run_status)) .route("/runs/{id}/questions", get(crate::demo::get_questions_stub)) - .route("/runs/{id}/questions/{qid}/answer", post(crate::demo::answer_stub)) + .route( + "/runs/{id}/questions/{qid}/answer", + post(crate::demo::answer_stub), + ) .route("/runs/{id}/events", get(crate::demo::run_events_stub)) .route("/runs/{id}/checkpoint", get(crate::demo::checkpoint_stub)) .route("/runs/{id}/context", get(crate::demo::context_stub)) @@ -73,25 +78,58 @@ pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { .route("/runs/{id}/graph", get(crate::demo::get_run_graph)) .route("/runs/{id}/retro", get(crate::demo::get_run_retro)) .route("/runs/{id}/stages", get(crate::demo::get_run_stages)) - .route("/runs/{id}/stages/{stageId}/turns", get(crate::demo::get_stage_turns)) + .route( + "/runs/{id}/stages/{stageId}/turns", + get(crate::demo::get_stage_turns), + ) .route("/runs/{id}/files", get(crate::demo::get_run_files)) .route("/runs/{id}/usage", get(crate::demo::get_run_usage)) - .route("/runs/{id}/verifications", get(crate::demo::get_run_verifications)) - .route("/runs/{id}/configuration", get(crate::demo::get_run_configuration)) + .route( + "/runs/{id}/verifications", + get(crate::demo::get_run_verifications), + ) + .route( + "/runs/{id}/configuration", + get(crate::demo::get_run_configuration), + ) .route("/runs/{id}/steer", post(crate::demo::steer_run_stub)) - .route("/runs/{id}/preview", post(crate::demo::generate_preview_url_stub)) + .route( + "/runs/{id}/preview", + post(crate::demo::generate_preview_url_stub), + ) .route("/workflows", get(crate::demo::list_workflows)) .route("/workflows/{name}", get(crate::demo::get_workflow)) - .route("/workflows/{name}/runs", get(crate::demo::list_workflow_runs).post(crate::demo::trigger_workflow_run_stub)) + .route( + "/workflows/{name}/runs", + get(crate::demo::list_workflow_runs).post(crate::demo::trigger_workflow_run_stub), + ) .route("/verifications", get(crate::demo::list_verifications)) - .route("/verifications/{slug}", get(crate::demo::get_verification_detail)) + .route( + "/verifications/{slug}", + get(crate::demo::get_verification_detail), + ) .route("/retros", get(crate::demo::list_retros)) - .route("/sessions", get(crate::demo::list_sessions).post(crate::demo::create_session_stub)) + .route( + "/sessions", + get(crate::demo::list_sessions).post(crate::demo::create_session_stub), + ) .route("/sessions/{id}", get(crate::demo::get_session)) - .route("/sessions/{id}/messages", post(crate::demo::send_message_stub)) - .route("/sessions/{id}/events", get(crate::demo::session_events_stub)) - .route("/insights/queries", get(crate::demo::list_saved_queries).post(crate::demo::save_query_stub)) - .route("/insights/queries/{id}", put(crate::demo::update_query_stub).delete(crate::demo::delete_query_stub)) + .route( + "/sessions/{id}/messages", + post(crate::demo::send_message_stub), + ) + .route( + "/sessions/{id}/events", + get(crate::demo::session_events_stub), + ) + .route( + "/insights/queries", + get(crate::demo::list_saved_queries).post(crate::demo::save_query_stub), + ) + .route( + "/insights/queries/{id}", + put(crate::demo::update_query_stub).delete(crate::demo::delete_query_stub), + ) .route("/insights/execute", post(crate::demo::execute_query_stub)) .route("/insights/history", get(crate::demo::list_query_history)) .route("/settings", get(crate::demo::get_settings)) @@ -119,7 +157,10 @@ pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { .route("/runs/{id}/preview", post(not_implemented)) .route("/workflows", get(not_implemented)) .route("/workflows/{name}", get(not_implemented)) - .route("/workflows/{name}/runs", get(not_implemented).post(not_implemented)) + .route( + "/workflows/{name}/runs", + get(not_implemented).post(not_implemented), + ) .route("/verifications", get(not_implemented)) .route("/verifications/{slug}", get(not_implemented)) .route("/retros", get(not_implemented)) @@ -127,8 +168,14 @@ pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { .route("/sessions/{id}", get(not_implemented)) .route("/sessions/{id}/messages", post(not_implemented)) .route("/sessions/{id}/events", get(not_implemented)) - .route("/insights/queries", get(not_implemented).post(not_implemented)) - .route("/insights/queries/{id}", put(not_implemented).delete(not_implemented)) + .route( + "/insights/queries", + get(not_implemented).post(not_implemented), + ) + .route( + "/insights/queries/{id}", + put(not_implemented).delete(not_implemented), + ) .route("/insights/execute", post(not_implemented)) .route("/insights/history", get(not_implemented)) .route("/settings", get(not_implemented)) @@ -136,9 +183,7 @@ pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { .route("/projects/{id}/branches", get(not_implemented)); } - router - .layer(axum::Extension(auth_mode)) - .with_state(state) + router.layer(axum::Extension(auth_mode)).with_state(state) } async fn not_implemented() -> Response { @@ -172,10 +217,7 @@ pub fn create_app_state_with_options( }) } -async fn list_runs( - _auth: AuthenticatedService, - State(state): State>, -) -> Response { +async fn list_runs(_auth: AuthenticatedService, State(state): State>) -> Response { let runs = state.runs.lock().expect("runs lock poisoned"); let items: Vec = runs .iter() @@ -223,8 +265,7 @@ async fn start_run( let registry = (state.registry_factory)(Arc::clone(&interviewer) as Arc); let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let sandbox: Arc = - Arc::new(LocalSandbox::new(cwd)); + let sandbox: Arc = Arc::new(LocalSandbox::new(cwd)); let engine = WorkflowRunEngine::with_interviewer( registry, Arc::new(emitter), @@ -304,10 +345,7 @@ async fn start_run( let _ = retro.save(&config.logs_root); } - let mut runs = state_clone - .runs - .lock() - .expect("runs lock poisoned"); + let mut runs = state_clone.runs.lock().expect("runs lock poisoned"); if let Some(managed_run) = runs.get_mut(&run_id_clone) { match result { Ok(_) => { @@ -330,11 +368,7 @@ async fn start_run( } }); - ( - StatusCode::CREATED, - Json(StartRunResponse { id: run_id }), - ) - .into_response() + (StatusCode::CREATED, Json(StartRunResponse { id: run_id })).into_response() } async fn get_run_status( diff --git a/crates/arc-api/src/server_config.rs b/crates/arc-api/src/server_config.rs index d0e667f25..0e1acda79 100644 --- a/crates/arc-api/src/server_config.rs +++ b/crates/arc-api/src/server_config.rs @@ -191,7 +191,10 @@ client_id = "Iv1.abc123" assert_eq!(config.web.auth.provider, AuthProvider::Github); assert_eq!(config.web.auth.allowed_usernames, vec!["brynary", "alice"]); assert_eq!(config.api.base_url, "http://example.com:8080"); - assert_eq!(config.api.authentication_strategies, vec![ApiAuthStrategy::Jwt]); + assert_eq!( + config.api.authentication_strategies, + vec![ApiAuthStrategy::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")); @@ -330,7 +333,10 @@ authentication_strategies = [] authentication_strategies = ["jwt"] "#; let config: ServerConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.api.authentication_strategies, vec![ApiAuthStrategy::Jwt]); + assert_eq!( + config.api.authentication_strategies, + vec![ApiAuthStrategy::Jwt] + ); assert!(config.api.tls.is_none()); } } diff --git a/crates/arc-api/src/tls.rs b/crates/arc-api/src/tls.rs index 0e16e27ae..1ef849da5 100644 --- a/crates/arc-api/src/tls.rs +++ b/crates/arc-api/src/tls.rs @@ -34,7 +34,9 @@ pub fn build_rustls_config(tls_config: &TlsConfig, client_auth: ClientAuth) -> A let ca_certs = load_certs(&tls_config.ca); let mut root_store = rustls::RootCertStore::empty(); for cert in ca_certs { - root_store.add(cert).expect("failed to add CA certificate to root store"); + root_store + .add(cert) + .expect("failed to add CA certificate to root store"); } let builder = WebPkiClientVerifier::builder(Arc::new(root_store)); @@ -86,9 +88,8 @@ pub async fn serve_tls( // Extract peer certificates once per connection (not per request) let (_, server_conn) = tls_stream.get_ref(); - let peer_certs = PeerCertificates( - server_conn.peer_certificates().map(|certs| certs.to_vec()), - ); + let peer_certs = + PeerCertificates(server_conn.peer_certificates().map(|certs| certs.to_vec())); let io = TokioIo::new(tls_stream); diff --git a/crates/arc-api/tests/integration.rs b/crates/arc-api/tests/integration.rs index dcd616636..785af7d3b 100644 --- a/crates/arc-api/tests/integration.rs +++ b/crates/arc-api/tests/integration.rs @@ -38,25 +38,47 @@ mod mtls_e2e { // CA key let ca_key_path = dir.join("ca.key"); let ca_cert_path = dir.join("ca.crt"); - run_openssl(&["genpkey", "-algorithm", "Ed25519", "-out", ca_key_path.to_str().unwrap()]); run_openssl(&[ - "req", "-new", "-x509", - "-key", ca_key_path.to_str().unwrap(), - "-out", ca_cert_path.to_str().unwrap(), - "-days", "1", - "-subj", &format!("/CN={ca_cn}"), + "genpkey", + "-algorithm", + "Ed25519", + "-out", + ca_key_path.to_str().unwrap(), + ]); + run_openssl(&[ + "req", + "-new", + "-x509", + "-key", + ca_key_path.to_str().unwrap(), + "-out", + ca_cert_path.to_str().unwrap(), + "-days", + "1", + "-subj", + &format!("/CN={ca_cn}"), ]); // Server key + cert signed by CA let server_key_path = dir.join("server.key"); let server_csr_path = dir.join("server.csr"); let server_cert_path = dir.join("server.crt"); - run_openssl(&["genpkey", "-algorithm", "Ed25519", "-out", server_key_path.to_str().unwrap()]); run_openssl(&[ - "req", "-new", - "-key", server_key_path.to_str().unwrap(), - "-out", server_csr_path.to_str().unwrap(), - "-subj", &format!("/CN={server_cn}"), + "genpkey", + "-algorithm", + "Ed25519", + "-out", + server_key_path.to_str().unwrap(), + ]); + run_openssl(&[ + "req", + "-new", + "-key", + server_key_path.to_str().unwrap(), + "-out", + server_csr_path.to_str().unwrap(), + "-subj", + &format!("/CN={server_cn}"), ]); // Create extension file for SAN (reqwest validates server cert hostname) @@ -64,35 +86,58 @@ mod mtls_e2e { std::fs::write(&ext_path, "subjectAltName=IP:127.0.0.1").unwrap(); run_openssl(&[ - "x509", "-req", - "-in", server_csr_path.to_str().unwrap(), - "-CA", ca_cert_path.to_str().unwrap(), - "-CAkey", ca_key_path.to_str().unwrap(), + "x509", + "-req", + "-in", + server_csr_path.to_str().unwrap(), + "-CA", + ca_cert_path.to_str().unwrap(), + "-CAkey", + ca_key_path.to_str().unwrap(), "-CAcreateserial", - "-out", server_cert_path.to_str().unwrap(), - "-days", "1", - "-extfile", ext_path.to_str().unwrap(), + "-out", + server_cert_path.to_str().unwrap(), + "-days", + "1", + "-extfile", + ext_path.to_str().unwrap(), ]); // Client key + cert signed by CA let client_key_path = dir.join("client.key"); let client_csr_path = dir.join("client.csr"); let client_cert_path = dir.join("client.crt"); - run_openssl(&["genpkey", "-algorithm", "Ed25519", "-out", client_key_path.to_str().unwrap()]); run_openssl(&[ - "req", "-new", - "-key", client_key_path.to_str().unwrap(), - "-out", client_csr_path.to_str().unwrap(), - "-subj", &format!("/CN={client_cn}"), + "genpkey", + "-algorithm", + "Ed25519", + "-out", + client_key_path.to_str().unwrap(), ]); run_openssl(&[ - "x509", "-req", - "-in", client_csr_path.to_str().unwrap(), - "-CA", ca_cert_path.to_str().unwrap(), - "-CAkey", ca_key_path.to_str().unwrap(), + "req", + "-new", + "-key", + client_key_path.to_str().unwrap(), + "-out", + client_csr_path.to_str().unwrap(), + "-subj", + &format!("/CN={client_cn}"), + ]); + run_openssl(&[ + "x509", + "-req", + "-in", + client_csr_path.to_str().unwrap(), + "-CA", + ca_cert_path.to_str().unwrap(), + "-CAkey", + ca_key_path.to_str().unwrap(), "-CAcreateserial", - "-out", client_cert_path.to_str().unwrap(), - "-days", "1", + "-out", + client_cert_path.to_str().unwrap(), + "-days", + "1", ]); PkiPaths { @@ -192,11 +237,7 @@ mod mtls_e2e { let auth_mode = AuthMode::Strategies(vec![AuthStrategy::Mtls]); let addr = start_tls_server(&tls_config, ClientAuth::Required, auth_mode).await; - let client = build_client( - &pki.ca_cert, - Some(&pki.client_cert), - Some(&pki.client_key), - ); + let client = build_client(&pki.ca_cert, Some(&pki.client_cert), Some(&pki.client_key)); let response = client .get(format!("https://127.0.0.1:{}/runs", addr.port())) @@ -465,9 +506,7 @@ mod server_lifecycle { // 3. Submit answer selecting first option (Approve) let req = Request::builder() .method("POST") - .uri(format!( - "/runs/{run_id}/questions/{question_id}/answer" - )) + .uri(format!("/runs/{run_id}/questions/{question_id}/answer")) .header("content-type", "application/json") .body(Body::from( serde_json::to_string(&serde_json::json!({"value": "A"})).unwrap(), diff --git a/crates/arc-cli/src/doctor.rs b/crates/arc-cli/src/doctor.rs index 54e5c2ad9..83a877065 100644 --- a/crates/arc-cli/src/doctor.rs +++ b/crates/arc-cli/src/doctor.rs @@ -40,9 +40,7 @@ pub struct DoctorReport { impl DoctorReport { pub fn has_errors(&self) -> bool { - self.checks - .iter() - .any(|c| c.status == CheckStatus::Error) + self.checks.iter().any(|c| c.status == CheckStatus::Error) } pub fn issue_count(&self) -> usize { @@ -90,7 +88,11 @@ impl DoctorReport { writeln!( out, "Doctor found issues in {issues} {}.", - if issues == 1 { "category" } else { "categories" } + if issues == 1 { + "category" + } else { + "categories" + } ) .unwrap(); @@ -159,8 +161,7 @@ pub enum ProbeOutcome { static OPENSSL_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?:OpenSSL|LibreSSL)\s+(\d+)\.(\d+)\.(\d+)").unwrap()); -static NODE_RE: LazyLock = - LazyLock::new(|| Regex::new(r"v(\d+)\.(\d+)\.(\d+)").unwrap()); +static NODE_RE: LazyLock = LazyLock::new(|| Regex::new(r"v(\d+)\.(\d+)\.(\d+)").unwrap()); static GH_RE: LazyLock = LazyLock::new(|| Regex::new(r"gh version (\d+)\.(\d+)\.(\d+)").unwrap()); static DOT_RE: LazyLock = @@ -176,10 +177,34 @@ fn parse_version(re: &Regex, output: &str) -> Option { } pub const DEP_SPECS: &[DepSpec] = &[ - DepSpec { name: "openssl", command: &["openssl", "version"], required: true, min_version: Version::new(3, 0, 0), pattern: &OPENSSL_RE }, - DepSpec { name: "node", command: &["node", "--version"], required: true, min_version: Version::new(20, 0, 0), pattern: &NODE_RE }, - DepSpec { name: "gh", command: &["gh", "--version"], required: false, min_version: Version::new(2, 0, 0), pattern: &GH_RE }, - DepSpec { name: "dot", command: &["dot", "-V"], required: false, min_version: Version::new(2, 0, 0), pattern: &DOT_RE }, + DepSpec { + name: "openssl", + command: &["openssl", "version"], + required: true, + min_version: Version::new(3, 0, 0), + pattern: &OPENSSL_RE, + }, + DepSpec { + name: "node", + command: &["node", "--version"], + required: true, + min_version: Version::new(20, 0, 0), + pattern: &NODE_RE, + }, + DepSpec { + name: "gh", + command: &["gh", "--version"], + required: false, + min_version: Version::new(2, 0, 0), + pattern: &GH_RE, + }, + DepSpec { + name: "dot", + command: &["dot", "-V"], + required: false, + min_version: Version::new(2, 0, 0), + pattern: &DOT_RE, + }, ]; pub fn probe_system_deps() -> Vec { @@ -208,7 +233,11 @@ pub fn probe_system_deps() -> Vec { fn dep_issue(name: &str, issue: &str, required: bool) -> (CheckStatus, String) { let severity = if required { "required" } else { "optional" }; - let status = if required { CheckStatus::Error } else { CheckStatus::Warning }; + let status = if required { + CheckStatus::Error + } else { + CheckStatus::Warning + }; (status, format!("{name}: {issue} ({severity})")) } @@ -607,16 +636,16 @@ fn format_auth_strategies(strategies: &[ApiAuthStrategy]) -> String { .join(", ") } -pub fn check_api( - status: &ApiStatus, - live_result: Option<&Result<(), String>>, -) -> CheckResult { +pub fn check_api(status: &ApiStatus, live_result: Option<&Result<(), String>>) -> CheckResult { let mut details = vec![ CheckDetail { text: format!("Base URL: {}", status.base_url), }, CheckDetail { - text: format!("Authentication: {}", format_auth_strategies(&status.authentication_strategies)), + text: format!( + "Authentication: {}", + format_auth_strategies(&status.authentication_strategies) + ), }, ]; @@ -648,16 +677,16 @@ fn format_auth_provider(provider: &AuthProvider) -> &'static str { } } -pub fn check_web( - status: &WebStatus, - live_result: Option<&Result<(), String>>, -) -> CheckResult { +pub fn check_web(status: &WebStatus, live_result: Option<&Result<(), String>>) -> CheckResult { let mut details = vec![ CheckDetail { text: format!("URL: {}", status.url), }, CheckDetail { - text: format!("Auth provider: {}", format_auth_provider(&status.auth_provider)), + text: format!( + "Auth provider: {}", + format_auth_provider(&status.auth_provider) + ), }, CheckDetail { text: format!("Allowed usernames: {}", status.allowed_usernames_count), @@ -946,7 +975,11 @@ async fn probe_llm_provider( metadata: None, provider_options: None, }; - let result = client.complete(&request).await.map(|_| ()).map_err(|e| e.to_string()); + let result = client + .complete(&request) + .await + .map(|_| ()) + .map_err(|e| e.to_string()); (provider, result) } @@ -979,9 +1012,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 { // Gather state let config_path = dirs::home_dir().map(|h| h.join(".arc").join("server.toml")); - let config_exists = config_path - .as_ref() - .is_some_and(|p| p.exists()); + let config_exists = config_path.as_ref().is_some_and(|p| p.exists()); let llm_statuses: Vec<(Provider, bool)> = Provider::ALL .iter() @@ -990,8 +1021,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 { let brave_key_set = std::env::var("BRAVE_SEARCH_API_KEY").is_ok(); - let server_config = arc_api::server_config::load_server_config() - .unwrap_or_default(); + let server_config = arc_api::server_config::load_server_config().unwrap_or_default(); let api_status = ApiStatus { base_url: server_config.api.base_url.clone(), @@ -1127,7 +1157,11 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 { print!("{}", report.render(&styles, verbose, live)); - if report.has_errors() { 1 } else { 0 } + if report.has_errors() { + 1 + } else { + 0 + } } // --------------------------------------------------------------------------- @@ -1314,8 +1348,7 @@ mod tests { #[test] fn check_llm_all_configured() { - let statuses: Vec<(Provider, bool)> = - Provider::ALL.iter().map(|p| (*p, true)).collect(); + let statuses: Vec<(Provider, bool)> = Provider::ALL.iter().map(|p| (*p, true)).collect(); let result = check_llm_providers(&statuses, None); assert_eq!(result.status, CheckStatus::Pass); assert!(result.summary.contains("7 of 7")); @@ -1337,8 +1370,7 @@ mod tests { #[test] fn check_llm_none_configured() { - let statuses: Vec<(Provider, bool)> = - Provider::ALL.iter().map(|p| (*p, false)).collect(); + let statuses: Vec<(Provider, bool)> = Provider::ALL.iter().map(|p| (*p, false)).collect(); let result = check_llm_providers(&statuses, None); assert_eq!(result.status, CheckStatus::Error); assert!(result.summary.contains("0 of 7")); @@ -1350,7 +1382,10 @@ mod tests { let live = vec![(Provider::Anthropic, Ok(()))]; let result = check_llm_providers(&statuses, Some(&live)); assert_eq!(result.status, CheckStatus::Pass); - assert!(result.details.iter().any(|d| d.text.contains("connectivity: OK"))); + assert!(result + .details + .iter() + .any(|d| d.text.contains("connectivity: OK"))); } #[test] @@ -1548,7 +1583,10 @@ mod tests { let live = Ok(()); let result = check_api(&status, Some(&live)); assert_eq!(result.status, CheckStatus::Pass); - assert!(result.details.iter().any(|d| d.text.contains("Connectivity: OK"))); + assert!(result + .details + .iter() + .any(|d| d.text.contains("Connectivity: OK"))); } #[test] @@ -1560,7 +1598,10 @@ mod tests { let live = Err("connection refused".to_string()); let result = check_api(&status, Some(&live)); assert_eq!(result.status, CheckStatus::Warning); - assert!(result.details.iter().any(|d| d.text.contains("connection refused"))); + assert!(result + .details + .iter() + .any(|d| d.text.contains("connection refused"))); } // -- check_web -- @@ -1606,7 +1647,10 @@ mod tests { let live = Ok(()); let result = check_web(&status, Some(&live)); assert_eq!(result.status, CheckStatus::Pass); - assert!(result.details.iter().any(|d| d.text.contains("Connectivity: OK"))); + assert!(result + .details + .iter() + .any(|d| d.text.contains("Connectivity: OK"))); } #[test] @@ -1619,7 +1663,10 @@ mod tests { let live = Err("connection refused".to_string()); let result = check_web(&status, Some(&live)); assert_eq!(result.status, CheckStatus::Warning); - assert!(result.details.iter().any(|d| d.text.contains("connection refused"))); + assert!(result + .details + .iter() + .any(|d| d.text.contains("connection refused"))); } // -- render: multiple issues -- @@ -1638,25 +1685,37 @@ mod tests { #[test] fn parse_version_openssl() { assert_eq!( - parse_version(&OPENSSL_RE, "OpenSSL 3.4.1 11 Feb 2025 (Library: OpenSSL 3.4.1 11 Feb 2025)"), + parse_version( + &OPENSSL_RE, + "OpenSSL 3.4.1 11 Feb 2025 (Library: OpenSSL 3.4.1 11 Feb 2025)" + ), Some(Version::new(3, 4, 1)), ); } #[test] fn parse_version_libressl() { - assert_eq!(parse_version(&OPENSSL_RE, "LibreSSL 3.3.6"), Some(Version::new(3, 3, 6))); + assert_eq!( + parse_version(&OPENSSL_RE, "LibreSSL 3.3.6"), + Some(Version::new(3, 3, 6)) + ); } #[test] fn parse_version_node() { - assert_eq!(parse_version(&NODE_RE, "v22.14.0"), Some(Version::new(22, 14, 0))); + assert_eq!( + parse_version(&NODE_RE, "v22.14.0"), + Some(Version::new(22, 14, 0)) + ); } #[test] fn parse_version_gh() { assert_eq!( - parse_version(&GH_RE, "gh version 2.67.0 (2025-01-31)\nhttps://github.com/cli/cli/releases/tag/v2.67.0"), + parse_version( + &GH_RE, + "gh version 2.67.0 (2025-01-31)\nhttps://github.com/cli/cli/releases/tag/v2.67.0" + ), Some(Version::new(2, 67, 0)), ); } @@ -1700,10 +1759,18 @@ mod tests { spec("dot", false, Version::new(2, 0, 0)), ]; let outcomes = [ - ProbeOutcome::Ok { version: Some(Version::new(3, 4, 1)) }, - ProbeOutcome::Ok { version: Some(Version::new(22, 14, 0)) }, - ProbeOutcome::Ok { version: Some(Version::new(2, 67, 0)) }, - ProbeOutcome::Ok { version: Some(Version::new(12, 2, 1)) }, + ProbeOutcome::Ok { + version: Some(Version::new(3, 4, 1)), + }, + ProbeOutcome::Ok { + version: Some(Version::new(22, 14, 0)), + }, + ProbeOutcome::Ok { + version: Some(Version::new(2, 67, 0)), + }, + ProbeOutcome::Ok { + version: Some(Version::new(12, 2, 1)), + }, ]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Pass); @@ -1731,7 +1798,9 @@ mod tests { #[test] fn check_system_deps_outdated_is_warning() { let specs = [spec("openssl", true, Version::new(3, 0, 0))]; - let outcomes = [ProbeOutcome::Ok { version: Some(Version::new(1, 1, 1)) }]; + let outcomes = [ProbeOutcome::Ok { + version: Some(Version::new(1, 1, 1)), + }]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Warning); assert!(result.details[0].text.contains("1.1.1")); @@ -1782,20 +1851,31 @@ mod tests { fn generate_test_tls_cert() -> (String, String) { let output = std::process::Command::new("openssl") .args([ - "req", "-x509", "-newkey", "ec", - "-pkeyopt", "ec_paramgen_curve:prime256v1", - "-keyout", "/dev/stdout", "-out", "/dev/stdout", - "-days", "3650", "-nodes", "-subj", "/CN=test-server", + "req", + "-x509", + "-newkey", + "ec", + "-pkeyopt", + "ec_paramgen_curve:prime256v1", + "-keyout", + "/dev/stdout", + "-out", + "/dev/stdout", + "-days", + "3650", + "-nodes", + "-subj", + "/CN=test-server", ]) .output() .expect("openssl must be available for tests"); let combined = String::from_utf8(output.stdout).unwrap(); let key_start = combined.find("-----BEGIN PRIVATE KEY-----").unwrap(); - let key_end = combined.find("-----END PRIVATE KEY-----").unwrap() - + "-----END PRIVATE KEY-----".len(); + let key_end = + combined.find("-----END PRIVATE KEY-----").unwrap() + "-----END PRIVATE KEY-----".len(); let cert_start = combined.find("-----BEGIN CERTIFICATE-----").unwrap(); - let cert_end = combined.find("-----END CERTIFICATE-----").unwrap() - + "-----END CERTIFICATE-----".len(); + let cert_end = + combined.find("-----END CERTIFICATE-----").unwrap() + "-----END CERTIFICATE-----".len(); let key_pem = combined[key_start..key_end].to_string(); let cert_pem = combined[cert_start..cert_end].to_string(); (cert_pem, key_pem) @@ -1814,7 +1894,12 @@ mod tests { .spawn() .and_then(|mut child| { use std::io::Write; - child.stdin.take().unwrap().write_all(private_pem.as_bytes()).unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(private_pem.as_bytes()) + .unwrap(); child.wait_with_output() }) .expect("openssl pkey failed"); @@ -1913,14 +1998,20 @@ mod tests { fn crypto_jwt_configured_but_key_missing() { let result = check_crypto(&crypto_input(vec![ApiAuthStrategy::Jwt])); assert_eq!(result.status, CheckStatus::Error); - assert!(result.details.iter().any(|d| d.text.contains("ARC_JWT_PUBLIC_KEY not set"))); + assert!(result + .details + .iter() + .any(|d| d.text.contains("ARC_JWT_PUBLIC_KEY not set"))); } #[test] fn crypto_mtls_configured_but_tls_not_set() { let result = check_crypto(&crypto_input(vec![ApiAuthStrategy::Mtls])); assert_eq!(result.status, CheckStatus::Error); - assert!(result.details.iter().any(|d| d.text.contains("[api.tls] not set"))); + assert!(result + .details + .iter() + .any(|d| d.text.contains("[api.tls] not set"))); } #[test] @@ -1931,29 +2022,42 @@ mod tests { }; let result = check_crypto(&input); assert_eq!(result.status, CheckStatus::Error); - assert!(result.details.iter().any(|d| d.text.contains("Permission denied"))); + assert!(result + .details + .iter() + .any(|d| d.text.contains("Permission denied"))); } #[test] fn crypto_invalid_jwt_public_key() { let input = CryptoInput { - jwt_public_key: Some("-----BEGIN PUBLIC KEY-----\nINVALID\n-----END PUBLIC KEY-----".to_string()), + jwt_public_key: Some( + "-----BEGIN PUBLIC KEY-----\nINVALID\n-----END PUBLIC KEY-----".to_string(), + ), ..crypto_input(vec![ApiAuthStrategy::Jwt]) }; let result = check_crypto(&input); assert_eq!(result.status, CheckStatus::Error); - assert!(result.details.iter().any(|d| d.text.contains("JWT public key: invalid"))); + assert!(result + .details + .iter() + .any(|d| d.text.contains("JWT public key: invalid"))); } #[test] fn crypto_invalid_jwt_private_key() { let input = CryptoInput { - jwt_private_key: Some("-----BEGIN PRIVATE KEY-----\nINVALID\n-----END PRIVATE KEY-----".to_string()), + jwt_private_key: Some( + "-----BEGIN PRIVATE KEY-----\nINVALID\n-----END PRIVATE KEY-----".to_string(), + ), ..crypto_input(vec![]) }; let result = check_crypto(&input); assert_eq!(result.status, CheckStatus::Error); - assert!(result.details.iter().any(|d| d.text.contains("JWT private key: invalid"))); + assert!(result + .details + .iter() + .any(|d| d.text.contains("JWT private key: invalid"))); } #[test] @@ -1969,7 +2073,10 @@ mod tests { }; let result = check_crypto(&input); assert_eq!(result.status, CheckStatus::Pass); - assert!(result.details.iter().any(|d| d.text.contains("JWT public key: valid"))); + assert!(result + .details + .iter() + .any(|d| d.text.contains("JWT public key: valid"))); } #[test] diff --git a/crates/arc-cli/src/setup.rs b/crates/arc-cli/src/setup.rs index 86a2eff83..4ef392e8b 100644 --- a/crates/arc-cli/src/setup.rs +++ b/crates/arc-cli/src/setup.rs @@ -71,7 +71,8 @@ fn generate_session_secret() -> String { fn generate_jwt_keypair() -> Result<(String, String)> { let private_pem = run_openssl(&["genpkey", "-algorithm", "Ed25519"], "generate keypair")?; - let public_pem = run_openssl_with_stdin(&["pkey", "-pubout"], &private_pem, "extract public key")?; + let public_pem = + run_openssl_with_stdin(&["pkey", "-pubout"], &private_pem, "extract public key")?; let private_str = String::from_utf8(private_pem).context("private key is not valid UTF-8")?; let public_str = String::from_utf8(public_pem).context("public key is not valid UTF-8")?; @@ -91,7 +92,19 @@ fn generate_mtls_certs(dir: &Path) -> Result<()> { std::fs::write(&ca_key_path, &ca_key)?; let ca_cert = run_openssl( - &["req", "-new", "-x509", "-key", ca_key_path.to_str().context("CA key path is not valid UTF-8")?, "-days", "3650", "-subj", "/CN=Arc CA"], + &[ + "req", + "-new", + "-x509", + "-key", + ca_key_path + .to_str() + .context("CA key path is not valid UTF-8")?, + "-days", + "3650", + "-subj", + "/CN=Arc CA", + ], "generate CA cert", )?; let ca_cert_path = dir.join("ca.crt"); @@ -103,7 +116,14 @@ fn generate_mtls_certs(dir: &Path) -> Result<()> { std::fs::write(&server_key_path, &server_key)?; let csr = run_openssl_with_stdin( - &["req", "-new", "-key", "/dev/stdin", "-subj", "/CN=localhost"], + &[ + "req", + "-new", + "-key", + "/dev/stdin", + "-subj", + "/CN=localhost", + ], &server_key, "generate server CSR", )?; @@ -113,11 +133,21 @@ fn generate_mtls_certs(dir: &Path) -> Result<()> { let server_cert = run_openssl( &[ - "x509", "-req", - "-in", csr_path.to_str().context("CSR path is not valid UTF-8")?, - "-CA", ca_cert_path.to_str().context("CA cert path is not valid UTF-8")?, - "-CAkey", ca_key_path.to_str().context("CA key path is not valid UTF-8")?, - "-CAcreateserial", "-days", "3650", + "x509", + "-req", + "-in", + csr_path.to_str().context("CSR path is not valid UTF-8")?, + "-CA", + ca_cert_path + .to_str() + .context("CA cert path is not valid UTF-8")?, + "-CAkey", + ca_key_path + .to_str() + .context("CA key path is not valid UTF-8")?, + "-CAcreateserial", + "-days", + "3650", ], "sign server cert", )?; @@ -201,7 +231,9 @@ fn provider_key_url(provider: Provider) -> &'static str { Provider::Gemini => "https://aistudio.google.com/apikey", Provider::Kimi => "https://platform.moonshot.cn/console/api-keys", Provider::Zai => "https://open.bigmodel.cn/usercenter/apikeys", - Provider::Minimax => "https://platform.minimaxi.com/user-center/basic-information/interface-key", + Provider::Minimax => { + "https://platform.minimaxi.com/user-center/basic-information/interface-key" + } Provider::Inception => "https://console.inceptionlabs.ai/api-keys", } } @@ -223,16 +255,20 @@ fn provider_display_name(provider: Provider) -> &'static str { // --------------------------------------------------------------------------- fn prompt_confirm(prompt: &str, default: bool) -> Result { - Ok(Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) - .with_prompt(prompt) - .default(default) - .interact_on(&dialoguer::console::Term::stderr())?) + Ok( + Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt(prompt) + .default(default) + .interact_on(&dialoguer::console::Term::stderr())?, + ) } fn prompt_input(prompt: &str) -> Result { - Ok(Input::with_theme(&dialoguer::theme::ColorfulTheme::default()) - .with_prompt(prompt) - .interact_on(&dialoguer::console::Term::stderr())?) + Ok( + Input::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt(prompt) + .interact_on(&dialoguer::console::Term::stderr())?, + ) } fn prompt_multiselect(prompt: &str, items: &[String]) -> Result> { @@ -306,10 +342,9 @@ pub async fn run_setup() -> Result<()> { }; if write_config { - let username: String = tokio::task::spawn_blocking(|| { - prompt_input("GitHub username for allowed access") - }) - .await??; + let username: String = + tokio::task::spawn_blocking(|| prompt_input("GitHub username for allowed access")) + .await??; let toml_content = format_config_toml(&username); std::fs::write(&config_path, &toml_content)?; @@ -357,8 +392,7 @@ pub async fn run_setup() -> Result<()> { eprintln!(" Get your API key at: {url}"); let prompt = env_var.to_string(); - let key: String = - tokio::task::spawn_blocking(move || prompt_input(&prompt)).await??; + let key: String = tokio::task::spawn_blocking(move || prompt_input(&prompt)).await??; env_pairs.push((env_var.to_string(), key)); } @@ -413,8 +447,7 @@ pub async fn run_setup() -> Result<()> { // Step 6: Verify setup eprintln!("[Step 6/7] Verify setup"); let run_doctor = - tokio::task::spawn_blocking(|| prompt_confirm("Run arc doctor to verify?", true)) - .await??; + tokio::task::spawn_blocking(|| prompt_confirm("Run arc doctor to verify?", true)).await??; if run_doctor { eprintln!(); @@ -489,8 +522,7 @@ mod tests { #[test] fn jwt_keypair_public_parses() { let (_, public) = generate_jwt_keypair().unwrap(); - jsonwebtoken::DecodingKey::from_ed_pem(public.as_bytes()) - .expect("public key should parse"); + jsonwebtoken::DecodingKey::from_ed_pem(public.as_bytes()).expect("public key should parse"); } // -- mTLS cert generation -- @@ -616,10 +648,7 @@ mod tests { #[test] fn merge_env_full_scenario() { - let result = merge_env( - "FOO=old\nBAR=keep", - &[("FOO", "new"), ("BAZ", "added")], - ); + let result = merge_env("FOO=old\nBAR=keep", &[("FOO", "new"), ("BAZ", "added")]); assert_eq!(result, "FOO=new\nBAR=keep\nBAZ=added\n"); } diff --git a/crates/arc-cli/tests/cli.rs b/crates/arc-cli/tests/cli.rs index f927ae61f..22f8fc052 100644 --- a/crates/arc-cli/tests/cli.rs +++ b/crates/arc-cli/tests/cli.rs @@ -528,7 +528,6 @@ fn doctor_no_color_when_no_color_set() { .stdout(predicate::str::contains("\x1b[").not()); } - #[test] fn doctor_live_flag_accepted() { arc() @@ -570,10 +569,7 @@ fn dry_run_writes_jsonl_and_live_json() { // Every line must be valid JSON with ts, run_id, and event keys let first_line: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); - assert!( - first_line.get("ts").is_some(), - "line should have ts" - ); + assert!(first_line.get("ts").is_some(), "line should have ts"); assert!( first_line.get("run_id").is_some(), "line should have run_id" @@ -585,10 +581,7 @@ fn dry_run_writes_jsonl_and_live_json() { let parsed: serde_json::Value = serde_json::from_str(line).unwrap(); parsed["event"].as_str() == Some("WorkflowRunStarted") }); - assert!( - has_run_started, - "events should contain WorkflowRunStarted" - ); + assert!(has_run_started, "events should contain WorkflowRunStarted"); // run_id should be non-empty after WorkflowRunStarted let last_line: serde_json::Value = serde_json::from_str(lines[lines.len() - 1]).unwrap(); diff --git a/crates/arc-db/src/migrate.rs b/crates/arc-db/src/migrate.rs index 2454561ef..ee392edf5 100644 --- a/crates/arc-db/src/migrate.rs +++ b/crates/arc-db/src/migrate.rs @@ -15,7 +15,11 @@ pub async fn initialize_db(pool: &SqlitePool) -> Result<(), sqlx::Error> { let from_version = row.0; if from_version < CURRENT_VERSION { - info!(from_version = from_version, to_version = CURRENT_VERSION, "Running database migrations"); + info!( + from_version = from_version, + to_version = CURRENT_VERSION, + "Running database migrations" + ); let mut tx = pool.begin().await?; if from_version < 1 { @@ -29,7 +33,10 @@ pub async fn initialize_db(pool: &SqlitePool) -> Result<(), sqlx::Error> { tx.commit().await?; info!(version = CURRENT_VERSION, "Database migrations complete"); } else { - debug!(version = from_version, "Database already at current version"); + debug!( + version = from_version, + "Database already at current version" + ); } Ok(()) diff --git a/crates/arc-devcontainer/src/dockerfile.rs b/crates/arc-devcontainer/src/dockerfile.rs index b4f1ab7b6..1785ee1a6 100644 --- a/crates/arc-devcontainer/src/dockerfile.rs +++ b/crates/arc-devcontainer/src/dockerfile.rs @@ -145,10 +145,7 @@ mod tests { fn empty_env_map_treated_as_none() { let env = HashMap::new(); let result = generate("FROM alpine", &[], &env, None); - assert_eq!( - result, - "# Generated by arc-devcontainer\n\nFROM alpine\n" - ); + assert_eq!(result, "# Generated by arc-devcontainer\n\nFROM alpine\n"); } #[test] diff --git a/crates/arc-devcontainer/src/features.rs b/crates/arc-devcontainer/src/features.rs index ce0300b7f..073e0c69e 100644 --- a/crates/arc-devcontainer/src/features.rs +++ b/crates/arc-devcontainer/src/features.rs @@ -37,11 +37,7 @@ fn dir_name_from_id(feature_id: &str) -> String { let stripped = feature_id .trim_start_matches("../") .trim_start_matches("./"); - return stripped - .rsplit('/') - .next() - .unwrap_or(stripped) - .to_string(); + return stripped.rsplit('/').next().unwrap_or(stripped).to_string(); } // HTTPS URL: take filename, strip .tgz extension @@ -118,9 +114,7 @@ async fn ensure_oras() -> crate::Result<()> { ]) .status() .await - .map_err(|e| { - DevcontainerError::OrasInstall(format!("failed to download oras: {e}")) - })?; + .map_err(|e| DevcontainerError::OrasInstall(format!("failed to download oras: {e}")))?; if !status.success() { return Err(DevcontainerError::OrasInstall( @@ -155,40 +149,32 @@ async fn read_feature_metadata(feature_dir: &Path) -> crate::Result crate::Result { +async fn create_feature_dir( + output_dir: &Path, + feature_id: &str, +) -> crate::Result { let dir_name = dir_name_from_id(feature_id); let feature_dir = output_dir.join(&dir_name); - tokio::fs::create_dir_all(&feature_dir) - .await - .map_err(|e| { - DevcontainerError::Feature(format!( - "failed to create dir {}: {e}", - feature_dir.display() - )) - })?; + tokio::fs::create_dir_all(&feature_dir).await.map_err(|e| { + DevcontainerError::Feature(format!( + "failed to create dir {}: {e}", + feature_dir.display() + )) + })?; Ok(feature_dir) } /// Fetch a single OCI feature using `oras pull` and extract its contents. -async fn fetch_feature_oci( - feature_id: &str, - output_dir: &Path, -) -> crate::Result { +async fn fetch_feature_oci(feature_id: &str, output_dir: &Path) -> crate::Result { let feature_dir = create_feature_dir(output_dir, feature_id).await?; info!(feature_id, "pulling feature with oras"); @@ -243,9 +229,9 @@ async fn fetch_feature_https( info!(feature_id, "downloading feature from HTTPS"); - let response = reqwest::get(feature_id).await.map_err(|e| { - DevcontainerError::Feature(format!("failed to download {feature_id}: {e}")) - })?; + let response = reqwest::get(feature_id) + .await + .map_err(|e| DevcontainerError::Feature(format!("failed to download {feature_id}: {e}")))?; if !response.status().is_success() { return Err(DevcontainerError::Feature(format!( @@ -260,10 +246,7 @@ async fn fetch_feature_https( let tgz_path = feature_dir.join("devcontainer-feature.tgz"); tokio::fs::write(&tgz_path, &bytes).await.map_err(|e| { - DevcontainerError::Feature(format!( - "failed to write {}: {e}", - tgz_path.display() - )) + DevcontainerError::Feature(format!("failed to write {}: {e}", tgz_path.display())) })?; extract_tgz(&feature_dir, feature_id).await?; @@ -294,35 +277,33 @@ async fn fetch_feature_dispatch( /// Recursively copy a directory. async fn copy_dir_recursive(src: &Path, dst: &Path) -> crate::Result<()> { tokio::fs::create_dir_all(dst).await.map_err(|e| { - DevcontainerError::Feature(format!( - "failed to create dir {}: {e}", - dst.display() - )) + DevcontainerError::Feature(format!("failed to create dir {}: {e}", dst.display())) })?; let mut entries = tokio::fs::read_dir(src).await.map_err(|e| { - DevcontainerError::Feature(format!( - "failed to read dir {}: {e}", - src.display() - )) + DevcontainerError::Feature(format!("failed to read dir {}: {e}", src.display())) })?; - while let Some(entry) = entries.next_entry().await.map_err(|e| { - DevcontainerError::Feature(format!("failed to read dir entry: {e}")) - })? { + while let Some(entry) = entries + .next_entry() + .await + .map_err(|e| DevcontainerError::Feature(format!("failed to read dir entry: {e}")))? + { let entry_path = entry.path(); let dest_path = dst.join(entry.file_name()); if entry_path.is_dir() { Box::pin(copy_dir_recursive(&entry_path, &dest_path)).await?; } else { - tokio::fs::copy(&entry_path, &dest_path).await.map_err(|e| { - DevcontainerError::Feature(format!( - "failed to copy {} to {}: {e}", - entry_path.display(), - dest_path.display() - )) - })?; + tokio::fs::copy(&entry_path, &dest_path) + .await + .map_err(|e| { + DevcontainerError::Feature(format!( + "failed to copy {} to {}: {e}", + entry_path.display(), + dest_path.display() + )) + })?; } } @@ -371,7 +352,10 @@ fn topo_sort( // candidate -> id (candidate must come before id) let edge = (candidate.as_str(), id.as_str()); if edge_set.insert(edge) { - edges.entry(candidate.as_str()).or_default().push(id.as_str()); + edges + .entry(candidate.as_str()) + .or_default() + .push(id.as_str()); *in_degree.entry(id.as_str()).or_insert(0) += 1; } } @@ -421,7 +405,13 @@ fn topo_sort( fn option_id_to_env_name(id: &str) -> String { let replaced: String = id .chars() - .map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' }) + .map(|c| { + if c.is_alphanumeric() || c == '_' { + c + } else { + '_' + } + }) .collect(); let trimmed = replaced.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit()); if trimmed.is_empty() { @@ -544,9 +534,9 @@ pub async fn resolve_features( .as_nanos() ); let tmp_dir = std::env::temp_dir().join(unique_id); - tokio::fs::create_dir_all(&tmp_dir).await.map_err(|e| { - DevcontainerError::Feature(format!("failed to create temp dir: {e}")) - })?; + tokio::fs::create_dir_all(&tmp_dir) + .await + .map_err(|e| DevcontainerError::Feature(format!("failed to create temp dir: {e}")))?; // Collect feature IDs in a stable order let mut feature_ids: Vec = features.keys().cloned().collect(); @@ -558,7 +548,9 @@ pub async fn resolve_features( let mut oras_checked = false; let mut metadata_map: HashMap = HashMap::new(); for feature_id in &feature_ids { - let metadata = fetch_feature_dispatch(feature_id, &tmp_dir, devcontainer_dir, &mut oras_checked).await?; + let metadata = + fetch_feature_dispatch(feature_id, &tmp_dir, devcontainer_dir, &mut oras_checked) + .await?; metadata_map.insert(feature_id.clone(), metadata); } @@ -577,7 +569,13 @@ pub async fn resolve_features( }); if !already_present { info!(dep_id, "auto-injecting missing dependsOn target"); - let dep_metadata = fetch_feature_dispatch(dep_id, &tmp_dir, devcontainer_dir, &mut oras_checked).await?; + let dep_metadata = fetch_feature_dispatch( + dep_id, + &tmp_dir, + devcontainer_dir, + &mut oras_checked, + ) + .await?; metadata_map.insert(dep_id.clone(), dep_metadata); feature_ids.push(dep_id.clone()); all_options.insert(dep_id.clone(), dep_options.clone()); @@ -595,9 +593,10 @@ pub async fn resolve_features( let mut resolved = ResolvedFeatures::default(); for id in &sorted_ids { let dir_name = dir_name_from_id(id); - let options = all_options.get(id).cloned().unwrap_or(serde_json::Value::Object( - serde_json::Map::new(), - )); + let options = all_options + .get(id) + .cloned() + .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); let metadata = metadata_map .get(id) .cloned() @@ -684,10 +683,7 @@ mod tests { dir_name_from_id("https://example.com/features/python.tar.gz"), "python" ); - assert_eq!( - dir_name_from_id("https://example.com/features/go"), - "go" - ); + assert_eq!(dir_name_from_id("https://example.com/features/go"), "go"); } #[test] @@ -739,11 +735,11 @@ mod tests { version: None, options: HashMap::new(), installs_after: Vec::new(), - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, - post_create_command: None, - post_start_command: None, + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, + post_create_command: None, + post_start_command: None, }, ) }) @@ -1074,10 +1070,9 @@ mod tests { #[ignore = "requires oras"] async fn fetch_feature_oci_integration() { let tmp = tempfile::tempdir().unwrap(); - let metadata = - fetch_feature_oci("ghcr.io/devcontainers/features/node:1", tmp.path()) - .await - .unwrap(); + let metadata = fetch_feature_oci("ghcr.io/devcontainers/features/node:1", tmp.path()) + .await + .unwrap(); assert!(metadata.id.is_some()); assert!(tmp.path().join("node/install.sh").exists()); } @@ -1094,7 +1089,9 @@ mod tests { let resolved = resolve_features(&features, tmp.path(), None).await.unwrap(); assert_eq!(resolved.layers.len(), 1); assert_eq!(resolved.layers[0].dir_name, "node"); - assert!(resolved.layers[0].dockerfile_snippet.contains("export VERSION=\"20\"")); + assert!(resolved.layers[0] + .dockerfile_snippet + .contains("export VERSION=\"20\"")); } #[test] @@ -1143,8 +1140,14 @@ mod tests { } } - assert_eq!(resolved.container_env.get("FOO").map(String::as_str), Some("from_b")); - assert_eq!(resolved.container_env.get("BAR").map(String::as_str), Some("from_a")); + assert_eq!( + resolved.container_env.get("FOO").map(String::as_str), + Some("from_b") + ); + assert_eq!( + resolved.container_env.get("BAR").map(String::as_str), + Some("from_a") + ); } #[test] @@ -1162,9 +1165,13 @@ mod tests { resolved.post_start_commands.push(cmds[0].clone()); assert_eq!(resolved.on_create_commands.len(), 1); - assert!(matches!(&resolved.on_create_commands[0], LifecycleCommand::String(s) if s == "setup-a")); + assert!( + matches!(&resolved.on_create_commands[0], LifecycleCommand::String(s) if s == "setup-a") + ); assert_eq!(resolved.post_create_commands.len(), 1); - assert!(matches!(&resolved.post_create_commands[0], LifecycleCommand::Array(arr) if arr.len() == 2)); + assert!( + matches!(&resolved.post_create_commands[0], LifecycleCommand::Array(arr) if arr.len() == 2) + ); assert_eq!(resolved.post_start_commands.len(), 1); } diff --git a/crates/arc-devcontainer/src/lib.rs b/crates/arc-devcontainer/src/lib.rs index 47dc70927..201e46a7e 100644 --- a/crates/arc-devcontainer/src/lib.rs +++ b/crates/arc-devcontainer/src/lib.rs @@ -138,10 +138,8 @@ impl DevcontainerResolver { })? .clone(); - let compose_config = - compose::parse_compose_multi(&compose_paths, &service_name).map_err(|e| { - DevcontainerError::Compose(e) - })?; + let compose_config = compose::parse_compose_multi(&compose_paths, &service_name) + .map_err(|e| DevcontainerError::Compose(e))?; let mut environment = HashMap::new(); for (k, v) in compose_config.environment { @@ -163,14 +161,15 @@ impl DevcontainerResolver { let df_path = compose_base_dir .join(&build.context) .join(build.dockerfile.as_deref().unwrap_or("Dockerfile")); - std::fs::read_to_string(&df_path).map_err(|source| { - DevcontainerError::ReadFile { - path: df_path, - source, - } + std::fs::read_to_string(&df_path).map_err(|source| DevcontainerError::ReadFile { + path: df_path, + source, })? } else { - format!("FROM {}", compose_config.image.as_deref().unwrap_or("ubuntu")) + format!( + "FROM {}", + compose_config.image.as_deref().unwrap_or("ubuntu") + ) }; return Ok(DevcontainerConfig { @@ -178,11 +177,11 @@ impl DevcontainerResolver { build_context: compose_base_dir.to_path_buf(), build_args: HashMap::new(), build_target: None, - initialize_commands: Self::collect_commands(&devcontainer.initialize_command, &vars), - on_create_commands: Self::collect_commands( - &devcontainer.on_create_command, + initialize_commands: Self::collect_commands( + &devcontainer.initialize_command, &vars, ), + on_create_commands: Self::collect_commands(&devcontainer.on_create_command, &vars), post_create_commands: Self::collect_commands( &devcontainer.post_create_command, &vars, @@ -193,10 +192,7 @@ impl DevcontainerResolver { ), environment, container_env: Self::collect_container_env(&devcontainer.container_env, &vars), - remote_user: devcontainer - .remote_user - .clone() - .or(compose_config.user), + remote_user: devcontainer.remote_user.clone().or(compose_config.user), workspace_folder, forwarded_ports: { let mut ports = compose_config.ports; @@ -213,45 +209,54 @@ impl DevcontainerResolver { } // Image or Dockerfile mode - let (base_dockerfile, build_context, build_args, build_target) = if let Some(build) = - &devcontainer.build - { - let context_dir = build - .context - .as_ref() - .map(|c| base_dir.join(variables::substitute(c, &vars))) - .unwrap_or_else(|| base_dir.to_path_buf()); - let df_path = base_dir.join(variables::substitute( - build.dockerfile.as_deref().unwrap_or("Dockerfile"), - &vars, - )); - let content = std::fs::read_to_string(&df_path).map_err(|source| { - DevcontainerError::ReadFile { - path: df_path, - source, - } - })?; - let args: HashMap = build - .args - .iter() - .map(|(k, v)| (k.clone(), variables::substitute(v, &vars))) - .collect(); - let target = build - .target - .as_ref() - .map(|t| variables::substitute(t, &vars)); - (content, context_dir, args, target) - } else { - let image = devcontainer - .image - .as_deref() - .unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu"); - (format!("FROM {image}"), base_dir.to_path_buf(), HashMap::new(), None) - }; + let (base_dockerfile, build_context, build_args, build_target) = + if let Some(build) = &devcontainer.build { + let context_dir = build + .context + .as_ref() + .map(|c| base_dir.join(variables::substitute(c, &vars))) + .unwrap_or_else(|| base_dir.to_path_buf()); + let df_path = base_dir.join(variables::substitute( + build.dockerfile.as_deref().unwrap_or("Dockerfile"), + &vars, + )); + let content = std::fs::read_to_string(&df_path).map_err(|source| { + DevcontainerError::ReadFile { + path: df_path, + source, + } + })?; + let args: HashMap = build + .args + .iter() + .map(|(k, v)| (k.clone(), variables::substitute(v, &vars))) + .collect(); + let target = build + .target + .as_ref() + .map(|t| variables::substitute(t, &vars)); + (content, context_dir, args, target) + } else { + let image = devcontainer + .image + .as_deref() + .unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu"); + ( + format!("FROM {image}"), + base_dir.to_path_buf(), + HashMap::new(), + None, + ) + }; // Features let resolved_features = if !devcontainer.features.is_empty() { - features::resolve_features(&devcontainer.features, base_dir, devcontainer.remote_user.as_deref()).await? + features::resolve_features( + &devcontainer.features, + base_dir, + devcontainer.remote_user.as_deref(), + ) + .await? } else { features::ResolvedFeatures::default() }; @@ -283,8 +288,10 @@ impl DevcontainerResolver { // Collect devcontainer.json lifecycle commands, then append feature lifecycle commands let mut on_create_commands = Self::collect_commands(&devcontainer.on_create_command, &vars); - let mut post_create_commands = Self::collect_commands(&devcontainer.post_create_command, &vars); - let mut post_start_commands = Self::collect_commands(&devcontainer.post_start_command, &vars); + let mut post_create_commands = + Self::collect_commands(&devcontainer.post_create_command, &vars); + let mut post_start_commands = + Self::collect_commands(&devcontainer.post_start_command, &vars); for cmd in &resolved_features.on_create_commands { on_create_commands.push(Self::convert_lifecycle_command(cmd)); @@ -337,17 +344,12 @@ impl DevcontainerResolver { } // Check if path itself is a devcontainer.json - if path.is_file() - && path - .file_name() - .is_some_and(|n| n == "devcontainer.json") - { - let raw = std::fs::read_to_string(path).map_err(|source| { - DevcontainerError::ReadFile { + if path.is_file() && path.file_name().is_some_and(|n| n == "devcontainer.json") { + let raw = + std::fs::read_to_string(path).map_err(|source| DevcontainerError::ReadFile { path: path.to_path_buf(), source, - } - })?; + })?; let stripped = jsonc::strip_jsonc(&raw); let parsed: DevcontainerJson = serde_json::from_str(&stripped)?; return Ok((path.to_path_buf(), parsed)); @@ -441,9 +443,7 @@ impl DevcontainerResolver { } Some(types::LifecycleCommand::Array(arr)) => { vec![Command::Args( - arr.iter() - .map(|s| variables::substitute(s, vars)) - .collect(), + arr.iter().map(|s| variables::substitute(s, vars)).collect(), )] } Some(types::LifecycleCommand::Object(map)) => { diff --git a/crates/arc-devcontainer/src/types.rs b/crates/arc-devcontainer/src/types.rs index d160b09bd..6e416d907 100644 --- a/crates/arc-devcontainer/src/types.rs +++ b/crates/arc-devcontainer/src/types.rs @@ -262,9 +262,15 @@ mod tests { "postStartCommand": {"server": "python app.py"} }"#; let meta: FeatureMetadata = serde_json::from_str(json).unwrap(); - assert!(matches!(meta.on_create_command, Some(LifecycleCommand::String(ref s)) if s == "pip install -r requirements.txt")); - assert!(matches!(meta.post_create_command, Some(LifecycleCommand::Array(ref arr)) if arr == &["python", "setup.py"])); - assert!(matches!(meta.post_start_command, Some(LifecycleCommand::Object(ref map)) if map.len() == 1)); + assert!( + matches!(meta.on_create_command, Some(LifecycleCommand::String(ref s)) if s == "pip install -r requirements.txt") + ); + assert!( + matches!(meta.post_create_command, Some(LifecycleCommand::Array(ref arr)) if arr == &["python", "setup.py"]) + ); + assert!( + matches!(meta.post_start_command, Some(LifecycleCommand::Object(ref map)) if map.len() == 1) + ); } #[test] @@ -278,7 +284,10 @@ mod tests { }"#; let meta: FeatureMetadata = serde_json::from_str(json).unwrap(); assert_eq!(meta.container_env.len(), 2); - assert_eq!(meta.container_env.get("NODE_ENV").map(String::as_str), Some("development")); + assert_eq!( + meta.container_env.get("NODE_ENV").map(String::as_str), + Some("development") + ); } #[test] @@ -292,7 +301,9 @@ mod tests { }"#; let meta: FeatureMetadata = serde_json::from_str(json).unwrap(); assert_eq!(meta.depends_on.len(), 2); - assert!(meta.depends_on.contains_key("ghcr.io/devcontainers/features/common-utils:1")); + assert!(meta + .depends_on + .contains_key("ghcr.io/devcontainers/features/common-utils:1")); assert_eq!( meta.depends_on.get("ghcr.io/devcontainers/features/node:1"), Some(&serde_json::json!({"version": "20"})) diff --git a/crates/arc-devcontainer/src/variables.rs b/crates/arc-devcontainer/src/variables.rs index b4d241cb6..ef8aab333 100644 --- a/crates/arc-devcontainer/src/variables.rs +++ b/crates/arc-devcontainer/src/variables.rs @@ -146,20 +146,14 @@ mod tests { #[test] fn unknown_variable_left_as_is() { let ctx = test_ctx(); - assert_eq!( - substitute("${unknownVariable}", &ctx), - "${unknownVariable}" - ); + assert_eq!(substitute("${unknownVariable}", &ctx), "${unknownVariable}"); } #[test] fn local_env_with_set_variable() { let ctx = test_ctx(); std::env::set_var("ARC_TEST_VAR_SET", "hello"); - assert_eq!( - substitute("${localEnv:ARC_TEST_VAR_SET}", &ctx), - "hello" - ); + assert_eq!(substitute("${localEnv:ARC_TEST_VAR_SET}", &ctx), "hello"); std::env::remove_var("ARC_TEST_VAR_SET"); } @@ -167,10 +161,7 @@ mod tests { fn local_env_unset_returns_empty() { let ctx = test_ctx(); std::env::remove_var("ARC_TEST_VAR_UNSET_123"); - assert_eq!( - substitute("${localEnv:ARC_TEST_VAR_UNSET_123}", &ctx), - "" - ); + assert_eq!(substitute("${localEnv:ARC_TEST_VAR_UNSET_123}", &ctx), ""); } #[test] @@ -197,7 +188,10 @@ mod tests { #[test] fn no_closing_brace() { let ctx = test_ctx(); - assert_eq!(substitute("${localWorkspaceFolder", &ctx), "${localWorkspaceFolder"); + assert_eq!( + substitute("${localWorkspaceFolder", &ctx), + "${localWorkspaceFolder" + ); } #[test] @@ -216,7 +210,10 @@ mod tests { fn adjacent_variables() { let ctx = test_ctx(); assert_eq!( - substitute("${localWorkspaceFolderBasename}${containerWorkspaceFolderBasename}", &ctx), + substitute( + "${localWorkspaceFolderBasename}${containerWorkspaceFolderBasename}", + &ctx + ), "projectproject" ); } diff --git a/crates/arc-devcontainer/tests/e2e.rs b/crates/arc-devcontainer/tests/e2e.rs index 8b919cd02..e4b99f08c 100644 --- a/crates/arc-devcontainer/tests/e2e.rs +++ b/crates/arc-devcontainer/tests/e2e.rs @@ -32,7 +32,10 @@ async fn realistic_python_project() { assert!(config.dockerfile.contains("ENV PIP_NO_CACHE_DIR=1")); assert!(config.dockerfile.contains("ENV PYTHONDONTWRITEBYTECODE=1")); assert_eq!( - config.container_env.get("PIP_NO_CACHE_DIR").map(String::as_str), + config + .container_env + .get("PIP_NO_CACHE_DIR") + .map(String::as_str), Some("1") ); @@ -40,7 +43,10 @@ async fn realistic_python_project() { assert!(config.dockerfile.contains("ENV PYTHONUNBUFFERED=1")); // environment HashMap gets the remoteEnv value assert_eq!( - config.environment.get("PYTHONUNBUFFERED").map(String::as_str), + config + .environment + .get("PYTHONUNBUFFERED") + .map(String::as_str), Some("yes") ); @@ -161,7 +167,9 @@ async fn all_lifecycle_command_forms() { // Gap 1: onCreateCommand as array assert_eq!(config.on_create_commands.len(), 1); - assert!(matches!(&config.on_create_commands[0], Command::Args(args) if args == &["make", "setup"])); + assert!( + matches!(&config.on_create_commands[0], Command::Args(args) if args == &["make", "setup"]) + ); // postCreateCommand as object (parallel) assert_eq!(config.post_create_commands.len(), 1); @@ -188,7 +196,10 @@ async fn container_env_separate_from_environment() { assert!(!config.environment.contains_key("PIP_NO_CACHE_DIR")); // PYTHONUNBUFFERED is in both - environment gets remoteEnv value assert_eq!( - config.environment.get("PYTHONUNBUFFERED").map(String::as_str), + config + .environment + .get("PYTHONUNBUFFERED") + .map(String::as_str), Some("yes") ); } @@ -231,7 +242,9 @@ async fn remote_env_excluded_from_dockerfile() { .unwrap(); // containerEnv IS in the Dockerfile - assert!(config.dockerfile.contains("ENV DEBIAN_FRONTEND=noninteractive")); + assert!(config + .dockerfile + .contains("ENV DEBIAN_FRONTEND=noninteractive")); // remoteEnv is NOT in the Dockerfile assert!(!config.dockerfile.contains("EDITOR=code")); @@ -327,7 +340,9 @@ async fn local_feature_refs_resolved() { .unwrap(); // Base image preserved - assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")); + assert!(config + .dockerfile + .contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")); // Feature install.sh snippets are in the Dockerfile assert!(config.dockerfile.contains("node-feature")); @@ -366,7 +381,9 @@ async fn feature_container_env_merged() { // Feature containerEnv values baked into Dockerfile assert!(config.dockerfile.contains("ENV NODE_INSTALLED=true")); - assert!(config.dockerfile.contains("ENV NODE_PATH=/usr/local/lib/node_modules")); + assert!(config + .dockerfile + .contains("ENV NODE_PATH=/usr/local/lib/node_modules")); assert!(config.dockerfile.contains("ENV PYTHON_INSTALLED=true")); assert!(config.dockerfile.contains("ENV BASE_UTILS_INSTALLED=true")); @@ -374,10 +391,31 @@ async fn feature_container_env_merged() { assert!(config.dockerfile.contains("ENV DEVCONTAINER=true")); // All values in config.container_env - assert_eq!(config.container_env.get("NODE_INSTALLED").map(String::as_str), Some("true")); - assert_eq!(config.container_env.get("PYTHON_INSTALLED").map(String::as_str), Some("true")); - assert_eq!(config.container_env.get("BASE_UTILS_INSTALLED").map(String::as_str), Some("true")); - assert_eq!(config.container_env.get("DEVCONTAINER").map(String::as_str), Some("true")); + assert_eq!( + config + .container_env + .get("NODE_INSTALLED") + .map(String::as_str), + Some("true") + ); + assert_eq!( + config + .container_env + .get("PYTHON_INSTALLED") + .map(String::as_str), + Some("true") + ); + assert_eq!( + config + .container_env + .get("BASE_UTILS_INSTALLED") + .map(String::as_str), + Some("true") + ); + assert_eq!( + config.container_env.get("DEVCONTAINER").map(String::as_str), + Some("true") + ); } /// Gap 3: Feature lifecycle hooks are appended after devcontainer.json lifecycle commands. @@ -392,7 +430,9 @@ async fn feature_lifecycle_hooks_appended() { // base-utils: "echo base-utils-setup" // node-feature: "echo node-setup" assert!(config.on_create_commands.len() >= 2); - assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == "echo devcontainer-setup")); + assert!( + matches!(&config.on_create_commands[0], Command::Shell(s) if s == "echo devcontainer-setup") + ); // Feature on_create_commands appear after devcontainer.json's let feature_on_create: Vec<&str> = config.on_create_commands[1..] @@ -407,7 +447,9 @@ async fn feature_lifecycle_hooks_appended() { // postCreateCommand: devcontainer.json first, then python-feature assert!(config.post_create_commands.len() >= 2); - assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "echo devcontainer-post-create")); + assert!( + matches!(&config.post_create_commands[0], Command::Shell(s) if s == "echo devcontainer-post-create") + ); let feature_post_create: Vec<&str> = config.post_create_commands[1..] .iter() .filter_map(|cmd| match cmd { @@ -419,7 +461,8 @@ async fn feature_lifecycle_hooks_appended() { // postStartCommand: only node-feature contributes (no devcontainer.json postStartCommand) assert!(!config.post_start_commands.is_empty()); - let post_start: Vec<&str> = config.post_start_commands + let post_start: Vec<&str> = config + .post_start_commands .iter() .filter_map(|cmd| match cmd { Command::Shell(s) => Some(s.as_str()), @@ -481,7 +524,9 @@ async fn feature_install_user_env_vars() { "_CONTAINER_USER should always be root", ); assert!( - config.dockerfile.contains("_REMOTE_USER_HOME=\"/home/developer\""), + config + .dockerfile + .contains("_REMOTE_USER_HOME=\"/home/developer\""), "_REMOTE_USER_HOME should be /home/developer", ); assert!( @@ -504,7 +549,9 @@ async fn feature_install_user_env_vars_default_root() { config.dockerfile, ); assert!( - config.dockerfile.contains("_REMOTE_USER_HOME=\"/home/vscode\""), + config + .dockerfile + .contains("_REMOTE_USER_HOME=\"/home/vscode\""), "_REMOTE_USER_HOME should be /home/vscode", ); } diff --git a/crates/arc-devcontainer/tests/integration.rs b/crates/arc-devcontainer/tests/integration.rs index 8ee707cd0..88bc45db0 100644 --- a/crates/arc-devcontainer/tests/integration.rs +++ b/crates/arc-devcontainer/tests/integration.rs @@ -13,10 +13,15 @@ async fn resolve_image_only() { .await .unwrap(); - assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")); + assert!(config + .dockerfile + .contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")); assert_eq!(config.remote_user.as_deref(), Some("vscode")); assert_eq!(config.forwarded_ports, vec![3000, 80, 9090]); - assert_eq!(config.environment.get("EDITOR").map(String::as_str), Some("code")); + assert_eq!( + config.environment.get("EDITOR").map(String::as_str), + Some("code") + ); assert_eq!(config.workspace_folder, "/workspaces/image-only"); assert!(config.compose_files.is_empty()); assert!(config.compose_service.is_none()); @@ -29,9 +34,14 @@ async fn resolve_image_only() { assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == "setup.sh")); // containerEnv baked into Dockerfile - assert!(config.dockerfile.contains("ENV DEBIAN_FRONTEND=noninteractive")); + assert!(config + .dockerfile + .contains("ENV DEBIAN_FRONTEND=noninteractive")); assert_eq!( - config.container_env.get("DEBIAN_FRONTEND").map(String::as_str), + config + .container_env + .get("DEBIAN_FRONTEND") + .map(String::as_str), Some("noninteractive") ); } @@ -52,7 +62,10 @@ async fn resolve_dockerfile_mode() { assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "npm install")); // build.args - assert_eq!(config.build_args.get("NODE_VERSION").map(String::as_str), Some("20")); + assert_eq!( + config.build_args.get("NODE_VERSION").map(String::as_str), + Some("20") + ); // build.target assert_eq!(config.build_target.as_deref(), Some("dev")); @@ -75,8 +88,14 @@ async fn resolve_compose_mode() { assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]); // Environment merged from compose + remoteEnv - assert_eq!(config.environment.get("NODE_ENV").map(String::as_str), Some("development")); - assert_eq!(config.environment.get("DEBUG").map(String::as_str), Some("true")); + assert_eq!( + config.environment.get("NODE_ENV").map(String::as_str), + Some("development") + ); + assert_eq!( + config.environment.get("DEBUG").map(String::as_str), + Some("true") + ); } #[tokio::test] @@ -132,7 +151,9 @@ async fn resolve_subdirectory_mode() { .await .unwrap(); - assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/python:3.12")); + assert!(config + .dockerfile + .contains("FROM mcr.microsoft.com/devcontainers/python:3.12")); assert_eq!(config.remote_user.as_deref(), Some("vscode")); assert_eq!(config.workspace_folder, "/workspaces/subdirectory-mode"); } @@ -144,7 +165,9 @@ async fn resolve_subdirectory_multiple_picks_alphabetical_first() { .unwrap(); // "alpha" sorts before "beta", so alpha's config is used - assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")); + assert!(config + .dockerfile + .contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")); assert_eq!(config.remote_user.as_deref(), Some("alpha-user")); } @@ -155,7 +178,9 @@ async fn resolve_subdirectory_standard_wins_over_subdirs() { .unwrap(); // Standard .devcontainer/devcontainer.json takes priority over subdirectory format - assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")); + assert!(config + .dockerfile + .contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")); assert_eq!(config.remote_user.as_deref(), Some("standard-user")); } @@ -166,7 +191,9 @@ async fn generated_dockerfile_is_well_formed() { .unwrap(); // Should start with the generated header - assert!(config.dockerfile.contains("# Generated by arc-devcontainer")); + assert!(config + .dockerfile + .contains("# Generated by arc-devcontainer")); // Should have the base image assert!(config.dockerfile.contains("FROM")); // Should end with a newline diff --git a/crates/arc-llm/src/catalog.rs b/crates/arc-llm/src/catalog.rs index fec3b63be..7961d1954 100644 --- a/crates/arc-llm/src/catalog.rs +++ b/crates/arc-llm/src/catalog.rs @@ -161,7 +161,6 @@ mod tests { assert_eq!(get_model_info("mercury").unwrap().id, "mercury-2"); } - #[test] fn model_info_costs() { let claude = get_model_info("claude-opus-4-6").unwrap(); diff --git a/crates/arc-llm/src/cli.rs b/crates/arc-llm/src/cli.rs index 50dad795a..f4687bfbf 100644 --- a/crates/arc-llm/src/cli.rs +++ b/crates/arc-llm/src/cli.rs @@ -119,7 +119,8 @@ fn print_models_table(models: &[crate::types::ModelInfo], s: &Styles) { format_context_window(model.context_window), format_cost(model.input_cost_per_million), format_cost(model.output_cost_per_million), - s.cyan.apply_to(format!("{:>10}", format_speed(model.estimated_output_tps))), + s.cyan + .apply_to(format!("{:>10}", format_speed(model.estimated_output_tps))), ); } } @@ -431,7 +432,8 @@ async fn test_models(provider: Option<&str>, model: Option<&str>, s: &Styles) -> format_context_window(info.context_window), format_cost(info.input_cost_per_million), format_cost(info.output_cost_per_million), - s.cyan.apply_to(format!("{:>10}", format_speed(info.estimated_output_tps))), + s.cyan + .apply_to(format!("{:>10}", format_speed(info.estimated_output_tps))), status_color.apply_to(&status), ); } @@ -606,8 +608,7 @@ mod tests { #[test] fn apply_options_unknown_key_goes_to_provider_opts() { let params = GenerateParams::new("test-model"); - let result = - apply_options(params, &[("custom_key".into(), "custom_val".into())]).unwrap(); + let result = apply_options(params, &[("custom_key".into(), "custom_val".into())]).unwrap(); let opts = result.provider_options.unwrap(); assert_eq!(opts["custom_key"], "custom_val"); } @@ -615,9 +616,7 @@ mod tests { #[test] fn apply_options_invalid_temperature_errors() { let params = GenerateParams::new("test-model"); - assert!( - apply_options(params, &[("temperature".into(), "not_a_number".into())]).is_err() - ); + assert!(apply_options(params, &[("temperature".into(), "not_a_number".into())]).is_err()); } #[test] diff --git a/crates/arc-llm/src/generate.rs b/crates/arc-llm/src/generate.rs index 8285de02e..7569189ee 100644 --- a/crates/arc-llm/src/generate.rs +++ b/crates/arc-llm/src/generate.rs @@ -192,7 +192,11 @@ pub async fn generate(params: GenerateParams) -> Result 0 { - debug!(tool_calls = tool_calls.len(), round = round, "Executing tool calls"); + debug!( + tool_calls = tool_calls.len(), + round = round, + "Executing tool calls" + ); let tools = params.tools.as_ref().expect("checked above"); if tools.iter().any(|t| t.is_active()) { let tool_refs: Vec<&Tool> = diff --git a/crates/arc-types/build.rs b/crates/arc-types/build.rs index b82fb1e65..51e195b97 100644 --- a/crates/arc-types/build.rs +++ b/crates/arc-types/build.rs @@ -14,8 +14,8 @@ fn main() { let spec_text = fs::read_to_string(&spec_path) .unwrap_or_else(|e| panic!("failed to read {}: {e}", spec_path.display())); - let spec: serde_json::Value = serde_yaml::from_str(&spec_text) - .unwrap_or_else(|e| panic!("failed to parse YAML: {e}")); + let spec: serde_json::Value = + serde_yaml::from_str(&spec_text).unwrap_or_else(|e| panic!("failed to parse YAML: {e}")); let schemas = spec["components"]["schemas"] .as_object() diff --git a/crates/arc-util/src/terminal.rs b/crates/arc-util/src/terminal.rs index 79187d655..55ada8045 100644 --- a/crates/arc-util/src/terminal.rs +++ b/crates/arc-util/src/terminal.rs @@ -84,27 +84,45 @@ mod tests { fn combined_styles_work() { let s = Styles::new(true); let output = format!("{}", s.bold_dim.apply_to("header")); - assert!(output.contains("\x1b["), "bold_dim should contain ANSI codes"); + assert!( + output.contains("\x1b["), + "bold_dim should contain ANSI codes" + ); assert!(output.contains("header")); let output = format!("{}", s.bold_cyan.apply_to("tool")); - assert!(output.contains("\x1b["), "bold_cyan should contain ANSI codes"); + assert!( + output.contains("\x1b["), + "bold_cyan should contain ANSI codes" + ); assert!(output.contains("tool")); let output = format!("{}", s.bold_green.apply_to("pass")); - assert!(output.contains("\x1b["), "bold_green should contain ANSI codes"); + assert!( + output.contains("\x1b["), + "bold_green should contain ANSI codes" + ); assert!(output.contains("pass")); let output = format!("{}", s.bold_red.apply_to("fail")); - assert!(output.contains("\x1b["), "bold_red should contain ANSI codes"); + assert!( + output.contains("\x1b["), + "bold_red should contain ANSI codes" + ); assert!(output.contains("fail")); let output = format!("{}", s.magenta.apply_to("medium")); - assert!(output.contains("\x1b["), "magenta should contain ANSI codes"); + assert!( + output.contains("\x1b["), + "magenta should contain ANSI codes" + ); assert!(output.contains("medium")); let output = format!("{}", s.underline.apply_to("path")); - assert!(output.contains("\x1b["), "underline should contain ANSI codes"); + assert!( + output.contains("\x1b["), + "underline should contain ANSI codes" + ); assert!(output.contains("path")); } diff --git a/crates/arc-workflows/src/artifact.rs b/crates/arc-workflows/src/artifact.rs index 0d9f2200a..d1f911a1a 100644 --- a/crates/arc-workflows/src/artifact.rs +++ b/crates/arc-workflows/src/artifact.rs @@ -179,7 +179,9 @@ impl ArtifactStore { /// Returns `None` if no `base_dir` is configured. #[must_use] pub fn artifacts_dir(&self) -> Option { - self.base_dir.as_ref().map(|b| b.join("artifacts").join("values")) + self.base_dir + .as_ref() + .map(|b| b.join("artifacts").join("values")) } /// Remove all artifacts. Also deletes file-backed data from disk. diff --git a/crates/arc-workflows/src/asset_snapshot.rs b/crates/arc-workflows/src/asset_snapshot.rs index 69bb3061e..310c1b670 100644 --- a/crates/arc-workflows/src/asset_snapshot.rs +++ b/crates/arc-workflows/src/asset_snapshot.rs @@ -52,11 +52,7 @@ const EXCLUDE_DIRS: &[&str] = &[ ]; /// Path segments that indicate excluded tool cache directories. -const EXCLUDE_SEGMENTS: &[&str] = &[ - ".cache/ms-playwright", - "playwright/.cache", - ".yarn/cache", -]; +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; @@ -197,8 +193,7 @@ pub fn is_asset_candidate(path: &str) -> bool { 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'/'; + let after_ok = after_pos >= path.len() || path.as_bytes()[after_pos] == b'/'; if before_ok && after_ok { return true; } @@ -333,9 +328,7 @@ fn normalize_paths(discovered: Vec, root: &str) -> Vec Result, String> { +pub async fn snapshot(sandbox: &dyn Sandbox) -> Result, String> { let root = sandbox.working_directory(); let platform = sandbox.platform(); let cmd = build_find_command(root, platform); @@ -454,11 +447,7 @@ mod tests { } impl AssetMockSandbox { - fn new( - files: HashMap, - exec_stdout: &str, - platform: &'static str, - ) -> Self { + fn new(files: HashMap, exec_stdout: &str, platform: &'static str) -> Self { Self { files, exec_result: ExecResult { @@ -476,34 +465,85 @@ mod tests { #[async_trait::async_trait] impl Sandbox for AssetMockSandbox { - async fn read_file(&self, _: &str, _: Option, _: Option) -> Result { + async fn read_file( + &self, + _: &str, + _: Option, + _: Option, + ) -> Result { Err("not implemented".into()) } - async fn write_file(&self, _: &str, _: &str) -> Result<(), String> { Ok(()) } - async fn delete_file(&self, _: &str) -> Result<(), String> { Ok(()) } - async fn file_exists(&self, _: &str) -> Result { Ok(false) } - async fn list_directory(&self, _: &str, _: Option) -> Result, String> { Ok(vec![]) } - async fn exec_command(&self, _: &str, _: u64, _: Option<&str>, _: Option<&std::collections::HashMap>, _: Option) -> Result { + async fn write_file(&self, _: &str, _: &str) -> Result<(), String> { + Ok(()) + } + async fn delete_file(&self, _: &str) -> Result<(), String> { + Ok(()) + } + async fn file_exists(&self, _: &str) -> Result { + Ok(false) + } + async fn list_directory( + &self, + _: &str, + _: Option, + ) -> Result, String> { + Ok(vec![]) + } + async fn exec_command( + &self, + _: &str, + _: u64, + _: Option<&str>, + _: Option<&std::collections::HashMap>, + _: Option, + ) -> Result { Ok(self.exec_result.clone()) } - async fn grep(&self, _: &str, _: &str, _: &arc_agent::sandbox::GrepOptions) -> Result, String> { Ok(vec![]) } - async fn glob(&self, _: &str, _: Option<&str>) -> Result, String> { Ok(vec![]) } - async fn download_file_to_local(&self, remote_path: &str, local_path: &std::path::Path) -> Result<(), String> { - let content = self.files.get(remote_path) + async fn grep( + &self, + _: &str, + _: &str, + _: &arc_agent::sandbox::GrepOptions, + ) -> Result, String> { + Ok(vec![]) + } + async fn glob(&self, _: &str, _: Option<&str>) -> Result, String> { + Ok(vec![]) + } + async fn download_file_to_local( + &self, + remote_path: &str, + local_path: &std::path::Path, + ) -> Result<(), String> { + let content = self + .files + .get(remote_path) .ok_or_else(|| format!("File not found: {remote_path}"))?; if let Some(parent) = local_path.parent() { - tokio::fs::create_dir_all(parent).await + tokio::fs::create_dir_all(parent) + .await .map_err(|e| format!("Failed to create dirs: {e}"))?; } - tokio::fs::write(local_path, content.as_bytes()).await + tokio::fs::write(local_path, content.as_bytes()) + .await .map_err(|e| format!("Failed to write: {e}"))?; Ok(()) } - async fn initialize(&self) -> Result<(), String> { Ok(()) } - async fn cleanup(&self) -> Result<(), String> { Ok(()) } - fn working_directory(&self) -> &str { self.working_dir } - fn platform(&self) -> &str { self.platform_str } - fn os_version(&self) -> String { "Linux 6.1.0".into() } + async fn initialize(&self) -> Result<(), String> { + Ok(()) + } + async fn cleanup(&self) -> Result<(), String> { + Ok(()) + } + fn working_directory(&self) -> &str { + self.working_dir + } + fn platform(&self) -> &str { + self.platform_str + } + fn os_version(&self) -> String { + "Linux 6.1.0".into() + } } #[test] @@ -530,7 +570,9 @@ mod tests { #[test] fn is_asset_candidate_rejects_excluded_paths() { - assert!(!is_asset_candidate(".cache/ms-playwright/chromium/file.txt")); + 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")); } @@ -729,11 +771,7 @@ mod tests { 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", - ); + 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) @@ -763,11 +801,7 @@ mod tests { 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", - ); + let mock = AssetMockSandbox::new(files, "1024\t2000.0\ttest-results/r.xml\n", "linux"); // Provide a baseline with the same fingerprint let mut baseline = HashMap::new(); diff --git a/crates/arc-workflows/src/cli/backend.rs b/crates/arc-workflows/src/cli/backend.rs index dbded1794..70d4c03ad 100644 --- a/crates/arc-workflows/src/cli/backend.rs +++ b/crates/arc-workflows/src/cli/backend.rs @@ -5,8 +5,8 @@ use async_trait::async_trait; use arc_agent::{ subagent::{SessionFactory, SubAgentManager}, - AgentEvent, AnthropicProfile, Sandbox, GeminiProfile, OpenAiProfile, - ProviderProfile, Session, SessionConfig, Turn, + AgentEvent, AnthropicProfile, GeminiProfile, OpenAiProfile, ProviderProfile, Sandbox, Session, + SessionConfig, Turn, }; use arc_llm::client::Client; use arc_llm::provider::Provider; @@ -295,7 +295,9 @@ impl CodergenBackend for AgentApiBackend { styles.dim.apply_to(format!("[{node_id}]")), styles.dim.apply_to("\u{25cf}"), styles.bold_cyan.apply_to(tool_name), - styles.dim.apply_to(format!("({})", format_tool_args(arguments))), + styles + .dim + .apply_to(format!("({})", format_tool_args(arguments))), ); } AgentEvent::Error { error } => { @@ -449,7 +451,12 @@ mod tests { #[test] fn agent_backend_stores_config() { let styles = Box::leak(Box::new(Styles::new(false))); - let backend = AgentApiBackend::new("claude-opus-4-6".to_string(), Provider::OpenAi, true, styles); + let backend = AgentApiBackend::new( + "claude-opus-4-6".to_string(), + Provider::OpenAi, + true, + styles, + ); assert_eq!(backend.model, "claude-opus-4-6"); assert_eq!(backend.provider, Provider::OpenAi); assert!(backend.verbose); diff --git a/crates/arc-workflows/src/cli/cli_backend.rs b/crates/arc-workflows/src/cli/cli_backend.rs index b09e6cd48..2ad382df0 100644 --- a/crates/arc-workflows/src/cli/cli_backend.rs +++ b/crates/arc-workflows/src/cli/cli_backend.rs @@ -227,10 +227,7 @@ impl AgentCliBackend { } /// Detect changed files by comparing git state before and after the CLI run. - async fn detect_changed_files( - &self, - sandbox: &Arc, - ) -> Vec { + async fn detect_changed_files(&self, sandbox: &Arc) -> Vec { // Get unstaged changes let diff_result = sandbox .exec_command("git diff --name-only", 30_000, None, None, None) @@ -420,25 +417,13 @@ impl CodergenBackend for BackendRouter { if self.should_use_cli(node) { self.cli_backend .run( - node, - prompt, - context, - thread_id, - emitter, - stage_dir, - sandbox, + node, prompt, context, thread_id, emitter, stage_dir, sandbox, ) .await } else { self.api_backend .run( - node, - prompt, - context, - thread_id, - emitter, - stage_dir, - sandbox, + node, prompt, context, thread_id, emitter, stage_dir, sandbox, ) .await } diff --git a/crates/arc-workflows/src/cli/mod.rs b/crates/arc-workflows/src/cli/mod.rs index 47d5c6c2f..fe9b37341 100644 --- a/crates/arc-workflows/src/cli/mod.rs +++ b/crates/arc-workflows/src/cli/mod.rs @@ -2,8 +2,8 @@ pub mod backend; pub mod cli_backend; pub mod progress; pub mod run; -pub mod runs; pub mod run_config; +pub mod runs; pub mod validate; use std::path::Path; @@ -164,7 +164,9 @@ pub fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles) { ), Severity::Info => eprintln!( "{}", - styles.dim.apply_to(format!("info{location}: {} ({})", d.message, d.rule)), + styles + .dim + .apply_to(format!("info{location}: {} ({})", d.message, d.rule)), ), } } @@ -183,8 +185,9 @@ pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String total_cost, .. } => { - let mut s = - format!("[WORKFLOW_RUN_COMPLETED] duration={duration_ms}ms artifacts={artifact_count}"); + let mut s = format!( + "[WORKFLOW_RUN_COMPLETED] duration={duration_ms}ms artifacts={artifact_count}" + ); if let Some(cost) = total_cost { s.push_str(&format!(" total_cost={}", format_cost(*cost))); } @@ -539,7 +542,12 @@ pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String WorkflowRunEvent::StallWatchdogTimeout { node, idle_seconds } => { format!("[STALL_WATCHDOG_TIMEOUT] node={node} idle_seconds={idle_seconds}") } - WorkflowRunEvent::AssetsCaptured { node_id, files_copied, total_bytes, files_skipped } => { + WorkflowRunEvent::AssetsCaptured { + node_id, + files_copied, + total_bytes, + files_skipped, + } => { format!("[ASSETS_CAPTURED] node={node_id} files_copied={files_copied} total_bytes={} files_skipped={files_skipped}", HumanBytes(*total_bytes)) } }; @@ -614,7 +622,8 @@ mod tests { } fn test_styles() -> &'static Styles { - static STYLES: std::sync::LazyLock = std::sync::LazyLock::new(|| Styles::new(false)); + static STYLES: std::sync::LazyLock = + std::sync::LazyLock::new(|| Styles::new(false)); &STYLES } diff --git a/crates/arc-workflows/src/cli/progress.rs b/crates/arc-workflows/src/cli/progress.rs index 014a31ae3..0febf8e61 100644 --- a/crates/arc-workflows/src/cli/progress.rs +++ b/crates/arc-workflows/src/cli/progress.rs @@ -27,11 +27,20 @@ macro_rules! cached_style { }; } -cached_style!(style_header_running, " {spinner:.dim} {wide_msg} {elapsed:.dim}"); +cached_style!( + style_header_running, + " {spinner:.dim} {wide_msg} {elapsed:.dim}" +); cached_style!(style_header_done, " {wide_msg} {prefix:.dim}"); -cached_style!(style_stage_running, " {spinner:.cyan} {wide_msg} {elapsed:.dim}"); +cached_style!( + style_stage_running, + " {spinner:.cyan} {wide_msg} {elapsed:.dim}" +); cached_style!(style_stage_done, " {wide_msg} {prefix:.dim}"); -cached_style!(style_tool_running, " {spinner:.dim} {wide_msg} {elapsed:.dim}"); +cached_style!( + style_tool_running, + " {spinner:.dim} {wide_msg} {elapsed:.dim}" +); cached_style!(style_tool_done, " {wide_msg}"); cached_style!(style_static_dim, " {wide_msg:.dim}"); cached_style!(style_empty, ""); @@ -210,9 +219,7 @@ impl ProgressUI { }; self.finish_stage(node_id, name, glyph, &prefix); } - WorkflowRunEvent::StageFailed { - node_id, name, .. - } => { + WorkflowRunEvent::StageFailed { node_id, name, .. } => { self.finish_stage(node_id, name, red_cross(), ""); } WorkflowRunEvent::Agent { stage, event } => { @@ -415,12 +422,7 @@ impl ProgressUI { } } - fn on_tool_call_completed( - &mut self, - stage_node_id: &str, - tool_call_id: &str, - is_error: bool, - ) { + fn on_tool_call_completed(&mut self, stage_node_id: &str, tool_call_id: &str, is_error: bool) { if let ProgressRenderer::Tty(_) = &self.renderer { if let Some(stage) = self.active_stages.get_mut(stage_node_id) { if let Some(entry) = stage diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index d4211bdad..6fdedff49 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -5,27 +5,25 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; use anyhow::bail; -use arc_agent::{ - DockerSandboxConfig, DockerSandbox, Sandbox, LocalSandbox, -}; +use arc_agent::{DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox}; use arc_util::terminal::Styles; use chrono::{Local, Utc}; use crate::checkpoint::Checkpoint; -use crate::engine::{GitCheckpointMode, WorkflowRunEngine, RunConfig}; +use crate::engine::{GitCheckpointMode, RunConfig, WorkflowRunEngine}; use crate::event::EventEmitter; use crate::handler::default_registry; use crate::interviewer::auto_approve::AutoApproveInterviewer; use crate::interviewer::console::ConsoleInterviewer; use crate::interviewer::Interviewer; use crate::outcome::StageStatus; -use crate::workflow::WorkflowBuilder; use crate::validation::Severity; +use crate::workflow::WorkflowBuilder; use arc_llm::provider::Provider; use super::backend::AgentApiBackend; -use super::cli_backend::{BackendRouter, AgentCliBackend}; +use super::cli_backend::{AgentCliBackend, BackendRouter}; use super::progress; use super::run_config; use super::run_config::{RunDefaults, WorkflowRunConfig}; @@ -33,9 +31,8 @@ use indicatif::HumanDuration; use std::time::Duration; use super::{ - compute_stage_cost, format_cost, - format_event_summary, format_tokens_human, print_diagnostics, read_dot_file, - SandboxProvider, RunArgs, + compute_stage_cost, format_cost, format_event_summary, format_tokens_human, print_diagnostics, + read_dot_file, RunArgs, SandboxProvider, }; /// Return the default model string for a given provider. @@ -67,10 +64,7 @@ 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_model = run_defaults.llm.as_ref().and_then(|l| l.model.as_deref()); let defaults_provider = run_defaults .llm .as_ref() @@ -80,23 +74,13 @@ fn resolve_model_provider( let provider = cli_provider .or(toml_provider) .or(defaults_provider) - .or_else(|| { - graph - .attrs - .get("default_provider") - .and_then(|v| v.as_str()) - }) + .or_else(|| graph.attrs.get("default_provider").and_then(|v| v.as_str())) .map(String::from); let model = cli_model .or(toml_model) .or(defaults_model) - .or_else(|| { - graph - .attrs - .get("default_model") - .and_then(|v| v.as_str()) - }) + .or_else(|| graph.attrs.get("default_model").and_then(|v| v.as_str())) .map(String::from) .unwrap_or_else(|| { let provider_enum = provider @@ -226,7 +210,11 @@ pub async fn run_command( "{} {} ({})", styles.bold.apply_to("Parsed workflow:"), graph.name, - styles.dim.apply_to(format!("{} nodes, {} edges", graph.nodes.len(), graph.edges.len())), + styles.dim.apply_to(format!( + "{} nodes, {} edges", + graph.nodes.len(), + graph.edges.len() + )), ); let goal = graph.goal(); @@ -252,7 +240,16 @@ pub async fn run_command( }; if args.preflight { - return run_preflight(&graph, &run_cfg, &args, &run_defaults, git_clean, sandbox_provider, styles).await; + return run_preflight( + &graph, + &run_cfg, + &args, + &run_defaults, + git_clean, + sandbox_provider, + styles, + ) + .await; } // 3. Create logs directory @@ -283,7 +280,10 @@ pub async fn run_command( styles.underline.apply_to(logs_dir.display()), ); } else { - progress_ui.lock().expect("progress lock poisoned").show_logs_dir(&logs_dir); + progress_ui + .lock() + .expect("progress lock poisoned") + .show_logs_dir(&logs_dir); } // 3. Build event emitter @@ -333,18 +333,14 @@ pub async fn run_command( envelope.insert( "ts".to_string(), serde_json::Value::String( - Utc::now() - .to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), ), ); envelope.insert( "run_id".to_string(), serde_json::Value::String(run_id_clone.lock().unwrap().clone()), ); - envelope.insert( - "event".to_string(), - serde_json::Value::String(event_name), - ); + envelope.insert("event".to_string(), serde_json::Value::String(event_name)); for (k, v) in event_fields { if k != "ts" && k != "run_id" && k != "event" { envelope.insert(k, v); @@ -400,9 +396,9 @@ pub async fn run_command( } Err(e) => { eprintln!( - "{} Git worktree setup failed ({e}), running without worktree.", - styles.yellow.apply_to("Warning:"), - ); + "{} Git worktree setup failed ({e}), running without worktree.", + styles.yellow.apply_to("Warning:"), + ); (None, None, None, None, None) } } @@ -435,8 +431,7 @@ pub async fn run_command( .await .map_err(|e| anyhow::anyhow!("Failed to create Daytona client: {e}"))?; let config = daytona_config.clone().unwrap_or_default(); - let mut env = - crate::daytona_sandbox::DaytonaSandbox::new(daytona_client, config); + let mut env = crate::daytona_sandbox::DaytonaSandbox::new(daytona_client, config); let emitter_cb = Arc::clone(&emitter); env.set_event_callback(Arc::new(move |event| { emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event }); @@ -475,22 +470,21 @@ pub async fn run_command( }); // Set up git inside Daytona sandbox (if applicable) - let (daytona_run_id, daytona_base_sha, daytona_branch) = if sandbox_provider - == SandboxProvider::Daytona - { - match setup_daytona_git(&*sandbox).await { - Ok((rid, base, branch)) => (Some(rid), Some(base), Some(branch)), - Err(e) => { - eprintln!( - "{} Daytona git setup failed ({e}), running without git checkpoints.", - styles.yellow.apply_to("Warning:"), - ); - (None, None, None) + let (daytona_run_id, daytona_base_sha, daytona_branch) = + if sandbox_provider == SandboxProvider::Daytona { + match setup_daytona_git(&*sandbox).await { + Ok((rid, base, branch)) => (Some(rid), Some(base), Some(branch)), + Err(e) => { + eprintln!( + "{} Daytona git setup failed ({e}), running without git checkpoints.", + styles.yellow.apply_to("Warning:"), + ); + (None, None, None) + } } - } - } else { - (None, None, None) - }; + } else { + (None, None, None) + }; // Run setup commands inside the sandbox (once, not per-stage) if !setup_commands.is_empty() { @@ -664,7 +658,10 @@ pub async fn run_command( // Auto-derive retro (always, cheap) and optionally run retro agent { let (failed, failure_reason) = match &engine_result { - Ok(o) => (o.status == StageStatus::Fail, o.failure_reason().map(String::from)), + Ok(o) => ( + o.status == StageStatus::Fail, + o.failure_reason().map(String::from), + ), Err(e) => (true, Some(e.to_string())), }; generate_retro( @@ -693,21 +690,18 @@ pub async fn run_command( } // 8. Print result - eprintln!( - "\n{}", - styles.bold.apply_to("=== Run Result ==="), - ); + eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="),); let status_str = outcome.status.to_string().to_uppercase(); let status_color = match outcome.status { StageStatus::Success | StageStatus::PartialSuccess => &styles.bold_green, _ => &styles.bold_red, }; + eprintln!("Status: {}", status_color.apply_to(&status_str),); eprintln!( - "Status: {}", - status_color.apply_to(&status_str), + "Duration: {}", + HumanDuration(Duration::from_millis(run_duration_ms)) ); - eprintln!("Duration: {}", HumanDuration(Duration::from_millis(run_duration_ms))); let acc = accumulator.lock().unwrap(); let total_tokens = acc.total_input_tokens + acc.total_output_tokens; @@ -747,10 +741,7 @@ pub async fn run_command( eprintln!("Notes: {notes}"); } if let Some(failure) = outcome.failure_reason() { - eprintln!( - "{}", - styles.red.apply_to(format!("Failure: {failure}")), - ); + eprintln!("{}", styles.red.apply_to(format!("Failure: {failure}")),); } eprintln!( "{} {}", @@ -988,7 +979,10 @@ async fn run_from_branch( // Auto-derive retro { let (failed, failure_reason) = match &engine_result { - Ok(o) => (o.status == StageStatus::Fail, o.failure_reason().map(String::from)), + Ok(o) => ( + o.status == StageStatus::Fail, + o.failure_reason().map(String::from), + ), Err(e) => (true, Some(e.to_string())), }; @@ -1018,19 +1012,13 @@ async fn run_from_branch( let outcome = engine_result?; - eprintln!( - "\n{}", - styles.bold.apply_to("=== Run Result ==="), - ); + eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="),); let status_str = outcome.status.to_string().to_uppercase(); let status_color = match outcome.status { StageStatus::Success | StageStatus::PartialSuccess => &styles.bold_green, _ => &styles.bold_red, }; - eprintln!( - "Status: {}", - status_color.apply_to(&status_str), - ); + eprintln!("Status: {}", status_color.apply_to(&status_str),); eprintln!( "Duration: {}", HumanDuration(Duration::from_millis(run_duration_ms)) @@ -1077,16 +1065,14 @@ async fn run_preflight( .map(|env| Arc::new(env) as Arc) .map_err(|e| format!("Docker sandbox creation failed: {e}")) } - SandboxProvider::Daytona => { - match daytona_sdk::Client::new().await { - Ok(daytona_client) => { - let config = daytona_config.unwrap_or_default(); - let env = crate::daytona_sandbox::DaytonaSandbox::new(daytona_client, config); - Ok(Arc::new(env) as Arc) - } - Err(e) => Err(format!("Daytona client creation failed: {e}")), + SandboxProvider::Daytona => match daytona_sdk::Client::new().await { + Ok(daytona_client) => { + let config = daytona_config.unwrap_or_default(); + let env = crate::daytona_sandbox::DaytonaSandbox::new(daytona_client, config); + Ok(Arc::new(env) as Arc) } - } + Err(e) => Err(format!("Daytona client creation failed: {e}")), + }, SandboxProvider::Local => { Ok(Arc::new(LocalSandbox::new(original_cwd.clone())) as Arc) } @@ -1113,7 +1099,11 @@ async fn run_preflight( // 2. LLM client check let (llm_available, llm_providers) = match arc_llm::client::Client::from_env().await { Ok(c) => { - let names = c.provider_names().iter().map(|s| s.to_string()).collect::>(); + let names = c + .provider_names() + .iter() + .map(|s| s.to_string()) + .collect::>(); if names.is_empty() { errors.push("No LLM providers configured (no API keys found)".to_string()); (false, names) @@ -1172,25 +1162,16 @@ async fn run_preflight( // 7. Print warnings/errors to stderr for err in &errors { - eprintln!( - "{}: {err}", - styles.red.apply_to("error"), - ); + eprintln!("{}: {err}", styles.red.apply_to("error"),); } // 8. Final verdict let ok = sandbox_ready && llm_available && provider_valid; if ok { - eprintln!( - "\n{}", - styles.bold_green.apply_to("Preflight: OK"), - ); + eprintln!("\n{}", styles.bold_green.apply_to("Preflight: OK"),); Ok(()) } else { - eprintln!( - "\n{}", - styles.bold_red.apply_to("Preflight: FAIL"), - ); + eprintln!("\n{}", styles.bold_red.apply_to("Preflight: FAIL"),); std::process::exit(1); } } @@ -1252,8 +1233,7 @@ async fn generate_retro( let narrative_result = if dry_run_mode { Ok(crate::retro_agent::dry_run_narrative()) } else if let Some(client) = llm_client { - crate::retro_agent::run_retro_agent(sandbox, logs_dir, client, provider_enum, model) - .await + crate::retro_agent::run_retro_agent(sandbox, logs_dir, client, provider_enum, model).await } else { Err(anyhow::anyhow!("No LLM client available")) }; @@ -1266,7 +1246,9 @@ async fn generate_retro( eprintln!( "{} {}", styles.dim.apply_to("Retro saved to"), - styles.underline.apply_to(format!("{}/retro.json", logs_dir.display())), + styles + .underline + .apply_to(format!("{}/retro.json", logs_dir.display())), ); } Err(e) => { @@ -1292,7 +1274,10 @@ mod tests { #[test] fn default_model_for_anthropic() { - assert_eq!(default_model_for_provider(Provider::Anthropic), "claude-opus-4-6"); + assert_eq!( + default_model_for_provider(Provider::Anthropic), + "claude-opus-4-6" + ); } #[test] @@ -1302,7 +1287,10 @@ mod tests { #[test] fn default_model_for_gemini() { - assert_eq!(default_model_for_provider(Provider::Gemini), "gemini-3.1-pro-preview"); + assert_eq!( + default_model_for_provider(Provider::Gemini), + "gemini-3.1-pro-preview" + ); } #[test] @@ -1317,7 +1305,10 @@ mod tests { #[test] fn default_model_for_minimax() { - assert_eq!(default_model_for_provider(Provider::Minimax), "minimax-m2.5"); + assert_eq!( + default_model_for_provider(Provider::Minimax), + "minimax-m2.5" + ); } #[test] @@ -1367,8 +1358,14 @@ mod tests { fn resolve_model_provider_toml_overrides_graph() { use crate::graph::types::AttrValue; let mut graph = crate::graph::types::Graph::new("test"); - 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())); + 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 { @@ -1393,8 +1390,14 @@ mod tests { fn resolve_model_provider_graph_attrs_used_as_fallback() { use crate::graph::types::AttrValue; let mut graph = crate::graph::types::Graph::new("test"); - 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())); + 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 defaults = RunDefaults::default(); let (model, provider) = resolve_model_provider(None, None, None, &defaults, &graph); diff --git a/crates/arc-workflows/src/cli/run_config.rs b/crates/arc-workflows/src/cli/run_config.rs index bafe9378d..fe412eed0 100644 --- a/crates/arc-workflows/src/cli/run_config.rs +++ b/crates/arc-workflows/src/cli/run_config.rs @@ -388,8 +388,7 @@ graph = "p.dot" "#; let err = parse_run_config(toml).unwrap_err(); assert!( - err.to_string() - .contains("Unsupported run config version 2"), + err.to_string().contains("Unsupported run config version 2"), "unexpected error: {err}" ); } diff --git a/crates/arc-workflows/src/cli/runs.rs b/crates/arc-workflows/src/cli/runs.rs index d4629479b..ac9cfea97 100644 --- a/crates/arc-workflows/src/cli/runs.rs +++ b/crates/arc-workflows/src/cli/runs.rs @@ -82,10 +82,7 @@ pub fn scan_runs(base: &Path) -> Result> { continue; } - let dir_name = entry - .file_name() - .to_string_lossy() - .to_string(); + let dir_name = entry.file_name().to_string_lossy().to_string(); debug!(dir = %dir_name, "scanning run directory"); @@ -95,18 +92,12 @@ pub fn scan_runs(base: &Path) -> Result> { debug!(dir = %dir_name, "reading manifest"); let manifest: serde_json::Value = serde_json::from_str(&manifest_text)?; - let run_id = manifest["run_id"] - .as_str() - .unwrap_or(&dir_name) - .to_string(); + let run_id = manifest["run_id"].as_str().unwrap_or(&dir_name).to_string(); let workflow_name = manifest["workflow_name"] .as_str() .unwrap_or("unknown") .to_string(); - let start_time = manifest["start_time"] - .as_str() - .unwrap_or("") - .to_string(); + let start_time = manifest["start_time"].as_str().unwrap_or("").to_string(); let labels: HashMap = manifest .get("labels") .and_then(|v| serde_json::from_value(v.clone()).ok()) @@ -308,10 +299,7 @@ pub fn prune_from(args: &RunsPruneArgs, base: &Path) -> Result<()> { } else { for run in &filtered { debug!(run_id = %run.run_id, "would delete run (dry-run)"); - println!( - "would delete: {} ({})", - run.dir_name, run.workflow_name - ); + println!("would delete: {} ({})", run.dir_name, run.workflow_name); } eprintln!( "\n{} run(s) would be deleted. Pass --yes to confirm.", @@ -336,12 +324,18 @@ mod tests { let dir = base.join(dir_name); fs::create_dir_all(&dir).unwrap(); if let Some(m) = manifest { - fs::write(dir.join("manifest.json"), serde_json::to_string_pretty(&m).unwrap()) - .unwrap(); + fs::write( + dir.join("manifest.json"), + serde_json::to_string_pretty(&m).unwrap(), + ) + .unwrap(); } if let Some(f) = final_json { - fs::write(dir.join("final.json"), serde_json::to_string_pretty(&f).unwrap()) - .unwrap(); + fs::write( + dir.join("final.json"), + serde_json::to_string_pretty(&f).unwrap(), + ) + .unwrap(); } if pid_file { fs::write(dir.join("run.pid"), "12345").unwrap(); @@ -599,7 +593,10 @@ mod tests { prune_from(&args, base).unwrap(); assert!(!dir.exists(), "--yes should delete matching directory"); - assert!(keep_dir.exists(), "non-matching directory should be preserved"); + assert!( + keep_dir.exists(), + "non-matching directory should be preserved" + ); } #[test] diff --git a/crates/arc-workflows/src/cli/validate.rs b/crates/arc-workflows/src/cli/validate.rs index 2fc6226d7..622478e23 100644 --- a/crates/arc-workflows/src/cli/validate.rs +++ b/crates/arc-workflows/src/cli/validate.rs @@ -1,8 +1,8 @@ use anyhow::bail; use arc_util::terminal::Styles; -use crate::workflow::WorkflowBuilder; use crate::validation::Severity; +use crate::workflow::WorkflowBuilder; use super::{print_diagnostics, read_dot_file, ValidateArgs}; @@ -17,7 +17,9 @@ pub fn validate_command(args: &ValidateArgs, styles: &Styles) -> anyhow::Result< eprintln!( "{} ({} nodes, {} edges)", - styles.bold.apply_to(format!("Parsed workflow: {}", graph.name)), + styles + .bold + .apply_to(format!("Parsed workflow: {}", graph.name)), graph.nodes.len(), graph.edges.len(), ); diff --git a/crates/arc-workflows/src/daytona_sandbox.rs b/crates/arc-workflows/src/daytona_sandbox.rs index 2789d0284..392b2f0dd 100644 --- a/crates/arc-workflows/src/daytona_sandbox.rs +++ b/crates/arc-workflows/src/daytona_sandbox.rs @@ -3,8 +3,8 @@ use std::path::Path; use std::time::Instant; use arc_agent::sandbox::{ - format_lines_numbered, DirEntry, SandboxEventCallback, ExecResult, SandboxEvent, - Sandbox, GrepOptions, + format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, + SandboxEventCallback, }; use async_trait::async_trait; use rand::Rng; diff --git a/crates/arc-workflows/src/engine.rs b/crates/arc-workflows/src/engine.rs index 8ce73990a..4002c84e8 100644 --- a/crates/arc-workflows/src/engine.rs +++ b/crates/arc-workflows/src/engine.rs @@ -42,9 +42,9 @@ fn millis_u64(d: std::time::Duration) -> u64 { fn classify_outcome(outcome: &Outcome) -> Option { match outcome.status { StageStatus::Success | StageStatus::PartialSuccess | StageStatus::Skipped => None, - StageStatus::Fail | StageStatus::Retry => { - outcome.failure_class().or(Some(FailureClass::Deterministic)) - } + StageStatus::Fail | StageStatus::Retry => outcome + .failure_class() + .or(Some(FailureClass::Deterministic)), } } @@ -650,11 +650,7 @@ async fn git_diff_remote(sandbox: &dyn Sandbox, base: &str) -> Option { // --- Remote worktree helpers (for Daytona / sandbox environments) --- /// Create a branch at a specific SHA inside a remote sandbox. -pub async fn git_create_branch_at_remote( - sandbox: &dyn Sandbox, - name: &str, - sha: &str, -) -> bool { +pub async fn git_create_branch_at_remote(sandbox: &dyn Sandbox, name: &str, sha: &str) -> bool { let cmd = format!("{GIT_REMOTE} branch --force {name} {sha}"); matches!( sandbox.exec_command(&cmd, 30_000, None, None, None).await, @@ -663,11 +659,7 @@ pub async fn git_create_branch_at_remote( } /// Add a git worktree inside a remote sandbox. -pub async fn git_add_worktree_remote( - sandbox: &dyn Sandbox, - path: &str, - branch: &str, -) -> bool { +pub async fn git_add_worktree_remote(sandbox: &dyn Sandbox, path: &str, branch: &str) -> bool { let cmd = format!("{GIT_REMOTE} worktree add {path} {branch}"); matches!( sandbox.exec_command(&cmd, 30_000, None, None, None).await, @@ -703,11 +695,7 @@ pub async fn git_head_sha_remote(sandbox: &dyn Sandbox) -> Option { } /// Remove any stale worktree at `path` (best-effort), then add a fresh one. -pub async fn git_replace_worktree_remote( - sandbox: &dyn Sandbox, - path: &str, - branch: &str, -) -> bool { +pub async fn git_replace_worktree_remote(sandbox: &dyn Sandbox, path: &str, branch: &str) -> bool { let _ = git_remove_worktree_remote(sandbox, path).await; git_add_worktree_remote(sandbox, path, branch).await } @@ -939,15 +927,17 @@ impl WorkflowRunEngine { }, will_retry: true, }); - self.services.emitter.emit(&WorkflowRunEvent::StageRetrying { - node_id: node.id.clone(), - name: node.label().to_string(), - index: stage_index, - attempt: usize::try_from(attempt).unwrap_or(usize::MAX), - max_attempts: usize::try_from(policy.max_attempts) - .unwrap_or(usize::MAX), - delay_ms: millis_u64(delay), - }); + self.services + .emitter + .emit(&WorkflowRunEvent::StageRetrying { + node_id: node.id.clone(), + name: node.label().to_string(), + index: stage_index, + attempt: usize::try_from(attempt).unwrap_or(usize::MAX), + max_attempts: usize::try_from(policy.max_attempts) + .unwrap_or(usize::MAX), + delay_ms: millis_u64(delay), + }); tokio::time::sleep(delay).await; continue; } @@ -965,15 +955,17 @@ impl WorkflowRunEngine { StageStatus::Retry => { if attempt < policy.max_attempts { let delay = policy.backoff.delay_for_attempt(attempt); - self.services.emitter.emit(&WorkflowRunEvent::StageRetrying { - node_id: node.id.clone(), - name: node.label().to_string(), - index: stage_index, - attempt: usize::try_from(attempt).unwrap_or(usize::MAX), - max_attempts: usize::try_from(policy.max_attempts) - .unwrap_or(usize::MAX), - delay_ms: millis_u64(delay), - }); + self.services + .emitter + .emit(&WorkflowRunEvent::StageRetrying { + node_id: node.id.clone(), + name: node.label().to_string(), + index: stage_index, + attempt: usize::try_from(attempt).unwrap_or(usize::MAX), + max_attempts: usize::try_from(policy.max_attempts) + .unwrap_or(usize::MAX), + delay_ms: millis_u64(delay), + }); tokio::time::sleep(delay).await; continue; } @@ -992,7 +984,10 @@ impl WorkflowRunEngine { } } - Ok((Outcome::fail_classify("max retries exceeded"), policy.max_attempts)) + Ok(( + Outcome::fail_classify("max retries exceeded"), + policy.max_attempts, + )) } /// Run the workflow. Returns the final outcome. @@ -1002,9 +997,9 @@ impl WorkflowRunEngine { /// Returns an error if no start node is found, a node is missing, or a goal gate fails /// without a retry target. pub async fn run(&self, graph: &Graph, config: &RunConfig) -> Result { - let (outcome, _context) = - self.run_internal(graph, config, None, None, None, LoopState::default()) - .await?; + let (outcome, _context) = self + .run_internal(graph, config, None, None, None, LoopState::default()) + .await?; Ok(outcome) } @@ -1077,16 +1072,18 @@ impl WorkflowRunEngine { }; self.services.set_git_state(git_state); - self.services.emitter.emit(&WorkflowRunEvent::WorkflowRunStarted { - name: graph.name.clone(), - run_id: run_id.clone(), - base_sha: config.base_sha.clone(), - run_branch: config.run_branch.clone(), - worktree_dir: match config.git_checkpoint { - Some(GitCheckpointMode::Host(ref p)) => Some(p.display().to_string()), - _ => None, - }, - }); + self.services + .emitter + .emit(&WorkflowRunEvent::WorkflowRunStarted { + name: graph.name.clone(), + run_id: run_id.clone(), + base_sha: config.base_sha.clone(), + run_branch: config.run_branch.clone(), + worktree_dir: match config.git_checkpoint { + Some(GitCheckpointMode::Host(ref p)) => Some(p.display().to_string()), + _ => None, + }, + }); self.inform(&format!("Run started: {}", graph.name), "run"); // Write manifest.json (spec 5.6) @@ -1271,14 +1268,16 @@ impl WorkflowRunEngine { continue; } let duration_ms = millis_u64(run_start.elapsed()); - let error = ArcError::engine( - format!("goal gate unsatisfied for node {failed_node_id} and no retry target") - ); - self.services.emitter.emit(&WorkflowRunEvent::WorkflowRunFailed { - error: error.clone(), - duration_ms, - git_commit_sha: last_git_sha.clone(), - }); + let error = ArcError::engine(format!( + "goal gate unsatisfied for node {failed_node_id} and no retry target" + )); + self.services + .emitter + .emit(&WorkflowRunEvent::WorkflowRunFailed { + error: error.clone(), + duration_ms, + git_commit_sha: last_git_sha.clone(), + }); return Ok((error.to_fail_outcome(), context)); } } @@ -1354,8 +1353,15 @@ impl WorkflowRunEngine { } } else { self.execute_with_retry( - node, &context, graph, &config.logs_root, &retry_policy, stage_index, visit, - ).await? + node, + &context, + graph, + &config.logs_root, + &retry_policy, + stage_index, + visit, + ) + .await? }; // Gap #5: Track retry count per node node_retries.insert(node.id.clone(), attempts_used); @@ -1386,12 +1392,7 @@ impl WorkflowRunEngine { .failure .as_ref() .and_then(|f| f.failure_signature.as_deref()); - let sig = FailureSignature::new( - &node.id, - fc, - sig_hint, - outcome.failure_reason(), - ); + let sig = FailureSignature::new(&node.id, fc, sig_hint, outcome.failure_reason()); if fc.is_signature_tracked() { let count = loop_state .loop_failure_signatures @@ -1421,21 +1422,24 @@ impl WorkflowRunEngine { will_retry: false, }); } else { - self.services.emitter.emit(&WorkflowRunEvent::StageCompleted { - node_id: node.id.clone(), - name: node.label().to_string(), - index: stage_index, - duration_ms: stage_duration_ms, - status: outcome.status.to_string(), - preferred_label: outcome.preferred_label.clone(), - suggested_next_ids: outcome.suggested_next_ids.clone(), - usage: outcome.usage.clone(), - failure: outcome.failure.clone(), - notes: outcome.notes.clone(), - files_touched: outcome.files_touched.clone(), - attempt: usize::try_from(attempts_used).unwrap_or(usize::MAX), - max_attempts: usize::try_from(retry_policy.max_attempts).unwrap_or(usize::MAX), - }); + self.services + .emitter + .emit(&WorkflowRunEvent::StageCompleted { + node_id: node.id.clone(), + name: node.label().to_string(), + index: stage_index, + duration_ms: stage_duration_ms, + status: outcome.status.to_string(), + preferred_label: outcome.preferred_label.clone(), + suggested_next_ids: outcome.suggested_next_ids.clone(), + usage: outcome.usage.clone(), + failure: outcome.failure.clone(), + notes: outcome.notes.clone(), + files_touched: outcome.files_touched.clone(), + attempt: usize::try_from(attempts_used).unwrap_or(usize::MAX), + max_attempts: usize::try_from(retry_policy.max_attempts) + .unwrap_or(usize::MAX), + }); self.inform(&format!("Stage completed: {}", node.label()), &node.id); } @@ -1449,8 +1453,7 @@ impl WorkflowRunEngine { // Sync artifact files to the sandbox (no-op for local envs) if let Err(e) = - sync_artifacts_to_env(&mut outcome.context_updates, &*self.services.sandbox) - .await + sync_artifacts_to_env(&mut outcome.context_updates, &*self.services.sandbox).await { context.append_log(format!("artifact sync failed: {e}")); } @@ -1505,9 +1508,11 @@ impl WorkflowRunEngine { if let Err(e) = checkpoint.save(&checkpoint_path) { context.append_log(format!("checkpoint save failed: {e}")); } else { - self.services.emitter.emit(&WorkflowRunEvent::CheckpointSaved { - node_id: node.id.clone(), - }); + self.services + .emitter + .emit(&WorkflowRunEvent::CheckpointSaved { + node_id: node.id.clone(), + }); } // Step 6b: Write shadow branch first, then run branch commit with trailer @@ -1586,12 +1591,14 @@ impl WorkflowRunEngine { if let Err(e) = checkpoint.save(&checkpoint_path) { context.append_log(format!("checkpoint re-save with SHA failed: {e}")); } - self.services.emitter.emit(&WorkflowRunEvent::GitCheckpoint { - run_id: run_id.clone(), - node_id: node.id.clone(), - status: outcome.status.to_string(), - git_commit_sha: sha.clone(), - }); + self.services + .emitter + .emit(&WorkflowRunEvent::GitCheckpoint { + run_id: run_id.clone(), + node_id: node.id.clone(), + status: outcome.status.to_string(), + git_commit_sha: sha.clone(), + }); // Save diff.patch for this stage let prev = last_git_sha @@ -1632,14 +1639,17 @@ impl WorkflowRunEngine { continue; } let duration_ms = millis_u64(run_start.elapsed()); - let error = ArcError::engine( - format!("stage {} failed with no outgoing fail edge", node.id) - ); - self.services.emitter.emit(&WorkflowRunEvent::WorkflowRunFailed { - error: error.clone(), - duration_ms, - git_commit_sha: last_git_sha.clone(), - }); + let error = ArcError::engine(format!( + "stage {} failed with no outgoing fail edge", + node.id + )); + self.services + .emitter + .emit(&WorkflowRunEvent::WorkflowRunFailed { + error: error.clone(), + duration_ms, + git_commit_sha: last_git_sha.clone(), + }); return Err(error); } break; @@ -2698,9 +2708,10 @@ mod tests { }; engine.run(&g, &config).await.unwrap(); - let manifest: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(dir.path().join("manifest.json")).unwrap()) - .unwrap(); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(dir.path().join("manifest.json")).unwrap(), + ) + .unwrap(); assert_eq!(manifest["labels"]["env"], "test"); } @@ -2723,9 +2734,10 @@ mod tests { }; engine.run(&g, &config).await.unwrap(); - let manifest: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(dir.path().join("manifest.json")).unwrap()) - .unwrap(); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(dir.path().join("manifest.json")).unwrap(), + ) + .unwrap(); assert!(manifest.get("labels").is_none()); } @@ -3595,10 +3607,7 @@ mod tests { let result = engine.run(&g, &config).await; assert!(result.is_err()); let err = result.unwrap_err().to_string(); - assert!( - err.contains("(limit 2)"), - "expected limit of 2, got: {err}" - ); + assert!(err.contains("(limit 2)"), "expected limit of 2, got: {err}"); } // --- node_dir visit-count tests --- @@ -4093,10 +4102,8 @@ mod tests { g.nodes.insert("start".to_string(), start); let mut work = Node::new("work"); - work.attrs.insert( - "type".to_string(), - AttrValue::String("slow".to_string()), - ); + work.attrs + .insert("type".to_string(), AttrValue::String("slow".to_string())); g.nodes.insert("work".to_string(), work); let mut exit = Node::new("exit"); @@ -4214,10 +4221,8 @@ mod tests { g.nodes.insert("start".to_string(), start); let mut work = Node::new("work"); - work.attrs.insert( - "type".to_string(), - AttrValue::String("slow".to_string()), - ); + work.attrs + .insert("type".to_string(), AttrValue::String("slow".to_string())); g.nodes.insert("work".to_string(), work); let mut exit = Node::new("exit"); diff --git a/crates/arc-workflows/src/error.rs b/crates/arc-workflows/src/error.rs index de4c6d995..ef42f71f1 100644 --- a/crates/arc-workflows/src/error.rs +++ b/crates/arc-workflows/src/error.rs @@ -1535,7 +1535,10 @@ mod tests { message: "connection refused".into(), }); let outcome = err.to_fail_outcome(); - assert!(outcome.failure_reason().unwrap().contains("connection refused")); + assert!(outcome + .failure_reason() + .unwrap() + .contains("connection refused")); } #[test] @@ -1637,10 +1640,7 @@ mod tests { fn to_fail_outcome_preserves_class() { let err = ArcError::handler("timeout"); let outcome = err.to_fail_outcome(); - assert_eq!( - outcome.failure_class(), - Some(FailureClass::TransientInfra) - ); + assert_eq!(outcome.failure_class(), Some(FailureClass::TransientInfra)); } // --- E2E error pipeline tests --- @@ -1709,7 +1709,10 @@ mod tests { // Verify wire format assert_eq!(v["type"], "handler"); - assert!(v["data"]["message"].as_str().unwrap().contains("connection refused")); + assert!(v["data"]["message"] + .as_str() + .unwrap() + .contains("connection refused")); assert_eq!(v["data"]["failure_class"], "transient_infra"); // Round-trip diff --git a/crates/arc-workflows/src/event.rs b/crates/arc-workflows/src/event.rs index 178efaf6a..936f16483 100644 --- a/crates/arc-workflows/src/event.rs +++ b/crates/arc-workflows/src/event.rs @@ -298,9 +298,7 @@ impl WorkflowRunEvent { } => { debug!( branch_count, - join_policy, - error_policy, - "Parallel execution started" + join_policy, error_policy, "Parallel execution started" ); } Self::ParallelBranchStarted { branch, index } => { @@ -314,10 +312,7 @@ impl WorkflowRunEvent { } => { debug!( branch, - index, - duration_ms, - status, - "Parallel branch completed" + index, duration_ms, status, "Parallel branch completed" ); } Self::ParallelCompleted { @@ -327,9 +322,7 @@ impl WorkflowRunEvent { } => { debug!( duration_ms, - success_count, - failure_count, - "Parallel execution completed" + success_count, failure_count, "Parallel execution completed" ); } Self::InterviewStarted { @@ -371,10 +364,7 @@ impl WorkflowRunEvent { "Edge selected" ); } - Self::LoopRestart { - from_node, - to_node, - } => { + Self::LoopRestart { from_node, to_node } => { debug!(from_node, to_node, "Loop restart"); } Self::Prompt { stage, text } => { @@ -389,9 +379,7 @@ impl WorkflowRunEvent { } => { warn!( reason, - completed_count, - pending_count, - "Parallel early termination" + completed_count, pending_count, "Parallel early termination" ); } Self::SubgraphStarted { @@ -408,10 +396,7 @@ impl WorkflowRunEvent { } => { debug!( node_id, - steps_executed, - status, - duration_ms, - "Subgraph completed" + steps_executed, status, duration_ms, "Subgraph completed" ); } Self::SetupStarted { command_count } => { @@ -428,10 +413,7 @@ impl WorkflowRunEvent { } => { debug!( command, - index, - exit_code, - duration_ms, - "Setup command completed" + index, exit_code, duration_ms, "Setup command completed" ); } Self::SetupCompleted { duration_ms } => { @@ -445,10 +427,7 @@ impl WorkflowRunEvent { } => { error!(command, index, exit_code, "Setup command failed"); } - Self::StallWatchdogTimeout { - node, - idle_seconds, - } => { + Self::StallWatchdogTimeout { node, idle_seconds } => { warn!(node, idle_seconds, "Stall watchdog timeout"); } Self::AssetsCaptured { @@ -459,10 +438,7 @@ impl WorkflowRunEvent { } => { debug!( node_id, - files_copied, - total_bytes, - files_skipped, - "Assets captured" + files_copied, total_bytes, files_skipped, "Assets captured" ); } } @@ -510,7 +486,9 @@ fn flatten_agent(inner: serde_json::Value) -> (String, serde_json::Map { @@ -591,7 +569,9 @@ fn flatten_sub_agent_event( // Extract the inner event name for dot notation, but keep full inner // event as `nested_event` JSON to avoid field collisions when sub-agents // are themselves nested (SubAgentEvent wrapping SubAgentEvent). - let nested_event = sub_fields.remove("event").unwrap_or(serde_json::Value::Null); + let nested_event = sub_fields + .remove("event") + .unwrap_or(serde_json::Value::Null); let inner_name = match &nested_event { serde_json::Value::Object(map) => map.keys().next().cloned(), serde_json::Value::String(name) => Some(name.clone()), @@ -667,7 +647,9 @@ fn rename_fields(event_name: &str, fields: &mut serde_json::Map Option { - match self.attrs.get("stall_timeout").and_then(AttrValue::as_duration) { + match self + .attrs + .get("stall_timeout") + .and_then(AttrValue::as_duration) + { Some(d) if d.is_zero() => None, Some(d) => Some(d), None => Some(Duration::from_secs(600)), diff --git a/crates/arc-workflows/src/handler/fan_in.rs b/crates/arc-workflows/src/handler/fan_in.rs index ae8665ddc..3db081067 100644 --- a/crates/arc-workflows/src/handler/fan_in.rs +++ b/crates/arc-workflows/src/handler/fan_in.rs @@ -37,7 +37,9 @@ impl Handler for FanInHandler { ) -> Result { let results = context.get("parallel.results"); let Some(results) = results else { - return Ok(Outcome::fail_deterministic("No parallel results to evaluate")); + return Ok(Outcome::fail_deterministic( + "No parallel results to evaluate", + )); }; let prompt = node.prompt().filter(|p| !p.is_empty()); diff --git a/crates/arc-workflows/src/handler/manager_loop.rs b/crates/arc-workflows/src/handler/manager_loop.rs index 65a0e2b58..b69ea9c0d 100644 --- a/crates/arc-workflows/src/handler/manager_loop.rs +++ b/crates/arc-workflows/src/handler/manager_loop.rs @@ -8,7 +8,7 @@ use async_trait::async_trait; use crate::condition::evaluate_condition; use crate::context::Context; -use crate::engine::{WorkflowRunEngine, RunConfig}; +use crate::engine::{RunConfig, WorkflowRunEngine}; use crate::error::ArcError; use crate::graph::{Graph, Node}; use crate::outcome::{Outcome, StageStatus}; @@ -44,10 +44,18 @@ fn parse_duration_str(s: &str) -> Duration { /// Read DOT source from node attributes: inline `stack.child_dot_source` or /// file path `stack.child_dotfile`. fn read_child_dot(node: &Node) -> Result { - if let Some(dot) = node.attrs.get("stack.child_dot_source").and_then(|v| v.as_str()) { + if let Some(dot) = node + .attrs + .get("stack.child_dot_source") + .and_then(|v| v.as_str()) + { return Ok(dot.to_string()); } - if let Some(path) = node.attrs.get("stack.child_dotfile").and_then(|v| v.as_str()) { + if let Some(path) = node + .attrs + .get("stack.child_dotfile") + .and_then(|v| v.as_str()) + { return std::fs::read_to_string(path) .map_err(|e| ArcError::handler(format!("Failed to read child dotfile {path}: {e}"))); } @@ -152,10 +160,11 @@ impl Handler for SubWorkflowHandler { // Spawn child engine let engine = WorkflowRunEngine::from_services(services); - let mut child_handle = - tokio::spawn( - async move { engine.run_with_context(&child_graph, &child_config, child_context).await }, - ); + let mut child_handle = tokio::spawn(async move { + engine + .run_with_context(&child_graph, &child_config, child_context) + .await + }); // Poll loop for cycle in 1..=max_cycles { @@ -269,7 +278,11 @@ mod tests { .await .unwrap(); assert_eq!(outcome.status, StageStatus::Success); - assert!(outcome.notes.as_deref().unwrap().contains("Child completed")); + assert!(outcome + .notes + .as_deref() + .unwrap() + .contains("Child completed")); } #[tokio::test] @@ -348,10 +361,9 @@ mod tests { outcome .context_updates .insert("review.result".to_string(), serde_json::json!("approved")); - outcome.context_updates.insert( - "review.echo".to_string(), - serde_json::json!(target), - ); + outcome + .context_updates + .insert("review.echo".to_string(), serde_json::json!(target)); Ok(outcome) } } @@ -500,10 +512,7 @@ mod tests { .await .unwrap(); assert_eq!(outcome.status, StageStatus::Fail); - assert!(outcome - .failure_reason() - .unwrap() - .contains("Max cycles")); + assert!(outcome.failure_reason().unwrap().contains("Max cycles")); } #[tokio::test] diff --git a/crates/arc-workflows/src/handler/parallel.rs b/crates/arc-workflows/src/handler/parallel.rs index d516d8d90..b01ba230c 100644 --- a/crates/arc-workflows/src/handler/parallel.rs +++ b/crates/arc-workflows/src/handler/parallel.rs @@ -82,7 +82,9 @@ impl Sandbox for WorktreeSandbox { remote_path: &str, local_path: &std::path::Path, ) -> Result<(), String> { - self.inner.download_file_to_local(remote_path, local_path).await + self.inner + .download_file_to_local(remote_path, local_path) + .await } async fn initialize(&self) -> Result<(), String> { self.inner.initialize().await @@ -277,102 +279,104 @@ impl Handler for ParallelHandler { let target_id = edge.to.clone(); let branch_context = context.clone_context(); - let (branch_sandbox, worktree_path): (Arc, Option) = - if let (Some(ref gs), Some(ref bsha)) = (&git_state, &base_sha) { - let branch_key = &target_id; - let visit = crate::engine::visit_from_context(&branch_context); - let branch_name = format!( - "arc/run/parallel/{}/{}/pass{}/{}", - gs.run_id, - crate::git::sanitize_ref_component(&node.id), - visit, - crate::git::sanitize_ref_component(branch_key), - ); + let (branch_sandbox, worktree_path): (Arc, Option) = if let ( + Some(ref gs), + Some(ref bsha), + ) = + (&git_state, &base_sha) + { + let branch_key = &target_id; + let visit = crate::engine::visit_from_context(&branch_context); + let branch_name = format!( + "arc/run/parallel/{}/{}/pass{}/{}", + gs.run_id, + crate::git::sanitize_ref_component(&node.id), + visit, + crate::git::sanitize_ref_component(branch_key), + ); - match &gs.mode { - GitCheckpointMode::Host(work_dir) => { - let wt_path = logs_root - .join("parallel") - .join(&node.id) - .join(branch_key) - .join("worktree"); - tracing::debug!(branch = %branch_name, path = %wt_path.display(), "Creating worktree for parallel branch"); - let wd = work_dir.clone(); - let bn = branch_name.clone(); - let bs = bsha.clone(); - let wtp = wt_path.clone(); - tokio::task::spawn_blocking(move || { - crate::git::create_branch_at(&wd, &bn, &bs)?; - crate::git::replace_worktree(&wd, &wtp, &bn)?; - crate::git::reset_hard(&wtp, &bs) - }) - .await - .map_err(|e| { - ArcError::handler(format!("worktree setup join error: {e}")) - })??; - branch_context.set( - "internal.work_dir", - serde_json::json!(wt_path.to_string_lossy().as_ref()), - ); - let env: Arc = Arc::new( - arc_agent::LocalSandbox::new(wt_path.clone()), - ); - (env, Some(wt_path)) - } - GitCheckpointMode::Remote(_) => { - let wt_path_str = format!( - "{}/.arc/logs/{}/parallel/{}/{}", - services.sandbox.working_directory(), - gs.run_id, - node.id, - branch_key - ); - let ok = crate::engine::git_create_branch_at_remote( - &*services.sandbox, - &branch_name, - bsha, - ) - .await; - if !ok { - return Err(ArcError::handler(format!( - "failed to create remote branch {branch_name}" - ))); - } - let ok = crate::engine::git_replace_worktree_remote( - &*services.sandbox, - &wt_path_str, - &branch_name, - ) - .await; - if !ok { - return Err(ArcError::handler(format!( - "failed to add remote worktree {wt_path_str}" - ))); - } - // Reset worktree to the base SHA for a clean start - let reset_cmd = - format!("{} reset --hard {bsha}", crate::engine::GIT_REMOTE); - let reset_result = services - .sandbox - .exec_command(&reset_cmd, 30_000, Some(&wt_path_str), None, None) - .await; - if !matches!(reset_result, Ok(ref r) if r.exit_code == 0) { - return Err(ArcError::handler(format!( - "failed to reset remote worktree {wt_path_str}" - ))); - } - branch_context - .set("internal.work_dir", serde_json::json!(&wt_path_str)); - let env: Arc = Arc::new(WorktreeSandbox { - inner: Arc::clone(&services.sandbox), - worktree_dir: wt_path_str.clone(), - }); - (env, Some(PathBuf::from(wt_path_str))) - } + match &gs.mode { + GitCheckpointMode::Host(work_dir) => { + let wt_path = logs_root + .join("parallel") + .join(&node.id) + .join(branch_key) + .join("worktree"); + tracing::debug!(branch = %branch_name, path = %wt_path.display(), "Creating worktree for parallel branch"); + let wd = work_dir.clone(); + let bn = branch_name.clone(); + let bs = bsha.clone(); + let wtp = wt_path.clone(); + tokio::task::spawn_blocking(move || { + crate::git::create_branch_at(&wd, &bn, &bs)?; + crate::git::replace_worktree(&wd, &wtp, &bn)?; + crate::git::reset_hard(&wtp, &bs) + }) + .await + .map_err(|e| { + ArcError::handler(format!("worktree setup join error: {e}")) + })??; + branch_context.set( + "internal.work_dir", + serde_json::json!(wt_path.to_string_lossy().as_ref()), + ); + let env: Arc = + Arc::new(arc_agent::LocalSandbox::new(wt_path.clone())); + (env, Some(wt_path)) } - } else { - (Arc::clone(&services.sandbox), None) - }; + GitCheckpointMode::Remote(_) => { + let wt_path_str = format!( + "{}/.arc/logs/{}/parallel/{}/{}", + services.sandbox.working_directory(), + gs.run_id, + node.id, + branch_key + ); + let ok = crate::engine::git_create_branch_at_remote( + &*services.sandbox, + &branch_name, + bsha, + ) + .await; + if !ok { + return Err(ArcError::handler(format!( + "failed to create remote branch {branch_name}" + ))); + } + let ok = crate::engine::git_replace_worktree_remote( + &*services.sandbox, + &wt_path_str, + &branch_name, + ) + .await; + if !ok { + return Err(ArcError::handler(format!( + "failed to add remote worktree {wt_path_str}" + ))); + } + // Reset worktree to the base SHA for a clean start + let reset_cmd = + format!("{} reset --hard {bsha}", crate::engine::GIT_REMOTE); + let reset_result = services + .sandbox + .exec_command(&reset_cmd, 30_000, Some(&wt_path_str), None, None) + .await; + if !matches!(reset_result, Ok(ref r) if r.exit_code == 0) { + return Err(ArcError::handler(format!( + "failed to reset remote worktree {wt_path_str}" + ))); + } + branch_context.set("internal.work_dir", serde_json::json!(&wt_path_str)); + let env: Arc = Arc::new(WorktreeSandbox { + inner: Arc::clone(&services.sandbox), + worktree_dir: wt_path_str.clone(), + }); + (env, Some(PathBuf::from(wt_path_str))) + } + } + } else { + (Arc::clone(&services.sandbox), None) + }; branch_setups.push(BranchSetup { target_id, @@ -407,8 +411,10 @@ impl Handler for ParallelHandler { let branch_start = Instant::now(); let Some(target_node) = graph.nodes.get(&setup.target_id) else { - let outcome = - Outcome::fail_classify(format!("branch target node not found: {}", setup.target_id)); + let outcome = Outcome::fail_classify(format!( + "branch target node not found: {}", + setup.target_id + )); emitter.emit(&WorkflowRunEvent::ParallelBranchCompleted { branch: setup.target_id.clone(), index: setup.branch_index, @@ -572,11 +578,8 @@ impl Handler for ParallelHandler { } GitCheckpointMode::Remote(_) => { let wt_str = wt_path.to_string_lossy().to_string(); - crate::engine::git_remove_worktree_remote( - &*services.sandbox, - &wt_str, - ) - .await; + crate::engine::git_remove_worktree_remote(&*services.sandbox, &wt_str) + .await; } } } @@ -601,8 +604,7 @@ impl Handler for ParallelHandler { .await; } GitCheckpointMode::Remote(_) => { - crate::engine::git_merge_ff_only_remote(&*services.sandbox, sha) - .await; + crate::engine::git_merge_ff_only_remote(&*services.sandbox, sha).await; } } } diff --git a/crates/arc-workflows/src/handler/script.rs b/crates/arc-workflows/src/handler/script.rs index 1c4b34112..7d2947aad 100644 --- a/crates/arc-workflows/src/handler/script.rs +++ b/crates/arc-workflows/src/handler/script.rs @@ -197,10 +197,7 @@ mod tests { .await .unwrap(); assert_eq!(outcome.status, StageStatus::Fail); - assert_eq!( - outcome.failure_reason(), - Some("No script specified") - ); + assert_eq!(outcome.failure_reason(), Some("No script specified")); } #[tokio::test] diff --git a/crates/arc-workflows/src/handler/wait_human.rs b/crates/arc-workflows/src/handler/wait_human.rs index f2c3cda5b..61550343b 100644 --- a/crates/arc-workflows/src/handler/wait_human.rs +++ b/crates/arc-workflows/src/handler/wait_human.rs @@ -127,7 +127,9 @@ impl Handler for WaitHumanHandler { } if choices.is_empty() && freeform_target.is_none() { - return Ok(Outcome::fail_deterministic("No outgoing edges for human gate")); + return Ok(Outcome::fail_deterministic( + "No outgoing edges for human gate", + )); } // 2. Build question diff --git a/crates/arc-workflows/src/interviewer/console.rs b/crates/arc-workflows/src/interviewer/console.rs index aeda03ed0..e1f60a0f7 100644 --- a/crates/arc-workflows/src/interviewer/console.rs +++ b/crates/arc-workflows/src/interviewer/console.rs @@ -163,11 +163,7 @@ impl Interviewer for ConsoleInterviewer { // Non-TTY fallback: line-based stdin reading let s = self.styles; - eprintln!( - "{} {}", - s.bold_cyan.apply_to("?"), - question.text, - ); + eprintln!("{} {}", s.bold_cyan.apply_to("?"), question.text,); match question.question_type { QuestionType::MultipleChoice | QuestionType::MultiSelect => { diff --git a/crates/arc-workflows/src/lib.rs b/crates/arc-workflows/src/lib.rs index 45763f1b8..51f912f00 100644 --- a/crates/arc-workflows/src/lib.rs +++ b/crates/arc-workflows/src/lib.rs @@ -14,10 +14,10 @@ pub mod handler; pub mod interviewer; pub mod outcome; pub mod parser; -pub mod workflow; pub mod preamble; pub mod retro; pub mod retro_agent; pub mod stylesheet; pub mod transform; pub mod validation; +pub mod workflow; diff --git a/crates/arc-workflows/src/preamble.rs b/crates/arc-workflows/src/preamble.rs index 3162caf08..5d5748bb8 100644 --- a/crates/arc-workflows/src/preamble.rs +++ b/crates/arc-workflows/src/preamble.rs @@ -600,7 +600,10 @@ mod tests { let completed_nodes = vec!["plan".to_string(), "code".to_string()]; let mut node_outcomes: HashMap = HashMap::new(); node_outcomes.insert("plan".to_string(), Outcome::success()); - node_outcomes.insert("code".to_string(), Outcome::fail_classify("compilation error")); + node_outcomes.insert( + "code".to_string(), + Outcome::fail_classify("compilation error"), + ); let preamble = build_preamble( "compact", @@ -1329,7 +1332,10 @@ mod tests { let context = Context::new(); let completed_nodes = vec!["work".to_string()]; let mut node_outcomes: HashMap = HashMap::new(); - node_outcomes.insert("work".to_string(), Outcome::fail_classify("connection timeout")); + node_outcomes.insert( + "work".to_string(), + Outcome::fail_classify("connection timeout"), + ); let preamble = build_preamble( "summary:high", diff --git a/crates/arc-workflows/src/retro_agent.rs b/crates/arc-workflows/src/retro_agent.rs index 0f85a25e0..218df9b88 100644 --- a/crates/arc-workflows/src/retro_agent.rs +++ b/crates/arc-workflows/src/retro_agent.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use arc_agent::{ - AnthropicProfile, Sandbox, GeminiProfile, OpenAiProfile, ProviderProfile, Session, + AnthropicProfile, GeminiProfile, OpenAiProfile, ProviderProfile, Sandbox, Session, SessionConfig, }; use arc_llm::client::Client; @@ -161,12 +161,7 @@ pub async fn run_retro_agent( ..SessionConfig::default() }; - let mut session = Session::new( - llm_client.clone(), - profile, - Arc::clone(sandbox), - config, - ); + let mut session = Session::new(llm_client.clone(), profile, Arc::clone(sandbox), config); session.initialize().await; diff --git a/crates/arc-workflows/tests/daytona_integration.rs b/crates/arc-workflows/tests/daytona_integration.rs index 76b20f829..b45570c6b 100644 --- a/crates/arc-workflows/tests/daytona_integration.rs +++ b/crates/arc-workflows/tests/daytona_integration.rs @@ -13,7 +13,7 @@ use arc_workflows::artifact::sync_artifacts_to_env; use arc_workflows::checkpoint::Checkpoint; use arc_workflows::context::Context; use arc_workflows::daytona_sandbox::{DaytonaConfig, DaytonaSandbox}; -use arc_workflows::engine::{WorkflowRunEngine, RunConfig}; +use arc_workflows::engine::{RunConfig, WorkflowRunEngine}; use arc_workflows::error::ArcError; use arc_workflows::event::EventEmitter; use arc_workflows::graph::{AttrValue, Edge, Graph, Node}; @@ -1081,22 +1081,26 @@ async fn daytona_asset_collection() { ); let mut start = Node::new("start"); - start - .attrs - .insert("shape".to_string(), AttrValue::String("Mdiamond".to_string())); + start.attrs.insert( + "shape".to_string(), + AttrValue::String("Mdiamond".to_string()), + ); graph.nodes.insert("start".to_string(), start); let mut create_assets = Node::new("create_assets"); - create_assets - .attrs - .insert("label".to_string(), AttrValue::String("Create Assets".to_string())); + create_assets.attrs.insert( + "label".to_string(), + AttrValue::String("Create Assets".to_string()), + ); graph .nodes .insert("create_assets".to_string(), create_assets); let mut exit = Node::new("exit"); - exit.attrs - .insert("shape".to_string(), AttrValue::String("Msquare".to_string())); + exit.attrs.insert( + "shape".to_string(), + AttrValue::String("Msquare".to_string()), + ); graph.nodes.insert("exit".to_string(), exit); graph.edges.push(Edge::new("start", "create_assets")); diff --git a/crates/arc-workflows/tests/integration.rs b/crates/arc-workflows/tests/integration.rs index 5c3d1bccb..42f930741 100644 --- a/crates/arc-workflows/tests/integration.rs +++ b/crates/arc-workflows/tests/integration.rs @@ -7,7 +7,7 @@ use arc_util::terminal::Styles; use arc_workflows::checkpoint::Checkpoint; use arc_workflows::cli::backend::AgentApiBackend; use arc_workflows::context::Context; -use arc_workflows::engine::{WorkflowRunEngine, RunConfig}; +use arc_workflows::engine::{RunConfig, WorkflowRunEngine}; use arc_workflows::error::ArcError; use arc_workflows::event::{EventEmitter, WorkflowRunEvent}; use arc_workflows::graph::{AttrValue, Edge, Graph, Node}; @@ -492,7 +492,10 @@ impl Handler for AlwaysFailHandler { _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, ) -> Result { - Ok(Outcome::fail_classify(format!("forced failure for {}", node.id))) + Ok(Outcome::fail_classify(format!( + "forced failure for {}", + node.id + ))) } } @@ -1249,10 +1252,7 @@ fn make_full_registry(interviewer: Arc) -> HandlerRegistry { registry.register("conditional", Box::new(ConditionalHandler)); registry.register("script", Box::new(ScriptHandler)); registry.register("wait.human", Box::new(WaitHumanHandler::new(interviewer))); - registry.register( - "stack.manager_loop", - Box::new(SubWorkflowHandler), - ); + registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); registry } @@ -3383,10 +3383,9 @@ async fn manager_loop_context_flows_e2e() { outcome .context_updates .insert("review.result".to_string(), serde_json::json!("approved")); - outcome.context_updates.insert( - "review.echo".to_string(), - serde_json::json!(target), - ); + outcome + .context_updates + .insert("review.echo".to_string(), serde_json::json!(target)); Ok(outcome) } } @@ -3406,18 +3405,18 @@ async fn manager_loop_context_flows_e2e() { _services: &arc_workflows::handler::EngineServices, ) -> Result { let mut outcome = Outcome::success(); - outcome - .context_updates - .insert("review.target".to_string(), serde_json::json!("src/main.rs")); + outcome.context_updates.insert( + "review.target".to_string(), + serde_json::json!("src/main.rs"), + ); Ok(outcome) } } let mut setter = Node::new("setter"); - setter.attrs.insert( - "type".to_string(), - AttrValue::String("setter".to_string()), - ); + setter + .attrs + .insert("type".to_string(), AttrValue::String("setter".to_string())); graph.nodes.insert("setter".to_string(), setter); let mut supervisor = Node::new("supervisor"); @@ -4876,7 +4875,8 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { captures: captures_low.clone(), }), ); - let engine_low = WorkflowRunEngine::new(registry_low, Arc::new(EventEmitter::new()), local_env()); + let engine_low = + WorkflowRunEngine::new(registry_low, Arc::new(EventEmitter::new()), local_env()); let config_low = RunConfig { logs_root: dir_low.path().to_path_buf(), cancel_token: None, @@ -4940,7 +4940,8 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { captures: captures_med.clone(), }), ); - let engine_med = WorkflowRunEngine::new(registry_med, Arc::new(EventEmitter::new()), local_env()); + let engine_med = + WorkflowRunEngine::new(registry_med, Arc::new(EventEmitter::new()), local_env()); let config_med = RunConfig { logs_root: dir_med.path().to_path_buf(), cancel_token: None, @@ -5542,7 +5543,7 @@ mod real_llm { use super::local_env; use arc_workflows::checkpoint::Checkpoint; - use arc_workflows::engine::{WorkflowRunEngine, RunConfig}; + use arc_workflows::engine::{RunConfig, WorkflowRunEngine}; use arc_workflows::event::EventEmitter; use arc_workflows::graph::{AttrValue, Edge, Graph}; use arc_workflows::handler::exit::ExitHandler; @@ -7694,7 +7695,11 @@ impl arc_agent::Sandbox for RemoteMockEnv { Ok(()) } - async fn download_file_to_local(&self, _: &str, _: &std::path::Path) -> std::result::Result<(), String> { + async fn download_file_to_local( + &self, + _: &str, + _: &std::path::Path, + ) -> std::result::Result<(), String> { Err("not implemented".to_string()) } @@ -7738,7 +7743,8 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() { registry.register("exit", Box::new(ExitHandler)); let remote_env = Arc::new(RemoteMockEnv::new("/sandbox")); - let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), remote_env.clone()); + let engine = + WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), remote_env.clone()); let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, @@ -7922,7 +7928,7 @@ async fn node_dir_uses_visit_count_on_revisit() { // CLI Backend end-to-end tests // --------------------------------------------------------------------------- -use arc_workflows::cli::cli_backend::{BackendRouter, AgentCliBackend}; +use arc_workflows::cli::cli_backend::{AgentCliBackend, BackendRouter}; /// A mock sandbox for CLI backend e2e tests. /// Records all exec_command and write_file calls, and returns configurable @@ -8355,8 +8361,7 @@ async fn cli_backend_run_fails_on_nonzero_exit() { #[tokio::test] async fn cli_backend_run_fails_on_unparseable_output() { - let env: Arc = - Arc::new(CliTestEnv::new("this is not json at all")); + let env: Arc = Arc::new(CliTestEnv::new("this is not json at all")); let backend = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic); let node = Node::new("step"); @@ -9080,9 +9085,8 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() { let mut emitter = EventEmitter::new(); let events = collect_events(&mut emitter); - let env: Arc = Arc::new( - arc_agent::LocalSandbox::new(worktree_path.clone()), - ); + let env: Arc = + Arc::new(arc_agent::LocalSandbox::new(worktree_path.clone())); let mut registry = HandlerRegistry::new(Box::new(ContextSetterHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); @@ -9260,9 +9264,8 @@ async fn git_checkpoint_host_writes_shadow_branch() { std::fs::write(logs_dir.path().join("graph.dot"), "digraph {}").unwrap(); let emitter = EventEmitter::new(); - let env: Arc = Arc::new( - arc_agent::LocalSandbox::new(worktree_path.clone()), - ); + let env: Arc = + Arc::new(arc_agent::LocalSandbox::new(worktree_path.clone())); let mut registry = HandlerRegistry::new(Box::new(ContextSetterHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); @@ -9447,9 +9450,8 @@ async fn parallel_git_branching_host_e2e() { let mut emitter = EventEmitter::new(); let events = collect_events(&mut emitter); - let env: Arc = Arc::new( - arc_agent::LocalSandbox::new(worktree_path.clone()), - ); + let env: Arc = + Arc::new(arc_agent::LocalSandbox::new(worktree_path.clone())); let mut registry = HandlerRegistry::new(Box::new(FileWriterHandler)); registry.register("start", Box::new(StartHandler)); @@ -9700,8 +9702,10 @@ impl Handler for SignatureHintHandler { _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, ) -> Result { - Ok(Outcome::fail_classify("error at line 42 in commit abc123def0") - .with_signature(Some("custom-grouping-key"))) + Ok( + Outcome::fail_classify("error at line 42 in commit abc123def0") + .with_signature(Some("custom-grouping-key")), + ) } } @@ -10669,8 +10673,7 @@ impl Handler for ClassifiedFailHandler { if n >= self.succeed_on { return Ok(Outcome::success()); } - let failure_class: arc_workflows::error::FailureClass = - self.failure_class.parse().unwrap(); + let failure_class: arc_workflows::error::FailureClass = self.failure_class.parse().unwrap(); let mut outcome = Outcome::fail_classify("classified failure"); if let Some(ref mut f) = outcome.failure { f.failure_class = failure_class; @@ -10706,7 +10709,10 @@ async fn e2e_loop_restart_blocked_for_deterministic_failure() { }; let result = engine.run(&graph, &config).await; - assert!(result.is_err(), "deterministic failure should not loop_restart"); + assert!( + result.is_err(), + "deterministic failure should not loop_restart" + ); let err = result.unwrap_err().to_string(); assert!( err.contains("loop_restart blocked"), @@ -10741,7 +10747,10 @@ async fn e2e_loop_restart_blocked_for_structural_failure() { }; let result = engine.run(&graph, &config).await; - assert!(result.is_err(), "structural failure should not loop_restart"); + assert!( + result.is_err(), + "structural failure should not loop_restart" + ); let err = result.unwrap_err().to_string(); assert!( err.contains("loop_restart blocked"), @@ -10776,7 +10785,10 @@ async fn e2e_loop_restart_blocked_for_budget_exhausted_failure() { }; let result = engine.run(&graph, &config).await; - assert!(result.is_err(), "budget_exhausted failure should not loop_restart"); + assert!( + result.is_err(), + "budget_exhausted failure should not loop_restart" + ); let err = result.unwrap_err().to_string(); assert!( err.contains("loop_restart blocked"), @@ -10846,7 +10858,10 @@ async fn e2e_loop_restart_blocked_for_compilation_loop_failure() { }; let result = engine.run(&graph, &config).await; - assert!(result.is_err(), "compilation_loop failure should not loop_restart"); + assert!( + result.is_err(), + "compilation_loop failure should not loop_restart" + ); let err = result.unwrap_err().to_string(); assert!( err.contains("loop_restart blocked"), @@ -10967,10 +10982,7 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() { let events_clone = events.clone(); let mut emitter = EventEmitter::new(); emitter.on_event(move |event| { - events_clone - .lock() - .unwrap() - .push(format!("{event:?}")); + events_clone.lock().unwrap().push(format!("{event:?}")); }); let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), local_env()); @@ -11040,7 +11052,10 @@ async fn e2e_stall_watchdog_kept_alive_by_handler_events() { labels: std::collections::HashMap::new(), }; - let outcome = engine.run(&graph, &config).await.expect("pipeline should succeed"); + let outcome = engine + .run(&graph, &config) + .await + .expect("pipeline should succeed"); assert_eq!(outcome.status, StageStatus::Success); } @@ -11056,7 +11071,11 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() { start -> work -> exit }"#; let graph = parse(dot).expect("parse should succeed"); - assert_eq!(graph.stall_timeout(), None, "zero timeout should disable watchdog"); + assert_eq!( + graph.stall_timeout(), + None, + "zero timeout should disable watchdog" + ); let dir = tempfile::tempdir().unwrap(); let mut registry = HandlerRegistry::new(Box::new(StartHandler)); @@ -11077,7 +11096,10 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() { labels: std::collections::HashMap::new(), }; - let outcome = engine.run(&graph, &config).await.expect("pipeline should succeed"); + let outcome = engine + .run(&graph, &config) + .await + .expect("pipeline should succeed"); assert_eq!(outcome.status, StageStatus::Success); } @@ -11228,22 +11250,26 @@ async fn asset_collection_local_sandbox_success() { ); let mut start = Node::new("start"); - start - .attrs - .insert("shape".to_string(), AttrValue::String("Mdiamond".to_string())); + start.attrs.insert( + "shape".to_string(), + AttrValue::String("Mdiamond".to_string()), + ); graph.nodes.insert("start".to_string(), start); let mut create_assets = Node::new("create_assets"); - create_assets - .attrs - .insert("label".to_string(), AttrValue::String("Create Assets".to_string())); + create_assets.attrs.insert( + "label".to_string(), + AttrValue::String("Create Assets".to_string()), + ); graph .nodes .insert("create_assets".to_string(), create_assets); let mut exit = Node::new("exit"); - exit.attrs - .insert("shape".to_string(), AttrValue::String("Msquare".to_string())); + exit.attrs.insert( + "shape".to_string(), + AttrValue::String("Msquare".to_string()), + ); graph.nodes.insert("exit".to_string(), exit); graph.edges.push(Edge::new("start", "create_assets")); @@ -11261,7 +11287,10 @@ async fn asset_collection_local_sandbox_success() { labels: std::collections::HashMap::new(), }; - let outcome = engine.run(&graph, &config).await.expect("run should succeed"); + let outcome = engine + .run(&graph, &config) + .await + .expect("run should succeed"); assert_eq!(outcome.status, StageStatus::Success); // Check that asset files were collected into the stage directory @@ -11318,11 +11347,7 @@ async fn asset_collection_local_sandbox_on_failure() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunEngine::new( - registry, - Arc::new(EventEmitter::new()), - sandbox.clone(), - ); + let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), sandbox.clone()); let mut graph = Graph::new("AssetCollectionFailTest"); graph.attrs.insert( @@ -11331,22 +11356,26 @@ async fn asset_collection_local_sandbox_on_failure() { ); let mut start = Node::new("start"); - start - .attrs - .insert("shape".to_string(), AttrValue::String("Mdiamond".to_string())); + start.attrs.insert( + "shape".to_string(), + AttrValue::String("Mdiamond".to_string()), + ); graph.nodes.insert("start".to_string(), start); let mut create_assets = Node::new("create_assets"); - create_assets - .attrs - .insert("label".to_string(), AttrValue::String("Create Assets".to_string())); + create_assets.attrs.insert( + "label".to_string(), + AttrValue::String("Create Assets".to_string()), + ); graph .nodes .insert("create_assets".to_string(), create_assets); let mut exit = Node::new("exit"); - exit.attrs - .insert("shape".to_string(), AttrValue::String("Msquare".to_string())); + exit.attrs.insert( + "shape".to_string(), + AttrValue::String("Msquare".to_string()), + ); graph.nodes.insert("exit".to_string(), exit); graph.edges.push(Edge::new("start", "create_assets")); @@ -11364,7 +11393,10 @@ async fn asset_collection_local_sandbox_on_failure() { labels: std::collections::HashMap::new(), }; - let outcome = engine.run(&graph, &config).await.expect("run should succeed"); + let outcome = engine + .run(&graph, &config) + .await + .expect("run should succeed"); // The pipeline completes (handler returned Fail, not an error), but assets should still be collected assert_eq!(outcome.status, StageStatus::Fail); @@ -11404,11 +11436,7 @@ async fn asset_collection_docker_sandbox() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunEngine::new( - registry, - Arc::new(EventEmitter::new()), - sandbox.clone(), - ); + let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), sandbox.clone()); let mut graph = Graph::new("DockerAssetTest"); graph.attrs.insert( @@ -11417,22 +11445,26 @@ async fn asset_collection_docker_sandbox() { ); let mut start = Node::new("start"); - start - .attrs - .insert("shape".to_string(), AttrValue::String("Mdiamond".to_string())); + start.attrs.insert( + "shape".to_string(), + AttrValue::String("Mdiamond".to_string()), + ); graph.nodes.insert("start".to_string(), start); let mut create_assets = Node::new("create_assets"); - create_assets - .attrs - .insert("label".to_string(), AttrValue::String("Create Assets".to_string())); + create_assets.attrs.insert( + "label".to_string(), + AttrValue::String("Create Assets".to_string()), + ); graph .nodes .insert("create_assets".to_string(), create_assets); let mut exit = Node::new("exit"); - exit.attrs - .insert("shape".to_string(), AttrValue::String("Msquare".to_string())); + exit.attrs.insert( + "shape".to_string(), + AttrValue::String("Msquare".to_string()), + ); graph.nodes.insert("exit".to_string(), exit); graph.edges.push(Edge::new("start", "create_assets"));