cargo fmt

This commit is contained in:
Bryan Helmkamp 2026-03-08 09:51:10 -04:00
parent 445050dc57
commit 3dce68d664
62 changed files with 2299 additions and 1510 deletions

View file

@ -87,7 +87,10 @@ impl AgentArgs {
.or_else(|| provider.map(String::from))
.or_else(|| Some("anthropic".to_string()));
self.model = self.model.take().or_else(|| model.map(String::from));
self.permissions = self.permissions.or(permissions).or(Some(PermissionLevel::ReadWrite));
self.permissions = self
.permissions
.or(permissions)
.or(Some(PermissionLevel::ReadWrite));
self.output_format = self
.output_format
.or(output_format)
@ -381,10 +384,10 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> {
// Resolve model and build profile
let model = args.model.unwrap_or_else(|| {
arc_llm::catalog::default_model_for_provider(provider.as_str())
.map(|m| m.id)
.unwrap_or_else(|| provider.as_str().to_string())
});
arc_llm::catalog::default_model_for_provider(provider.as_str())
.map(|m| m.id)
.unwrap_or_else(|| provider.as_str().to_string())
});
eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}")));
let mut profile = build_profile(provider, &model, Some(client.clone()));
@ -523,7 +526,11 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> {
..
} => {
let short_id = &agent_id[..8.min(agent_id.len())];
let task_preview = if task.len() > 60 { &task[..crate::truncation::floor_char_boundary(task, 60)] } else { task };
let task_preview = if task.len() > 60 {
&task[..crate::truncation::floor_char_boundary(task, 60)]
} else {
task
};
eprintln!(
" {}",
s.dim.apply_to(format!(

View file

@ -197,7 +197,10 @@ pub fn render_turns_for_summary(turns: &[Turn]) -> String {
for tc in tool_calls {
let args_str = tc.arguments.to_string();
let truncated = if args_str.len() > 500 {
format!("{}...", &args_str[..crate::truncation::floor_char_boundary(&args_str, 500)])
format!(
"{}...",
&args_str[..crate::truncation::floor_char_boundary(&args_str, 500)]
)
} else {
args_str
};
@ -208,7 +211,11 @@ pub fn render_turns_for_summary(turns: &[Turn]) -> String {
for r in results {
let content_str = r.content.to_string();
let truncated = if content_str.len() > 500 {
format!("{}...", &content_str[..crate::truncation::floor_char_boundary(&content_str, 500)])
format!(
"{}...",
&content_str
[..crate::truncation::floor_char_boundary(&content_str, 500)]
)
} else {
content_str
};

View file

@ -748,10 +748,7 @@ impl Sandbox for DockerSandbox {
}
fn sandbox_info(&self) -> String {
self.container_id
.get()
.cloned()
.unwrap_or_default()
self.container_id.get().cloned().unwrap_or_default()
}
}

View file

@ -442,7 +442,10 @@ mod tests {
..
} = assistant_turn
{
assert!(provider_parts.is_empty(), "reasoning items should be stripped");
assert!(
provider_parts.is_empty(),
"reasoning items should be stripped"
);
assert_eq!(tool_calls.len(), 1, "tool_calls should be preserved");
assert_eq!(content, "response", "text content should be preserved");
} else {
@ -479,7 +482,11 @@ mod tests {
let assistant_turn = &history.turns()[2];
if let Turn::Assistant { provider_parts, .. } = assistant_turn {
assert_eq!(provider_parts.len(), 1, "thinking block should be preserved");
assert_eq!(
provider_parts.len(),
1,
"thinking block should be preserved"
);
assert!(matches!(&provider_parts[0], ContentPart::Thinking(_)));
} else {
panic!("expected Assistant turn");

View file

@ -219,10 +219,19 @@ pub fn make_shell_tool_with_config(config: &SessionConfig) -> RegisteredTool {
.unwrap_or(default_timeout)
.min(max_timeout);
tracing::debug!(env_var_count = ctx.tool_env.as_ref().map_or(0, |e| e.len()), "Injecting sandbox env vars into tool execution");
tracing::debug!(
env_var_count = ctx.tool_env.as_ref().map_or(0, |e| e.len()),
"Injecting sandbox env vars into tool execution"
);
let result = ctx
.env
.exec_command(command, timeout_ms, None, ctx.tool_env.as_ref(), Some(ctx.cancel))
.exec_command(
command,
timeout_ms,
None,
ctx.tool_env.as_ref(),
Some(ctx.cancel),
)
.await?;
let mut output = String::new();

View file

@ -20,8 +20,13 @@ pub struct Hunk {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PatchOperation {
Add { path: String, content: String },
Delete { path: String },
Add {
path: String,
content: String,
},
Delete {
path: String,
},
Update {
path: String,
new_path: Option<String>,
@ -156,13 +161,12 @@ pub fn parse_v4a_patch(text: &str) -> Result<Vec<PatchOperation>, String> {
}
// Check for *** End of File marker
let end_of_file =
if i < lines.len() && lines[i].trim() == "*** End of File" {
i += 1;
true
} else {
false
};
let end_of_file = if i < lines.len() && lines[i].trim() == "*** End of File" {
i += 1;
true
} else {
false
};
hunks.push(Hunk {
context_line,
@ -309,9 +313,7 @@ fn apply_hunks(content: &str, hunks: &[Hunk]) -> Result<String, String> {
Change::Remove(t) | Change::Context(t) => Some(t.as_str()),
Change::Add(_) => None,
})
.ok_or(
"Hunk with bare @@ has no remove or context lines to locate position",
)?;
.ok_or("Hunk with bare @@ has no remove or context lines to locate position")?;
find_line_match_reverse(&lines, first_match_text)
}
.ok_or_else(|| {
@ -660,8 +662,14 @@ mod tests {
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].context_line, "");
assert_eq!(hunks[0].changes.len(), 4);
assert_eq!(hunks[0].changes[0], Change::Context("fn unchanged() {".into()));
assert_eq!(hunks[0].changes[1], Change::Remove(" old_line();".into()));
assert_eq!(
hunks[0].changes[0],
Change::Context("fn unchanged() {".into())
);
assert_eq!(
hunks[0].changes[1],
Change::Remove(" old_line();".into())
);
assert_eq!(hunks[0].changes[2], Change::Add(" new_line();".into()));
assert_eq!(hunks[0].changes[3], Change::Context("}".into()));
}
@ -981,10 +989,7 @@ mod tests {
}];
let result = apply_hunks(content, &hunks).unwrap();
// First "pass" should be untouched, second should be replaced
assert_eq!(
result,
"def foo():\n pass\n\ndef bar():\n return 99"
);
assert_eq!(result, "def foo():\n pass\n\ndef bar():\n return 99");
}
// Phase 4: *** Move to:
@ -1447,14 +1452,12 @@ def farewell(name):
let provider = Arc::new(MockLlmProvider::new(responses));
let client = make_client(provider).await;
let profile = Arc::new(TestProfile::with_tools(registry));
let mut session = Session::new(
client,
profile,
env.clone(),
SessionConfig::default(),
);
let mut session = Session::new(client, profile, env.clone(), SessionConfig::default());
session.initialize().await;
session.process_input("Update the greeting functions").await.unwrap();
session
.process_input("Update the greeting functions")
.await
.unwrap();
let content = env.read_file("src/app.py", None, None).await.unwrap();
assert!(content.contains("Hello, {name}!"));

View file

@ -6,9 +6,7 @@ use arc_llm::provider::Provider;
fn profile_context_window_matches_catalog_for_default_models() {
for &provider in Provider::ALL {
let catalog_info = catalog::default_model_for_provider(provider.as_str())
.unwrap_or_else(|| {
panic!("no default model for {:?} in catalog", provider)
});
.unwrap_or_else(|| panic!("no default model for {:?} in catalog", provider));
let model = &catalog_info.id;
let profile: Box<dyn ProviderProfile> = match provider {

View file

@ -120,9 +120,19 @@ macro_rules! provider_test {
macro_rules! provider_tests {
($scenario:ident) => {
provider_test!($scenario, Provider::Anthropic, "claude-haiku-4-5", anthropic);
provider_test!(
$scenario,
Provider::Anthropic,
"claude-haiku-4-5",
anthropic
);
provider_test!($scenario, Provider::OpenAi, "gpt-5-mini", openai);
provider_test!($scenario, Provider::Gemini, "gemini-3-flash-preview", gemini);
provider_test!(
$scenario,
Provider::Gemini,
"gemini-3-flash-preview",
gemini
);
provider_test!($scenario, Provider::Kimi, "kimi-k2.5", kimi);
provider_test!($scenario, Provider::Zai, "glm-4.7", zai);
provider_test!($scenario, Provider::Minimax, "minimax-m2.5", minimax);
@ -157,8 +167,18 @@ provider_tests!(error_recovery);
// gpt-5-mini is too weak to reliably apply precise file edits (uses apply_patch, not edit_file).
macro_rules! non_openai_provider_tests {
($scenario:ident) => {
provider_test!($scenario, Provider::Anthropic, "claude-haiku-4-5", anthropic);
provider_test!($scenario, Provider::Gemini, "gemini-3-flash-preview", gemini);
provider_test!(
$scenario,
Provider::Anthropic,
"claude-haiku-4-5",
anthropic
);
provider_test!(
$scenario,
Provider::Gemini,
"gemini-3-flash-preview",
gemini
);
provider_test!($scenario, Provider::Kimi, "kimi-k2.5", kimi);
provider_test!($scenario, Provider::Zai, "glm-4.7", zai);
provider_test!($scenario, Provider::Minimax, "minimax-m2.5", minimax);
@ -399,9 +419,17 @@ macro_rules! reasoning_effort_tests {
};
}
reasoning_effort_tests!(Provider::Anthropic, "claude-haiku-4-5", anthropic_reasoning_effort);
reasoning_effort_tests!(
Provider::Anthropic,
"claude-haiku-4-5",
anthropic_reasoning_effort
);
// gpt-5-mini does not support the reasoning.effort parameter, so no OpenAI test.
reasoning_effort_tests!(Provider::Gemini, "gemini-3-flash-preview", gemini_reasoning_effort);
reasoning_effort_tests!(
Provider::Gemini,
"gemini-3-flash-preview",
gemini_reasoning_effort
);
reasoning_effort_tests!(Provider::Kimi, "kimi-k2.5", kimi_reasoning_effort);
reasoning_effort_tests!(Provider::Zai, "glm-4.7", zai_reasoning_effort);
reasoning_effort_tests!(Provider::Minimax, "minimax-m2.5", minimax_reasoning_effort);
@ -445,9 +473,17 @@ macro_rules! loop_detection_tests {
};
}
loop_detection_tests!(Provider::Anthropic, "claude-haiku-4-5", anthropic_loop_detection);
loop_detection_tests!(
Provider::Anthropic,
"claude-haiku-4-5",
anthropic_loop_detection
);
loop_detection_tests!(Provider::OpenAi, "gpt-5-mini", openai_loop_detection);
loop_detection_tests!(Provider::Gemini, "gemini-3-flash-preview", gemini_loop_detection);
loop_detection_tests!(
Provider::Gemini,
"gemini-3-flash-preview",
gemini_loop_detection
);
loop_detection_tests!(Provider::Kimi, "kimi-k2.5", kimi_loop_detection);
loop_detection_tests!(Provider::Zai, "glm-4.7", zai_loop_detection);
loop_detection_tests!(Provider::Minimax, "minimax-m2.5", minimax_loop_detection);

File diff suppressed because it is too large Load diff

View file

@ -120,10 +120,8 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
let (auth_mode, client_auth, max_concurrent_runs) = {
let cfg = shared_config.read().expect("config lock poisoned");
let auth_mode = crate::jwt_auth::resolve_auth_mode(
&cfg.api,
cfg.web.auth.allowed_usernames.clone(),
);
let auth_mode =
crate::jwt_auth::resolve_auth_mode(&cfg.api, cfg.web.auth.allowed_usernames.clone());
let client_auth = cfg
.api
.tls
@ -143,7 +141,13 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
cfg.git.author.email.clone(),
)
};
let state = crate::server::create_app_state_with_options(db, factory, dry_run_mode, max_concurrent_runs, git_author);
let state = crate::server::create_app_state_with_options(
db,
factory,
dry_run_mode,
max_concurrent_runs,
git_author,
);
crate::server::spawn_scheduler(Arc::clone(&state));
let router = build_router(state, auth_mode);

View file

@ -137,11 +137,7 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
let demo = demo_router.clone();
let real = real_router.clone();
async move {
if req
.headers()
.get("x-arc-demo")
.map_or(false, |v| v == "1")
{
if req.headers().get("x-arc-demo").map_or(false, |v| v == "1") {
demo.oneshot(req).await
} else {
real.oneshot(req).await
@ -267,26 +263,34 @@ fn real_routes() -> Router<Arc<AppState>> {
.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),
)
.route("/workflows/{name}/runs", get(not_implemented))
.route("/verification/criteria", get(not_implemented))
.route("/verification/criteria/{id}", get(not_implemented))
.route("/verification/controls", get(not_implemented))
.route("/verification/controls/{id}", get(not_implemented))
.route("/retros", get(not_implemented))
.route("/sessions", get(crate::sessions::list_sessions).post(crate::sessions::create_session))
.route(
"/sessions",
get(crate::sessions::list_sessions).post(crate::sessions::create_session),
)
.route("/sessions/{id}", get(crate::sessions::retrieve_session))
.route("/sessions/{id}/messages", post(crate::sessions::send_message))
.route("/sessions/{id}/events", get(crate::sessions::stream_session_events))
.route(
"/sessions/{id}/messages",
post(crate::sessions::send_message),
)
.route(
"/sessions/{id}/events",
get(crate::sessions::stream_session_events),
)
.route(
"/insights/queries",
get(not_implemented).post(not_implemented),
)
.route(
"/insights/queries/{id}",
get(not_implemented).put(not_implemented).delete(not_implemented),
get(not_implemented)
.put(not_implemented)
.delete(not_implemented),
)
.route("/insights/execute", post(not_implemented))
.route("/insights/history", get(not_implemented))
@ -318,7 +322,8 @@ async fn health() -> Response {
async fn openapi_spec() -> Response {
let yaml = include_str!("../../../docs/api-reference/arc-api.yaml");
let value: serde_json::Value = serde_yaml::from_str(yaml).expect("embedded OpenAPI YAML is invalid");
let value: serde_json::Value =
serde_yaml::from_str(yaml).expect("embedded OpenAPI YAML is invalid");
Json(value).into_response()
}
@ -330,7 +335,10 @@ async fn get_aggregate_usage(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
) -> Response {
let agg = state.aggregate_usage.lock().expect("aggregate_usage lock poisoned");
let agg = state
.aggregate_usage
.lock()
.expect("aggregate_usage lock poisoned");
let by_model: Vec<arc_types::UsageByModel> = agg
.by_model
.iter()
@ -365,7 +373,13 @@ pub fn create_app_state(
db: sqlx::SqlitePool,
registry_factory: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
) -> Arc<AppState> {
create_app_state_with_options(db, registry_factory, false, 5, arc_workflows::git::GitAuthor::default())
create_app_state_with_options(
db,
registry_factory,
false,
5,
arc_workflows::git::GitAuthor::default(),
)
}
/// Create an `AppState` with the given database pool, registry factory, dry-run flag, and concurrency limit.
@ -404,7 +418,9 @@ async fn list_runs(
.map(|(id, managed_run)| RunStatusResponse {
id: id.clone(),
status: managed_run.status,
error: managed_run.error.as_ref().map(|msg| arc_types::RunError { message: msg.clone() }),
error: managed_run.error.as_ref().map(|msg| arc_types::RunError {
message: msg.clone(),
}),
queue_position: queue_positions.get(id).copied(),
created_at: managed_run.created_at,
})
@ -485,7 +501,6 @@ async fn start_run(
queue_position: None,
created_at,
}),
)
.into_response()
}
@ -680,7 +695,9 @@ pub fn spawn_scheduler(state: Arc<AppState>) {
let runs = state.runs.lock().expect("runs lock poisoned");
let active = runs
.values()
.filter(|r| r.status == RunStatus::Starting || r.status == RunStatus::Running)
.filter(|r| {
r.status == RunStatus::Starting || r.status == RunStatus::Running
})
.count();
if active >= state.max_concurrent_runs {
break;
@ -721,7 +738,9 @@ async fn get_run_status(
Json(RunStatusResponse {
id: id.clone(),
status: managed_run.status,
error: managed_run.error.as_ref().map(|msg| arc_types::RunError { message: msg.clone() }),
error: managed_run.error.as_ref().map(|msg| arc_types::RunError {
message: msg.clone(),
}),
created_at: managed_run.created_at,
queue_position,
}),
@ -742,7 +761,13 @@ async fn get_questions(
Some(managed_run) => {
let interviewer = match &managed_run.interviewer {
Some(i) => i,
None => return (StatusCode::OK, Json(ListResponse::new(Vec::<ApiQuestion>::new()))).into_response(),
None => {
return (
StatusCode::OK,
Json(ListResponse::new(Vec::<ApiQuestion>::new())),
)
.into_response()
}
};
let pending = interviewer.pending_questions();
let questions: Vec<ApiQuestion> = pending
@ -809,9 +834,8 @@ async fn submit_answer(
let pq = pending.iter().find(|pq| pq.id == qid);
let mut options = Vec::new();
for key in &req.selected_option_keys {
let opt = pq.and_then(|pq| {
pq.question.options.iter().find(|o| o.key == *key).cloned()
});
let opt = pq
.and_then(|pq| pq.question.options.iter().find(|o| o.key == *key).cloned());
match opt {
Some(o) => options.push(o),
None => {
@ -853,7 +877,9 @@ async fn get_events(
match runs.get(&id) {
Some(managed_run) => match &managed_run.event_tx {
Some(tx) => tx.subscribe(),
None => return ApiError::new(StatusCode::GONE, "Event stream closed.").into_response(),
None => {
return ApiError::new(StatusCode::GONE, "Event stream closed.").into_response()
}
},
None => return ApiError::not_found("Run not found.").into_response(),
}
@ -1033,9 +1059,8 @@ async fn create_completion(
if use_stream {
let sse_stream = futures_util::stream::iter(vec![
Ok::<_, std::convert::Infallible>(
Event::default()
.event("message_start")
.data(serde_json::json!({
Event::default().event("message_start").data(
serde_json::json!({
"type": "message_start",
"message": {
"id": msg_id,
@ -1046,7 +1071,9 @@ async fn create_completion(
"stop_reason": null,
"usage": {"input_tokens": 0}
}
}).to_string()),
})
.to_string(),
),
),
Ok(Event::default()
.event("message_stop")
@ -1081,10 +1108,12 @@ async fn create_completion(
let msg_id = ulid::Ulid::new().to_string();
let model_for_stream = model_id.clone();
let sse_stream = futures_util::stream::once(futures_util::future::ready(Ok::<_, std::convert::Infallible>(
Event::default()
.event("message_start")
.data(serde_json::json!({
let sse_stream = futures_util::stream::once(futures_util::future::ready(Ok::<
_,
std::convert::Infallible,
>(
Event::default().event("message_start").data(
serde_json::json!({
"type": "message_start",
"message": {
"id": msg_id,
@ -1095,69 +1124,74 @@ async fn create_completion(
"stop_reason": null,
"usage": {"input_tokens": 0}
}
}).to_string()),
})
.to_string(),
),
)))
.chain(futures_util::stream::once(futures_util::future::ready(Ok(
Event::default()
.event("content_block_start")
.data(serde_json::json!({
Event::default().event("content_block_start").data(
serde_json::json!({
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": ""}
}).to_string()),
})
.to_string(),
),
))))
.chain(futures_util::stream::once(futures_util::future::ready(Ok(
Event::default()
.event("ping")
.data(serde_json::json!({"type": "ping"}).to_string()),
))))
.chain(
tokio_stream::StreamExt::map(stream_result, |event| {
match event {
Ok(arc_llm::types::StreamEvent::TextDelta { delta, .. }) => Ok(
Event::default()
.event("content_block_delta")
.data(serde_json::json!({
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": delta}
}).to_string()),
),
Ok(arc_llm::types::StreamEvent::TextEnd { .. }) => Ok(
Event::default()
.event("content_block_stop")
.data(serde_json::json!({"type": "content_block_stop", "index": 0}).to_string()),
),
Ok(arc_llm::types::StreamEvent::Finish { finish_reason, usage, .. }) => Ok(
Event::default()
.event("message_delta")
.data(serde_json::json!({
"type": "message_delta",
"delta": {"stop_reason": finish_reason_to_stop_reason(&finish_reason)},
"usage": {"output_tokens": usage.output_tokens}
}).to_string()),
),
Ok(arc_llm::types::StreamEvent::Error { error, .. }) => Ok(
Event::default()
.event("error")
.data(serde_json::json!({
"type": "error",
"error": {"type": "server_error", "message": error.to_string()}
}).to_string()),
),
Err(e) => Ok(
Event::default()
.event("error")
.data(serde_json::json!({
"type": "error",
"error": {"type": "server_error", "message": e.to_string()}
}).to_string()),
),
// Skip events we don't map (StreamStart, TextStart, reasoning, tool calls, etc.)
_ => Ok(Event::default().comment("ignored")),
.chain(tokio_stream::StreamExt::map(stream_result, |event| {
match event {
Ok(arc_llm::types::StreamEvent::TextDelta { delta, .. }) => {
Ok(Event::default().event("content_block_delta").data(
serde_json::json!({
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": delta}
})
.to_string(),
))
}
}),
)
Ok(arc_llm::types::StreamEvent::TextEnd { .. }) => {
Ok(Event::default().event("content_block_stop").data(
serde_json::json!({"type": "content_block_stop", "index": 0}).to_string(),
))
}
Ok(arc_llm::types::StreamEvent::Finish {
finish_reason,
usage,
..
}) => Ok(Event::default().event("message_delta").data(
serde_json::json!({
"type": "message_delta",
"delta": {"stop_reason": finish_reason_to_stop_reason(&finish_reason)},
"usage": {"output_tokens": usage.output_tokens}
})
.to_string(),
)),
Ok(arc_llm::types::StreamEvent::Error { error, .. }) => {
Ok(Event::default().event("error").data(
serde_json::json!({
"type": "error",
"error": {"type": "server_error", "message": error.to_string()}
})
.to_string(),
))
}
Err(e) => Ok(Event::default().event("error").data(
serde_json::json!({
"type": "error",
"error": {"type": "server_error", "message": e.to_string()}
})
.to_string(),
)),
// Skip events we don't map (StreamStart, TextStart, reasoning, tool calls, etc.)
_ => Ok(Event::default().comment("ignored")),
}
}))
.chain(futures_util::stream::once(futures_util::future::ready(Ok(
Event::default()
.event("message_stop")
@ -1193,10 +1227,8 @@ async fn create_completion(
output: result.output,
})
.into_response(),
Err(e) => {
ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}"))
.into_response()
}
Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}"))
.into_response(),
}
} else {
match arc_llm::generate::generate(params).await {
@ -1212,10 +1244,8 @@ async fn create_completion(
output: None,
})
.into_response(),
Err(e) => {
ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}"))
.into_response()
}
Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}"))
.into_response(),
}
}
}
@ -1266,7 +1296,11 @@ async fn get_graph(
{
Ok(child) => child,
Err(_) => {
return ApiError::new(StatusCode::BAD_GATEWAY, "Graphviz dot command not available.").into_response();
return ApiError::new(
StatusCode::BAD_GATEWAY,
"Graphviz dot command not available.",
)
.into_response();
}
};
@ -1287,9 +1321,8 @@ async fn get_graph(
let stderr = String::from_utf8_lossy(&output.stderr);
ApiError::new(StatusCode::BAD_GATEWAY, format!("dot failed: {stderr}")).into_response()
}
Err(e) => {
ApiError::new(StatusCode::BAD_GATEWAY, format!("dot process error: {e}")).into_response()
}
Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("dot process error: {e}"))
.into_response(),
}
}
@ -1376,7 +1409,13 @@ mod tests {
#[tokio::test]
async fn test_model_dry_run_returns_ok() {
let state = create_app_state_with_options(test_db().await, test_registry, true, 5, arc_workflows::git::GitAuthor::default());
let state = create_app_state_with_options(
test_db().await,
test_registry,
true,
5,
arc_workflows::git::GitAuthor::default(),
);
let app = build_router(state, AuthMode::Disabled);
let req = Request::builder()
@ -1396,7 +1435,13 @@ mod tests {
#[tokio::test]
async fn test_model_dry_run_unknown_returns_404() {
let state = create_app_state_with_options(test_db().await, test_registry, true, 5, arc_workflows::git::GitAuthor::default());
let state = create_app_state_with_options(
test_db().await,
test_registry,
true,
5,
arc_workflows::git::GitAuthor::default(),
);
let app = build_router(state, AuthMode::Disabled);
let req = Request::builder()
@ -1484,7 +1529,10 @@ mod tests {
assert_eq!(body["id"].as_str().unwrap(), run_id);
let status = body["status"].as_str().unwrap();
assert!(
status == "queued" || status == "starting" || status == "running" || status == "completed",
status == "queued"
|| status == "starting"
|| status == "running"
|| status == "completed",
"unexpected status: {status}"
);
}
@ -2077,7 +2125,13 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrency_limit_respected() {
let state = create_app_state_with_options(test_db().await, test_registry, false, 1, arc_workflows::git::GitAuthor::default());
let state = create_app_state_with_options(
test_db().await,
test_registry,
false,
1,
arc_workflows::git::GitAuthor::default(),
);
let app = test_app_with_scheduler(state);
// Submit two runs with max_concurrent_runs=1

View file

@ -34,10 +34,17 @@ pub struct SessionState {
#[derive(Clone, Debug)]
pub enum SessionEvent {
TextDelta { delta: String },
AssistantTurnComplete { content: String, created_at: chrono::DateTime<chrono::Utc> },
TextDelta {
delta: String,
},
AssistantTurnComplete {
content: String,
created_at: chrono::DateTime<chrono::Utc>,
},
Done,
Error { message: String },
Error {
message: String,
},
}
fn generate_title(content: &str) -> String {
@ -77,12 +84,7 @@ fn turns_to_messages(turns: &[arc_types::SessionTurn]) -> Vec<arc_llm::types::Me
.collect()
}
fn spawn_generation(
store: SessionStore,
session_id: uuid::Uuid,
dry_run: bool,
seq_at_start: u64,
) {
fn spawn_generation(store: SessionStore, session_id: uuid::Uuid, dry_run: bool, seq_at_start: u64) {
tokio::spawn(async move {
let (event_tx, model_id, model_provider, system_prompt, messages, generation_seq) = {
let store = store.read().expect("session store lock poisoned");
@ -233,12 +235,7 @@ pub async fn create_session(
store.insert(session_id, session);
}
spawn_generation(
Arc::clone(&state.sessions),
session_id,
state.dry_run,
1,
);
spawn_generation(Arc::clone(&state.sessions), session_id, state.dry_run, 1);
(
StatusCode::CREATED,
@ -289,13 +286,13 @@ pub async fn send_message(
match store.get_mut(&id) {
Some(session) => {
let now = chrono::Utc::now();
session.turns.push(arc_types::SessionTurn::UserTurn(
arc_types::UserTurn {
session
.turns
.push(arc_types::SessionTurn::UserTurn(arc_types::UserTurn {
kind: arc_types::UserTurnKind::User,
content: req.content,
created_at: now,
},
));
}));
session.updated_at = now;
let seq = session.generation_seq.fetch_add(1, Ordering::Relaxed) + 1;
seq
@ -304,12 +301,7 @@ pub async fn send_message(
}
};
spawn_generation(
Arc::clone(&state.sessions),
id,
state.dry_run,
seq,
);
spawn_generation(Arc::clone(&state.sessions), id, state.dry_run, seq);
(
StatusCode::ACCEPTED,
@ -333,8 +325,8 @@ pub async fn stream_session_events(
use tokio_stream::StreamExt;
let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|result| {
match result {
let stream =
tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|result| match result {
Ok(event) => {
let sse: Option<Event> = match event {
SessionEvent::TextDelta { delta } => Some(
@ -342,7 +334,10 @@ pub async fn stream_session_events(
.event("content_delta")
.data(serde_json::json!({"delta": delta}).to_string()),
),
SessionEvent::AssistantTurnComplete { content, created_at } => Some(
SessionEvent::AssistantTurnComplete {
content,
created_at,
} => Some(
Event::default().event("assistant_turn").data(
serde_json::json!({
"kind": "assistant",
@ -352,11 +347,7 @@ pub async fn stream_session_events(
.to_string(),
),
),
SessionEvent::Done => Some(
Event::default()
.event("done")
.data("{}"),
),
SessionEvent::Done => Some(Event::default().event("done").data("{}")),
SessionEvent::Error { message } => Some(
Event::default()
.event("error")
@ -366,8 +357,7 @@ pub async fn stream_session_events(
sse.map(|e| Ok::<_, std::convert::Infallible>(e))
}
Err(_) => None,
}
});
});
Sse::new(stream).into_response()
}

View file

@ -420,8 +420,8 @@ mod server_lifecycle {
use arc_api::server::{build_router, create_app_state};
use arc_workflows::handler::agent::AgentHandler;
use arc_workflows::handler::exit::ExitHandler;
use arc_workflows::handler::start::StartHandler;
use arc_workflows::handler::human::HumanHandler;
use arc_workflows::handler::start::StartHandler;
use arc_workflows::handler::HandlerRegistry;
use arc_workflows::interviewer::Interviewer;
use axum::body::Body;

View file

@ -43,10 +43,7 @@ async fn get_json(app: axum::Router, uri: &str) -> serde_json::Value {
/// Assert that a value has the paginated shape: `{ data: [...], meta: { has_more: bool } }`
fn assert_paginated_shape(json: &serde_json::Value, context: &str) {
assert!(json.get("data").is_some(), "{context}: missing 'data' key");
assert!(
json["data"].is_array(),
"{context}: 'data' is not an array"
);
assert!(json["data"].is_array(), "{context}: 'data' is not an array");
assert!(json.get("meta").is_some(), "{context}: missing 'meta' key");
assert!(
json["meta"].get("has_more").is_some(),

View file

@ -69,9 +69,7 @@ pub fn resolve_mode(
cli_server_url: Option<&str>,
config: &CliConfig,
) -> ResolvedMode {
let mode = cli_mode
.or_else(|| config.mode.clone())
.unwrap_or_default();
let mode = cli_mode.or_else(|| config.mode.clone()).unwrap_or_default();
let server_defaults = config.server.as_ref();

View file

@ -34,7 +34,9 @@ fn parse_execution_mode(s: &str) -> Result<cli_config::ExecutionMode, String> {
match s {
"standalone" => Ok(cli_config::ExecutionMode::Standalone),
"server" => Ok(cli_config::ExecutionMode::Server),
_ => Err(format!("invalid mode '{s}', expected 'standalone' or 'server'")),
_ => Err(format!(
"invalid mode '{s}', expected 'standalone' or 'server'"
)),
}
}
@ -138,15 +140,18 @@ async fn main() -> Result<()> {
};
let config_log_level = if let Command::Serve(ref args) = cli.command {
let server_config =
arc_api::server_config::load_server_config(args.config.as_deref())?;
let server_config = arc_api::server_config::load_server_config(args.config.as_deref())?;
server_config.log.level
} else {
let cli_config = cli_config::load_cli_config(None)?;
cli_config.log.level
};
let log_prefix = if command_name == "serve" { "serve" } else { "cli" };
let log_prefix = if command_name == "serve" {
"serve"
} else {
"cli"
};
if let Err(err) = logging::init_tracing(cli.debug, config_log_level.as_deref(), log_prefix) {
eprintln!("Warning: failed to initialize logging: {err:#}");
}
@ -162,15 +167,11 @@ async fn main() -> Result<()> {
if args.model.is_none() {
args.model = llm_defaults.and_then(|l| l.model.clone());
}
let resolved = cli_config::resolve_mode(
cli.mode,
cli.server_url.as_deref(),
&cli_config,
);
let resolved =
cli_config::resolve_mode(cli.mode, cli.server_url.as_deref(), &cli_config);
match resolved.mode {
cli_config::ExecutionMode::Server => {
let client =
cli_config::build_server_client(resolved.tls.as_ref())?;
let client = cli_config::build_server_client(resolved.tls.as_ref())?;
let server = arc_llm::cli::ServerConnection {
client,
base_url: resolved.server_base_url,
@ -186,15 +187,11 @@ async fn main() -> Result<()> {
if args.model.is_none() {
args.model = llm_defaults.and_then(|l| l.model.clone());
}
let resolved = cli_config::resolve_mode(
cli.mode,
cli.server_url.as_deref(),
&cli_config,
);
let resolved =
cli_config::resolve_mode(cli.mode, cli.server_url.as_deref(), &cli_config);
match resolved.mode {
cli_config::ExecutionMode::Server => {
let client =
cli_config::build_server_client(resolved.tls.as_ref())?;
let client = cli_config::build_server_client(resolved.tls.as_ref())?;
let server = arc_llm::cli::ServerConnection {
client,
base_url: resolved.server_base_url,
@ -262,11 +259,8 @@ async fn main() -> Result<()> {
}
Command::Model { command } => {
let cli_config = cli_config::load_cli_config(None)?;
let resolved = cli_config::resolve_mode(
cli.mode,
cli.server_url.as_deref(),
&cli_config,
);
let resolved =
cli_config::resolve_mode(cli.mode, cli.server_url.as_deref(), &cli_config);
let server = match resolved.mode {
cli_config::ExecutionMode::Server => {
let client = cli_config::build_server_client(resolved.tls.as_ref())?;

View file

@ -59,7 +59,14 @@ pub struct ExeSandbox {
/// Factory for creating data-plane SSH runners, used during initialize().
/// In production, this connects to the VM host via OpensshRunner.
/// In tests, this is replaced with a closure that returns a MockSshRunner.
data_ssh_factory: Box<dyn Fn(&str) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Box<dyn SshRunner>, String>> + Send>> + Send + Sync>,
data_ssh_factory: Box<
dyn Fn(
&str,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Box<dyn SshRunner>, String>> + Send>,
> + Send
+ Sync,
>,
}
impl ExeSandbox {
@ -121,27 +128,24 @@ impl Sandbox for ExeSandbox {
let init_start = Instant::now();
// Create a new VM via the management plane
let output = self
.mgmt_ssh
.run_command("new --json")
.await
.map_err(|e| {
let err = format!("Failed to create exe.dev VM: {e}");
let duration_ms =
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: PROVIDER.into(),
error: err.clone(),
duration_ms,
});
err
})?;
let output = self.mgmt_ssh.run_command("new --json").await.map_err(|e| {
let err = format!("Failed to create exe.dev VM: {e}");
let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: PROVIDER.into(),
error: err.clone(),
duration_ms,
});
err
})?;
if output.exit_code != 0 {
let stderr = String::from_utf8_lossy(&output.stderr);
let err = format!("exe.dev VM creation failed (exit {}): {stderr}", output.exit_code);
let duration_ms =
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
let err = format!(
"exe.dev VM creation failed (exit {}): {stderr}",
output.exit_code
);
let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: PROVIDER.into(),
error: err.clone(),
@ -152,18 +156,16 @@ impl Sandbox for ExeSandbox {
// Parse JSON response to get VM name and host
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(stdout.trim())
.map_err(|e| {
let err = format!("Failed to parse exe.dev response: {e}");
let duration_ms =
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: PROVIDER.into(),
error: err.clone(),
duration_ms,
});
err
})?;
let json: serde_json::Value = serde_json::from_str(stdout.trim()).map_err(|e| {
let err = format!("Failed to parse exe.dev response: {e}");
let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: PROVIDER.into(),
error: err.clone(),
duration_ms,
});
err
})?;
let vm_name = json["vm_name"]
.as_str()
@ -184,8 +186,7 @@ impl Sandbox for ExeSandbox {
// Create data-plane SSH connection
let runner = (self.data_ssh_factory)(&data_host).await.map_err(|e| {
let err = format!("Failed to connect to exe.dev VM {data_host}: {e}");
let duration_ms =
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: PROVIDER.into(),
error: err.clone(),
@ -310,8 +311,8 @@ impl Sandbox for ExeSandbox {
return Err(format!("Failed to read {resolved}: {stderr}"));
}
let content =
String::from_utf8(output.stdout).map_err(|e| format!("File is not valid UTF-8: {e}"))?;
let content = String::from_utf8(output.stdout)
.map_err(|e| format!("File is not valid UTF-8: {e}"))?;
Ok(format_lines_numbered(&content, offset, limit))
}
@ -372,9 +373,7 @@ impl Sandbox for ExeSandbox {
max_depth,
);
let result = self
.exec_command(&cmd, 30_000, None, None, None)
.await?;
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
if result.exit_code != 0 {
return Err(format!(
@ -541,10 +540,7 @@ impl Sandbox for ExeSandbox {
}
fn sandbox_info(&self) -> String {
self.vm_name
.get()
.cloned()
.unwrap_or_default()
self.vm_name.get().cloned().unwrap_or_default()
}
}
@ -817,7 +813,10 @@ mod tests {
data.queue_response("a\nb\nc\nd\ne\n", "", 0);
let sandbox = sandbox_with_mock_data(data);
let content = sandbox.read_file("test.txt", Some(1), Some(2)).await.unwrap();
let content = sandbox
.read_file("test.txt", Some(1), Some(2))
.await
.unwrap();
assert!(content.contains("2 | b"));
assert!(content.contains("3 | c"));
assert!(!content.contains("1 | a"));
@ -831,10 +830,7 @@ mod tests {
data.queue_response("content\n", "", 0);
let sandbox = sandbox_with_mock_data(data);
sandbox
.read_file("/etc/hosts", None, None)
.await
.unwrap();
sandbox.read_file("/etc/hosts", None, None).await.unwrap();
let recorded = commands.lock().unwrap();
assert!(
@ -858,7 +854,10 @@ mod tests {
data.queue_response("", "", 0);
let sandbox = sandbox_with_mock_data(data);
sandbox.write_file("src/main.rs", "fn main() {}").await.unwrap();
sandbox
.write_file("src/main.rs", "fn main() {}")
.await
.unwrap();
let recorded = uploads.lock().unwrap();
assert_eq!(recorded[0].path, "/home/exedev/src/main.rs");
@ -936,7 +935,11 @@ mod tests {
// find output for list_directory (run via exec_command, so two responses:
// first for the rg_available check if it fires... but exec_command calls
// run_command_with_timeout directly, which will get the next response)
data.queue_response("f\t1024\tfile.txt\nd\t4096\tsrc\nf\t512\tREADME.md\n", "", 0);
data.queue_response(
"f\t1024\tfile.txt\nd\t4096\tsrc\nf\t512\tREADME.md\n",
"",
0,
);
let sandbox = sandbox_with_mock_data(data);
let entries = sandbox.list_directory(".", None).await.unwrap();
@ -959,7 +962,11 @@ mod tests {
// First call: rg --version check (cached)
data.queue_response("ripgrep 14.0.0", "", 0);
// Second call: the actual grep
data.queue_response("src/main.rs:1:fn main() {}\nsrc/lib.rs:5:fn helper() {}\n", "", 0);
data.queue_response(
"src/main.rs:1:fn main() {}\nsrc/lib.rs:5:fn helper() {}\n",
"",
0,
);
let sandbox = sandbox_with_mock_data(data);
let results = sandbox

View file

@ -73,7 +73,9 @@ pub fn closest_model(target_provider: &str, reference: &ModelInfo) -> Option<Mod
let ref_cost = reference.costs.input_cost_per_mtok.unwrap_or(0.0);
let cost_a = (a.costs.input_cost_per_mtok.unwrap_or(0.0) - ref_cost).abs();
let cost_b = (b.costs.input_cost_per_mtok.unwrap_or(0.0) - ref_cost).abs();
cost_a.partial_cmp(&cost_b).unwrap_or(std::cmp::Ordering::Equal)
cost_a
.partial_cmp(&cost_b)
.unwrap_or(std::cmp::Ordering::Equal)
})
.cloned()
}
@ -430,10 +432,7 @@ mod tests {
#[test]
fn build_fallback_chain_provider_not_in_map() {
let fallbacks = HashMap::from([(
"openai".to_string(),
vec!["anthropic".to_string()],
)]);
let fallbacks = HashMap::from([("openai".to_string(), vec!["anthropic".to_string()])]);
let chain = build_fallback_chain("anthropic", "claude-opus-4-6", &fallbacks);
assert!(chain.is_empty());
}
@ -460,10 +459,7 @@ mod tests {
#[test]
fn build_fallback_chain_unknown_primary_model() {
let fallbacks = HashMap::from([(
"anthropic".to_string(),
vec!["gemini".to_string()],
)]);
let fallbacks = HashMap::from([("anthropic".to_string(), vec!["gemini".to_string()])]);
let chain = build_fallback_chain("anthropic", "unknown-model-xyz", &fallbacks);
assert!(chain.is_empty());
}

View file

@ -512,7 +512,12 @@ pub async fn run_prompt_via_server(args: PromptArgs, server: &ServerConnection)
if args.usage {
let input = result["usage"]["input_tokens"].as_i64().unwrap_or(0);
let output = result["usage"]["output_tokens"].as_i64().unwrap_or(0);
eprintln!("Tokens: {} input, {} output, {} total", input, output, input + output);
eprintln!(
"Tokens: {} input, {} output, {} total",
input,
output,
input + output
);
}
}
@ -635,9 +640,7 @@ pub async fn run_chat_via_server(args: ChatArgs, server: &ServerConnection) -> R
.json(&body)
.send()
.await
.with_context(|| {
format!("Failed to connect to server at {}", server.base_url)
})?;
.with_context(|| format!("Failed to connect to server at {}", server.base_url))?;
let status = response.status();
if !status.is_success() {
@ -654,9 +657,7 @@ pub async fn run_chat_via_server(args: ChatArgs, server: &ServerConnection) -> R
.as_str()
.context("Missing session id in response")?
.to_string();
let model_id = create_resp["model"]["id"]
.as_str()
.unwrap_or("unknown");
let model_id = create_resp["model"]["id"].as_str().unwrap_or("unknown");
eprintln!("Using model: {model_id}");
// Stream events
@ -686,9 +687,7 @@ pub async fn run_chat_via_server(args: ChatArgs, server: &ServerConnection) -> R
.json(&body)
.send()
.await
.with_context(|| {
format!("Failed to connect to server at {}", server.base_url)
})?;
.with_context(|| format!("Failed to connect to server at {}", server.base_url))?;
let status = response.status();
if !status.is_success() {
@ -818,8 +817,7 @@ async fn test_models_via_server(
let mut failures = 0u32;
for info in &models_to_test {
let result =
test_model_via_server(&server.client, &server.base_url, &info.id).await;
let result = test_model_via_server(&server.client, &server.base_url, &info.id).await;
let (status_color, status) = match result {
Ok(resp) if resp.status == "ok" => (&s.green, "ok".to_string()),
@ -1160,15 +1158,20 @@ mod tests {
#[tokio::test]
async fn test_model_via_server_parses_ok() {
let server = httpmock::MockServer::start_async().await;
server.mock_async(|when, then| {
when.method("POST").path("/models/test-model/test");
then.status(200)
.header("Content-Type", "application/json")
.body(serde_json::json!({
"model_id": "test-model",
"status": "ok"
}).to_string());
}).await;
server
.mock_async(|when, then| {
when.method("POST").path("/models/test-model/test");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"model_id": "test-model",
"status": "ok"
})
.to_string(),
);
})
.await;
let client = reqwest::Client::new();
let resp = test_model_via_server(&client, &server.url(""), "test-model")
@ -1182,16 +1185,21 @@ mod tests {
#[tokio::test]
async fn test_model_via_server_parses_error() {
let server = httpmock::MockServer::start_async().await;
server.mock_async(|when, then| {
when.method("POST").path("/models/test-model/test");
then.status(200)
.header("Content-Type", "application/json")
.body(serde_json::json!({
"model_id": "test-model",
"status": "error",
"error_message": "timeout"
}).to_string());
}).await;
server
.mock_async(|when, then| {
when.method("POST").path("/models/test-model/test");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"model_id": "test-model",
"status": "error",
"error_message": "timeout"
})
.to_string(),
);
})
.await;
let client = reqwest::Client::new();
let resp = test_model_via_server(&client, &server.url(""), "test-model")
@ -1205,14 +1213,16 @@ mod tests {
#[tokio::test]
async fn test_model_via_server_404() {
let server = httpmock::MockServer::start_async().await;
server.mock_async(|when, then| {
when.method("POST").path("/models/bad-model/test");
then.status(404)
server
.mock_async(|when, then| {
when.method("POST").path("/models/bad-model/test");
then.status(404)
.header("Content-Type", "application/json")
.body(serde_json::json!({
"errors": [{"status": "404", "title": "Not Found", "detail": "Model not found"}]
}).to_string());
}).await;
})
.await;
let client = reqwest::Client::new();
let result = test_model_via_server(&client, &server.url(""), "bad-model").await;
@ -1260,38 +1270,43 @@ mod tests {
#[tokio::test]
async fn fetch_models_from_server_filters_by_provider() {
let server = httpmock::MockServer::start_async().await;
server.mock_async(|when, then| {
when.method("GET").path("/models");
then.status(200)
.header("Content-Type", "application/json")
.body(serde_json::json!({
"data": [
{
"id": "model-a",
"provider": "alpha",
"family": "a",
"display_name": "Model A",
"limits": { "context_window": 8000 },
"features": { "tools": false, "vision": false, "reasoning": false },
"costs": {},
"aliases": [],
"default": false
},
{
"id": "model-b",
"provider": "beta",
"family": "b",
"display_name": "Model B",
"limits": { "context_window": 8000 },
"features": { "tools": false, "vision": false, "reasoning": false },
"costs": {},
"aliases": [],
"default": false
}
],
"meta": { "has_more": false }
}).to_string());
}).await;
server
.mock_async(|when, then| {
when.method("GET").path("/models");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [
{
"id": "model-a",
"provider": "alpha",
"family": "a",
"display_name": "Model A",
"limits": { "context_window": 8000 },
"features": { "tools": false, "vision": false, "reasoning": false },
"costs": {},
"aliases": [],
"default": false
},
{
"id": "model-b",
"provider": "beta",
"family": "b",
"display_name": "Model B",
"limits": { "context_window": 8000 },
"features": { "tools": false, "vision": false, "reasoning": false },
"costs": {},
"aliases": [],
"default": false
}
],
"meta": { "has_more": false }
})
.to_string(),
);
})
.await;
let client = reqwest::Client::new();
let models = fetch_models_from_server(&client, &server.url(""), Some("alpha"))
@ -1305,10 +1320,12 @@ mod tests {
#[tokio::test]
async fn fetch_models_from_server_error_on_failure() {
let server = httpmock::MockServer::start_async().await;
server.mock_async(|when, then| {
when.method("GET").path("/models");
then.status(500).body("internal error");
}).await;
server
.mock_async(|when, then| {
when.method("GET").path("/models");
then.status(500).body("internal error");
})
.await;
let client = reqwest::Client::new();
let result = fetch_models_from_server(&client, &server.url(""), None).await;
@ -1320,18 +1337,23 @@ mod tests {
#[tokio::test]
async fn run_prompt_via_server_non_streaming() {
let mock_server = httpmock::MockServer::start_async().await;
let mock = mock_server.mock_async(|when, then| {
when.method("POST").path("/completions");
then.status(200)
.header("Content-Type", "application/json")
.body(serde_json::json!({
"id": "msg_123",
"model": "test-model",
"content": "Hello world",
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5}
}).to_string());
}).await;
let mock = mock_server
.mock_async(|when, then| {
when.method("POST").path("/completions");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"id": "msg_123",
"model": "test-model",
"content": "Hello world",
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5}
})
.to_string(),
);
})
.await;
let server = ServerConnection {
client: reqwest::Client::new(),
@ -1367,12 +1389,14 @@ event: message_stop\n\
data: {\"type\":\"message_stop\"}\n\
\n";
let mock = mock_server.mock_async(|when, then| {
when.method("POST").path("/completions");
then.status(200)
.header("Content-Type", "text/event-stream")
.body(sse_body);
}).await;
let mock = mock_server
.mock_async(|when, then| {
when.method("POST").path("/completions");
then.status(200)
.header("Content-Type", "text/event-stream")
.body(sse_body);
})
.await;
let server = ServerConnection {
client: reqwest::Client::new(),

View file

@ -1364,8 +1364,12 @@ mod tests {
assert!(has_tool_calls);
// reasoning + openai_message + text + function_call
assert_eq!(parts.len(), 4);
assert!(matches!(&parts[0], ContentPart::Other { kind, .. } if kind == ContentPart::OPENAI_REASONING));
assert!(matches!(&parts[1], ContentPart::Other { kind, data } if kind == ContentPart::OPENAI_MESSAGE && data["id"] == "msg_xyz"));
assert!(
matches!(&parts[0], ContentPart::Other { kind, .. } if kind == ContentPart::OPENAI_REASONING)
);
assert!(
matches!(&parts[1], ContentPart::Other { kind, data } if kind == ContentPart::OPENAI_MESSAGE && data["id"] == "msg_xyz")
);
assert!(matches!(&parts[2], ContentPart::Text(t) if t == "Hello"));
assert!(matches!(&parts[3], ContentPart::ToolCall(_)));
}
@ -1521,10 +1525,7 @@ mod tests {
}
fn empty_sse_state() -> SseStreamState {
let http_resp = http::Response::builder()
.status(200)
.body("")
.unwrap();
let http_resp = http::Response::builder().status(200).body("").unwrap();
let response = reqwest::Response::from(http_resp);
SseStreamState {
line_reader: crate::providers::common::LineReader::new(response, None),
@ -1556,7 +1557,9 @@ mod tests {
);
assert_eq!(events.len(), 2);
assert!(matches!(events[0], StreamEvent::ReasoningStart));
assert!(matches!(events[1], StreamEvent::ReasoningDelta { ref delta } if delta == "Let me think"));
assert!(
matches!(events[1], StreamEvent::ReasoningDelta { ref delta } if delta == "Let me think")
);
}
#[test]
@ -1565,24 +1568,20 @@ mod tests {
// First delta: should emit ReasoningStart + ReasoningDelta
let data1 = r#"{"type":"response.reasoning_text.delta","delta":"Step 1"}"#;
let events1 = process_sse_event(
&mut state,
Some("response.reasoning_text.delta"),
data1,
);
let events1 = process_sse_event(&mut state, Some("response.reasoning_text.delta"), data1);
assert_eq!(events1.len(), 2);
assert!(matches!(events1[0], StreamEvent::ReasoningStart));
assert!(matches!(events1[1], StreamEvent::ReasoningDelta { ref delta } if delta == "Step 1"));
assert!(
matches!(events1[1], StreamEvent::ReasoningDelta { ref delta } if delta == "Step 1")
);
// Second delta: should NOT emit duplicate ReasoningStart
let data2 = r#"{"type":"response.reasoning_text.delta","delta":"Step 2"}"#;
let events2 = process_sse_event(
&mut state,
Some("response.reasoning_text.delta"),
data2,
);
let events2 = process_sse_event(&mut state, Some("response.reasoning_text.delta"), data2);
assert_eq!(events2.len(), 1);
assert!(matches!(events2[0], StreamEvent::ReasoningDelta { ref delta } if delta == "Step 2"));
assert!(
matches!(events2[0], StreamEvent::ReasoningDelta { ref delta } if delta == "Step 2")
);
}
#[test]
@ -1591,11 +1590,7 @@ mod tests {
state.emitted_reasoning_start = true;
let data = r#"{"item":{"type":"reasoning","id":"rs_abc","summary":[]}}"#;
let events = process_sse_event(
&mut state,
Some("response.output_item.done"),
data,
);
let events = process_sse_event(&mut state, Some("response.output_item.done"), data);
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StreamEvent::ReasoningEnd));
assert!(!state.emitted_reasoning_start);

View file

@ -104,9 +104,7 @@ async fn ask_question(
let question_text = test_case.question.text.clone();
let is_freeform = test_case.question.question_type == QuestionType::Freeform;
let interviewer_clone = Arc::clone(interviewer);
let ask_handle = tokio::spawn(async move {
interviewer_clone.ask(test_case.question).await
});
let ask_handle = tokio::spawn(async move { interviewer_clone.ask(test_case.question).await });
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@ -190,7 +188,14 @@ async fn main() {
let cases = test_cases();
for case in cases {
ask_question(case, &interviewer, &thread_registry, &slack_client, &channel).await;
ask_question(
case,
&interviewer,
&thread_registry,
&slack_client,
&channel,
)
.await;
}
eprintln!("\nAll question types tested!");

View file

@ -24,7 +24,9 @@ fn button(label: &str, value: &str, action_id: &str) -> Value {
}
pub fn answered_blocks(question_text: &str, answer_text: &str) -> Vec<Value> {
vec![text_block(&format!("~{question_text}~\n*Answer:* {answer_text}"))]
vec![text_block(&format!(
"~{question_text}~\n*Answer:* {answer_text}"
))]
}
pub fn question_to_blocks(question_id: &str, question: &Question) -> Vec<Value> {

View file

@ -143,11 +143,8 @@ mod tests {
#[test]
fn post_message_request_body_format() {
let body = build_post_message_body(
"#general",
&[serde_json::json!({"type": "section"})],
None,
);
let body =
build_post_message_body("#general", &[serde_json::json!({"type": "section"})], None);
assert_eq!(body["channel"], "#general");
assert_eq!(body["blocks"][0]["type"], "section");
assert!(body["thread_ts"].is_null());

View file

@ -46,8 +46,7 @@ fn extract_checkbox_selections(question_id: &str, payload: &Value) -> Answer {
let block_id = format!("{question_id}:checkboxes");
let action_id = format!("{question_id}:select");
let selected = payload["state"]["values"][&block_id][&action_id]["selected_options"]
.as_array();
let selected = payload["state"]["values"][&block_id][&action_id]["selected_options"].as_array();
match selected {
Some(options) if !options.is_empty() => {

View file

@ -98,11 +98,7 @@ impl SpritesSandbox {
/// Build args for `sprite exec -s <name> [-o org] bash -c <command>`.
fn build_exec_args(&self, command: &str) -> Result<Vec<String>, String> {
let name = self.sprite_name()?;
let mut args = vec![
"exec".to_string(),
"-s".to_string(),
name.to_string(),
];
let mut args = vec!["exec".to_string(), "-s".to_string(), name.to_string()];
if let Some(ref org) = self.config.org {
args.push("-o".to_string());
args.push(org.clone());
@ -180,8 +176,7 @@ impl Sandbox for SpritesSandbox {
let url_refs: Vec<&str> = url_args.iter().map(|s| s.as_str()).collect();
let url_output = self.runner.run(&url_refs).await.map_err(|e| {
let err = format!("Failed to get sprite URL: {e}");
let duration_ms =
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: PROVIDER.into(),
error: err.clone(),
@ -273,7 +268,11 @@ impl Sandbox for SpritesSandbox {
if let Some(vars) = env_vars {
for (key, value) in vars {
parts.push(format!("export {}='{}';", key, value.replace('\'', "'\\''")));
parts.push(format!(
"export {}='{}';",
key,
value.replace('\'', "'\\''")
));
}
}
@ -816,10 +815,7 @@ mod tests {
runner.queue_response("content\n", "", 0);
let sandbox = sandbox_with_mock(runner);
sandbox
.read_file("/etc/hosts", None, None)
.await
.unwrap();
sandbox.read_file("/etc/hosts", None, None).await.unwrap();
let recorded = commands.lock().unwrap();
let cmd = recorded[0].args.last().unwrap();
@ -885,10 +881,7 @@ mod tests {
let recorded = commands.lock().unwrap();
let cmd = recorded[0].args.last().unwrap();
assert!(
cmd.contains("rm -f"),
"expected rm -f, got: {cmd}",
);
assert!(cmd.contains("rm -f"), "expected rm -f, got: {cmd}",);
}
// ---- file_exists ----
@ -980,11 +973,7 @@ mod tests {
#[tokio::test]
async fn glob_finds_files() {
let runner = MockSpriteRunner::new();
runner.queue_response(
"/home/sprite/src/main.rs\n/home/sprite/src/lib.rs\n",
"",
0,
);
runner.queue_response("/home/sprite/src/main.rs\n/home/sprite/src/lib.rs\n", "", 0);
let sandbox = sandbox_with_mock(runner);
let results = sandbox.glob("*.rs", Some("src")).await.unwrap();
@ -999,8 +988,7 @@ mod tests {
async fn download_file_to_local_writes_bytes() {
let runner = MockSpriteRunner::new();
use base64::Engine;
let encoded =
base64::engine::general_purpose::STANDARD.encode(b"binary content");
let encoded = base64::engine::general_purpose::STANDARD.encode(b"binary content");
runner.queue_response(&encoded, "", 0);
let sandbox = sandbox_with_mock(runner);

View file

@ -42,7 +42,11 @@ async fn run_operations(sandbox: &SpritesSandbox) -> Result<(), String> {
let result = sandbox
.exec_command("echo hello", 30_000, None, None, None)
.await?;
assert_eq!(result.exit_code, 0, "exec_command failed: {}", result.stderr);
assert_eq!(
result.exit_code, 0,
"exec_command failed: {}",
result.stderr
);
assert_eq!(result.stdout.trim(), "hello");
assert!(!result.timed_out);
@ -67,9 +71,7 @@ async fn run_operations(sandbox: &SpritesSandbox) -> Result<(), String> {
.write_file("test-e2e/hello.txt", "Hello, Sprites!\nSecond line\n")
.await?;
let content = sandbox
.read_file("test-e2e/hello.txt", None, None)
.await?;
let content = sandbox.read_file("test-e2e/hello.txt", None, None).await?;
assert!(
content.contains("Hello, Sprites!"),
"read_file missing content: {content}",
@ -118,12 +120,8 @@ async fn run_operations(sandbox: &SpritesSandbox) -> Result<(), String> {
);
// --- list_directory ---
sandbox
.write_file("test-e2e/sub/a.txt", "aaa")
.await?;
sandbox
.write_file("test-e2e/sub/b.txt", "bbb")
.await?;
sandbox.write_file("test-e2e/sub/a.txt", "aaa").await?;
sandbox.write_file("test-e2e/sub/b.txt", "bbb").await?;
let entries = sandbox.list_directory("test-e2e/sub", None).await?;
assert_eq!(entries.len(), 2, "expected 2 entries, got: {entries:?}");
assert_eq!(entries[0].name, "a.txt");
@ -132,7 +130,10 @@ async fn run_operations(sandbox: &SpritesSandbox) -> Result<(), String> {
// --- grep ---
sandbox
.write_file("test-e2e/search/code.rs", "fn main() {\n println!(\"hello\");\n}\n")
.write_file(
"test-e2e/search/code.rs",
"fn main() {\n println!(\"hello\");\n}\n",
)
.await?;
sandbox
.write_file("test-e2e/search/data.txt", "no match here\n")
@ -188,10 +189,7 @@ async fn run_operations(sandbox: &SpritesSandbox) -> Result<(), String> {
let downloaded = tokio::fs::read_to_string(&local_path)
.await
.map_err(|e| format!("read local: {e}"))?;
assert_eq!(
downloaded, download_content,
"download content mismatch",
);
assert_eq!(downloaded, download_content, "download content mismatch",);
Ok(())
}

View file

@ -297,7 +297,11 @@ mod tests {
#[test]
fn render_footer_text_when_provided() {
let r = report(vec![pass_check("Test")]);
let out = r.render(&Styles::new(false), false, Some("Run with --live to probe."));
let out = r.render(
&Styles::new(false),
false,
Some("Run with --live to probe."),
);
assert!(out.contains("Run with --live to probe."));
}

View file

@ -1,5 +1,5 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use super::event::Track;

View file

@ -68,7 +68,8 @@ fn spawn_event_forwarder(
..
} => {
if !*is_error {
if let Some(path) = pending_tool_calls.lock().unwrap().remove(tool_call_id) {
if let Some(path) = pending_tool_calls.lock().unwrap().remove(tool_call_id)
{
files_touched.lock().unwrap().insert(path);
}
} else {
@ -133,14 +134,7 @@ impl AgentApiBackend {
node: &Node,
sandbox: &Arc<dyn Sandbox>,
) -> Result<Session, ArcError> {
Self::create_session_for(
&self.model,
self.provider,
node,
sandbox,
&self.env,
)
.await
Self::create_session_for(&self.model, self.provider, node, sandbox, &self.env).await
}
async fn create_session_for(
@ -277,11 +271,17 @@ impl CodergenBackend for AgentApiBackend {
Ok(resp) => (
resp,
request.model.clone(),
request.provider.clone().unwrap_or_else(|| default_provider.clone()),
request
.provider
.clone()
.unwrap_or_else(|| default_provider.clone()),
),
Err(sdk_err) if sdk_err.failover_eligible() && !fallback_chain.is_empty() => {
let error_msg = sdk_err.to_string();
let from_provider = request.provider.clone().unwrap_or_else(|| default_provider.clone());
let from_provider = request
.provider
.clone()
.unwrap_or_else(|| default_provider.clone());
let from_model = request.model.clone();
let mut last_err = sdk_err;
@ -585,14 +585,19 @@ mod tests {
#[test]
fn agent_backend_stores_config() {
let backend = AgentApiBackend::new("claude-opus-4-6".to_string(), Provider::OpenAi, Vec::new());
let backend =
AgentApiBackend::new("claude-opus-4-6".to_string(), Provider::OpenAi, Vec::new());
assert_eq!(backend.model, "claude-opus-4-6");
assert_eq!(backend.provider, Provider::OpenAi);
}
#[test]
fn agent_backend_initializes_empty_sessions() {
let backend = AgentApiBackend::new("claude-opus-4-6".to_string(), Provider::Anthropic, Vec::new());
let backend = AgentApiBackend::new(
"claude-opus-4-6".to_string(),
Provider::Anthropic,
Vec::new(),
);
assert!(backend.sessions.lock().unwrap().is_empty());
}

View file

@ -73,7 +73,13 @@ async fn ensure_cli(
// Check if the CLI is already installed (include ~/.local/bin for npm-installed CLIs)
let version_check = sandbox
.exec_command(&format!("PATH=\"$HOME/.local/bin:$PATH\" {cli_name} --version"), 30_000, None, None, None)
.exec_command(
&format!("PATH=\"$HOME/.local/bin:$PATH\" {cli_name} --version"),
30_000,
None,
None,
None,
)
.await
.map_err(|e| ArcError::handler(format!("Failed to check {cli_name} version: {e}")))?;
@ -105,9 +111,23 @@ async fn ensure_cli(
let node_installed = true;
if install_result.exit_code != 0 {
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let output = if install_result.stderr.is_empty() { &install_result.stdout } else { &install_result.stderr };
let detail: String = output.chars().rev().take(500).collect::<Vec<_>>().into_iter().rev().collect();
let error_msg = format!("{cli_name} install exited with code {}: {detail}", install_result.exit_code);
let output = if install_result.stderr.is_empty() {
&install_result.stdout
} else {
&install_result.stderr
};
let detail: String = output
.chars()
.rev()
.take(500)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let error_msg = format!(
"{cli_name} install exited with code {}: {detail}",
install_result.exit_code
);
emitter.emit(&WorkflowRunEvent::CliEnsureFailed {
cli_name: cli_name.to_string(),
provider: provider_str.to_string(),
@ -508,9 +528,8 @@ impl CodergenBackend for AgentCliBackend {
tracing::info!(pid, "CLI process launched in background");
// 3c. Poll for completion
let poll_command = format!(
"[ -f {exit_code_path} ] && cat {exit_code_path} || echo running"
);
let poll_command =
format!("[ -f {exit_code_path} ] && cat {exit_code_path} || echo running");
let poll_interval = std::time::Duration::from_secs(5);
let exit_code: i32 = loop {
tokio::time::sleep(poll_interval).await;
@ -542,8 +561,7 @@ impl CodergenBackend for AgentCliBackend {
};
// 3d. Read results
let duration_ms =
u64::try_from(launch_start.elapsed().as_millis()).unwrap_or(u64::MAX);
let duration_ms = u64::try_from(launch_start.elapsed().as_millis()).unwrap_or(u64::MAX);
let stdout_result = sandbox
.exec_command(&format!("cat {stdout_path}"), 60_000, None, None, None)
.await
@ -563,13 +581,7 @@ impl CodergenBackend for AgentCliBackend {
// 3e. Cleanup temp files
let _ = sandbox
.exec_command(
&format!("rm -f {tmp_prefix}_*"),
30_000,
None,
None,
None,
)
.exec_command(&format!("rm -f {tmp_prefix}_*"), 30_000, None, None, None)
.await;
if let Ok(json) = serde_json::to_string_pretty(&serde_json::json!({
@ -585,11 +597,27 @@ impl CodergenBackend for AgentCliBackend {
let _ = tokio::fs::write(stage_dir.join("cli_stdout.log"), &result.stdout).await;
let _ = tokio::fs::write(stage_dir.join("cli_stderr.log"), &result.stderr).await;
let stderr: String = result.stderr.chars().rev().take(500).collect::<Vec<_>>().into_iter().rev().collect();
let stderr: String = result
.stderr
.chars()
.rev()
.take(500)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let detail = if !stderr.is_empty() {
stderr
} else {
let stdout: String = result.stdout.chars().rev().take(500).collect::<Vec<_>>().into_iter().rev().collect();
let stdout: String = result
.stdout
.chars()
.rev()
.take(500)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if !stdout.is_empty() {
format!("stdout: {stdout}")
} else {
@ -715,7 +743,10 @@ mod tests {
#[test]
fn agent_cli_for_provider() {
assert_eq!(AgentCli::for_provider(Provider::Anthropic), AgentCli::Claude);
assert_eq!(
AgentCli::for_provider(Provider::Anthropic),
AgentCli::Claude
);
assert_eq!(AgentCli::for_provider(Provider::OpenAi), AgentCli::Codex);
assert_eq!(AgentCli::for_provider(Provider::Gemini), AgentCli::Gemini);
assert_eq!(AgentCli::for_provider(Provider::Kimi), AgentCli::Codex);
@ -740,9 +771,9 @@ mod tests {
// -- ensure_cli --
use arc_agent::sandbox::{DirEntry, GrepOptions};
use std::collections::VecDeque;
use std::sync::Mutex;
use arc_agent::sandbox::{DirEntry, GrepOptions};
/// Mock sandbox that returns pre-configured ExecResults in FIFO order.
struct CliMockSandbox {
@ -765,13 +796,30 @@ mod tests {
#[async_trait]
impl Sandbox for CliMockSandbox {
async fn read_file(&self, _path: &str, _offset: Option<usize>, _limit: Option<usize>) -> Result<String, String> {
async fn read_file(
&self,
_path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> Result<String, String> {
Ok(String::new())
}
async fn write_file(&self, _path: &str, _content: &str) -> Result<(), String> { Ok(()) }
async fn delete_file(&self, _path: &str) -> Result<(), String> { Ok(()) }
async fn file_exists(&self, _path: &str) -> Result<bool, String> { Ok(false) }
async fn list_directory(&self, _path: &str, _depth: Option<usize>) -> Result<Vec<DirEntry>, String> { Ok(vec![]) }
async fn write_file(&self, _path: &str, _content: &str) -> Result<(), String> {
Ok(())
}
async fn delete_file(&self, _path: &str) -> Result<(), String> {
Ok(())
}
async fn file_exists(&self, _path: &str) -> Result<bool, String> {
Ok(false)
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> Result<Vec<DirEntry>, String> {
Ok(vec![])
}
async fn exec_command(
&self,
command: &str,
@ -781,25 +829,64 @@ mod tests {
_cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> Result<ExecResult, String> {
self.commands.lock().unwrap().push(command.to_string());
self.results.lock().unwrap().pop_front().ok_or_else(|| "no more mock results".to_string())
self.results
.lock()
.unwrap()
.pop_front()
.ok_or_else(|| "no more mock results".to_string())
}
async fn grep(
&self,
_pattern: &str,
_path: &str,
_options: &GrepOptions,
) -> Result<Vec<String>, String> {
Ok(vec![])
}
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result<Vec<String>, String> {
Ok(vec![])
}
async fn download_file_to_local(&self, _remote: &str, _local: &Path) -> Result<(), String> {
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
Ok(())
}
fn working_directory(&self) -> &str {
"/workspace"
}
fn platform(&self) -> &str {
"linux"
}
fn os_version(&self) -> String {
"Ubuntu 22.04".to_string()
}
async fn set_autostop_interval(&self, _minutes: i32) -> Result<(), String> {
Ok(())
}
async fn grep(&self, _pattern: &str, _path: &str, _options: &GrepOptions) -> Result<Vec<String>, String> { Ok(vec![]) }
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result<Vec<String>, String> { Ok(vec![]) }
async fn download_file_to_local(&self, _remote: &str, _local: &Path) -> Result<(), String> { Ok(()) }
async fn initialize(&self) -> Result<(), String> { Ok(()) }
async fn cleanup(&self) -> Result<(), String> { Ok(()) }
fn working_directory(&self) -> &str { "/workspace" }
fn platform(&self) -> &str { "linux" }
fn os_version(&self) -> String { "Ubuntu 22.04".to_string() }
async fn set_autostop_interval(&self, _minutes: i32) -> Result<(), String> { Ok(()) }
}
fn ok_result() -> ExecResult {
ExecResult { exit_code: 0, stdout: String::new(), stderr: String::new(), timed_out: false, duration_ms: 10 }
ExecResult {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
timed_out: false,
duration_ms: 10,
}
}
fn fail_result(code: i32) -> ExecResult {
ExecResult { exit_code: code, stdout: String::new(), stderr: "error".to_string(), timed_out: false, duration_ms: 10 }
ExecResult {
exit_code: code,
stdout: String::new(),
stderr: "error".to_string(),
timed_out: false,
duration_ms: 10,
}
}
#[tokio::test]
@ -820,8 +907,8 @@ mod tests {
async fn ensure_cli_installs_when_missing() {
// version check fails, combined install succeeds
let sandbox: Arc<dyn Sandbox> = Arc::new(CliMockSandbox::new(vec![
fail_result(127), // claude --version
ok_result(), // combined node + npm install
fail_result(127), // claude --version
ok_result(), // combined node + npm install
]));
let emitter = Arc::new(EventEmitter::new());
@ -837,14 +924,17 @@ mod tests {
#[tokio::test]
async fn ensure_cli_fails_on_install_failure() {
let sandbox: Arc<dyn Sandbox> = Arc::new(CliMockSandbox::new(vec![
fail_result(127), // claude --version
fail_result(1), // combined install fails
fail_result(127), // claude --version
fail_result(1), // combined install fails
]));
let emitter = Arc::new(EventEmitter::new());
let result = ensure_cli(AgentCli::Claude, Provider::Anthropic, &sandbox, &emitter).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("install exited with code"));
assert!(result
.unwrap_err()
.to_string()
.contains("install exited with code"));
}
// -- Cycle 1: cli_command_for_provider --

View file

@ -114,7 +114,11 @@ fn shorten_path(path: &str) -> String {
fn tool_display_name(tool_name: &str, arguments: &serde_json::Value) -> String {
let dim = Style::new().dim();
let arg = |key: &str| arguments.get(key).and_then(|v| v.as_str());
let path_arg = || arg("path").or_else(|| arg("file_path")).map(|p| truncate(&shorten_path(p), 60));
let path_arg = || {
arg("path")
.or_else(|| arg("file_path"))
.map(|p| truncate(&shorten_path(p), 60))
};
let detail = match tool_name {
"bash" | "shell" | "execute_command" => arg("command").map(|c| truncate(c, 60)),
@ -334,11 +338,7 @@ impl ProgressUI {
} => {
self.finish_stage(node_id, name, red_cross(), "");
let red = Style::new().red();
self.insert_info_line(&format!(
"{} {}",
red.apply_to("Error:"),
failure.message,
));
self.insert_info_line(&format!("{} {}", red.apply_to("Error:"), failure.message,));
}
WorkflowRunEvent::ParallelStarted { .. } => {
// The fork stage is the (only) active stage at this point.
@ -403,11 +403,7 @@ impl ProgressUI {
} else {
red_cross()
};
let msg = format!(
"{glyph} [{}/{total}] {}",
index + 1,
truncate(command, 60),
);
let msg = format!("{glyph} [{}/{total}] {}", index + 1, truncate(command, 60),);
match &self.renderer {
ProgressRenderer::Tty(tty) => {
let bar = if let Some(ref setup_bar) = self.setup_bar {
@ -480,9 +476,11 @@ impl ProgressUI {
} => {
let dur = format_duration_ms(*duration_ms);
let detail = match (name, cpu, memory) {
(Some(n), Some(c), Some(m)) => {
Some(format!("{n} ({} cpu, {} GB)", format_number(*c), format_number(*m)))
}
(Some(n), Some(c), Some(m)) => Some(format!(
"{n} ({} cpu, {} GB)",
format_number(*c),
format_number(*m)
)),
(Some(n), _, _) => Some(n.clone()),
_ => None,
};
@ -497,7 +495,8 @@ impl ProgressUI {
bar.set_prefix(dur);
bar.finish_with_message(format!("Sandbox: {display_provider}"));
if let Some(detail_str) = &detail {
let detail_bar = tty.multi.insert_after(&bar, ProgressBar::new_spinner());
let detail_bar =
tty.multi.insert_after(&bar, ProgressBar::new_spinner());
detail_bar.set_style(style_sandbox_detail());
detail_bar.finish_with_message(detail_str.clone());
}
@ -579,9 +578,18 @@ impl ProgressUI {
}
}
fn on_cli_ensure_completed(&mut self, cli_name: &str, already_installed: bool, duration_ms: u64) {
fn on_cli_ensure_completed(
&mut self,
cli_name: &str,
already_installed: bool,
duration_ms: u64,
) {
let dur = format_duration_ms(duration_ms);
let status = if already_installed { "found" } else { "installed" };
let status = if already_installed {
"found"
} else {
"installed"
};
match &self.renderer {
ProgressRenderer::Tty(_) => {
if let Some(bar) = self.cli_ensure_bar.take() {
@ -743,25 +751,23 @@ impl ProgressUI {
),
);
}
AgentEvent::CompactionStarted { .. } => {
match &self.renderer {
ProgressRenderer::Tty(tty) => {
if let Some(stage) = self.active_stages.get_mut(stage_node_id) {
if let Some(old) = stage.compaction_bar.take() {
old.finish_and_clear();
}
let bar = tty
.multi
.insert_after(stage.last_bar(), ProgressBar::new_spinner());
bar.set_style(style_tool_running());
bar.set_message("\u{27f3} compacting context\u{2026}");
bar.enable_steady_tick(Duration::from_millis(100));
stage.compaction_bar = Some(bar);
AgentEvent::CompactionStarted { .. } => match &self.renderer {
ProgressRenderer::Tty(tty) => {
if let Some(stage) = self.active_stages.get_mut(stage_node_id) {
if let Some(old) = stage.compaction_bar.take() {
old.finish_and_clear();
}
let bar = tty
.multi
.insert_after(stage.last_bar(), ProgressBar::new_spinner());
bar.set_style(style_tool_running());
bar.set_message("\u{27f3} compacting context\u{2026}");
bar.enable_steady_tick(Duration::from_millis(100));
stage.compaction_bar = Some(bar);
}
ProgressRenderer::Plain => {}
}
}
ProgressRenderer::Plain => {}
},
AgentEvent::CompactionCompleted {
original_turn_count,
preserved_turn_count,
@ -807,9 +813,7 @@ impl ProgressUI {
),
);
}
AgentEvent::SubAgentSpawned {
agent_id, task, ..
} if self.verbose => {
AgentEvent::SubAgentSpawned { agent_id, task, .. } if self.verbose => {
let dim = Style::new().dim();
let short_id = &agent_id[..agent_id.len().min(8)];
self.insert_info_line_for_stage(
@ -828,11 +832,7 @@ impl ProgressUI {
..
} if self.verbose => {
let short_id = &agent_id[..agent_id.len().min(8)];
let glyph = if *success {
green_check()
} else {
red_cross()
};
let glyph = if *success { green_check() } else { red_cross() };
self.insert_info_line_for_stage(
stage_node_id,
&format!("{glyph} subagent[{short_id}] ({turns_used} turns)"),
@ -864,10 +864,9 @@ impl ProgressUI {
evicted.bar.finish_and_clear();
}
}
let bar = tty.multi.insert_after(
stage.last_bar(),
ProgressBar::new_spinner(),
);
let bar = tty
.multi
.insert_after(stage.last_bar(), ProgressBar::new_spinner());
bar.set_style(style_tool_running());
bar.set_message(display_name.clone());
bar.enable_steady_tick(Duration::from_millis(100));
@ -892,10 +891,9 @@ impl ProgressUI {
if let ProgressRenderer::Tty(tty) = &self.renderer {
if let Some(stage) = self.active_stages.get_mut(&parent_id) {
let bar = tty.multi.insert_after(
stage.last_bar(),
ProgressBar::new_spinner(),
);
let bar = tty
.multi
.insert_after(stage.last_bar(), ProgressBar::new_spinner());
bar.set_style(style_tool_running());
bar.set_message(branch.to_string());
bar.enable_steady_tick(Duration::from_millis(100));
@ -912,7 +910,11 @@ impl ProgressUI {
fn on_parallel_branch_completed(&mut self, branch: &str, duration_ms: u64, status: &str) {
let succeeded = matches!(status, "success" | "partial_success");
let glyph = if succeeded { green_check() } else { red_cross() };
let glyph = if succeeded {
green_check()
} else {
red_cross()
};
let dur = format_duration_ms(duration_ms);
let parent_id = match &self.parallel_parent {
@ -1102,7 +1104,10 @@ mod tests {
let stage = ui.active_stages.get("fork1").unwrap();
assert_eq!(stage.tool_calls.len(), 1);
assert_eq!(stage.tool_calls[0].tool_call_id, "security");
assert!(matches!(stage.tool_calls[0].status, ToolCallStatus::Running));
assert!(matches!(
stage.tool_calls[0].status,
ToolCallStatus::Running
));
// Branch completed → marks entry as succeeded
ui.handle_event(&WorkflowRunEvent::ParallelBranchCompleted {
@ -1162,10 +1167,7 @@ mod tests {
});
let stage = ui.active_stages.get("fork1").unwrap();
assert!(matches!(
stage.tool_calls[0].status,
ToolCallStatus::Failed
));
assert!(matches!(stage.tool_calls[0].status, ToolCallStatus::Failed));
}
#[test]

View file

@ -5,10 +5,10 @@ use std::sync::{Arc, Mutex};
use std::time::Instant;
use anyhow::bail;
use tracing::debug;
use arc_agent::{DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox};
use arc_util::terminal::Styles;
use chrono::{Local, Utc};
use tracing::debug;
use crate::checkpoint::Checkpoint;
use crate::engine::{GitCheckpointMode, RunConfig, WorkflowRunEngine};
@ -244,9 +244,9 @@ pub async fn run_command(
};
let dot_dir = dot_path.parent().unwrap_or(std::path::Path::new("."));
let mut builder = WorkflowBuilder::new();
builder.register_transform(Box::new(
crate::transform::FileInliningTransform::new(dot_dir.to_path_buf()),
));
builder.register_transform(Box::new(crate::transform::FileInliningTransform::new(
dot_dir.to_path_buf(),
)));
let (mut graph, diagnostics) = builder.prepare(&source)?;
apply_goal_override(&mut graph, args.goal.as_deref());
@ -478,7 +478,11 @@ 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, github_app.clone());
let mut env = crate::daytona_sandbox::DaytonaSandbox::new(
daytona_client,
config,
github_app.clone(),
);
let emitter_cb = Arc::clone(&emitter);
env.set_event_callback(Arc::new(move |event| {
emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event });
@ -550,9 +554,7 @@ pub async fn run_command(
if let Some(ref daytona) = daytona_sandbox_ref {
match daytona.create_ssh_access().await {
Ok(ssh_command) => {
emitter.emit(&crate::event::WorkflowRunEvent::SshAccessReady {
ssh_command,
});
emitter.emit(&crate::event::WorkflowRunEvent::SshAccessReady { ssh_command });
}
Err(e) => {
eprintln!(
@ -652,11 +654,7 @@ pub async fn run_command(
.unwrap_or(Provider::Anthropic);
// Resolve fallback chain from config
let fallback_chain = resolve_fallback_chain(
provider_enum,
&model,
run_cfg.as_ref(),
);
let fallback_chain = resolve_fallback_chain(provider_enum, &model, run_cfg.as_ref());
// 7. Build engine
let sandbox_env: HashMap<String, String> = run_cfg
@ -671,8 +669,9 @@ pub async fn run_command(
if dry_run_mode {
None
} else {
let api = AgentApiBackend::new(model.clone(), provider_enum, fallback_chain.clone())
.with_env(sandbox_env.clone());
let api =
AgentApiBackend::new(model.clone(), provider_enum, fallback_chain.clone())
.with_env(sandbox_env.clone());
let cli = AgentCliBackend::new(model.clone(), provider_enum)
.with_env(sandbox_env.clone());
Some(Box::new(BackendRouter::new(Box::new(api), cli)))
@ -835,7 +834,12 @@ pub async fn run_command(
))
);
} else {
eprintln!("{}", styles.dim.apply_to(format!("Tokens: {}", format_tokens_human(total_tokens))));
eprintln!(
"{}",
styles
.dim
.apply_to(format!("Tokens: {}", format_tokens_human(total_tokens)))
);
}
if acc.total_cache_read_tokens > 0 {
eprintln!(
@ -875,10 +879,7 @@ pub async fn run_command(
styles.bold.apply_to("Info:")
);
} else {
eprintln!(
"\n{} sandbox preserved",
styles.bold.apply_to("Info:")
);
eprintln!("\n{} sandbox preserved", styles.bold.apply_to("Info:"));
}
} else if let Err(e) = sandbox.cleanup().await {
tracing::warn!(error = %e, "Sandbox cleanup failed");
@ -1042,8 +1043,8 @@ async fn run_from_branch(
.map_err(|e| anyhow::anyhow!("failed to attach worktree to {run_branch}: {e}"))?;
std::env::set_current_dir(&worktree_path)?;
let base_sha = crate::git::MetadataStore::read_manifest(&original_cwd, &run_id)?
.and_then(|m| m.base_sha);
let base_sha =
crate::git::MetadataStore::read_manifest(&original_cwd, &run_id)?.and_then(|m| m.base_sha);
// Build minimal sandbox (local only for now)
let emitter = Arc::new(EventEmitter::new());
@ -1239,13 +1240,27 @@ async fn run_preflight(
status: CheckStatus::Pass,
summary: graph.name.clone(),
details: vec![
CheckDetail { text: format!("Nodes: {}", graph.nodes.len()) },
CheckDetail { text: format!("Edges: {}", graph.edges.len()) },
CheckDetail { text: format!("Goal: {}", graph.goal()) },
CheckDetail { text: format!("Model: {model}") },
CheckDetail { text: format!("Provider: {}", provider.as_deref().unwrap_or("anthropic")) },
CheckDetail { text: format!("Setup commands: {setup_command_count}") },
CheckDetail { text: format!("Git clean: {git_clean}") },
CheckDetail {
text: format!("Nodes: {}", graph.nodes.len()),
},
CheckDetail {
text: format!("Edges: {}", graph.edges.len()),
},
CheckDetail {
text: format!("Goal: {}", graph.goal()),
},
CheckDetail {
text: format!("Model: {model}"),
},
CheckDetail {
text: format!("Provider: {}", provider.as_deref().unwrap_or("anthropic")),
},
CheckDetail {
text: format!("Setup commands: {setup_command_count}"),
},
CheckDetail {
text: format!("Git clean: {git_clean}"),
},
],
remediation: None,
});
@ -1267,7 +1282,8 @@ async fn run_preflight(
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, github_app);
let env =
crate::daytona_sandbox::DaytonaSandbox::new(daytona_client, config, github_app);
Ok(Arc::new(env) as Arc<dyn Sandbox>)
}
Err(e) => Err(format!("Daytona client creation failed: {e}")),
@ -1296,7 +1312,9 @@ async fn run_preflight(
name: "Sandbox".into(),
status: CheckStatus::Error,
summary: "failed".into(),
details: vec![CheckDetail { text: format!("Provider: {sandbox_provider}") }],
details: vec![CheckDetail {
text: format!("Provider: {sandbox_provider}"),
}],
remediation: Some(format!("Sandbox init failed: {e}")),
});
false
@ -1307,7 +1325,9 @@ async fn run_preflight(
name: "Sandbox".into(),
status: CheckStatus::Error,
summary: "failed".into(),
details: vec![CheckDetail { text: format!("Provider: {sandbox_provider}") }],
details: vec![CheckDetail {
text: format!("Provider: {sandbox_provider}"),
}],
remediation: Some(e),
});
false
@ -1319,7 +1339,9 @@ async fn run_preflight(
name: "Sandbox".into(),
status: CheckStatus::Pass,
summary: sandbox_provider.to_string(),
details: vec![CheckDetail { text: format!("Provider: {sandbox_provider}") }],
details: vec![CheckDetail {
text: format!("Provider: {sandbox_provider}"),
}],
remediation: None,
});
}
@ -1327,11 +1349,7 @@ async fn run_preflight(
// 3. LLM client check
let llm_ok = match arc_llm::client::Client::from_env().await {
Ok(c) => {
let names: Vec<String> = c
.provider_names()
.iter()
.map(|s| s.to_string())
.collect();
let names: Vec<String> = c.provider_names().iter().map(|s| s.to_string()).collect();
if names.is_empty() {
checks.push(CheckResult {
name: "LLM providers".into(),
@ -1469,7 +1487,10 @@ async fn generate_retro(
// Run retro agent session
eprintln!("\n{}", styles.bold.apply_to("=== Retro ==="));
eprintln!("{}", styles.dim.apply_to(format!("Running retro ({model})...")));
eprintln!(
"{}",
styles.dim.apply_to(format!("Running retro ({model})..."))
);
let retro_start = std::time::Instant::now();
let narrative_result = if dry_run_mode {
Ok(crate::retro_agent::dry_run_narrative())
@ -1491,10 +1512,7 @@ async fn generate_retro(
.as_ref()
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string());
let outcome_str = retro
.outcome
.as_deref()
.unwrap_or("No outcome recorded");
let outcome_str = retro.outcome.as_deref().unwrap_or("No outcome recorded");
let line1_content = format!("Retro: {smoothness_str} \u{2014} {outcome_str}");
let term_width = console::Term::stderr().size().1 as usize;
let dur_len = retro_dur.len();
@ -1502,19 +1520,17 @@ async fn generate_retro(
eprintln!(
"{} {}{:pad1$}{}",
styles.bold.apply_to("Retro:"),
styles.dim.apply_to(format!("{smoothness_str} \u{2014} {outcome_str}")),
styles
.dim
.apply_to(format!("{smoothness_str} \u{2014} {outcome_str}")),
"",
styles.dim.apply_to(&retro_dur),
);
// Line 2: friction + open items (only if non-zero)
let friction_count = retro
.friction_points
.as_ref()
.map(|v| v.len())
.unwrap_or(0);
let open_count =
retro.open_items.as_ref().map(|v| v.len()).unwrap_or(0);
let friction_count =
retro.friction_points.as_ref().map(|v| v.len()).unwrap_or(0);
let open_count = retro.open_items.as_ref().map(|v| v.len()).unwrap_or(0);
if friction_count > 0 || open_count > 0 {
let mut parts = Vec::new();
if friction_count > 0 {
@ -1537,10 +1553,7 @@ async fn generate_retro(
}
// Line 3: file path
let retro_path = format!(
"{}/retro.json",
super::tilde_path(logs_dir)
);
let retro_path = format!("{}/retro.json", super::tilde_path(logs_dir));
eprintln!(
" {} {}",
styles.dim.apply_to("Retro saved to"),
@ -1572,9 +1585,10 @@ mod tests {
fn apply_goal_override_replaces_graph_goal() {
use crate::graph::types::{AttrValue, Graph};
let mut graph = Graph::new("test");
graph
.attrs
.insert("goal".to_string(), AttrValue::String("original".to_string()));
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("original".to_string()),
);
apply_goal_override(&mut graph, Some("CLI goal"));
assert_eq!(graph.goal(), "CLI goal");
}
@ -1583,9 +1597,10 @@ mod tests {
fn apply_goal_override_noop_when_none() {
use crate::graph::types::{AttrValue, Graph};
let mut graph = Graph::new("test");
graph
.attrs
.insert("goal".to_string(), AttrValue::String("original".to_string()));
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("original".to_string()),
);
apply_goal_override(&mut graph, None);
assert_eq!(graph.goal(), "original");
}

View file

@ -189,11 +189,7 @@ pub fn load_run_config(path: &Path) -> anyhow::Result<WorkflowRunConfig> {
/// Only whole-value references are supported (no partial interpolation).
/// Missing host env vars produce a hard error.
fn resolve_sandbox_env(config: &mut WorkflowRunConfig) -> anyhow::Result<()> {
if let Some(env) = config
.sandbox
.as_mut()
.and_then(|s| s.env.as_mut())
{
if let Some(env) = config.sandbox.as_mut().and_then(|s| s.env.as_mut()) {
resolve_env_refs(env)?;
}
Ok(())
@ -211,9 +207,7 @@ pub fn resolve_env_refs(env: &mut HashMap<String, String>) -> anyhow::Result<()>
.and_then(|s| s.strip_suffix('}'))
{
*value = std::env::var(var_name).with_context(|| {
format!(
"sandbox.env.{key}: host environment variable {var_name:?} is not set"
)
format!("sandbox.env.{key}: host environment variable {var_name:?} is not set")
})?;
}
}
@ -624,8 +618,11 @@ dockerfile = { path = "./Dockerfile" }
fn load_run_config_resolves_dockerfile_path() {
let dir = tempfile::tempdir().unwrap();
let dockerfile_path = dir.path().join("Dockerfile");
std::fs::write(&dockerfile_path, "FROM rust:1.85-slim-bookworm\nRUN apt-get update")
.unwrap();
std::fs::write(
&dockerfile_path,
"FROM rust:1.85-slim-bookworm\nRUN apt-get update",
)
.unwrap();
let toml_path = dir.path().join("run.toml");
std::fs::write(
@ -1205,19 +1202,13 @@ anthropic = ["gemini"]
llm: Some(LlmConfig {
model: None,
provider: Some("anthropic".into()),
fallbacks: Some(HashMap::from([(
"anthropic".into(),
vec!["openai".into()],
)])),
fallbacks: Some(HashMap::from([("anthropic".into(), vec!["openai".into()])])),
}),
..RunDefaults::default()
};
cfg.apply_defaults(&defaults);
let llm = cfg.llm.unwrap();
assert_eq!(
llm.fallbacks.unwrap()["anthropic"],
vec!["gemini"]
);
assert_eq!(llm.fallbacks.unwrap()["anthropic"], vec!["gemini"]);
}
#[test]
@ -1237,19 +1228,13 @@ model = "opus"
llm: Some(LlmConfig {
model: None,
provider: Some("anthropic".into()),
fallbacks: Some(HashMap::from([(
"anthropic".into(),
vec!["openai".into()],
)])),
fallbacks: Some(HashMap::from([("anthropic".into(), vec!["openai".into()])])),
}),
..RunDefaults::default()
};
cfg.apply_defaults(&defaults);
let llm = cfg.llm.unwrap();
assert_eq!(
llm.fallbacks.unwrap()["anthropic"],
vec!["openai"]
);
assert_eq!(llm.fallbacks.unwrap()["anthropic"], vec!["openai"]);
}
#[tokio::test]
@ -1461,10 +1446,7 @@ exclude_globs = ["**/dist/**", "**/.cache/**"]
.unwrap();
let defaults = RunDefaults {
checkpoint: CheckpointConfig {
exclude_globs: vec![
"**/.cache/**".into(),
"**/node_modules/**".into(),
],
exclude_globs: vec!["**/.cache/**".into(), "**/node_modules/**".into()],
},
..RunDefaults::default()
};
@ -1492,10 +1474,7 @@ graph = "w.dot"
..RunDefaults::default()
};
cfg.apply_defaults(&defaults);
assert_eq!(
cfg.checkpoint.exclude_globs,
vec!["**/node_modules/**"]
);
assert_eq!(cfg.checkpoint.exclude_globs, vec!["**/node_modules/**"]);
}
#[test]
@ -1513,10 +1492,7 @@ exclude_globs = ["**/dist/**"]
.unwrap();
let defaults = RunDefaults::default();
cfg.apply_defaults(&defaults);
assert_eq!(
cfg.checkpoint.exclude_globs,
vec!["**/dist/**"]
);
assert_eq!(cfg.checkpoint.exclude_globs, vec!["**/dist/**"]);
}
#[test]
@ -1574,9 +1550,7 @@ provider = "daytona"
#[test]
fn resolve_env_refs_host_var() {
std::env::set_var("ARC_TEST_RESOLVE_VAR", "secret123");
let mut env = HashMap::from([
("MY_KEY".into(), "${env.ARC_TEST_RESOLVE_VAR}".into()),
]);
let mut env = HashMap::from([("MY_KEY".into(), "${env.ARC_TEST_RESOLVE_VAR}".into())]);
resolve_env_refs(&mut env).unwrap();
assert_eq!(env["MY_KEY"], "secret123");
std::env::remove_var("ARC_TEST_RESOLVE_VAR");
@ -1584,9 +1558,10 @@ provider = "daytona"
#[test]
fn resolve_env_refs_missing_var_errors() {
let mut env = HashMap::from([
("MY_KEY".into(), "${env.ARC_TEST_NONEXISTENT_VAR_12345}".into()),
]);
let mut env = HashMap::from([(
"MY_KEY".into(),
"${env.ARC_TEST_NONEXISTENT_VAR_12345}".into(),
)]);
let err = resolve_env_refs(&mut env).unwrap_err();
assert!(
err.to_string().contains("ARC_TEST_NONEXISTENT_VAR_12345"),
@ -1596,9 +1571,7 @@ provider = "daytona"
#[test]
fn resolve_env_refs_partial_not_interpolated() {
let mut env = HashMap::from([
("MIXED".into(), "prefix_${env.HOME}_suffix".into()),
]);
let mut env = HashMap::from([("MIXED".into(), "prefix_${env.HOME}_suffix".into())]);
// Partial interpolation is not supported — value is left as-is
resolve_env_refs(&mut env).unwrap();
assert_eq!(env["MIXED"], "prefix_${env.HOME}_suffix");

View file

@ -337,7 +337,9 @@ mod tests {
"edge_count": 1,
"labels": { "env": "prod" }
})),
Some(serde_json::json!({ "timestamp": "2026-01-01T12:01:00Z", "status": "success", "duration_ms": 60000 })),
Some(
serde_json::json!({ "timestamp": "2026-01-01T12:01:00Z", "status": "success", "duration_ms": 60000 }),
),
false,
);
@ -523,7 +525,9 @@ mod tests {
"node_count": 1,
"edge_count": 0
})),
Some(serde_json::json!({ "timestamp": "2025-01-01T12:01:00Z", "status": "success", "duration_ms": 60000 })),
Some(
serde_json::json!({ "timestamp": "2025-01-01T12:01:00Z", "status": "success", "duration_ms": 60000 }),
),
false,
);
@ -557,7 +561,9 @@ mod tests {
"node_count": 1,
"edge_count": 0
})),
Some(serde_json::json!({ "timestamp": "2025-01-01T12:01:00Z", "status": "success", "duration_ms": 60000 })),
Some(
serde_json::json!({ "timestamp": "2025-01-01T12:01:00Z", "status": "success", "duration_ms": 60000 }),
),
false,
);
@ -573,7 +579,9 @@ mod tests {
"node_count": 1,
"edge_count": 0
})),
Some(serde_json::json!({ "timestamp": "2026-03-01T12:01:00Z", "status": "success", "duration_ms": 60000 })),
Some(
serde_json::json!({ "timestamp": "2026-03-01T12:01:00Z", "status": "success", "duration_ms": 60000 }),
),
false,
);

View file

@ -17,9 +17,7 @@ pub fn validate_command(args: &ValidateArgs, styles: &Styles) -> anyhow::Result<
eprintln!(
"{} ({} nodes, {} edges)",
styles
.bold
.apply_to(format!("Workflow: {}", graph.name)),
styles.bold.apply_to(format!("Workflow: {}", graph.name)),
graph.nodes.len(),
graph.edges.len(),
);

View file

@ -53,10 +53,7 @@ mod tests {
assert_eq!(loaded.status, crate::outcome::StageStatus::Success);
assert_eq!(loaded.duration_ms, 12345);
assert!(loaded.failure_reason.is_none());
assert_eq!(
loaded.final_git_commit_sha.as_deref(),
Some("deadbeef")
);
assert_eq!(loaded.final_git_commit_sha.as_deref(), Some("deadbeef"));
}
#[test]

View file

@ -54,17 +54,17 @@ enum Op {
#[derive(Debug, Clone, PartialEq)]
enum Token {
Word(String),
OpEq, // =
OpNotEq, // !=
OpGt, // >
OpLt, // <
OpGte, // >=
OpLte, // <=
And, // &&
Or, // ||
Not, // !
Contains, // contains
Matches, // matches
OpEq, // =
OpNotEq, // !=
OpGt, // >
OpLt, // <
OpGte, // >=
OpLte, // <=
And, // &&
Or, // ||
Not, // !
Contains, // contains
Matches, // matches
}
fn tokenize(input: &str) -> Result<Vec<Token>, ArcError> {
@ -89,21 +89,57 @@ fn tokenize(input: &str) -> Result<Vec<Token>, ArcError> {
if i + 1 < len {
let two = format!("{}{}", chars[i], chars[i + 1]);
match two.as_str() {
"&&" => { tokens.push(Token::And); i += 2; continue; }
"||" => { tokens.push(Token::Or); i += 2; continue; }
"!=" => { tokens.push(Token::OpNotEq); i += 2; continue; }
">=" => { tokens.push(Token::OpGte); i += 2; continue; }
"<=" => { tokens.push(Token::OpLte); i += 2; continue; }
"&&" => {
tokens.push(Token::And);
i += 2;
continue;
}
"||" => {
tokens.push(Token::Or);
i += 2;
continue;
}
"!=" => {
tokens.push(Token::OpNotEq);
i += 2;
continue;
}
">=" => {
tokens.push(Token::OpGte);
i += 2;
continue;
}
"<=" => {
tokens.push(Token::OpLte);
i += 2;
continue;
}
_ => {}
}
}
// Single-char operators
match chars[i] {
'=' => { tokens.push(Token::OpEq); i += 1; continue; }
'>' => { tokens.push(Token::OpGt); i += 1; continue; }
'<' => { tokens.push(Token::OpLt); i += 1; continue; }
'!' => { tokens.push(Token::Not); i += 1; continue; }
'=' => {
tokens.push(Token::OpEq);
i += 1;
continue;
}
'>' => {
tokens.push(Token::OpGt);
i += 1;
continue;
}
'<' => {
tokens.push(Token::OpLt);
i += 1;
continue;
}
'!' => {
tokens.push(Token::Not);
i += 1;
continue;
}
_ => {}
}
@ -264,18 +300,15 @@ impl Parser {
if op == Op::Eq || op == Op::NotEq {
String::new()
} else {
return Err(ArcError::Parse(
"expected value after operator".to_string(),
));
return Err(ArcError::Parse("expected value after operator".to_string()));
}
}
};
// Validate regex at parse time
if op == Op::Matches {
regex::Regex::new(&value).map_err(|e| {
ArcError::Parse(format!("invalid regex pattern '{value}': {e}"))
})?;
regex::Regex::new(&value)
.map_err(|e| ArcError::Parse(format!("invalid regex pattern '{value}': {e}")))?;
}
Ok(ConditionExpr::Clause(Clause { key, op, value }))
@ -333,11 +366,7 @@ fn resolve_key(key: &str, outcome: &Outcome, context: &Context) -> String {
.map_or_else(String::new, |val| json_value_to_string(&val))
}
fn resolve_key_value(
key: &str,
outcome: &Outcome,
context: &Context,
) -> serde_json::Value {
fn resolve_key_value(key: &str, outcome: &Outcome, context: &Context) -> serde_json::Value {
if key == keys::OUTCOME {
return serde_json::Value::String(outcome.status.to_string());
}
@ -383,9 +412,7 @@ fn eval_expr(expr: &ConditionExpr, outcome: &Outcome, context: &Context) -> bool
}
children.iter().all(|c| eval_expr(c, outcome, context))
}
ConditionExpr::Or(children) => {
children.iter().any(|c| eval_expr(c, outcome, context))
}
ConditionExpr::Or(children) => children.iter().any(|c| eval_expr(c, outcome, context)),
ConditionExpr::Not(inner) => !eval_expr(inner, outcome, context),
ConditionExpr::Clause(clause) => eval_clause(clause, outcome, context),
}
@ -426,9 +453,9 @@ fn eval_clause(clause: &Clause, outcome: &Outcome, context: &Context) -> bool {
Op::Contains => {
let raw = resolve_key_value(&clause.key, outcome, context);
match &raw {
serde_json::Value::Array(arr) => arr.iter().any(|elem| {
json_value_to_string(elem) == clause.value
}),
serde_json::Value::Array(arr) => arr
.iter()
.any(|elem| json_value_to_string(elem) == clause.value),
_ => {
let s = json_value_to_string(&raw);
s.contains(&clause.value)
@ -747,7 +774,11 @@ mod tests {
context.set("score", serde_json::json!(90));
assert!(evaluate_condition("context.score > 80", &outcome, &context));
context.set("score", serde_json::json!(70));
assert!(!evaluate_condition("context.score > 80", &outcome, &context));
assert!(!evaluate_condition(
"context.score > 80",
&outcome,
&context
));
}
#[test]
@ -755,7 +786,11 @@ mod tests {
let outcome = make_outcome(StageStatus::Success);
let context = Context::new();
context.set("score", serde_json::json!(80));
assert!(evaluate_condition("context.score >= 80", &outcome, &context));
assert!(evaluate_condition(
"context.score >= 80",
&outcome,
&context
));
}
#[test]
@ -763,7 +798,11 @@ mod tests {
let outcome = make_outcome(StageStatus::Success);
let context = Context::new();
context.set("score", serde_json::json!(80));
assert!(evaluate_condition("context.score <= 80", &outcome, &context));
assert!(evaluate_condition(
"context.score <= 80",
&outcome,
&context
));
}
#[test]
@ -924,11 +963,7 @@ mod tests {
context.set("b", serde_json::json!("2"));
context.set("c", serde_json::json!("3"));
// a=1 is false, b=2 is true => AND is false; c=3 is true => OR is true
assert!(evaluate_condition(
"a=1 && b=2 || c=3",
&outcome,
&context
));
assert!(evaluate_condition("a=1 && b=2 || c=3", &outcome, &context));
}
#[test]
@ -940,11 +975,7 @@ mod tests {
context.set("b", serde_json::json!("2"));
context.set("c", serde_json::json!("0"));
// a=1 false; b=2 true, c=3 false => AND false; OR false
assert!(!evaluate_condition(
"a=1 || b=2 && c=3",
&outcome,
&context
));
assert!(!evaluate_condition("a=1 || b=2 && c=3", &outcome, &context));
}
#[test]

View file

@ -2,7 +2,6 @@
///
/// All context keys used across the engine, handlers, and preamble are
/// defined here to prevent typos and improve discoverability.
use std::fmt;
use std::str::FromStr;
@ -160,10 +159,7 @@ mod tests {
#[test]
fn thread_current_node_key_formats_correctly() {
assert_eq!(
thread_current_node_key("main"),
"thread.main.current_node"
);
assert_eq!(thread_current_node_key("main"), "thread.main.current_node");
}
#[test]

View file

@ -103,9 +103,7 @@ impl<'de> Deserialize<'de> for DaytonaNetwork {
let cidrs: Vec<String> = map.next_value()?;
if cidrs.is_empty() {
return Err(de::Error::custom(
"allow_list must not be empty",
));
return Err(de::Error::custom("allow_list must not be empty"));
}
if let Some(extra) = map.next_key::<String>()? {
@ -481,8 +479,8 @@ impl Sandbox for DaytonaSandbox {
// Resolve clone credentials via GitHub App or fall back to no auth
let (username, password) = match &self.github_app {
Some(creds) => {
let (owner, repo) =
crate::github_app::parse_github_owner_repo(&url).map_err(|e| {
let (owner, repo) = crate::github_app::parse_github_owner_repo(&url)
.map_err(|e| {
let err = format!("Failed to parse GitHub URL for clone: {e}");
self.emit(SandboxEvent::GitCloneFailed {
url: url.clone(),
@ -721,11 +719,8 @@ impl Sandbox for DaytonaSandbox {
.map_err(|e| format!("Failed to refresh GitHub App token: {e}"))?;
if let Some(token) = password {
let auth_url = origin_url.replacen(
"https://",
&format!("https://x-access-token:{token}@"),
1,
);
let auth_url =
origin_url.replacen("https://", &format!("https://x-access-token:{token}@"), 1);
let cmd = format!(
"git -c maintenance.auto=0 remote set-url origin '{}'",
auth_url.replace('\'', "'\\''"),
@ -920,12 +915,12 @@ impl Sandbox for DaytonaSandbox {
// Wrap with `bash -c` so pipes, env vars, and shell features work.
// The Daytona API uses direct exec, not a shell.
let wrapped = wrap_bash_command(&command_with_env);
let timeout_duration = std::time::Duration::from_millis(timeout_ms + 5000); // 5s grace period
let token = cancel_token.unwrap_or_default();
let exec_future = process_svc.execute_command(&wrapped, options);
let result = tokio::select! {
res = exec_future => {
res.map_err(|e| format!("Failed to execute command: {e}"))?
@ -1215,8 +1210,7 @@ mod tests {
#[test]
fn network_unknown_key_error() {
let err =
toml::from_str::<DaytonaConfig>(r#"network = { mode = "block" }"#).unwrap_err();
let err = toml::from_str::<DaytonaConfig>(r#"network = { mode = "block" }"#).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains(r#"unknown key "mode""#),
@ -1228,16 +1222,12 @@ mod tests {
fn network_empty_table_error() {
let err = toml::from_str::<DaytonaConfig>("network = {}").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("empty table"),
"unexpected error: {msg}"
);
assert!(msg.contains("empty table"), "unexpected error: {msg}");
}
#[test]
fn network_empty_allow_list_error() {
let err =
toml::from_str::<DaytonaConfig>("network = { allow_list = [] }").unwrap_err();
let err = toml::from_str::<DaytonaConfig>("network = { allow_list = [] }").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("allow_list must not be empty"),

View file

@ -25,8 +25,8 @@ use crate::graph::{Edge, Graph, Node};
use crate::handler::{EngineServices, HandlerRegistry};
use crate::hook::{HookContext, HookDecision, HookEvent, HookRunner};
use crate::interviewer::Interviewer;
use crate::outcome::{Outcome, StageStatus};
use crate::millis_u64;
use crate::outcome::{Outcome, StageStatus};
use crate::preamble::build_preamble;
/// Classify the failure mode of a completed outcome.
@ -283,7 +283,11 @@ pub fn resolve_thread_id(
// --- Run directory helpers (spec 5.6) ---
/// Write manifest.json at the start of a workflow run. Returns the manifest.
fn write_manifest(logs_root: &Path, graph: &Graph, config: &RunConfig) -> crate::manifest::Manifest {
fn write_manifest(
logs_root: &Path,
graph: &Graph,
config: &RunConfig,
) -> crate::manifest::Manifest {
let workflow_name = if graph.name.is_empty() {
"unnamed".to_string()
} else {
@ -692,11 +696,9 @@ async fn git_push_meta_host(
}
};
match crate::github_app::resolve_clone_credentials(creds, &owner, &repo).await {
Ok((_, Some(token))) => https_url.replacen(
"https://",
&format!("https://x-access-token:{token}@"),
1,
),
Ok((_, Some(token))) => {
https_url.replacen("https://", &format!("https://x-access-token:{token}@"), 1)
}
Ok(_) => {
tracing::warn!("No token returned for metadata push");
return;
@ -915,11 +917,7 @@ impl WorkflowRunEngine {
/// Run lifecycle hooks and return the merged decision.
/// Returns `Proceed` if no hook runner is configured.
async fn run_hooks(
&self,
hook_context: &HookContext,
work_dir: Option<&Path>,
) -> HookDecision {
async fn run_hooks(&self, hook_context: &HookContext, work_dir: Option<&Path>) -> HookDecision {
let Some(ref runner) = self.services.hook_runner else {
return HookDecision::Proceed;
};
@ -1251,11 +1249,8 @@ impl WorkflowRunEngine {
// RunStart hook (blocking — can prevent run)
{
let hook_ctx = HookContext::new(
HookEvent::RunStart,
run_id.clone(),
graph.name.clone(),
);
let hook_ctx =
HookContext::new(HookEvent::RunStart, run_id.clone(), graph.name.clone());
let decision = self.run_hooks(&hook_ctx, hook_work_dir.as_deref()).await;
if let HookDecision::Block { reason } = decision {
let msg = reason.unwrap_or_else(|| "blocked by RunStart hook".into());
@ -1457,17 +1452,15 @@ impl WorkflowRunEngine {
if is_terminal(node) {
match check_goal_gates(graph, &node_outcomes) {
Ok(()) => {
self.services
.emitter
.emit(&WorkflowRunEvent::StageStarted {
node_id: node.id.clone(),
name: node.label().to_string(),
index: stage_index,
handler_type: node.handler_type().map(String::from),
script: node_script(node),
attempt: 1,
max_attempts: 1,
});
self.services.emitter.emit(&WorkflowRunEvent::StageStarted {
node_id: node.id.clone(),
name: node.label().to_string(),
index: stage_index,
handler_type: node.handler_type().map(String::from),
script: node_script(node),
attempt: 1,
max_attempts: 1,
});
self.services
.emitter
.emit(&WorkflowRunEvent::StageCompleted {
@ -1504,7 +1497,13 @@ impl WorkflowRunEngine {
git_commit_sha: last_git_sha.clone(),
});
self.run_failed_hook(&run_id, &graph.name, &error, hook_work_dir.as_deref()).await;
self.run_failed_hook(
&run_id,
&graph.name,
&error,
hook_work_dir.as_deref(),
)
.await;
return Ok((error.to_fail_outcome(), context));
}
@ -1557,7 +1556,10 @@ impl WorkflowRunEngine {
// Step 2: Execute node handler with retry policy
let visit = *loop_state.node_visits.get(&current_node_id).unwrap_or(&1);
context.set(context::keys::INTERNAL_NODE_VISIT_COUNT, serde_json::json!(visit));
context.set(
context::keys::INTERNAL_NODE_VISIT_COUNT,
serde_json::json!(visit),
);
context.set(context::keys::CURRENT_NODE, serde_json::json!(&node.id));
let retry_policy = build_retry_policy(node, graph);
@ -1573,28 +1575,21 @@ impl WorkflowRunEngine {
// StageStart hook (blocking — can skip node)
{
let mut hook_ctx = HookContext::new(
HookEvent::StageStart,
run_id.clone(),
graph.name.clone(),
);
let mut hook_ctx =
HookContext::new(HookEvent::StageStart, run_id.clone(), graph.name.clone());
hook_ctx.cwd = hook_work_dir.as_ref().map(|p| p.display().to_string());
hook_ctx.node_id = Some(node.id.clone());
hook_ctx.node_label = Some(node.label().to_string());
hook_ctx.handler_type = node.handler_type().map(String::from);
hook_ctx.attempt = Some(1);
hook_ctx.max_attempts = Some(
usize::try_from(retry_policy.max_attempts).unwrap_or(usize::MAX),
);
let decision = self
.run_hooks(&hook_ctx, hook_work_dir.as_deref())
.await;
hook_ctx.max_attempts =
Some(usize::try_from(retry_policy.max_attempts).unwrap_or(usize::MAX));
let decision = self.run_hooks(&hook_ctx, hook_work_dir.as_deref()).await;
match decision {
HookDecision::Skip { reason } => {
let mut outcome = Outcome::skipped();
outcome.notes = Some(
reason.unwrap_or_else(|| "skipped by StageStart hook".into()),
);
outcome.notes =
Some(reason.unwrap_or_else(|| "skipped by StageStart hook".into()));
completed_nodes.push(node.id.clone());
node_outcomes.insert(node.id.clone(), outcome);
previous_node_id = Some(node.id.clone());
@ -1719,9 +1714,7 @@ impl WorkflowRunEngine {
hook_ctx.handler_type = node.handler_type().map(String::from);
hook_ctx.status = Some("fail".into());
hook_ctx.failure_reason = outcome.failure_reason().map(String::from);
let _ = self
.run_hooks(&hook_ctx, hook_work_dir.as_deref())
.await;
let _ = self.run_hooks(&hook_ctx, hook_work_dir.as_deref()).await;
}
} else {
self.services
@ -1754,9 +1747,7 @@ impl WorkflowRunEngine {
hook_ctx.node_label = Some(node.label().to_string());
hook_ctx.handler_type = node.handler_type().map(String::from);
hook_ctx.status = Some(outcome.status.to_string());
let _ = self
.run_hooks(&hook_ctx, hook_work_dir.as_deref())
.await;
let _ = self.run_hooks(&hook_ctx, hook_work_dir.as_deref()).await;
}
}
@ -1783,7 +1774,10 @@ impl WorkflowRunEngine {
// Step 4: Apply context updates from outcome
context.apply_updates(&outcome.context_updates);
context.set(context::keys::OUTCOME, serde_json::json!(outcome.status.to_string()));
context.set(
context::keys::OUTCOME,
serde_json::json!(outcome.status.to_string()),
);
context.set(
context::keys::FAILURE_CLASS,
serde_json::json!(outcome_failure_class.map_or(String::new(), |fc| fc.to_string())),
@ -1836,17 +1830,19 @@ impl WorkflowRunEngine {
);
hook_ctx.edge_from = Some(node.id.clone());
hook_ctx.edge_to = Some(to.clone());
hook_ctx.edge_label = next_edge.as_ref().and_then(|e| e.label().map(String::from));
let decision = self
.run_hooks(&hook_ctx, hook_work_dir.as_deref())
.await;
hook_ctx.edge_label =
next_edge.as_ref().and_then(|e| e.label().map(String::from));
let decision = self.run_hooks(&hook_ctx, hook_work_dir.as_deref()).await;
match decision {
HookDecision::Override { edge_to: new_target } => {
HookDecision::Override {
edge_to: new_target,
} => {
// Redirect routing to the hook-specified target
(None, Some(new_target))
}
HookDecision::Block { reason } => {
let msg = reason.unwrap_or_else(|| "blocked by EdgeSelected hook".into());
let msg =
reason.unwrap_or_else(|| "blocked by EdgeSelected hook".into());
return Err(ArcError::engine(msg));
}
_ => (next_edge, jump_target),
@ -1897,139 +1893,144 @@ impl WorkflowRunEngine {
// Step 6b: Write shadow branch first, then run branch commit with trailer
// Skip git checkpoint for the start node — it's a no-op, so the commit is always empty.
if start_node_id.as_deref() != Some(&*node.id) {
if let Some(ref mode) = config.git_checkpoint {
// Shadow commit (best-effort): extract repo path from either variant
let shadow_sha: Option<String> = if config.meta_branch.is_some() {
let repo_path = match mode {
GitCheckpointMode::Host(ref p) | GitCheckpointMode::Remote(ref p) => p,
};
let store = crate::git::MetadataStore::new(repo_path, &config.git_author);
serde_json::to_vec_pretty(&checkpoint)
.ok()
.and_then(|cp_json| {
let artifact_entries: Vec<(String, Vec<u8>)> = artifact_store
.list()
.iter()
.filter_map(|info| {
info.file_path.as_ref().and_then(|path| {
std::fs::read(path).ok().map(|data| {
(format!("artifacts/{}.json", info.id), data)
if let Some(ref mode) = config.git_checkpoint {
// Shadow commit (best-effort): extract repo path from either variant
let shadow_sha: Option<String> = if config.meta_branch.is_some() {
let repo_path = match mode {
GitCheckpointMode::Host(ref p) | GitCheckpointMode::Remote(ref p) => p,
};
let store = crate::git::MetadataStore::new(repo_path, &config.git_author);
serde_json::to_vec_pretty(&checkpoint)
.ok()
.and_then(|cp_json| {
let artifact_entries: Vec<(String, Vec<u8>)> = artifact_store
.list()
.iter()
.filter_map(|info| {
info.file_path.as_ref().and_then(|path| {
std::fs::read(path).ok().map(|data| {
(format!("artifacts/{}.json", info.id), data)
})
})
})
})
.collect();
let artifact_refs: Vec<(&str, &[u8])> = artifact_entries
.iter()
.map(|(k, v)| (k.as_str(), v.as_slice()))
.collect();
match store.write_checkpoint(&config.run_id, &cp_json, &artifact_refs) {
Ok(sha) => Some(sha),
Err(e) => {
context.append_log(format!(
"metadata checkpoint write failed: {e}"
));
None
.collect();
let artifact_refs: Vec<(&str, &[u8])> = artifact_entries
.iter()
.map(|(k, v)| (k.as_str(), v.as_slice()))
.collect();
match store.write_checkpoint(
&config.run_id,
&cp_json,
&artifact_refs,
) {
Ok(sha) => Some(sha),
Err(e) => {
context.append_log(format!(
"metadata checkpoint write failed: {e}"
));
None
}
}
}
})
} else {
None
};
})
} else {
None
};
// Run branch commit with Arc-Meta trailer pointing to shadow commit
let rid = run_id.clone();
let nid = node.id.clone();
let status_str = outcome.status.to_string();
let completed_count = completed_nodes.len();
// Run branch commit with Arc-Meta trailer pointing to shadow commit
let rid = run_id.clone();
let nid = node.id.clone();
let status_str = outcome.status.to_string();
let completed_count = completed_nodes.len();
let commit_result = match mode {
GitCheckpointMode::Host(work_dir) => {
git_checkpoint_host(
work_dir.clone(),
rid,
nid,
status_str,
completed_count,
shadow_sha,
config.checkpoint_exclude_globs.clone(),
config.git_author.clone(),
)
.await
}
GitCheckpointMode::Remote(_) => {
git_checkpoint_remote(
&*self.services.sandbox,
&run_id,
&node.id,
&outcome.status.to_string(),
completed_count,
shadow_sha,
&config.checkpoint_exclude_globs,
&config.git_author,
)
.await
}
};
if let Some(sha) = commit_result {
checkpoint.git_commit_sha = Some(sha.clone());
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(),
});
// Push run branch and metadata branch to origin after remote checkpoint
if let GitCheckpointMode::Remote(ref host_repo) = mode {
if let Some(ref branch) = config.run_branch {
git_push_remote(&*self.services.sandbox, branch).await;
}
if let Some(ref meta_branch) = config.meta_branch {
git_push_meta_host(
host_repo.clone(),
meta_branch.clone(),
config.github_app.clone(),
)
.await;
}
}
// Save diff.patch for this stage
let prev = last_git_sha
.as_deref()
.or(config.base_sha.as_deref())
.unwrap_or(&sha);
let diff_base = prev.to_string();
let diff_dest = node_dir(&config.logs_root, &node.id, visit).join("diff.patch");
let diff_result = match mode {
let commit_result = match mode {
GitCheckpointMode::Host(work_dir) => {
git_diff_host(work_dir.clone(), diff_base).await
git_checkpoint_host(
work_dir.clone(),
rid,
nid,
status_str,
completed_count,
shadow_sha,
config.checkpoint_exclude_globs.clone(),
config.git_author.clone(),
)
.await
}
GitCheckpointMode::Remote(_) => {
git_diff_remote(&*self.services.sandbox, &diff_base).await
git_checkpoint_remote(
&*self.services.sandbox,
&run_id,
&node.id,
&outcome.status.to_string(),
completed_count,
shadow_sha,
&config.checkpoint_exclude_globs,
&config.git_author,
)
.await
}
};
if let Some(patch) = diff_result {
if !patch.is_empty() {
let _ = std::fs::write(&diff_dest, patch);
}
} else {
context.append_log("git diff failed".to_string());
}
last_git_sha = Some(sha);
} else {
context.append_log("git checkpoint commit failed".to_string());
if let Some(sha) = commit_result {
checkpoint.git_commit_sha = Some(sha.clone());
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(),
});
// Push run branch and metadata branch to origin after remote checkpoint
if let GitCheckpointMode::Remote(ref host_repo) = mode {
if let Some(ref branch) = config.run_branch {
git_push_remote(&*self.services.sandbox, branch).await;
}
if let Some(ref meta_branch) = config.meta_branch {
git_push_meta_host(
host_repo.clone(),
meta_branch.clone(),
config.github_app.clone(),
)
.await;
}
}
// Save diff.patch for this stage
let prev = last_git_sha
.as_deref()
.or(config.base_sha.as_deref())
.unwrap_or(&sha);
let diff_base = prev.to_string();
let diff_dest =
node_dir(&config.logs_root, &node.id, visit).join("diff.patch");
let diff_result = match mode {
GitCheckpointMode::Host(work_dir) => {
git_diff_host(work_dir.clone(), diff_base).await
}
GitCheckpointMode::Remote(_) => {
git_diff_remote(&*self.services.sandbox, &diff_base).await
}
};
if let Some(patch) = diff_result {
if !patch.is_empty() {
let _ = std::fs::write(&diff_dest, patch);
}
} else {
context.append_log("git diff failed".to_string());
}
last_git_sha = Some(sha);
} else {
context.append_log("git checkpoint commit failed".to_string());
}
}
}
}
// Step 7: Follow selected edge (or direct jump)
if let Some(target) = jump_target {
@ -2059,7 +2060,13 @@ impl WorkflowRunEngine {
git_commit_sha: last_git_sha.clone(),
});
self.run_failed_hook(&run_id, &graph.name, &error, hook_work_dir.as_deref()).await;
self.run_failed_hook(
&run_id,
&graph.name,
&error,
hook_work_dir.as_deref(),
)
.await;
return Err(error);
}
@ -2141,11 +2148,8 @@ impl WorkflowRunEngine {
// RunComplete hook (non-blocking)
{
let hook_ctx = HookContext::new(
HookEvent::RunComplete,
run_id.clone(),
graph.name.clone(),
);
let hook_ctx =
HookContext::new(HookEvent::RunComplete, run_id.clone(), graph.name.clone());
let _ = self.run_hooks(&hook_ctx, hook_work_dir.as_deref()).await;
}
@ -3163,8 +3167,7 @@ mod tests {
};
engine.run(&g, &config).await.unwrap();
let manifest =
crate::manifest::Manifest::load(&dir.path().join("manifest.json")).unwrap();
let manifest = crate::manifest::Manifest::load(&dir.path().join("manifest.json")).unwrap();
assert_eq!(manifest.labels.get("env").map(String::as_str), Some("test"));
}
@ -3190,8 +3193,7 @@ mod tests {
};
engine.run(&g, &config).await.unwrap();
let manifest =
crate::manifest::Manifest::load(&dir.path().join("manifest.json")).unwrap();
let manifest = crate::manifest::Manifest::load(&dir.path().join("manifest.json")).unwrap();
assert!(manifest.labels.is_empty());
}
@ -4836,7 +4838,10 @@ mod tests {
// Check the checkpoint for the failure_signature context value
let checkpoint_path = dir.path().join("checkpoint.json");
let cp = Checkpoint::load(&checkpoint_path).unwrap();
let sig_value = cp.context_values.get(context::keys::FAILURE_SIGNATURE).unwrap();
let sig_value = cp
.context_values
.get(context::keys::FAILURE_SIGNATURE)
.unwrap();
let sig_str = sig_value.as_str().unwrap();
assert!(
sig_str.contains("work|deterministic|"),
@ -4855,7 +4860,16 @@ mod tests {
.output()
.unwrap();
std::process::Command::new("git")
.args(["-c", "user.name=Test", "-c", "user.email=test@test.com", "commit", "--allow-empty", "-m", "initial"])
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@test.com",
"commit",
"--allow-empty",
"-m",
"initial",
])
.current_dir(repo)
.output()
.unwrap();

View file

@ -520,13 +520,7 @@ impl WorkflowRunEvent {
error,
duration_ms,
} => {
error!(
cli_name,
provider,
error,
duration_ms,
"CLI ensure failed"
);
error!(cli_name, provider, error, duration_ms, "CLI ensure failed");
}
}
}

View file

@ -36,7 +36,6 @@ impl GitAuthor {
}
}
fn git_error(msg: impl Into<String>) -> ArcError {
ArcError::engine(msg.into())
}
@ -413,7 +412,10 @@ impl MetadataStore {
}
/// Read the manifest from the metadata branch. Returns `None` if not found.
pub fn read_manifest(repo_path: &Path, run_id: &str) -> Result<Option<crate::manifest::Manifest>> {
pub fn read_manifest(
repo_path: &Path,
run_id: &str,
) -> Result<Option<crate::manifest::Manifest>> {
match Self::read_file(repo_path, run_id, "manifest.json")? {
Some(bytes) => {
let manifest: crate::manifest::Manifest = serde_json::from_slice(&bytes)
@ -563,7 +565,17 @@ mod tests {
let wt = dir.path().join("ff-wt");
add_worktree(dir.path(), &wt, "ff-branch").unwrap();
fs::write(wt.join("new.txt"), "data").unwrap();
checkpoint_commit(&wt, "run", "node", "ok", 1, None, &[], &GitAuthor::default()).unwrap();
checkpoint_commit(
&wt,
"run",
"node",
"ok",
1,
None,
&[],
&GitAuthor::default(),
)
.unwrap();
let advanced_sha = head_sha(&wt).unwrap();
remove_worktree(dir.path(), &wt).unwrap();
@ -634,8 +646,17 @@ mod tests {
// Simulate a shadow commit SHA
let shadow_sha = "abcdef1234567890abcdef1234567890abcdef12";
let sha =
checkpoint_commit(&wt_path, "run1", "nodeA", "success", 3, Some(shadow_sha), &[], &GitAuthor::default()).unwrap();
let sha = checkpoint_commit(
&wt_path,
"run1",
"nodeA",
"success",
3,
Some(shadow_sha),
&[],
&GitAuthor::default(),
)
.unwrap();
assert_eq!(sha.len(), 40);
assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
@ -674,7 +695,17 @@ mod tests {
let wt_path = dir.path().join("worktree");
add_worktree(dir.path(), &wt_path, "run-branch2").unwrap();
let sha = checkpoint_commit(&wt_path, "run2", "nodeB", "completed", 1, None, &[], &GitAuthor::default()).unwrap();
let sha = checkpoint_commit(
&wt_path,
"run2",
"nodeB",
"completed",
1,
None,
&[],
&GitAuthor::default(),
)
.unwrap();
assert_eq!(sha.len(), 40);
// Verify Arc-Completed trailer present but no Arc-Meta
@ -718,7 +749,17 @@ mod tests {
let wt_path = dir.path().join("worktree");
add_worktree(dir.path(), &wt_path, "fallback-branch").unwrap();
let sha = checkpoint_commit(&wt_path, "run2", "nodeB", "completed", 0, None, &[], &GitAuthor::default()).unwrap();
let sha = checkpoint_commit(
&wt_path,
"run2",
"nodeB",
"completed",
0,
None,
&[],
&GitAuthor::default(),
)
.unwrap();
assert_eq!(sha.len(), 40);
remove_worktree(dir.path(), &wt_path).unwrap();
@ -1003,7 +1044,17 @@ mod tests {
fs::write(wt_path.join("node_modules/pkg/index.js"), "module").unwrap();
let excludes = vec!["**/node_modules/**".to_string()];
checkpoint_commit(&wt_path, "run", "node", "ok", 1, None, &excludes, &GitAuthor::default()).unwrap();
checkpoint_commit(
&wt_path,
"run",
"node",
"ok",
1,
None,
&excludes,
&GitAuthor::default(),
)
.unwrap();
// Verify kept.txt was committed
let output = Command::new("git")
@ -1036,14 +1087,34 @@ mod tests {
// Create and commit a file in the excluded dir first
fs::create_dir_all(wt_path.join(".cache")).unwrap();
fs::write(wt_path.join(".cache/data.bin"), "v1").unwrap();
checkpoint_commit(&wt_path, "run", "setup", "ok", 0, None, &[], &GitAuthor::default()).unwrap();
checkpoint_commit(
&wt_path,
"run",
"setup",
"ok",
0,
None,
&[],
&GitAuthor::default(),
)
.unwrap();
// Now modify the tracked excluded file and add a new non-excluded file
fs::write(wt_path.join(".cache/data.bin"), "v2").unwrap();
fs::write(wt_path.join("result.txt"), "done").unwrap();
let excludes = vec!["**/.cache/**".to_string()];
checkpoint_commit(&wt_path, "run", "step", "ok", 1, None, &excludes, &GitAuthor::default()).unwrap();
checkpoint_commit(
&wt_path,
"run",
"step",
"ok",
1,
None,
&excludes,
&GitAuthor::default(),
)
.unwrap();
let output = Command::new("git")
.args(["show", "--name-only", "--format=", "HEAD"])
@ -1090,9 +1161,14 @@ mod tests {
.unwrap();
Command::new("git")
.args([
"-c", "user.name=test",
"-c", "user.email=test@test",
"commit", "--allow-empty", "-m", "init",
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(&repo_dir)
.output()
@ -1110,7 +1186,10 @@ mod tests {
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("test-push"), "remote should have test-push branch");
assert!(
stdout.contains("test-push"),
"remote should have test-push branch"
);
}
#[test]
@ -1126,9 +1205,13 @@ mod tests {
.unwrap();
Command::new("git")
.args([
"-c", "user.name=test",
"-c", "user.email=test@test",
"commit", "-m", "add file",
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"-m",
"add file",
])
.current_dir(dir.path())
.output()

View file

@ -107,18 +107,14 @@ pub async fn create_installation_access_token(
));
}
403 => {
return Err(
"GitHub App installation is suspended. \
return Err("GitHub App installation is suspended. \
Re-enable it in your organization's GitHub App settings."
.to_string(),
);
.to_string());
}
401 => {
return Err(
"GitHub App authentication failed. \
return Err("GitHub App authentication failed. \
Check that app_id and GITHUB_APP_PRIVATE_KEY are correct."
.to_string(),
);
.to_string());
}
_ => {
return Err(format!(
@ -162,11 +158,9 @@ pub async fn create_installation_access_token(
));
}
401 => {
return Err(
"GitHub App authentication failed. \
return Err("GitHub App authentication failed. \
Check that app_id and GITHUB_APP_PRIVATE_KEY are correct."
.to_string(),
);
.to_string());
}
_ => {
return Err(format!(
@ -217,10 +211,7 @@ pub async fn resolve_clone_credentials(
let token =
create_installation_access_token(&client, &jwt, owner, repo, GITHUB_API_BASE_URL).await?;
Ok((
Some("x-access-token".to_string()),
Some(token),
))
Ok((Some("x-access-token".to_string()), Some(token)))
}
#[cfg(test)]
@ -307,7 +298,13 @@ mod tests {
fn test_rsa_key() -> String {
use std::process::Command;
let output = Command::new("openssl")
.args(["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048"])
.args([
"genpkey",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
])
.output()
.expect("openssl should be available");
assert!(output.status.success(), "openssl keygen failed");
@ -326,9 +323,11 @@ mod tests {
let pem = test_rsa_key();
let jwt = sign_app_jwt("12345", &pem).unwrap();
let header_b64 = jwt.split('.').next().unwrap();
let header_json =
base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, header_b64)
.unwrap();
let header_json = base64::Engine::decode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
header_b64,
)
.unwrap();
let header: serde_json::Value = serde_json::from_slice(&header_json).unwrap();
assert_eq!(header["alg"], "RS256");
}
@ -338,9 +337,11 @@ mod tests {
let pem = test_rsa_key();
let jwt = sign_app_jwt("99999", &pem).unwrap();
let payload_b64 = jwt.split('.').nth(1).unwrap();
let payload_json =
base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, payload_b64)
.unwrap();
let payload_json = base64::Engine::decode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
payload_b64,
)
.unwrap();
let claims: serde_json::Value = serde_json::from_slice(&payload_json).unwrap();
assert_eq!(claims["iss"], "99999");

View file

@ -70,7 +70,10 @@ impl AttrValue {
/// Returns true if the handler type is an LLM-based handler (agent or prompt, including legacy aliases).
#[must_use]
pub fn is_llm_handler_type(handler_type: Option<&str>) -> bool {
matches!(handler_type, Some("agent") | Some("agent_loop") | Some("prompt") | Some("one_shot"))
matches!(
handler_type,
Some("agent") | Some("agent_loop") | Some("prompt") | Some("one_shot")
)
}
/// Maps Graphviz shapes to handler type strings (Section 2.8).
@ -574,10 +577,8 @@ mod tests {
#[test]
fn node_handler_type_explicit() {
let mut node = Node::new("gate");
node.attrs.insert(
"type".to_string(),
AttrValue::String("human".to_string()),
);
node.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
assert_eq!(node.handler_type(), Some("human"));
}

View file

@ -73,7 +73,8 @@ impl AgentHandler {
/// `$gaol` at runtime.
pub(crate) fn expand_variables(text: &str, graph: &Graph) -> Result<String, ArcError> {
let vars = HashMap::from([("goal".to_string(), graph.goal().to_string())]);
crate::cli::run_config::expand_vars(text, &vars).map_err(|e| ArcError::Validation(e.to_string()))
crate::cli::run_config::expand_vars(text, &vars)
.map_err(|e| ArcError::Validation(e.to_string()))
}
/// Status fields that indicate a JSON object contains routing directives.
@ -143,7 +144,9 @@ pub(crate) fn extract_status_fields(text: &str, outcome: &mut Outcome) -> bool {
});
let Some(value) = parsed else { return false };
let Some(obj) = value.as_object() else { return false };
let Some(obj) = value.as_object() else {
return false;
};
if let Some(label) = obj.get("preferred_next_label").and_then(|v| v.as_str()) {
outcome.preferred_label = Some(label.to_string());

View file

@ -4,7 +4,6 @@ use std::time::Instant;
use async_trait::async_trait;
use crate::millis_u64;
use crate::context::keys;
use crate::context::Context;
use crate::error::ArcError;
@ -13,6 +12,7 @@ use crate::graph::{Graph, Node};
use crate::interviewer::{
Answer, AnswerValue, Interviewer, Question, QuestionOption, QuestionType,
};
use crate::millis_u64;
use crate::outcome::Outcome;
use super::{EngineServices, Handler};
@ -226,9 +226,10 @@ fn make_choice_outcome(key: &str, label: &str, to: &str) -> Outcome {
let mut outcome = Outcome::success();
outcome.preferred_label = Some(label.to_string());
outcome.suggested_next_ids = vec![to.to_string()];
outcome
.context_updates
.insert(keys::HUMAN_GATE_SELECTED.to_string(), serde_json::json!(key));
outcome.context_updates.insert(
keys::HUMAN_GATE_SELECTED.to_string(),
serde_json::json!(key),
);
outcome
.context_updates
.insert(keys::HUMAN_GATE_LABEL.to_string(), serde_json::json!(label));

View file

@ -150,7 +150,10 @@ impl Handler for SubWorkflowHandler {
labels: HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: git_state.as_ref().map(|gs| gs.git_author.clone()).unwrap_or_default(),
git_author: git_state
.as_ref()
.map(|gs| gs.git_author.clone())
.unwrap_or_default(),
};
// Clone parent context for child; inject parent preamble
@ -683,14 +686,8 @@ mod tests {
serde_json::json!("exit"),
);
after.insert("current_node".to_string(), serde_json::json!("exit"));
after.insert(
"response.plan".to_string(),
serde_json::json!("the plan"),
);
after.insert(
"review.result".to_string(),
serde_json::json!("approved"),
);
after.insert("response.plan".to_string(), serde_json::json!("the plan"));
after.insert("review.result".to_string(), serde_json::json!("approved"));
let raw_diff = context_diff(&before, &after);
let filtered: HashMap<String, serde_json::Value> = raw_diff
@ -803,8 +800,7 @@ mod tests {
_logs_root: &Path,
_services: &EngineServices,
) -> Result<Outcome, ArcError> {
let parent_preamble =
context.get_string(keys::INTERNAL_PARENT_PREAMBLE, "");
let parent_preamble = context.get_string(keys::INTERNAL_PARENT_PREAMBLE, "");
let mut outcome = Outcome::success();
outcome.context_updates.insert(
"echo.parent_preamble".to_string(),

View file

@ -123,14 +123,10 @@ pub fn default_registry(
interviewer: Arc<dyn Interviewer>,
make_backend: impl Fn() -> Option<Box<dyn agent::CodergenBackend>>,
) -> HandlerRegistry {
let mut registry =
HandlerRegistry::new(Box::new(agent::AgentHandler::new(make_backend())));
let mut registry = HandlerRegistry::new(Box::new(agent::AgentHandler::new(make_backend())));
registry.register("start", Box::new(start::StartHandler));
registry.register("exit", Box::new(exit::ExitHandler));
registry.register(
"agent",
Box::new(agent::AgentHandler::new(make_backend())),
);
registry.register("agent", Box::new(agent::AgentHandler::new(make_backend())));
// Legacy alias
registry.register(
"agent_loop",
@ -146,10 +142,7 @@ pub fn default_registry(
Box::new(prompt::PromptHandler::new(make_backend())),
);
registry.register("conditional", Box::new(conditional::ConditionalHandler));
registry.register(
"human",
Box::new(human::HumanHandler::new(interviewer)),
);
registry.register("human", Box::new(human::HumanHandler::new(interviewer)));
registry.register("command", Box::new(command::CommandHandler));
registry.register("tool", Box::new(command::CommandHandler));
registry.register("parallel", Box::new(parallel::ParallelHandler));
@ -201,10 +194,8 @@ mod tests {
);
let mut node = Node::new("gate");
node.attrs.insert(
"type".to_string(),
AttrValue::String("human".to_string()),
);
node.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
let handler = registry.resolve(&node);
// We can verify it returns the right handler by checking it doesn't panic
// and returns a valid reference

View file

@ -6,13 +6,13 @@ use arc_agent::Sandbox;
use async_trait::async_trait;
use tokio::sync::Semaphore;
use crate::millis_u64;
use crate::context::keys;
use crate::context::Context;
use crate::engine::GitCheckpointMode;
use crate::error::ArcError;
use crate::event::WorkflowRunEvent;
use crate::graph::{Graph, Node};
use crate::millis_u64;
use crate::outcome::{Outcome, StageStatus};
use super::{EngineServices, Handler};
@ -367,7 +367,8 @@ impl Handler for ParallelHandler {
"failed to reset remote worktree {wt_path_str}"
)));
}
branch_context.set(keys::INTERNAL_WORK_DIR, serde_json::json!(&wt_path_str));
branch_context
.set(keys::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(),
@ -400,7 +401,10 @@ impl Handler for ParallelHandler {
let sem = Arc::clone(&semaphore);
let has_git = git_state.is_some();
let run_id = git_state.as_ref().map(|gs| gs.run_id.clone());
let git_author = git_state.as_ref().map(|gs| gs.git_author.clone()).unwrap_or_default();
let git_author = git_state
.as_ref()
.map(|gs| gs.git_author.clone())
.unwrap_or_default();
let handle = tokio::spawn(async move {
let _permit = sem

View file

@ -57,8 +57,13 @@ impl Handler for PromptHandler {
.llm_provider()
.and_then(|s| s.parse::<Provider>().ok())
.unwrap_or(Provider::Anthropic);
let docs =
arc_agent::discover_project_docs(&*services.sandbox, working_dir, working_dir, provider).await;
let docs = arc_agent::discover_project_docs(
&*services.sandbox,
working_dir,
working_dir,
provider,
)
.await;
tracing::debug!(node = %node.id, doc_count = docs.len(), "Project docs discovered for prompt node");
if docs.is_empty() {
None
@ -279,8 +284,7 @@ mod tests {
_stage_dir: &Path,
) -> Result<CodergenResult, ArcError> {
*self.captured_prompt.lock().unwrap() = Some(prompt.to_string());
*self.captured_system_prompt.lock().unwrap() =
Some(system_prompt.map(String::from));
*self.captured_system_prompt.lock().unwrap() = Some(system_prompt.map(String::from));
Ok(CodergenResult::Text {
text: "classified".to_string(),
usage: None,
@ -306,7 +310,10 @@ mod tests {
AttrValue::String("Classify this".to_string()),
);
let context = Context::new();
context.set(keys::CURRENT_PREAMBLE, serde_json::json!("Prior output here"));
context.set(
keys::CURRENT_PREAMBLE,
serde_json::json!("Prior output here"),
);
let graph = Graph::new("test");
let tmp = TempDir::new().unwrap();
@ -371,10 +378,8 @@ mod tests {
"prompt".to_string(),
AttrValue::String("Classify this".to_string()),
);
node.attrs.insert(
"project_memory".to_string(),
AttrValue::Boolean(false),
);
node.attrs
.insert("project_memory".to_string(), AttrValue::Boolean(false));
let context = Context::new();
let graph = Graph::new("test");
let tmp = TempDir::new().unwrap();
@ -385,6 +390,10 @@ mod tests {
.unwrap();
let sys = captured_sys.lock().unwrap().clone();
assert_eq!(sys, Some(None), "system_prompt should be None when project_memory=false");
assert_eq!(
sys,
Some(None),
"system_prompt should be None when project_memory=false"
);
}
}

View file

@ -21,7 +21,9 @@ pub enum TlsMode {
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HookType {
Command { command: String },
Command {
command: String,
},
Http {
url: String,
headers: Option<std::collections::HashMap<String, String>>,
@ -70,15 +72,18 @@ impl HookDefinition {
if let Some(ref ht) = self.hook_type {
return Some(Cow::Borrowed(ht));
}
self.command
.as_ref()
.map(|cmd| Cow::Owned(HookType::Command { command: cmd.clone() }))
self.command.as_ref().map(|cmd| {
Cow::Owned(HookType::Command {
command: cmd.clone(),
})
})
}
/// Whether this hook is blocking for its event.
#[must_use]
pub fn is_blocking(&self) -> bool {
self.blocking.unwrap_or_else(|| self.event.is_blocking_by_default())
self.blocking
.unwrap_or_else(|| self.event.is_blocking_by_default())
}
/// Timeout duration for this hook.
@ -115,7 +120,8 @@ impl HookDefinition {
format!("{event_str}:{short}")
}
Some(HookType::Http { ref url, .. }) => format!("{event_str}:{url}"),
Some(HookType::Prompt { ref prompt, .. }) | Some(HookType::Agent { ref prompt, .. }) => {
Some(HookType::Prompt { ref prompt, .. })
| Some(HookType::Agent { ref prompt, .. }) => {
let short = &prompt[..arc_agent::floor_char_boundary(prompt, 20)];
format!("{event_str}:{short}")
}
@ -180,7 +186,9 @@ command = "./scripts/pre-check.sh"
assert_eq!(hook.event, HookEvent::StageStart);
assert_eq!(hook.command.as_deref(), Some("./scripts/pre-check.sh"));
let resolved = hook.resolved_hook_type().unwrap();
assert!(matches!(&*resolved, HookType::Command { command } if command == "./scripts/pre-check.sh"));
assert!(
matches!(&*resolved, HookType::Command { command } if command == "./scripts/pre-check.sh")
);
}
#[test]

View file

@ -190,9 +190,7 @@ impl HookExecutorImpl {
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
.unwrap_or(trimmed);
let inner = inner
.strip_suffix("```")
.unwrap_or(inner);
let inner = inner.strip_suffix("```").unwrap_or(inner);
inner.trim()
}
@ -522,22 +520,24 @@ impl HookExecutor for HookExecutorImpl {
let start = Instant::now();
let decision = match definition.resolved_hook_type() {
Some(Cow::Borrowed(HookType::Command { ref command })
| Cow::Owned(HookType::Command { ref command })) => {
Self::execute_command(definition, command, context, &sandbox, work_dir).await
}
Some(Cow::Borrowed(HookType::Http {
ref url,
ref headers,
ref allowed_env_vars,
ref tls,
})
| Cow::Owned(HookType::Http {
ref url,
ref headers,
ref allowed_env_vars,
ref tls,
})) => {
Some(
Cow::Borrowed(HookType::Command { ref command })
| Cow::Owned(HookType::Command { ref command }),
) => Self::execute_command(definition, command, context, &sandbox, work_dir).await,
Some(
Cow::Borrowed(HookType::Http {
ref url,
ref headers,
ref allowed_env_vars,
ref tls,
})
| Cow::Owned(HookType::Http {
ref url,
ref headers,
ref allowed_env_vars,
ref tls,
}),
) => {
let clients = HTTP_CLIENTS.get_or_init(HttpClientCache::new);
Self::execute_http(
clients.get(tls),
@ -550,26 +550,28 @@ impl HookExecutor for HookExecutorImpl {
)
.await
}
Some(Cow::Borrowed(HookType::Prompt {
ref prompt,
ref model,
})
| Cow::Owned(HookType::Prompt {
ref prompt,
ref model,
})) => {
Self::execute_prompt(prompt, model, context, definition.timeout()).await
}
Some(Cow::Borrowed(HookType::Agent {
ref prompt,
ref model,
ref max_tool_rounds,
})
| Cow::Owned(HookType::Agent {
ref prompt,
ref model,
ref max_tool_rounds,
})) => {
Some(
Cow::Borrowed(HookType::Prompt {
ref prompt,
ref model,
})
| Cow::Owned(HookType::Prompt {
ref prompt,
ref model,
}),
) => Self::execute_prompt(prompt, model, context, definition.timeout()).await,
Some(
Cow::Borrowed(HookType::Agent {
ref prompt,
ref model,
ref max_tool_rounds,
})
| Cow::Owned(HookType::Agent {
ref prompt,
ref model,
ref max_tool_rounds,
}),
) => {
Self::execute_agent(
prompt,
model,
@ -594,7 +596,6 @@ impl HookExecutor for HookExecutorImpl {
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -606,7 +607,9 @@ mod tests {
}
fn make_sandbox() -> Arc<dyn Sandbox> {
Arc::new(arc_agent::LocalSandbox::new(std::env::current_dir().unwrap()))
Arc::new(arc_agent::LocalSandbox::new(
std::env::current_dir().unwrap(),
))
}
fn test_http_client() -> reqwest::Client {
@ -717,8 +720,7 @@ mod tests {
#[tokio::test]
async fn command_executor_host_json_decision() {
let executor = HookExecutorImpl;
let def =
make_definition(r#"echo '{"decision": "skip", "reason": "test skip"}'"#);
let def = make_definition(r#"echo '{"decision": "skip", "reason": "test skip"}'"#);
let ctx = make_context();
let sandbox = make_sandbox();
let result = executor.execute(&def, &ctx, sandbox, None).await;
@ -800,7 +802,9 @@ mod tests {
#[test]
fn parse_prompt_response_strips_code_fences() {
assert_eq!(
HookExecutorImpl::parse_prompt_response("```json\n{\"ok\": false, \"reason\": \"no\"}\n```"),
HookExecutorImpl::parse_prompt_response(
"```json\n{\"ok\": false, \"reason\": \"no\"}\n```"
),
HookDecision::Block {
reason: Some("no".into())
},
@ -809,7 +813,10 @@ mod tests {
#[test]
fn strip_code_fences_plain() {
assert_eq!(HookExecutorImpl::strip_code_fences(r#"{"ok": true}"#), r#"{"ok": true}"#);
assert_eq!(
HookExecutorImpl::strip_code_fences(r#"{"ok": true}"#),
r#"{"ok": true}"#
);
}
#[test]
@ -833,10 +840,8 @@ mod tests {
#[test]
fn interpolate_resolves_allowed_var() {
std::env::set_var("ARC_TEST_KEY_1", "secret123");
let result = interpolate_env_vars(
"Bearer $ARC_TEST_KEY_1",
&["ARC_TEST_KEY_1".to_string()],
);
let result =
interpolate_env_vars("Bearer $ARC_TEST_KEY_1", &["ARC_TEST_KEY_1".to_string()]);
assert_eq!(result, "Bearer secret123");
std::env::remove_var("ARC_TEST_KEY_1");
}
@ -844,10 +849,7 @@ mod tests {
#[test]
fn interpolate_resolves_braced_var() {
std::env::set_var("ARC_TEST_KEY_2", "val");
let result = interpolate_env_vars(
"x${ARC_TEST_KEY_2}y",
&["ARC_TEST_KEY_2".to_string()],
);
let result = interpolate_env_vars("x${ARC_TEST_KEY_2}y", &["ARC_TEST_KEY_2".to_string()]);
assert_eq!(result, "xvaly");
std::env::remove_var("ARC_TEST_KEY_2");
}
@ -855,10 +857,7 @@ mod tests {
#[test]
fn interpolate_unlisted_var_becomes_empty() {
std::env::set_var("ARC_TEST_KEY_3", "should_not_appear");
let result = interpolate_env_vars(
"prefix-$ARC_TEST_KEY_3-suffix",
&[],
);
let result = interpolate_env_vars("prefix-$ARC_TEST_KEY_3-suffix", &[]);
assert_eq!(result, "prefix--suffix");
std::env::remove_var("ARC_TEST_KEY_3");
}
@ -866,10 +865,8 @@ mod tests {
#[test]
fn interpolate_missing_var_becomes_empty() {
std::env::remove_var("ARC_TEST_NOEXIST");
let result = interpolate_env_vars(
"a$ARC_TEST_NOEXIST-b",
&["ARC_TEST_NOEXIST".to_string()],
);
let result =
interpolate_env_vars("a$ARC_TEST_NOEXIST-b", &["ARC_TEST_NOEXIST".to_string()]);
assert_eq!(result, "a-b");
}
@ -1007,9 +1004,10 @@ mod tests {
.create_async()
.await;
let headers = HashMap::from([
("Authorization".to_string(), "Bearer $ARC_TEST_TOKEN".to_string()),
]);
let headers = HashMap::from([(
"Authorization".to_string(),
"Bearer $ARC_TEST_TOKEN".to_string(),
)]);
let client = test_http_client();
let decision = HookExecutorImpl::execute_http(

View file

@ -5,7 +5,7 @@ use std::sync::Arc;
use arc_agent::Sandbox;
use super::config::{HookConfig, HookDefinition};
use super::executor::{HookExecutorImpl, HookExecutor};
use super::executor::{HookExecutor, HookExecutorImpl};
use super::types::{HookContext, HookDecision};
/// Central orchestrator: filters matching hooks, executes them, merges decisions.
@ -234,7 +234,9 @@ mod tests {
}
fn make_sandbox() -> Arc<dyn Sandbox> {
Arc::new(arc_agent::LocalSandbox::new(std::env::current_dir().unwrap()))
Arc::new(arc_agent::LocalSandbox::new(
std::env::current_dir().unwrap(),
))
}
fn make_context(event: HookEvent) -> HookContext {
@ -287,9 +289,7 @@ mod tests {
async fn matcher_filters_by_node_id() {
let mut hook = make_hook(HookEvent::StageStart, "filtered");
hook.matcher = Some("agent".into());
let config = HookConfig {
hooks: vec![hook],
};
let config = HookConfig { hooks: vec![hook] };
let runner = HookRunner::with_executor(
config,
Arc::new(MockExecutor {
@ -316,9 +316,7 @@ mod tests {
async fn matcher_filters_by_handler_type() {
let mut hook = make_hook(HookEvent::StageStart, "filtered");
hook.matcher = Some("^agent$".into());
let config = HookConfig {
hooks: vec![hook],
};
let config = HookConfig { hooks: vec![hook] };
let runner = HookRunner::with_executor(
config,
Arc::new(MockExecutor {
@ -358,9 +356,7 @@ mod tests {
async fn blocking_hook_skip_decision() {
let mut hook = make_hook(HookEvent::StageStart, "skipper");
hook.blocking = Some(true);
let config = HookConfig {
hooks: vec![hook],
};
let config = HookConfig { hooks: vec![hook] };
let runner = HookRunner::with_executor(
config,
Arc::new(MockExecutor {
@ -379,9 +375,7 @@ mod tests {
async fn non_blocking_hook_doesnt_block() {
let mut hook = make_hook(HookEvent::StageComplete, "observer");
hook.blocking = Some(false);
let config = HookConfig {
hooks: vec![hook],
};
let config = HookConfig { hooks: vec![hook] };
let runner = HookRunner::with_executor(
config,
Arc::new(MockExecutor {

View file

@ -126,7 +126,6 @@ pub enum HookDecision {
},
}
impl HookDecision {
/// Merge two decisions. Block > Skip/Override > Proceed.
#[must_use]
@ -237,11 +236,7 @@ mod tests {
#[test]
fn hook_context_omits_none_fields() {
let ctx = HookContext::new(
HookEvent::RunStart,
"run-1".into(),
"wf".into(),
);
let ctx = HookContext::new(HookEvent::RunStart, "run-1".into(), "wf".into());
let json = serde_json::to_string(&ctx).unwrap();
assert!(!json.contains("node_id"));
assert!(!json.contains("failure_reason"));
@ -303,10 +298,7 @@ mod tests {
proceed.clone().merge(skip.clone()),
HookDecision::Skip { .. }
));
assert!(matches!(
skip.merge(proceed),
HookDecision::Skip { .. }
));
assert!(matches!(skip.merge(proceed), HookDecision::Skip { .. }));
}
#[test]
@ -318,10 +310,7 @@ mod tests {
edge_to: "x".into(),
};
// First non-Proceed wins when no Block
assert!(matches!(
skip.merge(override_d),
HookDecision::Skip { .. }
));
assert!(matches!(skip.merge(override_d), HookDecision::Skip { .. }));
}
#[test]

View file

@ -114,8 +114,14 @@ fn ask_multi_select_interactive(question: &Question) -> Answer {
match selection {
Ok(Some(indices)) if !indices.is_empty() => {
let keys: Vec<String> = indices.iter().map(|&i| question.options[i].key.clone()).collect();
let options: Vec<_> = indices.iter().map(|&i| question.options[i].clone()).collect();
let keys: Vec<String> = indices
.iter()
.map(|&i| question.options[i].key.clone())
.collect();
let options: Vec<_> = indices
.iter()
.map(|&i| question.options[i].clone())
.collect();
Answer::multi_selected(keys, options)
}
_ => Answer::skipped(),

View file

@ -29,14 +29,15 @@ pub mod artifact;
pub mod asset_snapshot;
pub mod checkpoint;
pub mod cli;
pub mod conclusion;
pub mod condition;
pub mod context;
pub mod daytona_sandbox;
pub mod engine;
pub mod github_app;
pub mod error;
pub mod event;
pub mod git;
pub mod github_app;
pub mod graph;
pub mod handler;
pub mod hook;
@ -47,7 +48,6 @@ pub mod parser;
pub mod preamble;
pub mod retro;
pub mod retro_agent;
pub mod conclusion;
pub mod stylesheet;
pub mod transform;
pub mod validation;

View file

@ -592,10 +592,7 @@ mod tests {
id: "gate".into(),
attrs: Some(vec![
("type".into(), AstValue::Str("human".into())),
(
"codergen_mode".into(),
AstValue::Str("one_shot".into()),
),
("codergen_mode".into(), AstValue::Str("one_shot".into())),
]),
})],
};

View file

@ -68,7 +68,9 @@ pub fn build_preamble(
let parent_preamble = context.get_string(keys::INTERNAL_PARENT_PREAMBLE, "");
if !parent_preamble.is_empty() && !preamble.is_empty() {
format!("## Parent workflow context\n{parent_preamble}\n\n## Current sub-workflow\n{preamble}")
format!(
"## Parent workflow context\n{parent_preamble}\n\n## Current sub-workflow\n{preamble}"
)
} else {
preamble
}
@ -728,9 +730,15 @@ mod tests {
context.set(keys::INTERNAL_FIDELITY, serde_json::json!("compact"));
context.set(&keys::retry_count_key("plan"), serde_json::json!(1));
context.set(keys::CURRENT_NODE, serde_json::json!("work"));
context.set(&keys::graph_attr_key("default_fidelity"), serde_json::json!("compact"));
context.set(
&keys::graph_attr_key("default_fidelity"),
serde_json::json!("compact"),
);
context.set("thread.main.current_node", serde_json::json!("work"));
context.set(&keys::response_key("plan"), serde_json::json!("some response"));
context.set(
&keys::response_key("plan"),
serde_json::json!("some response"),
);
context.set(keys::LAST_STAGE, serde_json::json!("plan"));
context.set(keys::LAST_RESPONSE, serde_json::json!("resp"));
context.set(keys::PREFERRED_LABEL, serde_json::json!("success"));
@ -916,9 +924,15 @@ mod tests {
fn compact_context_excludes_engine_keys() {
let graph = Graph::new("test");
let context = Context::new();
context.set(&keys::graph_attr_key("default_fidelity"), serde_json::json!("compact"));
context.set(
&keys::graph_attr_key("default_fidelity"),
serde_json::json!("compact"),
);
context.set("thread.main.current_node", serde_json::json!("work"));
context.set(&keys::response_key("plan"), serde_json::json!("some LLM response"));
context.set(
&keys::response_key("plan"),
serde_json::json!("some LLM response"),
);
context.set(keys::LAST_STAGE, serde_json::json!("plan"));
context.set("user.preference", serde_json::json!("dark"));
let completed_nodes: Vec<String> = Vec::new();
@ -1564,10 +1578,7 @@ mod tests {
preamble.contains("## Stage: report"),
"should have stage heading"
);
assert!(
preamble.contains("Handler: agent"),
"should show handler"
);
assert!(preamble.contains("Handler: agent"), "should show handler");
assert!(
preamble.contains("Model: claude-sonnet-4-20250514"),
"should show model"
@ -1669,9 +1680,13 @@ mod tests {
assert!(is_context_key_excluded(&keys::retry_count_key("plan")));
assert!(is_context_key_excluded(keys::CURRENT_NODE));
assert!(is_context_key_excluded(keys::CURRENT_PREAMBLE));
assert!(is_context_key_excluded(&keys::graph_attr_key("default_fidelity")));
assert!(is_context_key_excluded(&keys::graph_attr_key(
"default_fidelity"
)));
assert!(is_context_key_excluded(keys::GRAPH_GOAL));
assert!(is_context_key_excluded(&keys::thread_current_node_key("main")));
assert!(is_context_key_excluded(&keys::thread_current_node_key(
"main"
)));
assert!(is_context_key_excluded(&keys::response_key("plan")));
assert!(is_context_key_excluded(keys::OUTCOME));
assert!(is_context_key_excluded(keys::LAST_STAGE));
@ -1714,10 +1729,7 @@ mod tests {
!preamble.contains("**start**"),
"should not show start node, got:\n{preamble}"
);
assert!(
preamble.contains("**plan**"),
"should show non-meta nodes"
);
assert!(preamble.contains("**plan**"), "should show non-meta nodes");
}
#[test]
@ -1826,10 +1838,7 @@ mod tests {
!preamble.contains("- start:"),
"should not show start stage, got:\n{preamble}"
);
assert!(
preamble.contains("- work:"),
"should show non-meta stages"
);
assert!(preamble.contains("- work:"), "should show non-meta stages");
}
#[test]
@ -1862,10 +1871,7 @@ mod tests {
!preamble.contains("- start:"),
"should not show start stage, got:\n{preamble}"
);
assert!(
preamble.contains("- work:"),
"should show non-meta stages"
);
assert!(preamble.contains("- work:"), "should show non-meta stages");
}
#[test]

View file

@ -643,18 +643,19 @@ mod tests {
.unwrap();
std::process::Command::new("git")
.args([
"-c", "user.name=test",
"-c", "user.email=test@test",
"commit", "-m", "add",
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"-m",
"add",
])
.current_dir(dir.path())
.output()
.unwrap();
assert_eq!(
resolve_file_ref("@tracked.md", dir.path()),
"@tracked.md"
);
assert_eq!(resolve_file_ref("@tracked.md", dir.path()), "@tracked.md");
}
#[test]
@ -668,9 +669,14 @@ mod tests {
.unwrap();
std::process::Command::new("git")
.args([
"-c", "user.name=test",
"-c", "user.email=test@test",
"commit", "--allow-empty", "-m", "init",
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(dir.path())
.output()
@ -678,10 +684,7 @@ mod tests {
std::fs::write(dir.path().join("local.md"), "inlined content").unwrap();
assert_eq!(
resolve_file_ref("@local.md", dir.path()),
"inlined content"
);
assert_eq!(resolve_file_ref("@local.md", dir.path()), "inlined content");
}
// -----------------------------------------------------------------------
@ -699,9 +702,14 @@ mod tests {
.unwrap();
std::process::Command::new("git")
.args([
"-c", "user.name=test",
"-c", "user.email=test@test",
"commit", "--allow-empty", "-m", "init",
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(dir.path())
.output()
@ -756,9 +764,14 @@ mod tests {
.unwrap();
std::process::Command::new("git")
.args([
"-c", "user.name=test",
"-c", "user.email=test@test",
"commit", "--allow-empty", "-m", "init",
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(dir.path())
.output()
@ -781,9 +794,14 @@ mod tests {
.unwrap();
std::process::Command::new("git")
.args([
"-c", "user.name=test",
"-c", "user.email=test@test",
"commit", "--allow-empty", "-m", "init",
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(dir.path())
.output()
@ -814,9 +832,13 @@ mod tests {
.unwrap();
std::process::Command::new("git")
.args([
"-c", "user.name=test",
"-c", "user.email=test@test",
"commit", "-m", "add",
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"-m",
"add",
])
.current_dir(dir.path())
.output()

View file

@ -624,10 +624,7 @@ impl LintRule for PromptOnLlmNodesRule {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message: format!(
"LLM node '{}' has no prompt or label attribute",
node.id
),
message: format!("LLM node '{}' has no prompt or label attribute", node.id),
node_id: Some(node.id.clone()),
edge: None,
fix: Some("Add a prompt or label attribute".to_string()),
@ -1263,10 +1260,8 @@ mod tests {
fn type_known_rule_known_type() {
let mut g = minimal_graph();
let mut node = Node::new("gate");
node.attrs.insert(
"type".to_string(),
AttrValue::String("human".to_string()),
);
node.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
g.nodes.insert("gate".to_string(), node);
let rule = TypeKnownRule;
let d = rule.apply(&g);
@ -1925,10 +1920,8 @@ mod tests {
fn freeform_edge_count_rule_zero_freeform() {
let mut g = minimal_graph();
let mut gate = Node::new("gate");
gate.attrs.insert(
"type".to_string(),
AttrValue::String("human".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
g.nodes.insert("gate".to_string(), gate);
g.nodes.insert("a".to_string(), Node::new("a"));
g.edges.push(Edge::new("gate", "a"));
@ -2074,10 +2067,8 @@ mod tests {
let mut g = minimal_graph();
let mut n1 = Node::new("n1");
n1.attrs.insert(
"type".to_string(),
AttrValue::String("agent".to_string()),
);
n1.attrs
.insert("type".to_string(), AttrValue::String("agent".to_string()));
g.nodes.insert("n1".to_string(), n1);
let mut n2 = Node::new("n2");
@ -2124,10 +2115,8 @@ mod tests {
fn prompt_on_llm_nodes_rule_explicit_agent_type_no_prompt() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"type".to_string(),
AttrValue::String("agent".to_string()),
);
node.attrs
.insert("type".to_string(), AttrValue::String("agent".to_string()));
// No shape=box, but explicit type=agent
node.attrs.insert(
"shape".to_string(),
@ -2164,10 +2153,8 @@ mod tests {
fn freeform_edge_count_rule_explicit_type_two_freeform() {
let mut g = minimal_graph();
let mut gate = Node::new("gate");
gate.attrs.insert(
"type".to_string(),
AttrValue::String("human".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
g.nodes.insert("gate".to_string(), gate);
g.nodes.insert("a".to_string(), Node::new("a"));
g.nodes.insert("b".to_string(), Node::new("b"));

View file

@ -56,20 +56,18 @@ fn load_github_app_credentials() -> arc_workflows::github_app::GitHubAppCredenti
app_id: Option<String>,
}
let config: Config =
toml::from_str(&config_str).expect("Failed to parse server.toml");
let app_id = config.git.app_id.expect("app_id not set in server.toml [git] section");
let config: Config = toml::from_str(&config_str).expect("Failed to parse server.toml");
let app_id = config
.git
.app_id
.expect("app_id not set in server.toml [git] section");
let raw = std::env::var("GITHUB_APP_PRIVATE_KEY")
.expect("GITHUB_APP_PRIVATE_KEY not set");
let raw = std::env::var("GITHUB_APP_PRIVATE_KEY").expect("GITHUB_APP_PRIVATE_KEY not set");
let private_key_pem = if raw.starts_with("-----") {
raw
} else {
let bytes = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&raw,
)
.expect("GITHUB_APP_PRIVATE_KEY is not valid base64");
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &raw)
.expect("GITHUB_APP_PRIVATE_KEY is not valid base64");
String::from_utf8(bytes).expect("GITHUB_APP_PRIVATE_KEY decoded to invalid UTF-8")
};
arc_workflows::github_app::GitHubAppCredentials {
@ -179,9 +177,9 @@ async fn daytona_snapshot_sandbox() {
cpu: Some(2),
memory: Some(4),
disk: Some(10),
dockerfile: Some(
arc_workflows::daytona_sandbox::DockerfileSource::Inline("FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y ripgrep".to_string()),
),
dockerfile: Some(arc_workflows::daytona_sandbox::DockerfileSource::Inline(
"FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y ripgrep".to_string(),
)),
}),
..DaytonaConfig::default()
};
@ -1286,13 +1284,10 @@ async fn daytona_clone_public_repo_gets_credentials() {
let creds = load_github_app_credentials();
// Directly test resolve_clone_credentials against a known public repo
let (username, password) = arc_workflows::github_app::resolve_clone_credentials(
&creds,
"rust-lang",
"rust",
)
.await
.unwrap();
let (username, password) =
arc_workflows::github_app::resolve_clone_credentials(&creds, "rust-lang", "rust")
.await
.unwrap();
assert_eq!(
username.as_deref(),
@ -1312,14 +1307,13 @@ async fn daytona_clone_public_repo_gets_credentials() {
async fn daytona_iat_not_installed_gives_clear_error() {
let creds = load_github_app_credentials();
let result = arc_workflows::github_app::resolve_clone_credentials(
&creds,
"torvalds",
"linux",
)
.await;
let result =
arc_workflows::github_app::resolve_clone_credentials(&creds, "torvalds", "linux").await;
assert!(result.is_err(), "should fail for repo the app isn't installed on");
assert!(
result.is_err(),
"should fail for repo the app isn't installed on"
);
let err = result.unwrap_err();
assert!(
err.contains("not installed"),
@ -1374,14 +1368,17 @@ async fn daytona_git_push_run_branch_to_origin() {
);
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 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);
let mut work = Node::new("work");
@ -1472,8 +1469,14 @@ async fn daytona_toolbox_idle_diagnostic() {
let result = env
.exec_command("echo alive", 30_000, None, None, None)
.await;
eprintln!("[t=0s] exec_command after init: {:?}", result.as_ref().map(|r| r.exit_code));
assert!(result.is_ok(), "exec_command should work immediately after init");
eprintln!(
"[t=0s] exec_command after init: {:?}",
result.as_ref().map(|r| r.exit_code)
);
assert!(
result.is_ok(),
"exec_command should work immediately after init"
);
let sandbox_name = env.sandbox_info();
eprintln!("[t=0s] sandbox: {sandbox_name}");
@ -1489,7 +1492,11 @@ async fn daytona_toolbox_idle_diagnostic() {
match &result {
Ok(r) => {
eprintln!("[t=+{sleep_secs}s] OK exit_code={} stdout={}", r.exit_code, r.stdout.trim());
eprintln!(
"[t=+{sleep_secs}s] OK exit_code={} stdout={}",
r.exit_code,
r.stdout.trim()
);
}
Err(e) => {
eprintln!("[t=+{sleep_secs}s] FAILED: {e}");
@ -1525,13 +1532,18 @@ async fn daytona_toolbox_idle_diagnostic() {
// Get toolbox proxy URL and try a direct call
let proxy_resp = client
.get(format!("{api_url}/sandbox/{sandbox_name}/toolbox-proxy-url"))
.get(format!(
"{api_url}/sandbox/{sandbox_name}/toolbox-proxy-url"
))
.bearer_auth(&api_key)
.send()
.await;
if let Ok(resp) = proxy_resp {
let body = resp.text().await.unwrap_or_default();
eprintln!("[diag] proxy URL response: {}", &body[..body.len().min(200)]);
eprintln!(
"[diag] proxy URL response: {}",
&body[..body.len().min(200)]
);
if let Some(url) = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("url").and_then(|u| u.as_str()).map(String::from))
@ -1548,12 +1560,16 @@ async fn daytona_toolbox_idle_diagnostic() {
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eprintln!("[diag] direct call: {status} body={}", &body[..body.len().min(300)]);
eprintln!(
"[diag] direct call: {status} body={}",
&body[..body.len().min(300)]
);
}
Err(e) => {
// Walk the FULL error source chain
let mut msg = format!("[diag] direct call FAILED: {e}");
let mut source: Option<&dyn std::error::Error> = std::error::Error::source(&e);
let mut source: Option<&dyn std::error::Error> =
std::error::Error::source(&e);
while let Some(cause) = source {
msg.push_str(&format!("\n caused by: {cause}"));
source = cause.source();

View file

@ -10,14 +10,14 @@ 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};
use arc_workflows::handler::agent::{CodergenBackend, AgentHandler, CodergenResult};
use arc_workflows::handler::agent::{AgentHandler, CodergenBackend, CodergenResult};
use arc_workflows::handler::command::CommandHandler;
use arc_workflows::handler::conditional::ConditionalHandler;
use arc_workflows::handler::default_registry;
use arc_workflows::handler::exit::ExitHandler;
use arc_workflows::handler::manager_loop::SubWorkflowHandler;
use arc_workflows::handler::command::CommandHandler;
use arc_workflows::handler::start::StartHandler;
use arc_workflows::handler::human::HumanHandler;
use arc_workflows::handler::manager_loop::SubWorkflowHandler;
use arc_workflows::handler::start::StartHandler;
use arc_workflows::handler::wait::WaitHandler;
use arc_workflows::handler::{Handler, HandlerRegistry};
use arc_workflows::interviewer::auto_approve::AutoApproveInterviewer;
@ -401,10 +401,8 @@ async fn end_to_end_human_gate_pipeline() {
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs.insert(
"type".to_string(),
AttrValue::String("human".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Review Changes".to_string()),
@ -1959,10 +1957,8 @@ async fn auto_approve_interviewer_e2e() {
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs.insert(
"type".to_string(),
AttrValue::String("human".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs
.insert("label".to_string(), AttrValue::String("Review".to_string()));
graph.nodes.insert("gate".to_string(), gate);
@ -2174,10 +2170,8 @@ async fn human_gate_loops_back() {
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs.insert(
"type".to_string(),
AttrValue::String("human".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs
.insert("label".to_string(), AttrValue::String("Review".to_string()));
graph.nodes.insert("gate".to_string(), gate);
@ -5675,7 +5669,7 @@ mod real_llm {
use arc_workflows::context::Context;
use arc_workflows::error::ArcError;
use arc_workflows::graph::Node;
use arc_workflows::handler::agent::{CodergenBackend, AgentHandler, CodergenResult};
use arc_workflows::handler::agent::{AgentHandler, CodergenBackend, CodergenResult};
use arc_llm::client::Client;
use arc_llm::types::{Message, Request};
@ -5749,8 +5743,8 @@ mod real_llm {
use arc_workflows::event::EventEmitter;
use arc_workflows::graph::{AttrValue, Edge, Graph};
use arc_workflows::handler::exit::ExitHandler;
use arc_workflows::handler::start::StartHandler;
use arc_workflows::handler::human::HumanHandler;
use arc_workflows::handler::start::StartHandler;
use arc_workflows::handler::HandlerRegistry;
use arc_workflows::interviewer::auto_approve::AutoApproveInterviewer;
use arc_workflows::outcome::StageStatus;
@ -5835,7 +5829,7 @@ mod real_llm {
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
git_author: arc_workflows::git::GitAuthor::default(),
};
let outcome = tokio::time::timeout(
@ -5950,7 +5944,7 @@ mod real_llm {
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
git_author: arc_workflows::git::GitAuthor::default(),
};
let outcome = tokio::time::timeout(
@ -6016,10 +6010,8 @@ mod real_llm {
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs.insert(
"type".to_string(),
AttrValue::String("human".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Approve?".to_string()),
@ -6092,7 +6084,7 @@ mod real_llm {
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
git_author: arc_workflows::git::GitAuthor::default(),
};
let outcome = tokio::time::timeout(
@ -6200,7 +6192,7 @@ mod real_llm {
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
git_author: arc_workflows::git::GitAuthor::default(),
};
let outcome = tokio::time::timeout(
@ -6254,10 +6246,8 @@ async fn human_gate_freeform_only_routes_text() {
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs.insert(
"type".to_string(),
AttrValue::String("human".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Enter feedback".to_string()),
@ -6360,10 +6350,8 @@ async fn human_gate_freeform_with_fixed_choice_match() {
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs.insert(
"type".to_string(),
AttrValue::String("human".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Review Changes".to_string()),
@ -6483,10 +6471,8 @@ async fn human_gate_freeform_fallback_on_unmatched_text() {
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs.insert(
"type".to_string(),
AttrValue::String("human".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Review Changes".to_string()),
@ -6620,10 +6606,8 @@ async fn human_gate_freeform_sets_allow_freeform_on_question() {
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs.insert(
"type".to_string(),
AttrValue::String("human".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Pick or type".to_string()),
@ -6731,10 +6715,8 @@ async fn human_gate_without_freeform_sets_allow_freeform_false() {
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs.insert(
"type".to_string(),
AttrValue::String("human".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Pick one".to_string()),
@ -7010,9 +6992,7 @@ fn subgraph_without_label_no_class_derived() {
// ---------------------------------------------------------------------------
/// Helper: create a WorkflowRunEngine with hooks configured from HookDefinitions.
fn engine_with_hooks(
hooks: Vec<arc_workflows::hook::HookDefinition>,
) -> WorkflowRunEngine {
fn engine_with_hooks(hooks: Vec<arc_workflows::hook::HookDefinition>) -> WorkflowRunEngine {
let registry = make_linear_registry();
let emitter = Arc::new(EventEmitter::new());
let sandbox = local_env();
@ -7028,7 +7008,10 @@ fn engine_with_hooks(
/// Helper: create a WorkflowRunEngine with hooks and event capture.
fn engine_with_hooks_and_events(
hooks: Vec<arc_workflows::hook::HookDefinition>,
) -> (WorkflowRunEngine, Arc<std::sync::Mutex<Vec<WorkflowRunEvent>>>) {
) -> (
WorkflowRunEngine,
Arc<std::sync::Mutex<Vec<WorkflowRunEvent>>>,
) {
let registry = make_linear_registry();
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
@ -7151,13 +7134,17 @@ async fn hook_run_start_block_prevents_run() {
// WorkflowRunStarted should still have been emitted (it fires before the hook)
let captured = events.lock().unwrap();
assert!(
captured.iter().any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })),
captured
.iter()
.any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })),
"WorkflowRunStarted should be emitted before hook blocks"
);
// But no StageStarted — the run never reached node execution
assert!(
!captured.iter().any(|e| matches!(e, WorkflowRunEvent::StageStarted { .. })),
!captured
.iter()
.any(|e| matches!(e, WorkflowRunEvent::StageStarted { .. })),
"No stage should start when RunStart hook blocks"
);
}
@ -7201,7 +7188,11 @@ async fn hook_stage_start_proceed_allows_execution() {
// Work node should have executed (response.md exists)
assert!(
dir.path().join("nodes").join("work").join("response.md").exists(),
dir.path()
.join("nodes")
.join("work")
.join("response.md")
.exists(),
"response.md should exist when StageStart hook proceeds"
);
}
@ -7224,7 +7215,11 @@ async fn hook_stage_start_skip_bypasses_node() {
// response.md should NOT exist for the work node (it was skipped)
assert!(
!dir.path().join("nodes").join("work").join("response.md").exists(),
!dir.path()
.join("nodes")
.join("work")
.join("response.md")
.exists(),
"response.md should not exist when StageStart hook skips node"
);
@ -7232,8 +7227,10 @@ async fn hook_stage_start_skip_bypasses_node() {
let captured = events.lock().unwrap();
let stage_starts: Vec<_> = captured
.iter()
.filter(|e| matches!(e, WorkflowRunEvent::StageStarted { handler_type, .. }
if handler_type.as_deref() != Some("start") && handler_type.as_deref() != Some("exit")))
.filter(|e| {
matches!(e, WorkflowRunEvent::StageStarted { handler_type, .. }
if handler_type.as_deref() != Some("start") && handler_type.as_deref() != Some("exit"))
})
.collect();
assert!(
!stage_starts.is_empty(),
@ -7277,13 +7274,21 @@ async fn hook_stage_start_matcher_filters_by_node_id() {
// step1 should have executed (response.md exists)
assert!(
dir.path().join("nodes").join("step1").join("response.md").exists(),
dir.path()
.join("nodes")
.join("step1")
.join("response.md")
.exists(),
"step1 should execute because matcher doesn't match it"
);
// step2 should have been skipped (no response.md)
assert!(
!dir.path().join("nodes").join("step2").join("response.md").exists(),
!dir.path()
.join("nodes")
.join("step2")
.join("response.md")
.exists(),
"step2 should be skipped because matcher matches it"
);
}
@ -7291,10 +7296,7 @@ async fn hook_stage_start_matcher_filters_by_node_id() {
#[tokio::test]
async fn hook_stage_start_matcher_no_match_proceeds() {
// Hook with matcher that matches nothing
let mut hook = make_hook(
arc_workflows::hook::HookEvent::StageStart,
"exit 1",
);
let mut hook = make_hook(arc_workflows::hook::HookEvent::StageStart, "exit 1");
hook.matcher = Some("nonexistent_node".into());
let hooks = vec![hook];
@ -7326,7 +7328,10 @@ async fn hook_stage_complete_fires_after_success() {
assert_eq!(outcome.status, StageStatus::Success);
// Marker file should exist and contain node IDs
assert!(marker.exists(), "StageComplete hook should have written marker file");
assert!(
marker.exists(),
"StageComplete hook should have written marker file"
);
let content = std::fs::read_to_string(&marker).unwrap();
// start, step1, step2, exit all complete — hook fires for each
assert!(
@ -7565,10 +7570,7 @@ async fn hook_edge_selected_override_redirects_routing() {
#[tokio::test]
async fn hook_edge_selected_block_aborts_run() {
let mut hook = make_hook(
arc_workflows::hook::HookEvent::EdgeSelected,
"exit 1",
);
let mut hook = make_hook(arc_workflows::hook::HookEvent::EdgeSelected, "exit 1");
hook.matcher = Some("^plan$".into());
let hooks = vec![hook];
@ -7578,10 +7580,7 @@ async fn hook_edge_selected_block_aborts_run() {
let config = make_run_config(dir.path());
let result = engine.run(&graph, &config).await;
assert!(
result.is_err(),
"EdgeSelected block should abort the run"
);
assert!(result.is_err(), "EdgeSelected block should abort the run");
}
// --- CheckpointSaved hook ---
@ -7603,10 +7602,7 @@ async fn hook_checkpoint_saved_fires() {
assert_eq!(outcome.status, StageStatus::Success);
// Checkpoint is saved after each node
assert!(
marker.exists(),
"CheckpointSaved hook should have fired"
);
assert!(marker.exists(), "CheckpointSaved hook should have fired");
let content = std::fs::read_to_string(&marker).unwrap();
assert!(
content.contains("work"),
@ -7735,15 +7731,23 @@ event = "run_complete"
command = "echo done"
"#;
let cfg: arc_workflows::cli::run_config::WorkflowRunConfig =
toml::from_str(toml).unwrap();
let cfg: arc_workflows::cli::run_config::WorkflowRunConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.hooks.len(), 2);
assert_eq!(cfg.hooks[0].event, arc_workflows::hook::HookEvent::StageStart);
assert_eq!(
cfg.hooks[0].event,
arc_workflows::hook::HookEvent::StageStart
);
assert_eq!(cfg.hooks[0].matcher.as_deref(), Some("agent_loop"));
assert!(cfg.hooks[0].is_blocking());
assert!(!cfg.hooks[0].runs_in_sandbox());
assert_eq!(cfg.hooks[0].timeout(), std::time::Duration::from_millis(30000));
assert_eq!(cfg.hooks[1].event, arc_workflows::hook::HookEvent::RunComplete);
assert_eq!(
cfg.hooks[0].timeout(),
std::time::Duration::from_millis(30000)
);
assert_eq!(
cfg.hooks[1].event,
arc_workflows::hook::HookEvent::RunComplete
);
assert!(!cfg.hooks[1].is_blocking()); // RunComplete non-blocking by default
}
@ -7752,10 +7756,7 @@ command = "echo done"
#[tokio::test]
async fn hook_blocking_override_makes_non_blocking_event_blocking() {
// StageComplete is non-blocking by default, but force it to blocking
let mut hook = make_hook(
arc_workflows::hook::HookEvent::StageComplete,
"exit 1",
);
let mut hook = make_hook(arc_workflows::hook::HookEvent::StageComplete, "exit 1");
hook.blocking = Some(true);
let hooks = vec![hook];
@ -7777,10 +7778,7 @@ async fn hook_blocking_override_makes_non_blocking_event_blocking() {
#[tokio::test]
async fn hook_non_blocking_override_on_blocking_event() {
// RunStart is blocking by default, but force it to non-blocking
let mut hook = make_hook(
arc_workflows::hook::HookEvent::RunStart,
"exit 1",
);
let mut hook = make_hook(arc_workflows::hook::HookEvent::RunStart, "exit 1");
hook.blocking = Some(false);
let hooks = vec![hook];
@ -7818,11 +7816,19 @@ async fn hook_matcher_regex_pattern() {
// Both step1 and step2 should be skipped
assert!(
!dir.path().join("nodes").join("step1").join("response.md").exists(),
!dir.path()
.join("nodes")
.join("step1")
.join("response.md")
.exists(),
"step1 should be skipped by regex ^step"
);
assert!(
!dir.path().join("nodes").join("step2").join("response.md").exists(),
!dir.path()
.join("nodes")
.join("step2")
.join("response.md")
.exists(),
"step2 should be skipped by regex ^step"
);
}
@ -7857,7 +7863,10 @@ async fn hook_json_block_with_reason() {
let result = engine.run(&graph, &config).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("forbidden by policy"));
assert!(result
.unwrap_err()
.to_string()
.contains("forbidden by policy"));
}
// --- Sandbox field tests ---
@ -7908,21 +7917,29 @@ max_tool_rounds = 10
timeout_ms = 120000
"#;
let cfg: arc_workflows::cli::run_config::WorkflowRunConfig =
toml::from_str(toml).unwrap();
let cfg: arc_workflows::cli::run_config::WorkflowRunConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.hooks.len(), 2);
// Prompt hook
assert_eq!(cfg.hooks[0].event, arc_workflows::hook::HookEvent::StageStart);
assert_eq!(
cfg.hooks[0].event,
arc_workflows::hook::HookEvent::StageStart
);
assert!(matches!(
cfg.hooks[0].resolved_hook_type().as_deref(),
Some(arc_workflows::hook::HookType::Prompt { prompt, model })
if prompt == "Should this stage proceed?" && *model == Some("haiku".into())
));
assert_eq!(cfg.hooks[0].timeout(), std::time::Duration::from_millis(30000));
assert_eq!(
cfg.hooks[0].timeout(),
std::time::Duration::from_millis(30000)
);
// Agent hook
assert_eq!(cfg.hooks[1].event, arc_workflows::hook::HookEvent::RunComplete);
assert_eq!(
cfg.hooks[1].event,
arc_workflows::hook::HookEvent::RunComplete
);
assert!(matches!(
cfg.hooks[1].resolved_hook_type().as_deref(),
Some(arc_workflows::hook::HookType::Agent { prompt, model, max_tool_rounds })
@ -7930,7 +7947,10 @@ timeout_ms = 120000
&& *model == Some("sonnet".into())
&& *max_tool_rounds == Some(10)
));
assert_eq!(cfg.hooks[1].timeout(), std::time::Duration::from_millis(120000));
assert_eq!(
cfg.hooks[1].timeout(),
std::time::Duration::from_millis(120000)
);
}
// --- Prompt/Agent hook E2E with real LLM ---
@ -7987,7 +8007,10 @@ async fn hook_prompt_block_prevents_run() {
let config = make_run_config(dir.path());
let result = engine.run(&graph, &config).await;
assert!(result.is_err(), "Prompt hook block should cause error, got: {result:?}");
assert!(
result.is_err(),
"Prompt hook block should cause error, got: {result:?}"
);
}
#[tokio::test]
@ -8083,7 +8106,10 @@ async fn hooks_do_not_duplicate_workflow_events() {
.iter()
.filter(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. }))
.count();
assert_eq!(run_completed, 1, "Should have exactly 1 WorkflowRunCompleted");
assert_eq!(
run_completed, 1,
"Should have exactly 1 WorkflowRunCompleted"
);
// No WorkflowRunFailed
let run_failed = captured