mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
cargo fmt
This commit is contained in:
parent
445050dc57
commit
3dce68d664
62 changed files with 2299 additions and 1510 deletions
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}!"));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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())?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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!");
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
|
||||
use super::event::Track;
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 --
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(¤t_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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
]),
|
||||
})],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue