Fix cargo fmt formatting across workspace

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-04 00:36:44 -05:00
parent 3fc3387c34
commit dc3867a3c4
63 changed files with 2534 additions and 1365 deletions

View file

@ -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:?}")),
);
}
_ => {}

View file

@ -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;

View file

@ -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<dyn Sandbox> =
Arc::new(DockerSandbox::new(config).unwrap());
let env: Arc<dyn Sandbox> = Arc::new(DockerSandbox::new(config).unwrap());
// Initialize
env.initialize().await.unwrap();

View file

@ -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};

View file

@ -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();

View file

@ -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};

View file

@ -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::{

View file

@ -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};

View file

@ -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,

View file

@ -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");

View file

@ -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<dyn crate::provider_profile::ProviderProfile> =
Arc::new(TestProfile::new());
let env: Arc<dyn crate::sandbox::Sandbox> =
Arc::new(MockSandbox::default());
let env: Arc<dyn crate::sandbox::Sandbox> = Arc::new(MockSandbox::default());
let mut session = Session::new(client, profile, env, config);
// Subscribe to events before initialize

View file

@ -543,8 +543,7 @@ name: trimmed
let skills = Arc::new(test_skills());
let tool = make_use_skill_tool(skills);
let env: Arc<dyn crate::sandbox::Sandbox> =
Arc::new(MockSandbox::default());
let env: Arc<dyn crate::sandbox::Sandbox> = 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<dyn crate::sandbox::Sandbox> =
Arc::new(MockSandbox::default());
let env: Arc<dyn crate::sandbox::Sandbox> = 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<dyn crate::sandbox::Sandbox> =
Arc::new(MockSandbox::default());
let env: Arc<dyn crate::sandbox::Sandbox> = Arc::new(MockSandbox::default());
let args = serde_json::json!({});
let ctx = crate::tool_registry::ToolContext {
env,

View file

@ -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;

View file

@ -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 {

View file

@ -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};

File diff suppressed because it is too large Load diff

View file

@ -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()

View file

@ -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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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<Arc<AppState>>,
) -> Response {
async fn list_runs(_auth: AuthenticatedService, State(state): State<Arc<AppState>>) -> Response {
let runs = state.runs.lock().expect("runs lock poisoned");
let items: Vec<RunStatusResponse> = runs
.iter()
@ -223,8 +265,7 @@ async fn start_run(
let registry = (state.registry_factory)(Arc::clone(&interviewer) as Arc<dyn Interviewer>);
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let sandbox: Arc<dyn arc_agent::Sandbox> =
Arc::new(LocalSandbox::new(cwd));
let sandbox: Arc<dyn arc_agent::Sandbox> = 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(

View file

@ -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());
}
}

View file

@ -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);

View file

@ -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(),

View file

@ -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<Regex> =
LazyLock::new(|| Regex::new(r"(?:OpenSSL|LibreSSL)\s+(\d+)\.(\d+)\.(\d+)").unwrap());
static NODE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"v(\d+)\.(\d+)\.(\d+)").unwrap());
static NODE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"v(\d+)\.(\d+)\.(\d+)").unwrap());
static GH_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"gh version (\d+)\.(\d+)\.(\d+)").unwrap());
static DOT_RE: LazyLock<Regex> =
@ -176,10 +177,34 @@ fn parse_version(re: &Regex, output: &str) -> Option<Version> {
}
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<ProbeOutcome> {
@ -208,7 +233,11 @@ pub fn probe_system_deps() -> Vec<ProbeOutcome> {
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]

View file

@ -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<bool> {
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<String> {
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<Vec<usize>> {
@ -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");
}

View file

@ -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();

View file

@ -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(())

View file

@ -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]

View file

@ -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<FeatureMetad
let metadata_str = tokio::fs::read_to_string(&metadata_path)
.await
.map_err(|e| {
DevcontainerError::Feature(format!(
"failed to read {}: {e}",
metadata_path.display()
))
DevcontainerError::Feature(format!("failed to read {}: {e}", metadata_path.display()))
})?;
serde_json::from_str(&metadata_str).map_err(|e| {
DevcontainerError::Feature(format!(
"failed to parse {}: {e}",
metadata_path.display()
))
DevcontainerError::Feature(format!("failed to parse {}: {e}", metadata_path.display()))
})
}
/// Create a feature output directory under the temp dir.
async fn create_feature_dir(output_dir: &Path, feature_id: &str) -> crate::Result<std::path::PathBuf> {
async fn create_feature_dir(
output_dir: &Path,
feature_id: &str,
) -> crate::Result<std::path::PathBuf> {
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<FeatureMetadata> {
async fn fetch_feature_oci(feature_id: &str, output_dir: &Path) -> crate::Result<FeatureMetadata> {
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<String> = features.keys().cloned().collect();
@ -558,7 +548,9 @@ pub async fn resolve_features(
let mut oras_checked = false;
let mut metadata_map: HashMap<String, FeatureMetadata> = 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);
}

View file

@ -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<String, String> = 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<String, String> = 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)) => {

View file

@ -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"}))

View file

@ -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"
);
}

View file

@ -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",
);
}

View file

@ -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

View file

@ -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();

View file

@ -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]

View file

@ -192,7 +192,11 @@ pub async fn generate(params: GenerateParams) -> Result<GenerateResult, SdkError
&& params.tools.is_some()
&& max_tool_rounds > 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> =

View file

@ -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()

View file

@ -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"));
}

View file

@ -179,7 +179,9 @@ impl ArtifactStore {
/// Returns `None` if no `base_dir` is configured.
#[must_use]
pub fn artifacts_dir(&self) -> Option<PathBuf> {
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.

View file

@ -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<DiscoveredFile>, root: &str) -> Vec<Discovere
/// Take a snapshot of current asset files in the sandbox.
/// Returns a fingerprint map of discovered files.
pub async fn snapshot(
sandbox: &dyn Sandbox,
) -> Result<HashMap<String, FileFingerprint>, String> {
pub async fn snapshot(sandbox: &dyn Sandbox) -> Result<HashMap<String, FileFingerprint>, 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<String, String>,
exec_stdout: &str,
platform: &'static str,
) -> Self {
fn new(files: HashMap<String, String>, 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<usize>, _: Option<usize>) -> Result<String, String> {
async fn read_file(
&self,
_: &str,
_: Option<usize>,
_: Option<usize>,
) -> Result<String, String> {
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<bool, String> { Ok(false) }
async fn list_directory(&self, _: &str, _: Option<usize>) -> Result<Vec<arc_agent::sandbox::DirEntry>, String> { Ok(vec![]) }
async fn exec_command(&self, _: &str, _: u64, _: Option<&str>, _: Option<&std::collections::HashMap<String, String>>, _: Option<tokio_util::sync::CancellationToken>) -> Result<ExecResult, String> {
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<bool, String> {
Ok(false)
}
async fn list_directory(
&self,
_: &str,
_: Option<usize>,
) -> Result<Vec<arc_agent::sandbox::DirEntry>, String> {
Ok(vec![])
}
async fn exec_command(
&self,
_: &str,
_: u64,
_: Option<&str>,
_: Option<&std::collections::HashMap<String, String>>,
_: Option<tokio_util::sync::CancellationToken>,
) -> Result<ExecResult, String> {
Ok(self.exec_result.clone())
}
async fn grep(&self, _: &str, _: &str, _: &arc_agent::sandbox::GrepOptions) -> Result<Vec<String>, String> { Ok(vec![]) }
async fn glob(&self, _: &str, _: Option<&str>) -> Result<Vec<String>, 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<Vec<String>, String> {
Ok(vec![])
}
async fn glob(&self, _: &str, _: Option<&str>) -> Result<Vec<String>, 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(), "<test/>".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(), "<test/>".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();

View file

@ -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);

View file

@ -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<dyn Sandbox>,
) -> Vec<String> {
async fn detect_changed_files(&self, sandbox: &Arc<dyn Sandbox>) -> Vec<String> {
// 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
}

View file

@ -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<Styles> = std::sync::LazyLock::new(|| Styles::new(false));
static STYLES: std::sync::LazyLock<Styles> =
std::sync::LazyLock::new(|| Styles::new(false));
&STYLES
}

View file

@ -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

View file

@ -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<dyn Sandbox>)
.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<dyn Sandbox>)
}
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<dyn Sandbox>)
}
}
Err(e) => Err(format!("Daytona client creation failed: {e}")),
},
SandboxProvider::Local => {
Ok(Arc::new(LocalSandbox::new(original_cwd.clone())) as Arc<dyn Sandbox>)
}
@ -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::<Vec<_>>();
let names = c
.provider_names()
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>();
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);

View file

@ -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}"
);
}

View file

@ -82,10 +82,7 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
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<Vec<RunInfo>> {
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<String, String> = 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]

View file

@ -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(),
);

View file

@ -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;

View file

@ -42,9 +42,9 @@ fn millis_u64(d: std::time::Duration) -> u64 {
fn classify_outcome(outcome: &Outcome) -> Option<FailureClass> {
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<String> {
// --- 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<String> {
}
/// 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<Outcome> {
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");

View file

@ -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

View file

@ -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<String, s
return ("Agent".to_string(), serde_json::Map::new());
};
let stage = agent_fields.remove("stage");
let agent_event = agent_fields.remove("event").unwrap_or(serde_json::Value::Null);
let agent_event = agent_fields
.remove("event")
.unwrap_or(serde_json::Value::Null);
match agent_event {
serde_json::Value::Object(event_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<String, serde_js
})
.or_else(|| {
// For newtype string variants: { "data": "..." }
error_val.get("data").and_then(|d| d.as_str().map(String::from))
error_val
.get("data")
.and_then(|d| d.as_str().map(String::from))
})
.unwrap_or_else(|| error_val.to_string());
fields.insert("error".to_string(), serde_json::Value::String(display));
@ -948,7 +930,10 @@ mod tests {
preferred_label: None,
suggested_next_ids: vec![],
usage: None,
failure: Some(FailureDetail::new("lint errors remain", FailureClass::Deterministic)),
failure: Some(FailureDetail::new(
"lint errors remain",
FailureClass::Deterministic,
)),
notes: Some("fixed 3 of 5 issues".to_string()),
files_touched: vec!["src/main.rs".to_string()],
attempt: 2,

View file

@ -431,7 +431,11 @@ impl Graph {
/// Graph-level `stall_timeout`. Defaults to 600s. Returns `None` when set to zero (disabled).
pub fn stall_timeout(&self) -> Option<Duration> {
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)),

View file

@ -37,7 +37,9 @@ impl Handler for FanInHandler {
) -> Result<Outcome, ArcError> {
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());

View file

@ -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<String, ArcError> {
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]

View file

@ -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<dyn Sandbox>, Option<PathBuf>) =
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<dyn Sandbox>, Option<PathBuf>) = 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<dyn Sandbox> = 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<dyn Sandbox> = 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<dyn Sandbox> =
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<dyn Sandbox> = 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;
}
}
}

View file

@ -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]

View file

@ -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

View file

@ -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 => {

View file

@ -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;

View file

@ -600,7 +600,10 @@ mod tests {
let completed_nodes = vec!["plan".to_string(), "code".to_string()];
let mut node_outcomes: HashMap<String, Outcome> = 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<String, Outcome> = 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",

View file

@ -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;

View file

@ -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"));

View file

@ -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<Outcome, arc_workflows::error::ArcError> {
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<dyn Interviewer>) -> 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<Outcome, ArcError> {
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<dyn arc_agent::Sandbox> =
Arc::new(CliTestEnv::new("this is not json at all"));
let env: Arc<dyn arc_agent::Sandbox> = 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<dyn arc_agent::Sandbox> = Arc::new(
arc_agent::LocalSandbox::new(worktree_path.clone()),
);
let env: Arc<dyn arc_agent::Sandbox> =
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<dyn arc_agent::Sandbox> = Arc::new(
arc_agent::LocalSandbox::new(worktree_path.clone()),
);
let env: Arc<dyn arc_agent::Sandbox> =
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<dyn arc_agent::Sandbox> = Arc::new(
arc_agent::LocalSandbox::new(worktree_path.clone()),
);
let env: Arc<dyn arc_agent::Sandbox> =
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<Outcome, ArcError> {
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"));