mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Fix cargo fmt formatting across workspace
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3fc3387c34
commit
dc3867a3c4
63 changed files with 2534 additions and 1365 deletions
|
|
@ -1,7 +1,7 @@
|
|||
use crate::{
|
||||
subagent::{SessionFactory, SubAgentManager},
|
||||
AgentEvent, AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile,
|
||||
ProviderProfile, Session, SessionConfig, ToolApprovalFn, Turn,
|
||||
AgentEvent, AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile, ProviderProfile,
|
||||
Session, SessionConfig, ToolApprovalFn, Turn,
|
||||
};
|
||||
use arc_llm::client::Client;
|
||||
use arc_llm::provider::{ModelId, Provider};
|
||||
|
|
@ -497,10 +497,7 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> {
|
|||
);
|
||||
}
|
||||
AgentEvent::Error { error } => {
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.red.apply_to(format!("\u{2717} {error}")),
|
||||
);
|
||||
eprintln!(" {}", s.red.apply_to(format!("\u{2717} {error}")),);
|
||||
}
|
||||
AgentEvent::SubAgentSpawned {
|
||||
agent_id,
|
||||
|
|
@ -561,9 +558,8 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> {
|
|||
let short_id = &agent_id[..8.min(agent_id.len())];
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.dim.apply_to(format!(
|
||||
"[subagent {short_id}] {child_event:?}"
|
||||
)),
|
||||
s.dim
|
||||
.apply_to(format!("[subagent {short_id}] {child_event:?}")),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
|
|
|
|||
|
|
@ -120,7 +120,10 @@ and conversational filler.{file_ops_section}"
|
|||
.map_err(AgentError::Llm)?;
|
||||
|
||||
let summary_text = response.text();
|
||||
debug!(summary_len = summary_text.len(), "Compaction summary generated");
|
||||
debug!(
|
||||
summary_len = summary_text.len(),
|
||||
"Compaction summary generated"
|
||||
);
|
||||
let summary_content = format!("[Context Summary]\n{summary_text}");
|
||||
let summary_token_estimate = summary_content.len() / 4;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use crate::sandbox::{
|
||||
format_lines_numbered, DirEntry, SandboxEventCallback, ExecResult, SandboxEvent,
|
||||
Sandbox, GrepOptions,
|
||||
format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent,
|
||||
SandboxEventCallback,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use bollard::container::{
|
||||
|
|
@ -294,15 +294,13 @@ impl Sandbox for DockerSandbox {
|
|||
.await
|
||||
.map_err(|e| format!("Failed to create parent dirs: {e}"))?;
|
||||
}
|
||||
tokio::fs::copy(&host_path, local_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to copy {} to {}: {e}",
|
||||
host_path.display(),
|
||||
local_path.display()
|
||||
)
|
||||
})?;
|
||||
tokio::fs::copy(&host_path, local_path).await.map_err(|e| {
|
||||
format!(
|
||||
"Failed to copy {} to {}: {e}",
|
||||
host_path.display(),
|
||||
local_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -773,8 +771,7 @@ mod tests {
|
|||
std::fs::create_dir_all(&host_dir).unwrap();
|
||||
|
||||
let config = test_config(host_dir.to_str().unwrap());
|
||||
let env: Arc<dyn Sandbox> =
|
||||
Arc::new(DockerSandbox::new(config).unwrap());
|
||||
let env: Arc<dyn Sandbox> = Arc::new(DockerSandbox::new(config).unwrap());
|
||||
|
||||
// Initialize
|
||||
env.initialize().await.unwrap();
|
||||
|
|
|
|||
|
|
@ -29,13 +29,9 @@ pub mod v4a_patch;
|
|||
pub use arc_mcp::config::McpServerConfig;
|
||||
pub use config::{SessionConfig, ToolApprovalFn};
|
||||
#[cfg(feature = "docker")]
|
||||
pub use docker_sandbox::{DockerSandboxConfig, DockerSandbox};
|
||||
pub use docker_sandbox::{DockerSandbox, DockerSandboxConfig};
|
||||
pub use error::AgentError;
|
||||
pub use event::EventEmitter;
|
||||
pub use sandbox::{
|
||||
format_lines_numbered, DirEntry, SandboxEventCallback, ExecResult, SandboxEvent,
|
||||
Sandbox, GrepOptions,
|
||||
};
|
||||
pub use history::History;
|
||||
pub use local_sandbox::LocalSandbox;
|
||||
pub use loop_detection::detect_loop;
|
||||
|
|
@ -43,6 +39,10 @@ pub use profiles::{AnthropicProfile, EnvContext, GeminiProfile, OpenAiProfile};
|
|||
pub use project_docs::discover_project_docs;
|
||||
pub use provider_profile::{ProfileCapabilities, ProviderProfile};
|
||||
pub use read_before_write_sandbox::ReadBeforeWriteSandbox;
|
||||
pub use sandbox::{
|
||||
format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent,
|
||||
SandboxEventCallback,
|
||||
};
|
||||
pub use session::Session;
|
||||
pub use skills::Skill;
|
||||
pub use subagent::{SubAgent, SubAgentEventCallback, SubAgentManager, SubAgentResult};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use crate::sandbox::{
|
||||
format_lines_numbered, DirEntry, SandboxEventCallback, ExecResult, SandboxEvent,
|
||||
Sandbox, GrepOptions,
|
||||
format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent,
|
||||
SandboxEventCallback,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -356,9 +356,13 @@ impl Sandbox for LocalSandbox {
|
|||
.await
|
||||
.map_err(|e| format!("Failed to create parent dirs: {e}"))?;
|
||||
}
|
||||
tokio::fs::copy(&full_path, local_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to copy {} to {}: {e}", full_path.display(), local_path.display()))?;
|
||||
tokio::fs::copy(&full_path, local_path).await.map_err(|e| {
|
||||
format!(
|
||||
"Failed to copy {} to {}: {e}",
|
||||
full_path.display(),
|
||||
local_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -613,38 +617,20 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn env_var_filtering() {
|
||||
assert!(LocalSandbox::should_filter_env_var(
|
||||
"OPENAI_API_KEY"
|
||||
));
|
||||
assert!(LocalSandbox::should_filter_env_var(
|
||||
"ANTHROPIC_API_KEY"
|
||||
));
|
||||
assert!(LocalSandbox::should_filter_env_var(
|
||||
"DB_PASSWORD"
|
||||
));
|
||||
assert!(LocalSandbox::should_filter_env_var(
|
||||
"AWS_SECRET"
|
||||
));
|
||||
assert!(LocalSandbox::should_filter_env_var(
|
||||
"AUTH_TOKEN"
|
||||
));
|
||||
assert!(LocalSandbox::should_filter_env_var(
|
||||
"MY_CREDENTIAL"
|
||||
));
|
||||
assert!(LocalSandbox::should_filter_env_var("OPENAI_API_KEY"));
|
||||
assert!(LocalSandbox::should_filter_env_var("ANTHROPIC_API_KEY"));
|
||||
assert!(LocalSandbox::should_filter_env_var("DB_PASSWORD"));
|
||||
assert!(LocalSandbox::should_filter_env_var("AWS_SECRET"));
|
||||
assert!(LocalSandbox::should_filter_env_var("AUTH_TOKEN"));
|
||||
assert!(LocalSandbox::should_filter_env_var("MY_CREDENTIAL"));
|
||||
// Case insensitive
|
||||
assert!(LocalSandbox::should_filter_env_var(
|
||||
"my_api_key"
|
||||
));
|
||||
assert!(LocalSandbox::should_filter_env_var(
|
||||
"Some_Secret"
|
||||
));
|
||||
assert!(LocalSandbox::should_filter_env_var("my_api_key"));
|
||||
assert!(LocalSandbox::should_filter_env_var("Some_Secret"));
|
||||
// Should not filter
|
||||
assert!(!LocalSandbox::should_filter_env_var("PATH"));
|
||||
assert!(!LocalSandbox::should_filter_env_var("HOME"));
|
||||
assert!(!LocalSandbox::should_filter_env_var("EDITOR"));
|
||||
assert!(!LocalSandbox::should_filter_env_var(
|
||||
"SECRET_PATH"
|
||||
));
|
||||
assert!(!LocalSandbox::should_filter_env_var("SECRET_PATH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -838,9 +824,7 @@ mod tests {
|
|||
|
||||
let env = LocalSandbox::new(dir.clone());
|
||||
let dest = dir.join("deep/nested/dir/data.bin");
|
||||
env.download_file_to_local("data.bin", &dest)
|
||||
.await
|
||||
.unwrap();
|
||||
env.download_file_to_local("data.bin", &dest).await.unwrap();
|
||||
|
||||
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "binary-ish");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use crate::config::SessionConfig;
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::profiles::assemble_system_prompt;
|
||||
use crate::profiles::BaseProfile;
|
||||
use crate::provider_profile::{ProfileCapabilities, ProviderProfile};
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::skills::Skill;
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use crate::tools::{make_edit_file_tool, register_core_tools, WebFetchSummarizer};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use crate::config::SessionConfig;
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::profiles::assemble_system_prompt;
|
||||
use crate::profiles::BaseProfile;
|
||||
use crate::provider_profile::{ProfileCapabilities, ProviderProfile};
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::skills::Skill;
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use crate::tools::{
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use crate::config::SessionConfig;
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::profiles::assemble_system_prompt;
|
||||
use crate::profiles::BaseProfile;
|
||||
use crate::provider_profile::{ProfileCapabilities, ProviderProfile};
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::skills::Skill;
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use crate::tools::{register_core_tools, WebFetchSummarizer};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::sandbox::Sandbox;
|
||||
use crate::profiles::EnvContext;
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::skills::Skill;
|
||||
use crate::subagent::{
|
||||
make_close_agent_tool, make_send_input_tool, make_spawn_agent_tool, SessionFactory,
|
||||
|
|
|
|||
|
|
@ -211,7 +211,11 @@ impl SandboxEvent {
|
|||
error!(name, error, "Snapshot failed");
|
||||
}
|
||||
Self::GitCloneStarted { url, branch } => {
|
||||
debug!(url, branch = branch.as_deref().unwrap_or(""), "Git clone started");
|
||||
debug!(
|
||||
url,
|
||||
branch = branch.as_deref().unwrap_or(""),
|
||||
"Git clone started"
|
||||
);
|
||||
}
|
||||
Self::GitCloneCompleted { url, duration_ms } => {
|
||||
debug!(url, duration_ms, "Git clone completed");
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
use crate::config::SessionConfig;
|
||||
use crate::error::AgentError;
|
||||
use crate::event::EventEmitter;
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::file_tracker::FileTracker;
|
||||
use crate::history::History;
|
||||
use crate::loop_detection::detect_loop;
|
||||
use crate::profiles::EnvContext;
|
||||
use crate::project_docs::discover_project_docs;
|
||||
use crate::provider_profile::ProviderProfile;
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::skills::{
|
||||
default_skill_dirs, discover_skills, expand_skill, make_use_skill_tool, Skill,
|
||||
};
|
||||
|
|
@ -567,7 +567,9 @@ impl Session {
|
|||
self.event_emitter.emit(
|
||||
self.id.clone(),
|
||||
AgentEvent::Error {
|
||||
error: AgentError::InvalidState(format!("Context compaction failed: {e}")),
|
||||
error: AgentError::InvalidState(format!(
|
||||
"Context compaction failed: {e}"
|
||||
)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -1978,8 +1980,7 @@ mod tests {
|
|||
let client = make_client(provider).await;
|
||||
let profile: Arc<dyn crate::provider_profile::ProviderProfile> =
|
||||
Arc::new(TestProfile::new());
|
||||
let env: Arc<dyn crate::sandbox::Sandbox> =
|
||||
Arc::new(MockSandbox::default());
|
||||
let env: Arc<dyn crate::sandbox::Sandbox> = Arc::new(MockSandbox::default());
|
||||
let mut session = Session::new(client, profile, env, config);
|
||||
|
||||
// Subscribe to events before initialize
|
||||
|
|
|
|||
|
|
@ -543,8 +543,7 @@ name: trimmed
|
|||
let skills = Arc::new(test_skills());
|
||||
let tool = make_use_skill_tool(skills);
|
||||
|
||||
let env: Arc<dyn crate::sandbox::Sandbox> =
|
||||
Arc::new(MockSandbox::default());
|
||||
let env: Arc<dyn crate::sandbox::Sandbox> = Arc::new(MockSandbox::default());
|
||||
let args = serde_json::json!({"skill_name": "commit"});
|
||||
let ctx = crate::tool_registry::ToolContext {
|
||||
env,
|
||||
|
|
@ -562,8 +561,7 @@ name: trimmed
|
|||
let skills = Arc::new(test_skills());
|
||||
let tool = make_use_skill_tool(skills);
|
||||
|
||||
let env: Arc<dyn crate::sandbox::Sandbox> =
|
||||
Arc::new(MockSandbox::default());
|
||||
let env: Arc<dyn crate::sandbox::Sandbox> = Arc::new(MockSandbox::default());
|
||||
let args = serde_json::json!({"skill_name": "nonexistent"});
|
||||
let ctx = crate::tool_registry::ToolContext {
|
||||
env,
|
||||
|
|
@ -579,8 +577,7 @@ name: trimmed
|
|||
let skills = Arc::new(test_skills());
|
||||
let tool = make_use_skill_tool(skills);
|
||||
|
||||
let env: Arc<dyn crate::sandbox::Sandbox> =
|
||||
Arc::new(MockSandbox::default());
|
||||
let env: Arc<dyn crate::sandbox::Sandbox> = Arc::new(MockSandbox::default());
|
||||
let args = serde_json::json!({});
|
||||
let ctx = crate::tool_registry::ToolContext {
|
||||
env,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::config::SessionConfig;
|
||||
use crate::sandbox::*;
|
||||
use crate::profiles::EnvContext;
|
||||
use crate::provider_profile::{ProfileCapabilities, ProviderProfile};
|
||||
use crate::sandbox::*;
|
||||
use crate::session::Session;
|
||||
use crate::skills::Skill;
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
|
|
|
|||
|
|
@ -280,9 +280,7 @@ impl AgentEvent {
|
|||
} => {
|
||||
info!(
|
||||
session_id,
|
||||
estimated_tokens,
|
||||
context_window_size,
|
||||
"Context compaction started"
|
||||
estimated_tokens, context_window_size, "Context compaction started"
|
||||
);
|
||||
}
|
||||
Self::CompactionCompleted {
|
||||
|
|
@ -322,13 +320,7 @@ impl AgentEvent {
|
|||
depth,
|
||||
task,
|
||||
} => {
|
||||
debug!(
|
||||
session_id,
|
||||
agent_id,
|
||||
depth,
|
||||
task,
|
||||
"Sub-agent spawned"
|
||||
);
|
||||
debug!(session_id, agent_id, depth, task, "Sub-agent spawned");
|
||||
}
|
||||
Self::SubAgentCompleted {
|
||||
agent_id,
|
||||
|
|
@ -338,11 +330,7 @@ impl AgentEvent {
|
|||
} => {
|
||||
debug!(
|
||||
session_id,
|
||||
agent_id,
|
||||
depth,
|
||||
success,
|
||||
turns_used,
|
||||
"Sub-agent completed"
|
||||
agent_id, depth, success, turns_used, "Sub-agent completed"
|
||||
);
|
||||
}
|
||||
Self::SubAgentFailed {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ use std::path::Path;
|
|||
use std::sync::Arc;
|
||||
|
||||
use arc_agent::{
|
||||
AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile, ProviderProfile,
|
||||
Session, SessionConfig, SubAgentManager, WebFetchSummarizer,
|
||||
AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile, ProviderProfile, Session,
|
||||
SessionConfig, SubAgentManager, WebFetchSummarizer,
|
||||
};
|
||||
use arc_llm::client::Client;
|
||||
use arc_llm::provider::{ModelId, Provider};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -58,7 +58,8 @@ fn decode_pem_env(name: &str, value: &str) -> String {
|
|||
}
|
||||
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, value)
|
||||
.unwrap_or_else(|e| panic!("{name} is not valid PEM or base64: {e}"));
|
||||
String::from_utf8(bytes).unwrap_or_else(|e| panic!("{name} base64 decoded to invalid UTF-8: {e}"))
|
||||
String::from_utf8(bytes)
|
||||
.unwrap_or_else(|e| panic!("{name} base64 decoded to invalid UTF-8: {e}"))
|
||||
}
|
||||
|
||||
/// Resolve the authentication mode from the API config section.
|
||||
|
|
@ -162,8 +163,8 @@ fn try_mtls(parts: &Parts) -> Result<(), StatusCode> {
|
|||
|
||||
// Verify we can parse the leaf certificate and extract a CN
|
||||
let cert = &peer_certs[0];
|
||||
let (_, parsed) = x509_parser::parse_x509_certificate(cert)
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
let (_, parsed) =
|
||||
x509_parser::parse_x509_certificate(cert).map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
parsed
|
||||
.subject()
|
||||
|
|
@ -322,8 +323,15 @@ mod tests {
|
|||
let ca_cert = {
|
||||
let mut child = Command::new("openssl")
|
||||
.args([
|
||||
"req", "-new", "-x509", "-key", "/dev/stdin", "-days", "1",
|
||||
"-subj", "/CN=TestCA",
|
||||
"req",
|
||||
"-new",
|
||||
"-x509",
|
||||
"-key",
|
||||
"/dev/stdin",
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=TestCA",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
|
|
@ -344,9 +352,7 @@ mod tests {
|
|||
let subj = format!("/CN={cn}");
|
||||
let client_csr = {
|
||||
let mut child = Command::new("openssl")
|
||||
.args([
|
||||
"req", "-new", "-key", "/dev/stdin", "-subj", &subj,
|
||||
])
|
||||
.args(["req", "-new", "-key", "/dev/stdin", "-subj", &subj])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
|
|
@ -366,12 +372,17 @@ mod tests {
|
|||
|
||||
let client_cert_pem = Command::new("openssl")
|
||||
.args([
|
||||
"x509", "-req",
|
||||
"-in", csr_path.to_str().unwrap(),
|
||||
"-CA", ca_cert_path.to_str().unwrap(),
|
||||
"-CAkey", ca_key_path.to_str().unwrap(),
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
csr_path.to_str().unwrap(),
|
||||
"-CA",
|
||||
ca_cert_path.to_str().unwrap(),
|
||||
"-CAkey",
|
||||
ca_key_path.to_str().unwrap(),
|
||||
"-CAcreateserial",
|
||||
"-days", "1",
|
||||
"-days",
|
||||
"1",
|
||||
])
|
||||
.output()
|
||||
.expect("openssl x509 failed")
|
||||
|
|
@ -425,12 +436,7 @@ mod tests {
|
|||
let (encoding, decoding) = generate_test_keypair();
|
||||
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
|
||||
|
||||
let token = sign_token(
|
||||
&encoding,
|
||||
"arc-web",
|
||||
60,
|
||||
Some("https://github.com/brynary"),
|
||||
);
|
||||
let token = sign_token(&encoding, "arc-web", 60, Some("https://github.com/brynary"));
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/test")
|
||||
|
|
@ -486,12 +492,7 @@ mod tests {
|
|||
let (encoding, decoding) = generate_test_keypair();
|
||||
let app = test_router(jwt_mode(decoding, vec![]));
|
||||
|
||||
let token = sign_token(
|
||||
&encoding,
|
||||
"arc-web",
|
||||
60,
|
||||
Some("https://github.com/brynary"),
|
||||
);
|
||||
let token = sign_token(&encoding, "arc-web", 60, Some("https://github.com/brynary"));
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/test")
|
||||
|
|
@ -652,12 +653,7 @@ mod tests {
|
|||
]);
|
||||
let app = test_router(mode);
|
||||
|
||||
let token = sign_token(
|
||||
&encoding,
|
||||
"arc-web",
|
||||
60,
|
||||
Some("https://github.com/brynary"),
|
||||
);
|
||||
let token = sign_token(&encoding, "arc-web", 60, Some("https://github.com/brynary"));
|
||||
|
||||
// No peer certs, but valid JWT
|
||||
let mut req = Request::builder()
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ use arc_agent::LocalSandbox;
|
|||
use crate::jwt_auth::{AuthMode, AuthenticatedService};
|
||||
use arc_workflows::checkpoint::Checkpoint;
|
||||
use arc_workflows::context::Context;
|
||||
use arc_workflows::engine::{WorkflowRunEngine, RunConfig};
|
||||
use arc_workflows::engine::{RunConfig, WorkflowRunEngine};
|
||||
use arc_workflows::event::{EventEmitter, WorkflowRunEvent};
|
||||
use arc_workflows::handler::HandlerRegistry;
|
||||
use arc_workflows::interviewer::web::WebInterviewer;
|
||||
|
|
@ -30,7 +30,6 @@ pub use arc_types::{
|
|||
StartRunResponse, SubmitAnswerRequest, SubmitAnswerResponse,
|
||||
};
|
||||
|
||||
|
||||
/// Snapshot of a managed run.
|
||||
struct ManagedRun {
|
||||
dot_source: String,
|
||||
|
|
@ -62,10 +61,16 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
|
|||
|
||||
if is_demo {
|
||||
router = router
|
||||
.route("/runs", get(crate::demo::list_runs).post(crate::demo::start_run_stub))
|
||||
.route(
|
||||
"/runs",
|
||||
get(crate::demo::list_runs).post(crate::demo::start_run_stub),
|
||||
)
|
||||
.route("/runs/{id}", get(crate::demo::get_run_status))
|
||||
.route("/runs/{id}/questions", get(crate::demo::get_questions_stub))
|
||||
.route("/runs/{id}/questions/{qid}/answer", post(crate::demo::answer_stub))
|
||||
.route(
|
||||
"/runs/{id}/questions/{qid}/answer",
|
||||
post(crate::demo::answer_stub),
|
||||
)
|
||||
.route("/runs/{id}/events", get(crate::demo::run_events_stub))
|
||||
.route("/runs/{id}/checkpoint", get(crate::demo::checkpoint_stub))
|
||||
.route("/runs/{id}/context", get(crate::demo::context_stub))
|
||||
|
|
@ -73,25 +78,58 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
|
|||
.route("/runs/{id}/graph", get(crate::demo::get_run_graph))
|
||||
.route("/runs/{id}/retro", get(crate::demo::get_run_retro))
|
||||
.route("/runs/{id}/stages", get(crate::demo::get_run_stages))
|
||||
.route("/runs/{id}/stages/{stageId}/turns", get(crate::demo::get_stage_turns))
|
||||
.route(
|
||||
"/runs/{id}/stages/{stageId}/turns",
|
||||
get(crate::demo::get_stage_turns),
|
||||
)
|
||||
.route("/runs/{id}/files", get(crate::demo::get_run_files))
|
||||
.route("/runs/{id}/usage", get(crate::demo::get_run_usage))
|
||||
.route("/runs/{id}/verifications", get(crate::demo::get_run_verifications))
|
||||
.route("/runs/{id}/configuration", get(crate::demo::get_run_configuration))
|
||||
.route(
|
||||
"/runs/{id}/verifications",
|
||||
get(crate::demo::get_run_verifications),
|
||||
)
|
||||
.route(
|
||||
"/runs/{id}/configuration",
|
||||
get(crate::demo::get_run_configuration),
|
||||
)
|
||||
.route("/runs/{id}/steer", post(crate::demo::steer_run_stub))
|
||||
.route("/runs/{id}/preview", post(crate::demo::generate_preview_url_stub))
|
||||
.route(
|
||||
"/runs/{id}/preview",
|
||||
post(crate::demo::generate_preview_url_stub),
|
||||
)
|
||||
.route("/workflows", get(crate::demo::list_workflows))
|
||||
.route("/workflows/{name}", get(crate::demo::get_workflow))
|
||||
.route("/workflows/{name}/runs", get(crate::demo::list_workflow_runs).post(crate::demo::trigger_workflow_run_stub))
|
||||
.route(
|
||||
"/workflows/{name}/runs",
|
||||
get(crate::demo::list_workflow_runs).post(crate::demo::trigger_workflow_run_stub),
|
||||
)
|
||||
.route("/verifications", get(crate::demo::list_verifications))
|
||||
.route("/verifications/{slug}", get(crate::demo::get_verification_detail))
|
||||
.route(
|
||||
"/verifications/{slug}",
|
||||
get(crate::demo::get_verification_detail),
|
||||
)
|
||||
.route("/retros", get(crate::demo::list_retros))
|
||||
.route("/sessions", get(crate::demo::list_sessions).post(crate::demo::create_session_stub))
|
||||
.route(
|
||||
"/sessions",
|
||||
get(crate::demo::list_sessions).post(crate::demo::create_session_stub),
|
||||
)
|
||||
.route("/sessions/{id}", get(crate::demo::get_session))
|
||||
.route("/sessions/{id}/messages", post(crate::demo::send_message_stub))
|
||||
.route("/sessions/{id}/events", get(crate::demo::session_events_stub))
|
||||
.route("/insights/queries", get(crate::demo::list_saved_queries).post(crate::demo::save_query_stub))
|
||||
.route("/insights/queries/{id}", put(crate::demo::update_query_stub).delete(crate::demo::delete_query_stub))
|
||||
.route(
|
||||
"/sessions/{id}/messages",
|
||||
post(crate::demo::send_message_stub),
|
||||
)
|
||||
.route(
|
||||
"/sessions/{id}/events",
|
||||
get(crate::demo::session_events_stub),
|
||||
)
|
||||
.route(
|
||||
"/insights/queries",
|
||||
get(crate::demo::list_saved_queries).post(crate::demo::save_query_stub),
|
||||
)
|
||||
.route(
|
||||
"/insights/queries/{id}",
|
||||
put(crate::demo::update_query_stub).delete(crate::demo::delete_query_stub),
|
||||
)
|
||||
.route("/insights/execute", post(crate::demo::execute_query_stub))
|
||||
.route("/insights/history", get(crate::demo::list_query_history))
|
||||
.route("/settings", get(crate::demo::get_settings))
|
||||
|
|
@ -119,7 +157,10 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
|
|||
.route("/runs/{id}/preview", post(not_implemented))
|
||||
.route("/workflows", get(not_implemented))
|
||||
.route("/workflows/{name}", get(not_implemented))
|
||||
.route("/workflows/{name}/runs", get(not_implemented).post(not_implemented))
|
||||
.route(
|
||||
"/workflows/{name}/runs",
|
||||
get(not_implemented).post(not_implemented),
|
||||
)
|
||||
.route("/verifications", get(not_implemented))
|
||||
.route("/verifications/{slug}", get(not_implemented))
|
||||
.route("/retros", get(not_implemented))
|
||||
|
|
@ -127,8 +168,14 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
|
|||
.route("/sessions/{id}", get(not_implemented))
|
||||
.route("/sessions/{id}/messages", post(not_implemented))
|
||||
.route("/sessions/{id}/events", get(not_implemented))
|
||||
.route("/insights/queries", get(not_implemented).post(not_implemented))
|
||||
.route("/insights/queries/{id}", put(not_implemented).delete(not_implemented))
|
||||
.route(
|
||||
"/insights/queries",
|
||||
get(not_implemented).post(not_implemented),
|
||||
)
|
||||
.route(
|
||||
"/insights/queries/{id}",
|
||||
put(not_implemented).delete(not_implemented),
|
||||
)
|
||||
.route("/insights/execute", post(not_implemented))
|
||||
.route("/insights/history", get(not_implemented))
|
||||
.route("/settings", get(not_implemented))
|
||||
|
|
@ -136,9 +183,7 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
|
|||
.route("/projects/{id}/branches", get(not_implemented));
|
||||
}
|
||||
|
||||
router
|
||||
.layer(axum::Extension(auth_mode))
|
||||
.with_state(state)
|
||||
router.layer(axum::Extension(auth_mode)).with_state(state)
|
||||
}
|
||||
|
||||
async fn not_implemented() -> Response {
|
||||
|
|
@ -172,10 +217,7 @@ pub fn create_app_state_with_options(
|
|||
})
|
||||
}
|
||||
|
||||
async fn list_runs(
|
||||
_auth: AuthenticatedService,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Response {
|
||||
async fn list_runs(_auth: AuthenticatedService, State(state): State<Arc<AppState>>) -> Response {
|
||||
let runs = state.runs.lock().expect("runs lock poisoned");
|
||||
let items: Vec<RunStatusResponse> = runs
|
||||
.iter()
|
||||
|
|
@ -223,8 +265,7 @@ async fn start_run(
|
|||
|
||||
let registry = (state.registry_factory)(Arc::clone(&interviewer) as Arc<dyn Interviewer>);
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let sandbox: Arc<dyn arc_agent::Sandbox> =
|
||||
Arc::new(LocalSandbox::new(cwd));
|
||||
let sandbox: Arc<dyn arc_agent::Sandbox> = Arc::new(LocalSandbox::new(cwd));
|
||||
let engine = WorkflowRunEngine::with_interviewer(
|
||||
registry,
|
||||
Arc::new(emitter),
|
||||
|
|
@ -304,10 +345,7 @@ async fn start_run(
|
|||
let _ = retro.save(&config.logs_root);
|
||||
}
|
||||
|
||||
let mut runs = state_clone
|
||||
.runs
|
||||
.lock()
|
||||
.expect("runs lock poisoned");
|
||||
let mut runs = state_clone.runs.lock().expect("runs lock poisoned");
|
||||
if let Some(managed_run) = runs.get_mut(&run_id_clone) {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
|
|
@ -330,11 +368,7 @@ async fn start_run(
|
|||
}
|
||||
});
|
||||
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(StartRunResponse { id: run_id }),
|
||||
)
|
||||
.into_response()
|
||||
(StatusCode::CREATED, Json(StartRunResponse { id: run_id })).into_response()
|
||||
}
|
||||
|
||||
async fn get_run_status(
|
||||
|
|
|
|||
|
|
@ -191,7 +191,10 @@ client_id = "Iv1.abc123"
|
|||
assert_eq!(config.web.auth.provider, AuthProvider::Github);
|
||||
assert_eq!(config.web.auth.allowed_usernames, vec!["brynary", "alice"]);
|
||||
assert_eq!(config.api.base_url, "http://example.com:8080");
|
||||
assert_eq!(config.api.authentication_strategies, vec![ApiAuthStrategy::Jwt]);
|
||||
assert_eq!(
|
||||
config.api.authentication_strategies,
|
||||
vec![ApiAuthStrategy::Jwt]
|
||||
);
|
||||
assert_eq!(config.git.provider, GitProvider::Github);
|
||||
assert_eq!(config.git.app_id.as_deref(), Some("12345"));
|
||||
assert_eq!(config.git.client_id.as_deref(), Some("Iv1.abc123"));
|
||||
|
|
@ -330,7 +333,10 @@ authentication_strategies = []
|
|||
authentication_strategies = ["jwt"]
|
||||
"#;
|
||||
let config: ServerConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.api.authentication_strategies, vec![ApiAuthStrategy::Jwt]);
|
||||
assert_eq!(
|
||||
config.api.authentication_strategies,
|
||||
vec![ApiAuthStrategy::Jwt]
|
||||
);
|
||||
assert!(config.api.tls.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ pub fn build_rustls_config(tls_config: &TlsConfig, client_auth: ClientAuth) -> A
|
|||
let ca_certs = load_certs(&tls_config.ca);
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
for cert in ca_certs {
|
||||
root_store.add(cert).expect("failed to add CA certificate to root store");
|
||||
root_store
|
||||
.add(cert)
|
||||
.expect("failed to add CA certificate to root store");
|
||||
}
|
||||
|
||||
let builder = WebPkiClientVerifier::builder(Arc::new(root_store));
|
||||
|
|
@ -86,9 +88,8 @@ pub async fn serve_tls(
|
|||
|
||||
// Extract peer certificates once per connection (not per request)
|
||||
let (_, server_conn) = tls_stream.get_ref();
|
||||
let peer_certs = PeerCertificates(
|
||||
server_conn.peer_certificates().map(|certs| certs.to_vec()),
|
||||
);
|
||||
let peer_certs =
|
||||
PeerCertificates(server_conn.peer_certificates().map(|certs| certs.to_vec()));
|
||||
|
||||
let io = TokioIo::new(tls_stream);
|
||||
|
||||
|
|
|
|||
|
|
@ -38,25 +38,47 @@ mod mtls_e2e {
|
|||
// CA key
|
||||
let ca_key_path = dir.join("ca.key");
|
||||
let ca_cert_path = dir.join("ca.crt");
|
||||
run_openssl(&["genpkey", "-algorithm", "Ed25519", "-out", ca_key_path.to_str().unwrap()]);
|
||||
run_openssl(&[
|
||||
"req", "-new", "-x509",
|
||||
"-key", ca_key_path.to_str().unwrap(),
|
||||
"-out", ca_cert_path.to_str().unwrap(),
|
||||
"-days", "1",
|
||||
"-subj", &format!("/CN={ca_cn}"),
|
||||
"genpkey",
|
||||
"-algorithm",
|
||||
"Ed25519",
|
||||
"-out",
|
||||
ca_key_path.to_str().unwrap(),
|
||||
]);
|
||||
run_openssl(&[
|
||||
"req",
|
||||
"-new",
|
||||
"-x509",
|
||||
"-key",
|
||||
ca_key_path.to_str().unwrap(),
|
||||
"-out",
|
||||
ca_cert_path.to_str().unwrap(),
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
&format!("/CN={ca_cn}"),
|
||||
]);
|
||||
|
||||
// Server key + cert signed by CA
|
||||
let server_key_path = dir.join("server.key");
|
||||
let server_csr_path = dir.join("server.csr");
|
||||
let server_cert_path = dir.join("server.crt");
|
||||
run_openssl(&["genpkey", "-algorithm", "Ed25519", "-out", server_key_path.to_str().unwrap()]);
|
||||
run_openssl(&[
|
||||
"req", "-new",
|
||||
"-key", server_key_path.to_str().unwrap(),
|
||||
"-out", server_csr_path.to_str().unwrap(),
|
||||
"-subj", &format!("/CN={server_cn}"),
|
||||
"genpkey",
|
||||
"-algorithm",
|
||||
"Ed25519",
|
||||
"-out",
|
||||
server_key_path.to_str().unwrap(),
|
||||
]);
|
||||
run_openssl(&[
|
||||
"req",
|
||||
"-new",
|
||||
"-key",
|
||||
server_key_path.to_str().unwrap(),
|
||||
"-out",
|
||||
server_csr_path.to_str().unwrap(),
|
||||
"-subj",
|
||||
&format!("/CN={server_cn}"),
|
||||
]);
|
||||
|
||||
// Create extension file for SAN (reqwest validates server cert hostname)
|
||||
|
|
@ -64,35 +86,58 @@ mod mtls_e2e {
|
|||
std::fs::write(&ext_path, "subjectAltName=IP:127.0.0.1").unwrap();
|
||||
|
||||
run_openssl(&[
|
||||
"x509", "-req",
|
||||
"-in", server_csr_path.to_str().unwrap(),
|
||||
"-CA", ca_cert_path.to_str().unwrap(),
|
||||
"-CAkey", ca_key_path.to_str().unwrap(),
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
server_csr_path.to_str().unwrap(),
|
||||
"-CA",
|
||||
ca_cert_path.to_str().unwrap(),
|
||||
"-CAkey",
|
||||
ca_key_path.to_str().unwrap(),
|
||||
"-CAcreateserial",
|
||||
"-out", server_cert_path.to_str().unwrap(),
|
||||
"-days", "1",
|
||||
"-extfile", ext_path.to_str().unwrap(),
|
||||
"-out",
|
||||
server_cert_path.to_str().unwrap(),
|
||||
"-days",
|
||||
"1",
|
||||
"-extfile",
|
||||
ext_path.to_str().unwrap(),
|
||||
]);
|
||||
|
||||
// Client key + cert signed by CA
|
||||
let client_key_path = dir.join("client.key");
|
||||
let client_csr_path = dir.join("client.csr");
|
||||
let client_cert_path = dir.join("client.crt");
|
||||
run_openssl(&["genpkey", "-algorithm", "Ed25519", "-out", client_key_path.to_str().unwrap()]);
|
||||
run_openssl(&[
|
||||
"req", "-new",
|
||||
"-key", client_key_path.to_str().unwrap(),
|
||||
"-out", client_csr_path.to_str().unwrap(),
|
||||
"-subj", &format!("/CN={client_cn}"),
|
||||
"genpkey",
|
||||
"-algorithm",
|
||||
"Ed25519",
|
||||
"-out",
|
||||
client_key_path.to_str().unwrap(),
|
||||
]);
|
||||
run_openssl(&[
|
||||
"x509", "-req",
|
||||
"-in", client_csr_path.to_str().unwrap(),
|
||||
"-CA", ca_cert_path.to_str().unwrap(),
|
||||
"-CAkey", ca_key_path.to_str().unwrap(),
|
||||
"req",
|
||||
"-new",
|
||||
"-key",
|
||||
client_key_path.to_str().unwrap(),
|
||||
"-out",
|
||||
client_csr_path.to_str().unwrap(),
|
||||
"-subj",
|
||||
&format!("/CN={client_cn}"),
|
||||
]);
|
||||
run_openssl(&[
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
client_csr_path.to_str().unwrap(),
|
||||
"-CA",
|
||||
ca_cert_path.to_str().unwrap(),
|
||||
"-CAkey",
|
||||
ca_key_path.to_str().unwrap(),
|
||||
"-CAcreateserial",
|
||||
"-out", client_cert_path.to_str().unwrap(),
|
||||
"-days", "1",
|
||||
"-out",
|
||||
client_cert_path.to_str().unwrap(),
|
||||
"-days",
|
||||
"1",
|
||||
]);
|
||||
|
||||
PkiPaths {
|
||||
|
|
@ -192,11 +237,7 @@ mod mtls_e2e {
|
|||
let auth_mode = AuthMode::Strategies(vec![AuthStrategy::Mtls]);
|
||||
let addr = start_tls_server(&tls_config, ClientAuth::Required, auth_mode).await;
|
||||
|
||||
let client = build_client(
|
||||
&pki.ca_cert,
|
||||
Some(&pki.client_cert),
|
||||
Some(&pki.client_key),
|
||||
);
|
||||
let client = build_client(&pki.ca_cert, Some(&pki.client_cert), Some(&pki.client_key));
|
||||
|
||||
let response = client
|
||||
.get(format!("https://127.0.0.1:{}/runs", addr.port()))
|
||||
|
|
@ -465,9 +506,7 @@ mod server_lifecycle {
|
|||
// 3. Submit answer selecting first option (Approve)
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!(
|
||||
"/runs/{run_id}/questions/{question_id}/answer"
|
||||
))
|
||||
.uri(format!("/runs/{run_id}/questions/{question_id}/answer"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&serde_json::json!({"value": "A"})).unwrap(),
|
||||
|
|
|
|||
|
|
@ -40,9 +40,7 @@ pub struct DoctorReport {
|
|||
|
||||
impl DoctorReport {
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.checks
|
||||
.iter()
|
||||
.any(|c| c.status == CheckStatus::Error)
|
||||
self.checks.iter().any(|c| c.status == CheckStatus::Error)
|
||||
}
|
||||
|
||||
pub fn issue_count(&self) -> usize {
|
||||
|
|
@ -90,7 +88,11 @@ impl DoctorReport {
|
|||
writeln!(
|
||||
out,
|
||||
"Doctor found issues in {issues} {}.",
|
||||
if issues == 1 { "category" } else { "categories" }
|
||||
if issues == 1 {
|
||||
"category"
|
||||
} else {
|
||||
"categories"
|
||||
}
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -159,8 +161,7 @@ pub enum ProbeOutcome {
|
|||
|
||||
static OPENSSL_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?:OpenSSL|LibreSSL)\s+(\d+)\.(\d+)\.(\d+)").unwrap());
|
||||
static NODE_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"v(\d+)\.(\d+)\.(\d+)").unwrap());
|
||||
static NODE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"v(\d+)\.(\d+)\.(\d+)").unwrap());
|
||||
static GH_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"gh version (\d+)\.(\d+)\.(\d+)").unwrap());
|
||||
static DOT_RE: LazyLock<Regex> =
|
||||
|
|
@ -176,10 +177,34 @@ fn parse_version(re: &Regex, output: &str) -> Option<Version> {
|
|||
}
|
||||
|
||||
pub const DEP_SPECS: &[DepSpec] = &[
|
||||
DepSpec { name: "openssl", command: &["openssl", "version"], required: true, min_version: Version::new(3, 0, 0), pattern: &OPENSSL_RE },
|
||||
DepSpec { name: "node", command: &["node", "--version"], required: true, min_version: Version::new(20, 0, 0), pattern: &NODE_RE },
|
||||
DepSpec { name: "gh", command: &["gh", "--version"], required: false, min_version: Version::new(2, 0, 0), pattern: &GH_RE },
|
||||
DepSpec { name: "dot", command: &["dot", "-V"], required: false, min_version: Version::new(2, 0, 0), pattern: &DOT_RE },
|
||||
DepSpec {
|
||||
name: "openssl",
|
||||
command: &["openssl", "version"],
|
||||
required: true,
|
||||
min_version: Version::new(3, 0, 0),
|
||||
pattern: &OPENSSL_RE,
|
||||
},
|
||||
DepSpec {
|
||||
name: "node",
|
||||
command: &["node", "--version"],
|
||||
required: true,
|
||||
min_version: Version::new(20, 0, 0),
|
||||
pattern: &NODE_RE,
|
||||
},
|
||||
DepSpec {
|
||||
name: "gh",
|
||||
command: &["gh", "--version"],
|
||||
required: false,
|
||||
min_version: Version::new(2, 0, 0),
|
||||
pattern: &GH_RE,
|
||||
},
|
||||
DepSpec {
|
||||
name: "dot",
|
||||
command: &["dot", "-V"],
|
||||
required: false,
|
||||
min_version: Version::new(2, 0, 0),
|
||||
pattern: &DOT_RE,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn probe_system_deps() -> Vec<ProbeOutcome> {
|
||||
|
|
@ -208,7 +233,11 @@ pub fn probe_system_deps() -> Vec<ProbeOutcome> {
|
|||
|
||||
fn dep_issue(name: &str, issue: &str, required: bool) -> (CheckStatus, String) {
|
||||
let severity = if required { "required" } else { "optional" };
|
||||
let status = if required { CheckStatus::Error } else { CheckStatus::Warning };
|
||||
let status = if required {
|
||||
CheckStatus::Error
|
||||
} else {
|
||||
CheckStatus::Warning
|
||||
};
|
||||
(status, format!("{name}: {issue} ({severity})"))
|
||||
}
|
||||
|
||||
|
|
@ -607,16 +636,16 @@ fn format_auth_strategies(strategies: &[ApiAuthStrategy]) -> String {
|
|||
.join(", ")
|
||||
}
|
||||
|
||||
pub fn check_api(
|
||||
status: &ApiStatus,
|
||||
live_result: Option<&Result<(), String>>,
|
||||
) -> CheckResult {
|
||||
pub fn check_api(status: &ApiStatus, live_result: Option<&Result<(), String>>) -> CheckResult {
|
||||
let mut details = vec![
|
||||
CheckDetail {
|
||||
text: format!("Base URL: {}", status.base_url),
|
||||
},
|
||||
CheckDetail {
|
||||
text: format!("Authentication: {}", format_auth_strategies(&status.authentication_strategies)),
|
||||
text: format!(
|
||||
"Authentication: {}",
|
||||
format_auth_strategies(&status.authentication_strategies)
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -648,16 +677,16 @@ fn format_auth_provider(provider: &AuthProvider) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn check_web(
|
||||
status: &WebStatus,
|
||||
live_result: Option<&Result<(), String>>,
|
||||
) -> CheckResult {
|
||||
pub fn check_web(status: &WebStatus, live_result: Option<&Result<(), String>>) -> CheckResult {
|
||||
let mut details = vec![
|
||||
CheckDetail {
|
||||
text: format!("URL: {}", status.url),
|
||||
},
|
||||
CheckDetail {
|
||||
text: format!("Auth provider: {}", format_auth_provider(&status.auth_provider)),
|
||||
text: format!(
|
||||
"Auth provider: {}",
|
||||
format_auth_provider(&status.auth_provider)
|
||||
),
|
||||
},
|
||||
CheckDetail {
|
||||
text: format!("Allowed usernames: {}", status.allowed_usernames_count),
|
||||
|
|
@ -946,7 +975,11 @@ async fn probe_llm_provider(
|
|||
metadata: None,
|
||||
provider_options: None,
|
||||
};
|
||||
let result = client.complete(&request).await.map(|_| ()).map_err(|e| e.to_string());
|
||||
let result = client
|
||||
.complete(&request)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string());
|
||||
(provider, result)
|
||||
}
|
||||
|
||||
|
|
@ -979,9 +1012,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
|
|||
|
||||
// Gather state
|
||||
let config_path = dirs::home_dir().map(|h| h.join(".arc").join("server.toml"));
|
||||
let config_exists = config_path
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.exists());
|
||||
let config_exists = config_path.as_ref().is_some_and(|p| p.exists());
|
||||
|
||||
let llm_statuses: Vec<(Provider, bool)> = Provider::ALL
|
||||
.iter()
|
||||
|
|
@ -990,8 +1021,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
|
|||
|
||||
let brave_key_set = std::env::var("BRAVE_SEARCH_API_KEY").is_ok();
|
||||
|
||||
let server_config = arc_api::server_config::load_server_config()
|
||||
.unwrap_or_default();
|
||||
let server_config = arc_api::server_config::load_server_config().unwrap_or_default();
|
||||
|
||||
let api_status = ApiStatus {
|
||||
base_url: server_config.api.base_url.clone(),
|
||||
|
|
@ -1127,7 +1157,11 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
|
|||
|
||||
print!("{}", report.render(&styles, verbose, live));
|
||||
|
||||
if report.has_errors() { 1 } else { 0 }
|
||||
if report.has_errors() {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1314,8 +1348,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn check_llm_all_configured() {
|
||||
let statuses: Vec<(Provider, bool)> =
|
||||
Provider::ALL.iter().map(|p| (*p, true)).collect();
|
||||
let statuses: Vec<(Provider, bool)> = Provider::ALL.iter().map(|p| (*p, true)).collect();
|
||||
let result = check_llm_providers(&statuses, None);
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
assert!(result.summary.contains("7 of 7"));
|
||||
|
|
@ -1337,8 +1370,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn check_llm_none_configured() {
|
||||
let statuses: Vec<(Provider, bool)> =
|
||||
Provider::ALL.iter().map(|p| (*p, false)).collect();
|
||||
let statuses: Vec<(Provider, bool)> = Provider::ALL.iter().map(|p| (*p, false)).collect();
|
||||
let result = check_llm_providers(&statuses, None);
|
||||
assert_eq!(result.status, CheckStatus::Error);
|
||||
assert!(result.summary.contains("0 of 7"));
|
||||
|
|
@ -1350,7 +1382,10 @@ mod tests {
|
|||
let live = vec![(Provider::Anthropic, Ok(()))];
|
||||
let result = check_llm_providers(&statuses, Some(&live));
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
assert!(result.details.iter().any(|d| d.text.contains("connectivity: OK")));
|
||||
assert!(result
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.text.contains("connectivity: OK")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1548,7 +1583,10 @@ mod tests {
|
|||
let live = Ok(());
|
||||
let result = check_api(&status, Some(&live));
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
assert!(result.details.iter().any(|d| d.text.contains("Connectivity: OK")));
|
||||
assert!(result
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.text.contains("Connectivity: OK")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1560,7 +1598,10 @@ mod tests {
|
|||
let live = Err("connection refused".to_string());
|
||||
let result = check_api(&status, Some(&live));
|
||||
assert_eq!(result.status, CheckStatus::Warning);
|
||||
assert!(result.details.iter().any(|d| d.text.contains("connection refused")));
|
||||
assert!(result
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.text.contains("connection refused")));
|
||||
}
|
||||
|
||||
// -- check_web --
|
||||
|
|
@ -1606,7 +1647,10 @@ mod tests {
|
|||
let live = Ok(());
|
||||
let result = check_web(&status, Some(&live));
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
assert!(result.details.iter().any(|d| d.text.contains("Connectivity: OK")));
|
||||
assert!(result
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.text.contains("Connectivity: OK")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1619,7 +1663,10 @@ mod tests {
|
|||
let live = Err("connection refused".to_string());
|
||||
let result = check_web(&status, Some(&live));
|
||||
assert_eq!(result.status, CheckStatus::Warning);
|
||||
assert!(result.details.iter().any(|d| d.text.contains("connection refused")));
|
||||
assert!(result
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.text.contains("connection refused")));
|
||||
}
|
||||
|
||||
// -- render: multiple issues --
|
||||
|
|
@ -1638,25 +1685,37 @@ mod tests {
|
|||
#[test]
|
||||
fn parse_version_openssl() {
|
||||
assert_eq!(
|
||||
parse_version(&OPENSSL_RE, "OpenSSL 3.4.1 11 Feb 2025 (Library: OpenSSL 3.4.1 11 Feb 2025)"),
|
||||
parse_version(
|
||||
&OPENSSL_RE,
|
||||
"OpenSSL 3.4.1 11 Feb 2025 (Library: OpenSSL 3.4.1 11 Feb 2025)"
|
||||
),
|
||||
Some(Version::new(3, 4, 1)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_version_libressl() {
|
||||
assert_eq!(parse_version(&OPENSSL_RE, "LibreSSL 3.3.6"), Some(Version::new(3, 3, 6)));
|
||||
assert_eq!(
|
||||
parse_version(&OPENSSL_RE, "LibreSSL 3.3.6"),
|
||||
Some(Version::new(3, 3, 6))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_version_node() {
|
||||
assert_eq!(parse_version(&NODE_RE, "v22.14.0"), Some(Version::new(22, 14, 0)));
|
||||
assert_eq!(
|
||||
parse_version(&NODE_RE, "v22.14.0"),
|
||||
Some(Version::new(22, 14, 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_version_gh() {
|
||||
assert_eq!(
|
||||
parse_version(&GH_RE, "gh version 2.67.0 (2025-01-31)\nhttps://github.com/cli/cli/releases/tag/v2.67.0"),
|
||||
parse_version(
|
||||
&GH_RE,
|
||||
"gh version 2.67.0 (2025-01-31)\nhttps://github.com/cli/cli/releases/tag/v2.67.0"
|
||||
),
|
||||
Some(Version::new(2, 67, 0)),
|
||||
);
|
||||
}
|
||||
|
|
@ -1700,10 +1759,18 @@ mod tests {
|
|||
spec("dot", false, Version::new(2, 0, 0)),
|
||||
];
|
||||
let outcomes = [
|
||||
ProbeOutcome::Ok { version: Some(Version::new(3, 4, 1)) },
|
||||
ProbeOutcome::Ok { version: Some(Version::new(22, 14, 0)) },
|
||||
ProbeOutcome::Ok { version: Some(Version::new(2, 67, 0)) },
|
||||
ProbeOutcome::Ok { version: Some(Version::new(12, 2, 1)) },
|
||||
ProbeOutcome::Ok {
|
||||
version: Some(Version::new(3, 4, 1)),
|
||||
},
|
||||
ProbeOutcome::Ok {
|
||||
version: Some(Version::new(22, 14, 0)),
|
||||
},
|
||||
ProbeOutcome::Ok {
|
||||
version: Some(Version::new(2, 67, 0)),
|
||||
},
|
||||
ProbeOutcome::Ok {
|
||||
version: Some(Version::new(12, 2, 1)),
|
||||
},
|
||||
];
|
||||
let result = check_system_deps(&specs, &outcomes);
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
|
|
@ -1731,7 +1798,9 @@ mod tests {
|
|||
#[test]
|
||||
fn check_system_deps_outdated_is_warning() {
|
||||
let specs = [spec("openssl", true, Version::new(3, 0, 0))];
|
||||
let outcomes = [ProbeOutcome::Ok { version: Some(Version::new(1, 1, 1)) }];
|
||||
let outcomes = [ProbeOutcome::Ok {
|
||||
version: Some(Version::new(1, 1, 1)),
|
||||
}];
|
||||
let result = check_system_deps(&specs, &outcomes);
|
||||
assert_eq!(result.status, CheckStatus::Warning);
|
||||
assert!(result.details[0].text.contains("1.1.1"));
|
||||
|
|
@ -1782,20 +1851,31 @@ mod tests {
|
|||
fn generate_test_tls_cert() -> (String, String) {
|
||||
let output = std::process::Command::new("openssl")
|
||||
.args([
|
||||
"req", "-x509", "-newkey", "ec",
|
||||
"-pkeyopt", "ec_paramgen_curve:prime256v1",
|
||||
"-keyout", "/dev/stdout", "-out", "/dev/stdout",
|
||||
"-days", "3650", "-nodes", "-subj", "/CN=test-server",
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"ec",
|
||||
"-pkeyopt",
|
||||
"ec_paramgen_curve:prime256v1",
|
||||
"-keyout",
|
||||
"/dev/stdout",
|
||||
"-out",
|
||||
"/dev/stdout",
|
||||
"-days",
|
||||
"3650",
|
||||
"-nodes",
|
||||
"-subj",
|
||||
"/CN=test-server",
|
||||
])
|
||||
.output()
|
||||
.expect("openssl must be available for tests");
|
||||
let combined = String::from_utf8(output.stdout).unwrap();
|
||||
let key_start = combined.find("-----BEGIN PRIVATE KEY-----").unwrap();
|
||||
let key_end = combined.find("-----END PRIVATE KEY-----").unwrap()
|
||||
+ "-----END PRIVATE KEY-----".len();
|
||||
let key_end =
|
||||
combined.find("-----END PRIVATE KEY-----").unwrap() + "-----END PRIVATE KEY-----".len();
|
||||
let cert_start = combined.find("-----BEGIN CERTIFICATE-----").unwrap();
|
||||
let cert_end = combined.find("-----END CERTIFICATE-----").unwrap()
|
||||
+ "-----END CERTIFICATE-----".len();
|
||||
let cert_end =
|
||||
combined.find("-----END CERTIFICATE-----").unwrap() + "-----END CERTIFICATE-----".len();
|
||||
let key_pem = combined[key_start..key_end].to_string();
|
||||
let cert_pem = combined[cert_start..cert_end].to_string();
|
||||
(cert_pem, key_pem)
|
||||
|
|
@ -1814,7 +1894,12 @@ mod tests {
|
|||
.spawn()
|
||||
.and_then(|mut child| {
|
||||
use std::io::Write;
|
||||
child.stdin.take().unwrap().write_all(private_pem.as_bytes()).unwrap();
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(private_pem.as_bytes())
|
||||
.unwrap();
|
||||
child.wait_with_output()
|
||||
})
|
||||
.expect("openssl pkey failed");
|
||||
|
|
@ -1913,14 +1998,20 @@ mod tests {
|
|||
fn crypto_jwt_configured_but_key_missing() {
|
||||
let result = check_crypto(&crypto_input(vec![ApiAuthStrategy::Jwt]));
|
||||
assert_eq!(result.status, CheckStatus::Error);
|
||||
assert!(result.details.iter().any(|d| d.text.contains("ARC_JWT_PUBLIC_KEY not set")));
|
||||
assert!(result
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.text.contains("ARC_JWT_PUBLIC_KEY not set")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypto_mtls_configured_but_tls_not_set() {
|
||||
let result = check_crypto(&crypto_input(vec![ApiAuthStrategy::Mtls]));
|
||||
assert_eq!(result.status, CheckStatus::Error);
|
||||
assert!(result.details.iter().any(|d| d.text.contains("[api.tls] not set")));
|
||||
assert!(result
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.text.contains("[api.tls] not set")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1931,29 +2022,42 @@ mod tests {
|
|||
};
|
||||
let result = check_crypto(&input);
|
||||
assert_eq!(result.status, CheckStatus::Error);
|
||||
assert!(result.details.iter().any(|d| d.text.contains("Permission denied")));
|
||||
assert!(result
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.text.contains("Permission denied")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypto_invalid_jwt_public_key() {
|
||||
let input = CryptoInput {
|
||||
jwt_public_key: Some("-----BEGIN PUBLIC KEY-----\nINVALID\n-----END PUBLIC KEY-----".to_string()),
|
||||
jwt_public_key: Some(
|
||||
"-----BEGIN PUBLIC KEY-----\nINVALID\n-----END PUBLIC KEY-----".to_string(),
|
||||
),
|
||||
..crypto_input(vec![ApiAuthStrategy::Jwt])
|
||||
};
|
||||
let result = check_crypto(&input);
|
||||
assert_eq!(result.status, CheckStatus::Error);
|
||||
assert!(result.details.iter().any(|d| d.text.contains("JWT public key: invalid")));
|
||||
assert!(result
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.text.contains("JWT public key: invalid")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypto_invalid_jwt_private_key() {
|
||||
let input = CryptoInput {
|
||||
jwt_private_key: Some("-----BEGIN PRIVATE KEY-----\nINVALID\n-----END PRIVATE KEY-----".to_string()),
|
||||
jwt_private_key: Some(
|
||||
"-----BEGIN PRIVATE KEY-----\nINVALID\n-----END PRIVATE KEY-----".to_string(),
|
||||
),
|
||||
..crypto_input(vec![])
|
||||
};
|
||||
let result = check_crypto(&input);
|
||||
assert_eq!(result.status, CheckStatus::Error);
|
||||
assert!(result.details.iter().any(|d| d.text.contains("JWT private key: invalid")));
|
||||
assert!(result
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.text.contains("JWT private key: invalid")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1969,7 +2073,10 @@ mod tests {
|
|||
};
|
||||
let result = check_crypto(&input);
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
assert!(result.details.iter().any(|d| d.text.contains("JWT public key: valid")));
|
||||
assert!(result
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.text.contains("JWT public key: valid")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -71,7 +71,8 @@ fn generate_session_secret() -> String {
|
|||
|
||||
fn generate_jwt_keypair() -> Result<(String, String)> {
|
||||
let private_pem = run_openssl(&["genpkey", "-algorithm", "Ed25519"], "generate keypair")?;
|
||||
let public_pem = run_openssl_with_stdin(&["pkey", "-pubout"], &private_pem, "extract public key")?;
|
||||
let public_pem =
|
||||
run_openssl_with_stdin(&["pkey", "-pubout"], &private_pem, "extract public key")?;
|
||||
|
||||
let private_str = String::from_utf8(private_pem).context("private key is not valid UTF-8")?;
|
||||
let public_str = String::from_utf8(public_pem).context("public key is not valid UTF-8")?;
|
||||
|
|
@ -91,7 +92,19 @@ fn generate_mtls_certs(dir: &Path) -> Result<()> {
|
|||
std::fs::write(&ca_key_path, &ca_key)?;
|
||||
|
||||
let ca_cert = run_openssl(
|
||||
&["req", "-new", "-x509", "-key", ca_key_path.to_str().context("CA key path is not valid UTF-8")?, "-days", "3650", "-subj", "/CN=Arc CA"],
|
||||
&[
|
||||
"req",
|
||||
"-new",
|
||||
"-x509",
|
||||
"-key",
|
||||
ca_key_path
|
||||
.to_str()
|
||||
.context("CA key path is not valid UTF-8")?,
|
||||
"-days",
|
||||
"3650",
|
||||
"-subj",
|
||||
"/CN=Arc CA",
|
||||
],
|
||||
"generate CA cert",
|
||||
)?;
|
||||
let ca_cert_path = dir.join("ca.crt");
|
||||
|
|
@ -103,7 +116,14 @@ fn generate_mtls_certs(dir: &Path) -> Result<()> {
|
|||
std::fs::write(&server_key_path, &server_key)?;
|
||||
|
||||
let csr = run_openssl_with_stdin(
|
||||
&["req", "-new", "-key", "/dev/stdin", "-subj", "/CN=localhost"],
|
||||
&[
|
||||
"req",
|
||||
"-new",
|
||||
"-key",
|
||||
"/dev/stdin",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
],
|
||||
&server_key,
|
||||
"generate server CSR",
|
||||
)?;
|
||||
|
|
@ -113,11 +133,21 @@ fn generate_mtls_certs(dir: &Path) -> Result<()> {
|
|||
|
||||
let server_cert = run_openssl(
|
||||
&[
|
||||
"x509", "-req",
|
||||
"-in", csr_path.to_str().context("CSR path is not valid UTF-8")?,
|
||||
"-CA", ca_cert_path.to_str().context("CA cert path is not valid UTF-8")?,
|
||||
"-CAkey", ca_key_path.to_str().context("CA key path is not valid UTF-8")?,
|
||||
"-CAcreateserial", "-days", "3650",
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
csr_path.to_str().context("CSR path is not valid UTF-8")?,
|
||||
"-CA",
|
||||
ca_cert_path
|
||||
.to_str()
|
||||
.context("CA cert path is not valid UTF-8")?,
|
||||
"-CAkey",
|
||||
ca_key_path
|
||||
.to_str()
|
||||
.context("CA key path is not valid UTF-8")?,
|
||||
"-CAcreateserial",
|
||||
"-days",
|
||||
"3650",
|
||||
],
|
||||
"sign server cert",
|
||||
)?;
|
||||
|
|
@ -201,7 +231,9 @@ fn provider_key_url(provider: Provider) -> &'static str {
|
|||
Provider::Gemini => "https://aistudio.google.com/apikey",
|
||||
Provider::Kimi => "https://platform.moonshot.cn/console/api-keys",
|
||||
Provider::Zai => "https://open.bigmodel.cn/usercenter/apikeys",
|
||||
Provider::Minimax => "https://platform.minimaxi.com/user-center/basic-information/interface-key",
|
||||
Provider::Minimax => {
|
||||
"https://platform.minimaxi.com/user-center/basic-information/interface-key"
|
||||
}
|
||||
Provider::Inception => "https://console.inceptionlabs.ai/api-keys",
|
||||
}
|
||||
}
|
||||
|
|
@ -223,16 +255,20 @@ fn provider_display_name(provider: Provider) -> &'static str {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn prompt_confirm(prompt: &str, default: bool) -> Result<bool> {
|
||||
Ok(Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
|
||||
.with_prompt(prompt)
|
||||
.default(default)
|
||||
.interact_on(&dialoguer::console::Term::stderr())?)
|
||||
Ok(
|
||||
Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
|
||||
.with_prompt(prompt)
|
||||
.default(default)
|
||||
.interact_on(&dialoguer::console::Term::stderr())?,
|
||||
)
|
||||
}
|
||||
|
||||
fn prompt_input(prompt: &str) -> Result<String> {
|
||||
Ok(Input::with_theme(&dialoguer::theme::ColorfulTheme::default())
|
||||
.with_prompt(prompt)
|
||||
.interact_on(&dialoguer::console::Term::stderr())?)
|
||||
Ok(
|
||||
Input::with_theme(&dialoguer::theme::ColorfulTheme::default())
|
||||
.with_prompt(prompt)
|
||||
.interact_on(&dialoguer::console::Term::stderr())?,
|
||||
)
|
||||
}
|
||||
|
||||
fn prompt_multiselect(prompt: &str, items: &[String]) -> Result<Vec<usize>> {
|
||||
|
|
@ -306,10 +342,9 @@ pub async fn run_setup() -> Result<()> {
|
|||
};
|
||||
|
||||
if write_config {
|
||||
let username: String = tokio::task::spawn_blocking(|| {
|
||||
prompt_input("GitHub username for allowed access")
|
||||
})
|
||||
.await??;
|
||||
let username: String =
|
||||
tokio::task::spawn_blocking(|| prompt_input("GitHub username for allowed access"))
|
||||
.await??;
|
||||
|
||||
let toml_content = format_config_toml(&username);
|
||||
std::fs::write(&config_path, &toml_content)?;
|
||||
|
|
@ -357,8 +392,7 @@ pub async fn run_setup() -> Result<()> {
|
|||
eprintln!(" Get your API key at: {url}");
|
||||
|
||||
let prompt = env_var.to_string();
|
||||
let key: String =
|
||||
tokio::task::spawn_blocking(move || prompt_input(&prompt)).await??;
|
||||
let key: String = tokio::task::spawn_blocking(move || prompt_input(&prompt)).await??;
|
||||
|
||||
env_pairs.push((env_var.to_string(), key));
|
||||
}
|
||||
|
|
@ -413,8 +447,7 @@ pub async fn run_setup() -> Result<()> {
|
|||
// Step 6: Verify setup
|
||||
eprintln!("[Step 6/7] Verify setup");
|
||||
let run_doctor =
|
||||
tokio::task::spawn_blocking(|| prompt_confirm("Run arc doctor to verify?", true))
|
||||
.await??;
|
||||
tokio::task::spawn_blocking(|| prompt_confirm("Run arc doctor to verify?", true)).await??;
|
||||
|
||||
if run_doctor {
|
||||
eprintln!();
|
||||
|
|
@ -489,8 +522,7 @@ mod tests {
|
|||
#[test]
|
||||
fn jwt_keypair_public_parses() {
|
||||
let (_, public) = generate_jwt_keypair().unwrap();
|
||||
jsonwebtoken::DecodingKey::from_ed_pem(public.as_bytes())
|
||||
.expect("public key should parse");
|
||||
jsonwebtoken::DecodingKey::from_ed_pem(public.as_bytes()).expect("public key should parse");
|
||||
}
|
||||
|
||||
// -- mTLS cert generation --
|
||||
|
|
@ -616,10 +648,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn merge_env_full_scenario() {
|
||||
let result = merge_env(
|
||||
"FOO=old\nBAR=keep",
|
||||
&[("FOO", "new"), ("BAZ", "added")],
|
||||
);
|
||||
let result = merge_env("FOO=old\nBAR=keep", &[("FOO", "new"), ("BAZ", "added")]);
|
||||
assert_eq!(result, "FOO=new\nBAR=keep\nBAZ=added\n");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -528,7 +528,6 @@ fn doctor_no_color_when_no_color_set() {
|
|||
.stdout(predicate::str::contains("\x1b[").not());
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn doctor_live_flag_accepted() {
|
||||
arc()
|
||||
|
|
@ -570,10 +569,7 @@ fn dry_run_writes_jsonl_and_live_json() {
|
|||
|
||||
// Every line must be valid JSON with ts, run_id, and event keys
|
||||
let first_line: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
|
||||
assert!(
|
||||
first_line.get("ts").is_some(),
|
||||
"line should have ts"
|
||||
);
|
||||
assert!(first_line.get("ts").is_some(), "line should have ts");
|
||||
assert!(
|
||||
first_line.get("run_id").is_some(),
|
||||
"line should have run_id"
|
||||
|
|
@ -585,10 +581,7 @@ fn dry_run_writes_jsonl_and_live_json() {
|
|||
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
parsed["event"].as_str() == Some("WorkflowRunStarted")
|
||||
});
|
||||
assert!(
|
||||
has_run_started,
|
||||
"events should contain WorkflowRunStarted"
|
||||
);
|
||||
assert!(has_run_started, "events should contain WorkflowRunStarted");
|
||||
|
||||
// run_id should be non-empty after WorkflowRunStarted
|
||||
let last_line: serde_json::Value = serde_json::from_str(lines[lines.len() - 1]).unwrap();
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ pub async fn initialize_db(pool: &SqlitePool) -> Result<(), sqlx::Error> {
|
|||
let from_version = row.0;
|
||||
|
||||
if from_version < CURRENT_VERSION {
|
||||
info!(from_version = from_version, to_version = CURRENT_VERSION, "Running database migrations");
|
||||
info!(
|
||||
from_version = from_version,
|
||||
to_version = CURRENT_VERSION,
|
||||
"Running database migrations"
|
||||
);
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
if from_version < 1 {
|
||||
|
|
@ -29,7 +33,10 @@ pub async fn initialize_db(pool: &SqlitePool) -> Result<(), sqlx::Error> {
|
|||
tx.commit().await?;
|
||||
info!(version = CURRENT_VERSION, "Database migrations complete");
|
||||
} else {
|
||||
debug!(version = from_version, "Database already at current version");
|
||||
debug!(
|
||||
version = from_version,
|
||||
"Database already at current version"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -145,10 +145,7 @@ mod tests {
|
|||
fn empty_env_map_treated_as_none() {
|
||||
let env = HashMap::new();
|
||||
let result = generate("FROM alpine", &[], &env, None);
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\nFROM alpine\n"
|
||||
);
|
||||
assert_eq!(result, "# Generated by arc-devcontainer\n\nFROM alpine\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -37,11 +37,7 @@ fn dir_name_from_id(feature_id: &str) -> String {
|
|||
let stripped = feature_id
|
||||
.trim_start_matches("../")
|
||||
.trim_start_matches("./");
|
||||
return stripped
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(stripped)
|
||||
.to_string();
|
||||
return stripped.rsplit('/').next().unwrap_or(stripped).to_string();
|
||||
}
|
||||
|
||||
// HTTPS URL: take filename, strip .tgz extension
|
||||
|
|
@ -118,9 +114,7 @@ async fn ensure_oras() -> crate::Result<()> {
|
|||
])
|
||||
.status()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DevcontainerError::OrasInstall(format!("failed to download oras: {e}"))
|
||||
})?;
|
||||
.map_err(|e| DevcontainerError::OrasInstall(format!("failed to download oras: {e}")))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(DevcontainerError::OrasInstall(
|
||||
|
|
@ -155,40 +149,32 @@ async fn read_feature_metadata(feature_dir: &Path) -> crate::Result<FeatureMetad
|
|||
let metadata_str = tokio::fs::read_to_string(&metadata_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DevcontainerError::Feature(format!(
|
||||
"failed to read {}: {e}",
|
||||
metadata_path.display()
|
||||
))
|
||||
DevcontainerError::Feature(format!("failed to read {}: {e}", metadata_path.display()))
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&metadata_str).map_err(|e| {
|
||||
DevcontainerError::Feature(format!(
|
||||
"failed to parse {}: {e}",
|
||||
metadata_path.display()
|
||||
))
|
||||
DevcontainerError::Feature(format!("failed to parse {}: {e}", metadata_path.display()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a feature output directory under the temp dir.
|
||||
async fn create_feature_dir(output_dir: &Path, feature_id: &str) -> crate::Result<std::path::PathBuf> {
|
||||
async fn create_feature_dir(
|
||||
output_dir: &Path,
|
||||
feature_id: &str,
|
||||
) -> crate::Result<std::path::PathBuf> {
|
||||
let dir_name = dir_name_from_id(feature_id);
|
||||
let feature_dir = output_dir.join(&dir_name);
|
||||
tokio::fs::create_dir_all(&feature_dir)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DevcontainerError::Feature(format!(
|
||||
"failed to create dir {}: {e}",
|
||||
feature_dir.display()
|
||||
))
|
||||
})?;
|
||||
tokio::fs::create_dir_all(&feature_dir).await.map_err(|e| {
|
||||
DevcontainerError::Feature(format!(
|
||||
"failed to create dir {}: {e}",
|
||||
feature_dir.display()
|
||||
))
|
||||
})?;
|
||||
Ok(feature_dir)
|
||||
}
|
||||
|
||||
/// Fetch a single OCI feature using `oras pull` and extract its contents.
|
||||
async fn fetch_feature_oci(
|
||||
feature_id: &str,
|
||||
output_dir: &Path,
|
||||
) -> crate::Result<FeatureMetadata> {
|
||||
async fn fetch_feature_oci(feature_id: &str, output_dir: &Path) -> crate::Result<FeatureMetadata> {
|
||||
let feature_dir = create_feature_dir(output_dir, feature_id).await?;
|
||||
|
||||
info!(feature_id, "pulling feature with oras");
|
||||
|
|
@ -243,9 +229,9 @@ async fn fetch_feature_https(
|
|||
|
||||
info!(feature_id, "downloading feature from HTTPS");
|
||||
|
||||
let response = reqwest::get(feature_id).await.map_err(|e| {
|
||||
DevcontainerError::Feature(format!("failed to download {feature_id}: {e}"))
|
||||
})?;
|
||||
let response = reqwest::get(feature_id)
|
||||
.await
|
||||
.map_err(|e| DevcontainerError::Feature(format!("failed to download {feature_id}: {e}")))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(DevcontainerError::Feature(format!(
|
||||
|
|
@ -260,10 +246,7 @@ async fn fetch_feature_https(
|
|||
|
||||
let tgz_path = feature_dir.join("devcontainer-feature.tgz");
|
||||
tokio::fs::write(&tgz_path, &bytes).await.map_err(|e| {
|
||||
DevcontainerError::Feature(format!(
|
||||
"failed to write {}: {e}",
|
||||
tgz_path.display()
|
||||
))
|
||||
DevcontainerError::Feature(format!("failed to write {}: {e}", tgz_path.display()))
|
||||
})?;
|
||||
|
||||
extract_tgz(&feature_dir, feature_id).await?;
|
||||
|
|
@ -294,35 +277,33 @@ async fn fetch_feature_dispatch(
|
|||
/// Recursively copy a directory.
|
||||
async fn copy_dir_recursive(src: &Path, dst: &Path) -> crate::Result<()> {
|
||||
tokio::fs::create_dir_all(dst).await.map_err(|e| {
|
||||
DevcontainerError::Feature(format!(
|
||||
"failed to create dir {}: {e}",
|
||||
dst.display()
|
||||
))
|
||||
DevcontainerError::Feature(format!("failed to create dir {}: {e}", dst.display()))
|
||||
})?;
|
||||
|
||||
let mut entries = tokio::fs::read_dir(src).await.map_err(|e| {
|
||||
DevcontainerError::Feature(format!(
|
||||
"failed to read dir {}: {e}",
|
||||
src.display()
|
||||
))
|
||||
DevcontainerError::Feature(format!("failed to read dir {}: {e}", src.display()))
|
||||
})?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await.map_err(|e| {
|
||||
DevcontainerError::Feature(format!("failed to read dir entry: {e}"))
|
||||
})? {
|
||||
while let Some(entry) = entries
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| DevcontainerError::Feature(format!("failed to read dir entry: {e}")))?
|
||||
{
|
||||
let entry_path = entry.path();
|
||||
let dest_path = dst.join(entry.file_name());
|
||||
|
||||
if entry_path.is_dir() {
|
||||
Box::pin(copy_dir_recursive(&entry_path, &dest_path)).await?;
|
||||
} else {
|
||||
tokio::fs::copy(&entry_path, &dest_path).await.map_err(|e| {
|
||||
DevcontainerError::Feature(format!(
|
||||
"failed to copy {} to {}: {e}",
|
||||
entry_path.display(),
|
||||
dest_path.display()
|
||||
))
|
||||
})?;
|
||||
tokio::fs::copy(&entry_path, &dest_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DevcontainerError::Feature(format!(
|
||||
"failed to copy {} to {}: {e}",
|
||||
entry_path.display(),
|
||||
dest_path.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -371,7 +352,10 @@ fn topo_sort(
|
|||
// candidate -> id (candidate must come before id)
|
||||
let edge = (candidate.as_str(), id.as_str());
|
||||
if edge_set.insert(edge) {
|
||||
edges.entry(candidate.as_str()).or_default().push(id.as_str());
|
||||
edges
|
||||
.entry(candidate.as_str())
|
||||
.or_default()
|
||||
.push(id.as_str());
|
||||
*in_degree.entry(id.as_str()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -421,7 +405,13 @@ fn topo_sort(
|
|||
fn option_id_to_env_name(id: &str) -> String {
|
||||
let replaced: String = id
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' })
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let trimmed = replaced.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
|
||||
if trimmed.is_empty() {
|
||||
|
|
@ -544,9 +534,9 @@ pub async fn resolve_features(
|
|||
.as_nanos()
|
||||
);
|
||||
let tmp_dir = std::env::temp_dir().join(unique_id);
|
||||
tokio::fs::create_dir_all(&tmp_dir).await.map_err(|e| {
|
||||
DevcontainerError::Feature(format!("failed to create temp dir: {e}"))
|
||||
})?;
|
||||
tokio::fs::create_dir_all(&tmp_dir)
|
||||
.await
|
||||
.map_err(|e| DevcontainerError::Feature(format!("failed to create temp dir: {e}")))?;
|
||||
|
||||
// Collect feature IDs in a stable order
|
||||
let mut feature_ids: Vec<String> = features.keys().cloned().collect();
|
||||
|
|
@ -558,7 +548,9 @@ pub async fn resolve_features(
|
|||
let mut oras_checked = false;
|
||||
let mut metadata_map: HashMap<String, FeatureMetadata> = HashMap::new();
|
||||
for feature_id in &feature_ids {
|
||||
let metadata = fetch_feature_dispatch(feature_id, &tmp_dir, devcontainer_dir, &mut oras_checked).await?;
|
||||
let metadata =
|
||||
fetch_feature_dispatch(feature_id, &tmp_dir, devcontainer_dir, &mut oras_checked)
|
||||
.await?;
|
||||
metadata_map.insert(feature_id.clone(), metadata);
|
||||
}
|
||||
|
||||
|
|
@ -577,7 +569,13 @@ pub async fn resolve_features(
|
|||
});
|
||||
if !already_present {
|
||||
info!(dep_id, "auto-injecting missing dependsOn target");
|
||||
let dep_metadata = fetch_feature_dispatch(dep_id, &tmp_dir, devcontainer_dir, &mut oras_checked).await?;
|
||||
let dep_metadata = fetch_feature_dispatch(
|
||||
dep_id,
|
||||
&tmp_dir,
|
||||
devcontainer_dir,
|
||||
&mut oras_checked,
|
||||
)
|
||||
.await?;
|
||||
metadata_map.insert(dep_id.clone(), dep_metadata);
|
||||
feature_ids.push(dep_id.clone());
|
||||
all_options.insert(dep_id.clone(), dep_options.clone());
|
||||
|
|
@ -595,9 +593,10 @@ pub async fn resolve_features(
|
|||
let mut resolved = ResolvedFeatures::default();
|
||||
for id in &sorted_ids {
|
||||
let dir_name = dir_name_from_id(id);
|
||||
let options = all_options.get(id).cloned().unwrap_or(serde_json::Value::Object(
|
||||
serde_json::Map::new(),
|
||||
));
|
||||
let options = all_options
|
||||
.get(id)
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
|
||||
let metadata = metadata_map
|
||||
.get(id)
|
||||
.cloned()
|
||||
|
|
@ -684,10 +683,7 @@ mod tests {
|
|||
dir_name_from_id("https://example.com/features/python.tar.gz"),
|
||||
"python"
|
||||
);
|
||||
assert_eq!(
|
||||
dir_name_from_id("https://example.com/features/go"),
|
||||
"go"
|
||||
);
|
||||
assert_eq!(dir_name_from_id("https://example.com/features/go"), "go");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -739,11 +735,11 @@ mod tests {
|
|||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
@ -1074,10 +1070,9 @@ mod tests {
|
|||
#[ignore = "requires oras"]
|
||||
async fn fetch_feature_oci_integration() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let metadata =
|
||||
fetch_feature_oci("ghcr.io/devcontainers/features/node:1", tmp.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let metadata = fetch_feature_oci("ghcr.io/devcontainers/features/node:1", tmp.path())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(metadata.id.is_some());
|
||||
assert!(tmp.path().join("node/install.sh").exists());
|
||||
}
|
||||
|
|
@ -1094,7 +1089,9 @@ mod tests {
|
|||
let resolved = resolve_features(&features, tmp.path(), None).await.unwrap();
|
||||
assert_eq!(resolved.layers.len(), 1);
|
||||
assert_eq!(resolved.layers[0].dir_name, "node");
|
||||
assert!(resolved.layers[0].dockerfile_snippet.contains("export VERSION=\"20\""));
|
||||
assert!(resolved.layers[0]
|
||||
.dockerfile_snippet
|
||||
.contains("export VERSION=\"20\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1143,8 +1140,14 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
assert_eq!(resolved.container_env.get("FOO").map(String::as_str), Some("from_b"));
|
||||
assert_eq!(resolved.container_env.get("BAR").map(String::as_str), Some("from_a"));
|
||||
assert_eq!(
|
||||
resolved.container_env.get("FOO").map(String::as_str),
|
||||
Some("from_b")
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.container_env.get("BAR").map(String::as_str),
|
||||
Some("from_a")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1162,9 +1165,13 @@ mod tests {
|
|||
resolved.post_start_commands.push(cmds[0].clone());
|
||||
|
||||
assert_eq!(resolved.on_create_commands.len(), 1);
|
||||
assert!(matches!(&resolved.on_create_commands[0], LifecycleCommand::String(s) if s == "setup-a"));
|
||||
assert!(
|
||||
matches!(&resolved.on_create_commands[0], LifecycleCommand::String(s) if s == "setup-a")
|
||||
);
|
||||
assert_eq!(resolved.post_create_commands.len(), 1);
|
||||
assert!(matches!(&resolved.post_create_commands[0], LifecycleCommand::Array(arr) if arr.len() == 2));
|
||||
assert!(
|
||||
matches!(&resolved.post_create_commands[0], LifecycleCommand::Array(arr) if arr.len() == 2)
|
||||
);
|
||||
assert_eq!(resolved.post_start_commands.len(), 1);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -138,10 +138,8 @@ impl DevcontainerResolver {
|
|||
})?
|
||||
.clone();
|
||||
|
||||
let compose_config =
|
||||
compose::parse_compose_multi(&compose_paths, &service_name).map_err(|e| {
|
||||
DevcontainerError::Compose(e)
|
||||
})?;
|
||||
let compose_config = compose::parse_compose_multi(&compose_paths, &service_name)
|
||||
.map_err(|e| DevcontainerError::Compose(e))?;
|
||||
|
||||
let mut environment = HashMap::new();
|
||||
for (k, v) in compose_config.environment {
|
||||
|
|
@ -163,14 +161,15 @@ impl DevcontainerResolver {
|
|||
let df_path = compose_base_dir
|
||||
.join(&build.context)
|
||||
.join(build.dockerfile.as_deref().unwrap_or("Dockerfile"));
|
||||
std::fs::read_to_string(&df_path).map_err(|source| {
|
||||
DevcontainerError::ReadFile {
|
||||
path: df_path,
|
||||
source,
|
||||
}
|
||||
std::fs::read_to_string(&df_path).map_err(|source| DevcontainerError::ReadFile {
|
||||
path: df_path,
|
||||
source,
|
||||
})?
|
||||
} else {
|
||||
format!("FROM {}", compose_config.image.as_deref().unwrap_or("ubuntu"))
|
||||
format!(
|
||||
"FROM {}",
|
||||
compose_config.image.as_deref().unwrap_or("ubuntu")
|
||||
)
|
||||
};
|
||||
|
||||
return Ok(DevcontainerConfig {
|
||||
|
|
@ -178,11 +177,11 @@ impl DevcontainerResolver {
|
|||
build_context: compose_base_dir.to_path_buf(),
|
||||
build_args: HashMap::new(),
|
||||
build_target: None,
|
||||
initialize_commands: Self::collect_commands(&devcontainer.initialize_command, &vars),
|
||||
on_create_commands: Self::collect_commands(
|
||||
&devcontainer.on_create_command,
|
||||
initialize_commands: Self::collect_commands(
|
||||
&devcontainer.initialize_command,
|
||||
&vars,
|
||||
),
|
||||
on_create_commands: Self::collect_commands(&devcontainer.on_create_command, &vars),
|
||||
post_create_commands: Self::collect_commands(
|
||||
&devcontainer.post_create_command,
|
||||
&vars,
|
||||
|
|
@ -193,10 +192,7 @@ impl DevcontainerResolver {
|
|||
),
|
||||
environment,
|
||||
container_env: Self::collect_container_env(&devcontainer.container_env, &vars),
|
||||
remote_user: devcontainer
|
||||
.remote_user
|
||||
.clone()
|
||||
.or(compose_config.user),
|
||||
remote_user: devcontainer.remote_user.clone().or(compose_config.user),
|
||||
workspace_folder,
|
||||
forwarded_ports: {
|
||||
let mut ports = compose_config.ports;
|
||||
|
|
@ -213,45 +209,54 @@ impl DevcontainerResolver {
|
|||
}
|
||||
|
||||
// Image or Dockerfile mode
|
||||
let (base_dockerfile, build_context, build_args, build_target) = if let Some(build) =
|
||||
&devcontainer.build
|
||||
{
|
||||
let context_dir = build
|
||||
.context
|
||||
.as_ref()
|
||||
.map(|c| base_dir.join(variables::substitute(c, &vars)))
|
||||
.unwrap_or_else(|| base_dir.to_path_buf());
|
||||
let df_path = base_dir.join(variables::substitute(
|
||||
build.dockerfile.as_deref().unwrap_or("Dockerfile"),
|
||||
&vars,
|
||||
));
|
||||
let content = std::fs::read_to_string(&df_path).map_err(|source| {
|
||||
DevcontainerError::ReadFile {
|
||||
path: df_path,
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let args: HashMap<String, String> = build
|
||||
.args
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), variables::substitute(v, &vars)))
|
||||
.collect();
|
||||
let target = build
|
||||
.target
|
||||
.as_ref()
|
||||
.map(|t| variables::substitute(t, &vars));
|
||||
(content, context_dir, args, target)
|
||||
} else {
|
||||
let image = devcontainer
|
||||
.image
|
||||
.as_deref()
|
||||
.unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu");
|
||||
(format!("FROM {image}"), base_dir.to_path_buf(), HashMap::new(), None)
|
||||
};
|
||||
let (base_dockerfile, build_context, build_args, build_target) =
|
||||
if let Some(build) = &devcontainer.build {
|
||||
let context_dir = build
|
||||
.context
|
||||
.as_ref()
|
||||
.map(|c| base_dir.join(variables::substitute(c, &vars)))
|
||||
.unwrap_or_else(|| base_dir.to_path_buf());
|
||||
let df_path = base_dir.join(variables::substitute(
|
||||
build.dockerfile.as_deref().unwrap_or("Dockerfile"),
|
||||
&vars,
|
||||
));
|
||||
let content = std::fs::read_to_string(&df_path).map_err(|source| {
|
||||
DevcontainerError::ReadFile {
|
||||
path: df_path,
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let args: HashMap<String, String> = build
|
||||
.args
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), variables::substitute(v, &vars)))
|
||||
.collect();
|
||||
let target = build
|
||||
.target
|
||||
.as_ref()
|
||||
.map(|t| variables::substitute(t, &vars));
|
||||
(content, context_dir, args, target)
|
||||
} else {
|
||||
let image = devcontainer
|
||||
.image
|
||||
.as_deref()
|
||||
.unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu");
|
||||
(
|
||||
format!("FROM {image}"),
|
||||
base_dir.to_path_buf(),
|
||||
HashMap::new(),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
// Features
|
||||
let resolved_features = if !devcontainer.features.is_empty() {
|
||||
features::resolve_features(&devcontainer.features, base_dir, devcontainer.remote_user.as_deref()).await?
|
||||
features::resolve_features(
|
||||
&devcontainer.features,
|
||||
base_dir,
|
||||
devcontainer.remote_user.as_deref(),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
features::ResolvedFeatures::default()
|
||||
};
|
||||
|
|
@ -283,8 +288,10 @@ impl DevcontainerResolver {
|
|||
|
||||
// Collect devcontainer.json lifecycle commands, then append feature lifecycle commands
|
||||
let mut on_create_commands = Self::collect_commands(&devcontainer.on_create_command, &vars);
|
||||
let mut post_create_commands = Self::collect_commands(&devcontainer.post_create_command, &vars);
|
||||
let mut post_start_commands = Self::collect_commands(&devcontainer.post_start_command, &vars);
|
||||
let mut post_create_commands =
|
||||
Self::collect_commands(&devcontainer.post_create_command, &vars);
|
||||
let mut post_start_commands =
|
||||
Self::collect_commands(&devcontainer.post_start_command, &vars);
|
||||
|
||||
for cmd in &resolved_features.on_create_commands {
|
||||
on_create_commands.push(Self::convert_lifecycle_command(cmd));
|
||||
|
|
@ -337,17 +344,12 @@ impl DevcontainerResolver {
|
|||
}
|
||||
|
||||
// Check if path itself is a devcontainer.json
|
||||
if path.is_file()
|
||||
&& path
|
||||
.file_name()
|
||||
.is_some_and(|n| n == "devcontainer.json")
|
||||
{
|
||||
let raw = std::fs::read_to_string(path).map_err(|source| {
|
||||
DevcontainerError::ReadFile {
|
||||
if path.is_file() && path.file_name().is_some_and(|n| n == "devcontainer.json") {
|
||||
let raw =
|
||||
std::fs::read_to_string(path).map_err(|source| DevcontainerError::ReadFile {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
})?;
|
||||
let stripped = jsonc::strip_jsonc(&raw);
|
||||
let parsed: DevcontainerJson = serde_json::from_str(&stripped)?;
|
||||
return Ok((path.to_path_buf(), parsed));
|
||||
|
|
@ -441,9 +443,7 @@ impl DevcontainerResolver {
|
|||
}
|
||||
Some(types::LifecycleCommand::Array(arr)) => {
|
||||
vec![Command::Args(
|
||||
arr.iter()
|
||||
.map(|s| variables::substitute(s, vars))
|
||||
.collect(),
|
||||
arr.iter().map(|s| variables::substitute(s, vars)).collect(),
|
||||
)]
|
||||
}
|
||||
Some(types::LifecycleCommand::Object(map)) => {
|
||||
|
|
|
|||
|
|
@ -262,9 +262,15 @@ mod tests {
|
|||
"postStartCommand": {"server": "python app.py"}
|
||||
}"#;
|
||||
let meta: FeatureMetadata = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(meta.on_create_command, Some(LifecycleCommand::String(ref s)) if s == "pip install -r requirements.txt"));
|
||||
assert!(matches!(meta.post_create_command, Some(LifecycleCommand::Array(ref arr)) if arr == &["python", "setup.py"]));
|
||||
assert!(matches!(meta.post_start_command, Some(LifecycleCommand::Object(ref map)) if map.len() == 1));
|
||||
assert!(
|
||||
matches!(meta.on_create_command, Some(LifecycleCommand::String(ref s)) if s == "pip install -r requirements.txt")
|
||||
);
|
||||
assert!(
|
||||
matches!(meta.post_create_command, Some(LifecycleCommand::Array(ref arr)) if arr == &["python", "setup.py"])
|
||||
);
|
||||
assert!(
|
||||
matches!(meta.post_start_command, Some(LifecycleCommand::Object(ref map)) if map.len() == 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -278,7 +284,10 @@ mod tests {
|
|||
}"#;
|
||||
let meta: FeatureMetadata = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(meta.container_env.len(), 2);
|
||||
assert_eq!(meta.container_env.get("NODE_ENV").map(String::as_str), Some("development"));
|
||||
assert_eq!(
|
||||
meta.container_env.get("NODE_ENV").map(String::as_str),
|
||||
Some("development")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -292,7 +301,9 @@ mod tests {
|
|||
}"#;
|
||||
let meta: FeatureMetadata = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(meta.depends_on.len(), 2);
|
||||
assert!(meta.depends_on.contains_key("ghcr.io/devcontainers/features/common-utils:1"));
|
||||
assert!(meta
|
||||
.depends_on
|
||||
.contains_key("ghcr.io/devcontainers/features/common-utils:1"));
|
||||
assert_eq!(
|
||||
meta.depends_on.get("ghcr.io/devcontainers/features/node:1"),
|
||||
Some(&serde_json::json!({"version": "20"}))
|
||||
|
|
|
|||
|
|
@ -146,20 +146,14 @@ mod tests {
|
|||
#[test]
|
||||
fn unknown_variable_left_as_is() {
|
||||
let ctx = test_ctx();
|
||||
assert_eq!(
|
||||
substitute("${unknownVariable}", &ctx),
|
||||
"${unknownVariable}"
|
||||
);
|
||||
assert_eq!(substitute("${unknownVariable}", &ctx), "${unknownVariable}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_env_with_set_variable() {
|
||||
let ctx = test_ctx();
|
||||
std::env::set_var("ARC_TEST_VAR_SET", "hello");
|
||||
assert_eq!(
|
||||
substitute("${localEnv:ARC_TEST_VAR_SET}", &ctx),
|
||||
"hello"
|
||||
);
|
||||
assert_eq!(substitute("${localEnv:ARC_TEST_VAR_SET}", &ctx), "hello");
|
||||
std::env::remove_var("ARC_TEST_VAR_SET");
|
||||
}
|
||||
|
||||
|
|
@ -167,10 +161,7 @@ mod tests {
|
|||
fn local_env_unset_returns_empty() {
|
||||
let ctx = test_ctx();
|
||||
std::env::remove_var("ARC_TEST_VAR_UNSET_123");
|
||||
assert_eq!(
|
||||
substitute("${localEnv:ARC_TEST_VAR_UNSET_123}", &ctx),
|
||||
""
|
||||
);
|
||||
assert_eq!(substitute("${localEnv:ARC_TEST_VAR_UNSET_123}", &ctx), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -197,7 +188,10 @@ mod tests {
|
|||
#[test]
|
||||
fn no_closing_brace() {
|
||||
let ctx = test_ctx();
|
||||
assert_eq!(substitute("${localWorkspaceFolder", &ctx), "${localWorkspaceFolder");
|
||||
assert_eq!(
|
||||
substitute("${localWorkspaceFolder", &ctx),
|
||||
"${localWorkspaceFolder"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -216,7 +210,10 @@ mod tests {
|
|||
fn adjacent_variables() {
|
||||
let ctx = test_ctx();
|
||||
assert_eq!(
|
||||
substitute("${localWorkspaceFolderBasename}${containerWorkspaceFolderBasename}", &ctx),
|
||||
substitute(
|
||||
"${localWorkspaceFolderBasename}${containerWorkspaceFolderBasename}",
|
||||
&ctx
|
||||
),
|
||||
"projectproject"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,10 @@ async fn realistic_python_project() {
|
|||
assert!(config.dockerfile.contains("ENV PIP_NO_CACHE_DIR=1"));
|
||||
assert!(config.dockerfile.contains("ENV PYTHONDONTWRITEBYTECODE=1"));
|
||||
assert_eq!(
|
||||
config.container_env.get("PIP_NO_CACHE_DIR").map(String::as_str),
|
||||
config
|
||||
.container_env
|
||||
.get("PIP_NO_CACHE_DIR")
|
||||
.map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
|
||||
|
|
@ -40,7 +43,10 @@ async fn realistic_python_project() {
|
|||
assert!(config.dockerfile.contains("ENV PYTHONUNBUFFERED=1"));
|
||||
// environment HashMap gets the remoteEnv value
|
||||
assert_eq!(
|
||||
config.environment.get("PYTHONUNBUFFERED").map(String::as_str),
|
||||
config
|
||||
.environment
|
||||
.get("PYTHONUNBUFFERED")
|
||||
.map(String::as_str),
|
||||
Some("yes")
|
||||
);
|
||||
|
||||
|
|
@ -161,7 +167,9 @@ async fn all_lifecycle_command_forms() {
|
|||
|
||||
// Gap 1: onCreateCommand as array
|
||||
assert_eq!(config.on_create_commands.len(), 1);
|
||||
assert!(matches!(&config.on_create_commands[0], Command::Args(args) if args == &["make", "setup"]));
|
||||
assert!(
|
||||
matches!(&config.on_create_commands[0], Command::Args(args) if args == &["make", "setup"])
|
||||
);
|
||||
|
||||
// postCreateCommand as object (parallel)
|
||||
assert_eq!(config.post_create_commands.len(), 1);
|
||||
|
|
@ -188,7 +196,10 @@ async fn container_env_separate_from_environment() {
|
|||
assert!(!config.environment.contains_key("PIP_NO_CACHE_DIR"));
|
||||
// PYTHONUNBUFFERED is in both - environment gets remoteEnv value
|
||||
assert_eq!(
|
||||
config.environment.get("PYTHONUNBUFFERED").map(String::as_str),
|
||||
config
|
||||
.environment
|
||||
.get("PYTHONUNBUFFERED")
|
||||
.map(String::as_str),
|
||||
Some("yes")
|
||||
);
|
||||
}
|
||||
|
|
@ -231,7 +242,9 @@ async fn remote_env_excluded_from_dockerfile() {
|
|||
.unwrap();
|
||||
|
||||
// containerEnv IS in the Dockerfile
|
||||
assert!(config.dockerfile.contains("ENV DEBIAN_FRONTEND=noninteractive"));
|
||||
assert!(config
|
||||
.dockerfile
|
||||
.contains("ENV DEBIAN_FRONTEND=noninteractive"));
|
||||
|
||||
// remoteEnv is NOT in the Dockerfile
|
||||
assert!(!config.dockerfile.contains("EDITOR=code"));
|
||||
|
|
@ -327,7 +340,9 @@ async fn local_feature_refs_resolved() {
|
|||
.unwrap();
|
||||
|
||||
// Base image preserved
|
||||
assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu"));
|
||||
assert!(config
|
||||
.dockerfile
|
||||
.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu"));
|
||||
|
||||
// Feature install.sh snippets are in the Dockerfile
|
||||
assert!(config.dockerfile.contains("node-feature"));
|
||||
|
|
@ -366,7 +381,9 @@ async fn feature_container_env_merged() {
|
|||
|
||||
// Feature containerEnv values baked into Dockerfile
|
||||
assert!(config.dockerfile.contains("ENV NODE_INSTALLED=true"));
|
||||
assert!(config.dockerfile.contains("ENV NODE_PATH=/usr/local/lib/node_modules"));
|
||||
assert!(config
|
||||
.dockerfile
|
||||
.contains("ENV NODE_PATH=/usr/local/lib/node_modules"));
|
||||
assert!(config.dockerfile.contains("ENV PYTHON_INSTALLED=true"));
|
||||
assert!(config.dockerfile.contains("ENV BASE_UTILS_INSTALLED=true"));
|
||||
|
||||
|
|
@ -374,10 +391,31 @@ async fn feature_container_env_merged() {
|
|||
assert!(config.dockerfile.contains("ENV DEVCONTAINER=true"));
|
||||
|
||||
// All values in config.container_env
|
||||
assert_eq!(config.container_env.get("NODE_INSTALLED").map(String::as_str), Some("true"));
|
||||
assert_eq!(config.container_env.get("PYTHON_INSTALLED").map(String::as_str), Some("true"));
|
||||
assert_eq!(config.container_env.get("BASE_UTILS_INSTALLED").map(String::as_str), Some("true"));
|
||||
assert_eq!(config.container_env.get("DEVCONTAINER").map(String::as_str), Some("true"));
|
||||
assert_eq!(
|
||||
config
|
||||
.container_env
|
||||
.get("NODE_INSTALLED")
|
||||
.map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.container_env
|
||||
.get("PYTHON_INSTALLED")
|
||||
.map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.container_env
|
||||
.get("BASE_UTILS_INSTALLED")
|
||||
.map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
config.container_env.get("DEVCONTAINER").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
}
|
||||
|
||||
/// Gap 3: Feature lifecycle hooks are appended after devcontainer.json lifecycle commands.
|
||||
|
|
@ -392,7 +430,9 @@ async fn feature_lifecycle_hooks_appended() {
|
|||
// base-utils: "echo base-utils-setup"
|
||||
// node-feature: "echo node-setup"
|
||||
assert!(config.on_create_commands.len() >= 2);
|
||||
assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == "echo devcontainer-setup"));
|
||||
assert!(
|
||||
matches!(&config.on_create_commands[0], Command::Shell(s) if s == "echo devcontainer-setup")
|
||||
);
|
||||
|
||||
// Feature on_create_commands appear after devcontainer.json's
|
||||
let feature_on_create: Vec<&str> = config.on_create_commands[1..]
|
||||
|
|
@ -407,7 +447,9 @@ async fn feature_lifecycle_hooks_appended() {
|
|||
|
||||
// postCreateCommand: devcontainer.json first, then python-feature
|
||||
assert!(config.post_create_commands.len() >= 2);
|
||||
assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "echo devcontainer-post-create"));
|
||||
assert!(
|
||||
matches!(&config.post_create_commands[0], Command::Shell(s) if s == "echo devcontainer-post-create")
|
||||
);
|
||||
let feature_post_create: Vec<&str> = config.post_create_commands[1..]
|
||||
.iter()
|
||||
.filter_map(|cmd| match cmd {
|
||||
|
|
@ -419,7 +461,8 @@ async fn feature_lifecycle_hooks_appended() {
|
|||
|
||||
// postStartCommand: only node-feature contributes (no devcontainer.json postStartCommand)
|
||||
assert!(!config.post_start_commands.is_empty());
|
||||
let post_start: Vec<&str> = config.post_start_commands
|
||||
let post_start: Vec<&str> = config
|
||||
.post_start_commands
|
||||
.iter()
|
||||
.filter_map(|cmd| match cmd {
|
||||
Command::Shell(s) => Some(s.as_str()),
|
||||
|
|
@ -481,7 +524,9 @@ async fn feature_install_user_env_vars() {
|
|||
"_CONTAINER_USER should always be root",
|
||||
);
|
||||
assert!(
|
||||
config.dockerfile.contains("_REMOTE_USER_HOME=\"/home/developer\""),
|
||||
config
|
||||
.dockerfile
|
||||
.contains("_REMOTE_USER_HOME=\"/home/developer\""),
|
||||
"_REMOTE_USER_HOME should be /home/developer",
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -504,7 +549,9 @@ async fn feature_install_user_env_vars_default_root() {
|
|||
config.dockerfile,
|
||||
);
|
||||
assert!(
|
||||
config.dockerfile.contains("_REMOTE_USER_HOME=\"/home/vscode\""),
|
||||
config
|
||||
.dockerfile
|
||||
.contains("_REMOTE_USER_HOME=\"/home/vscode\""),
|
||||
"_REMOTE_USER_HOME should be /home/vscode",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,15 @@ async fn resolve_image_only() {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu"));
|
||||
assert!(config
|
||||
.dockerfile
|
||||
.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu"));
|
||||
assert_eq!(config.remote_user.as_deref(), Some("vscode"));
|
||||
assert_eq!(config.forwarded_ports, vec![3000, 80, 9090]);
|
||||
assert_eq!(config.environment.get("EDITOR").map(String::as_str), Some("code"));
|
||||
assert_eq!(
|
||||
config.environment.get("EDITOR").map(String::as_str),
|
||||
Some("code")
|
||||
);
|
||||
assert_eq!(config.workspace_folder, "/workspaces/image-only");
|
||||
assert!(config.compose_files.is_empty());
|
||||
assert!(config.compose_service.is_none());
|
||||
|
|
@ -29,9 +34,14 @@ async fn resolve_image_only() {
|
|||
assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == "setup.sh"));
|
||||
|
||||
// containerEnv baked into Dockerfile
|
||||
assert!(config.dockerfile.contains("ENV DEBIAN_FRONTEND=noninteractive"));
|
||||
assert!(config
|
||||
.dockerfile
|
||||
.contains("ENV DEBIAN_FRONTEND=noninteractive"));
|
||||
assert_eq!(
|
||||
config.container_env.get("DEBIAN_FRONTEND").map(String::as_str),
|
||||
config
|
||||
.container_env
|
||||
.get("DEBIAN_FRONTEND")
|
||||
.map(String::as_str),
|
||||
Some("noninteractive")
|
||||
);
|
||||
}
|
||||
|
|
@ -52,7 +62,10 @@ async fn resolve_dockerfile_mode() {
|
|||
assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "npm install"));
|
||||
|
||||
// build.args
|
||||
assert_eq!(config.build_args.get("NODE_VERSION").map(String::as_str), Some("20"));
|
||||
assert_eq!(
|
||||
config.build_args.get("NODE_VERSION").map(String::as_str),
|
||||
Some("20")
|
||||
);
|
||||
|
||||
// build.target
|
||||
assert_eq!(config.build_target.as_deref(), Some("dev"));
|
||||
|
|
@ -75,8 +88,14 @@ async fn resolve_compose_mode() {
|
|||
assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]);
|
||||
|
||||
// Environment merged from compose + remoteEnv
|
||||
assert_eq!(config.environment.get("NODE_ENV").map(String::as_str), Some("development"));
|
||||
assert_eq!(config.environment.get("DEBUG").map(String::as_str), Some("true"));
|
||||
assert_eq!(
|
||||
config.environment.get("NODE_ENV").map(String::as_str),
|
||||
Some("development")
|
||||
);
|
||||
assert_eq!(
|
||||
config.environment.get("DEBUG").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -132,7 +151,9 @@ async fn resolve_subdirectory_mode() {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/python:3.12"));
|
||||
assert!(config
|
||||
.dockerfile
|
||||
.contains("FROM mcr.microsoft.com/devcontainers/python:3.12"));
|
||||
assert_eq!(config.remote_user.as_deref(), Some("vscode"));
|
||||
assert_eq!(config.workspace_folder, "/workspaces/subdirectory-mode");
|
||||
}
|
||||
|
|
@ -144,7 +165,9 @@ async fn resolve_subdirectory_multiple_picks_alphabetical_first() {
|
|||
.unwrap();
|
||||
|
||||
// "alpha" sorts before "beta", so alpha's config is used
|
||||
assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu"));
|
||||
assert!(config
|
||||
.dockerfile
|
||||
.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu"));
|
||||
assert_eq!(config.remote_user.as_deref(), Some("alpha-user"));
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +178,9 @@ async fn resolve_subdirectory_standard_wins_over_subdirs() {
|
|||
.unwrap();
|
||||
|
||||
// Standard .devcontainer/devcontainer.json takes priority over subdirectory format
|
||||
assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu"));
|
||||
assert!(config
|
||||
.dockerfile
|
||||
.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu"));
|
||||
assert_eq!(config.remote_user.as_deref(), Some("standard-user"));
|
||||
}
|
||||
|
||||
|
|
@ -166,7 +191,9 @@ async fn generated_dockerfile_is_well_formed() {
|
|||
.unwrap();
|
||||
|
||||
// Should start with the generated header
|
||||
assert!(config.dockerfile.contains("# Generated by arc-devcontainer"));
|
||||
assert!(config
|
||||
.dockerfile
|
||||
.contains("# Generated by arc-devcontainer"));
|
||||
// Should have the base image
|
||||
assert!(config.dockerfile.contains("FROM"));
|
||||
// Should end with a newline
|
||||
|
|
|
|||
|
|
@ -161,7 +161,6 @@ mod tests {
|
|||
assert_eq!(get_model_info("mercury").unwrap().id, "mercury-2");
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn model_info_costs() {
|
||||
let claude = get_model_info("claude-opus-4-6").unwrap();
|
||||
|
|
|
|||
|
|
@ -119,7 +119,8 @@ fn print_models_table(models: &[crate::types::ModelInfo], s: &Styles) {
|
|||
format_context_window(model.context_window),
|
||||
format_cost(model.input_cost_per_million),
|
||||
format_cost(model.output_cost_per_million),
|
||||
s.cyan.apply_to(format!("{:>10}", format_speed(model.estimated_output_tps))),
|
||||
s.cyan
|
||||
.apply_to(format!("{:>10}", format_speed(model.estimated_output_tps))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -431,7 +432,8 @@ async fn test_models(provider: Option<&str>, model: Option<&str>, s: &Styles) ->
|
|||
format_context_window(info.context_window),
|
||||
format_cost(info.input_cost_per_million),
|
||||
format_cost(info.output_cost_per_million),
|
||||
s.cyan.apply_to(format!("{:>10}", format_speed(info.estimated_output_tps))),
|
||||
s.cyan
|
||||
.apply_to(format!("{:>10}", format_speed(info.estimated_output_tps))),
|
||||
status_color.apply_to(&status),
|
||||
);
|
||||
}
|
||||
|
|
@ -606,8 +608,7 @@ mod tests {
|
|||
#[test]
|
||||
fn apply_options_unknown_key_goes_to_provider_opts() {
|
||||
let params = GenerateParams::new("test-model");
|
||||
let result =
|
||||
apply_options(params, &[("custom_key".into(), "custom_val".into())]).unwrap();
|
||||
let result = apply_options(params, &[("custom_key".into(), "custom_val".into())]).unwrap();
|
||||
let opts = result.provider_options.unwrap();
|
||||
assert_eq!(opts["custom_key"], "custom_val");
|
||||
}
|
||||
|
|
@ -615,9 +616,7 @@ mod tests {
|
|||
#[test]
|
||||
fn apply_options_invalid_temperature_errors() {
|
||||
let params = GenerateParams::new("test-model");
|
||||
assert!(
|
||||
apply_options(params, &[("temperature".into(), "not_a_number".into())]).is_err()
|
||||
);
|
||||
assert!(apply_options(params, &[("temperature".into(), "not_a_number".into())]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -192,7 +192,11 @@ pub async fn generate(params: GenerateParams) -> Result<GenerateResult, SdkError
|
|||
&& params.tools.is_some()
|
||||
&& max_tool_rounds > 0
|
||||
{
|
||||
debug!(tool_calls = tool_calls.len(), round = round, "Executing tool calls");
|
||||
debug!(
|
||||
tool_calls = tool_calls.len(),
|
||||
round = round,
|
||||
"Executing tool calls"
|
||||
);
|
||||
let tools = params.tools.as_ref().expect("checked above");
|
||||
if tools.iter().any(|t| t.is_active()) {
|
||||
let tool_refs: Vec<&Tool> =
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ fn main() {
|
|||
|
||||
let spec_text = fs::read_to_string(&spec_path)
|
||||
.unwrap_or_else(|e| panic!("failed to read {}: {e}", spec_path.display()));
|
||||
let spec: serde_json::Value = serde_yaml::from_str(&spec_text)
|
||||
.unwrap_or_else(|e| panic!("failed to parse YAML: {e}"));
|
||||
let spec: serde_json::Value =
|
||||
serde_yaml::from_str(&spec_text).unwrap_or_else(|e| panic!("failed to parse YAML: {e}"));
|
||||
|
||||
let schemas = spec["components"]["schemas"]
|
||||
.as_object()
|
||||
|
|
|
|||
|
|
@ -84,27 +84,45 @@ mod tests {
|
|||
fn combined_styles_work() {
|
||||
let s = Styles::new(true);
|
||||
let output = format!("{}", s.bold_dim.apply_to("header"));
|
||||
assert!(output.contains("\x1b["), "bold_dim should contain ANSI codes");
|
||||
assert!(
|
||||
output.contains("\x1b["),
|
||||
"bold_dim should contain ANSI codes"
|
||||
);
|
||||
assert!(output.contains("header"));
|
||||
|
||||
let output = format!("{}", s.bold_cyan.apply_to("tool"));
|
||||
assert!(output.contains("\x1b["), "bold_cyan should contain ANSI codes");
|
||||
assert!(
|
||||
output.contains("\x1b["),
|
||||
"bold_cyan should contain ANSI codes"
|
||||
);
|
||||
assert!(output.contains("tool"));
|
||||
|
||||
let output = format!("{}", s.bold_green.apply_to("pass"));
|
||||
assert!(output.contains("\x1b["), "bold_green should contain ANSI codes");
|
||||
assert!(
|
||||
output.contains("\x1b["),
|
||||
"bold_green should contain ANSI codes"
|
||||
);
|
||||
assert!(output.contains("pass"));
|
||||
|
||||
let output = format!("{}", s.bold_red.apply_to("fail"));
|
||||
assert!(output.contains("\x1b["), "bold_red should contain ANSI codes");
|
||||
assert!(
|
||||
output.contains("\x1b["),
|
||||
"bold_red should contain ANSI codes"
|
||||
);
|
||||
assert!(output.contains("fail"));
|
||||
|
||||
let output = format!("{}", s.magenta.apply_to("medium"));
|
||||
assert!(output.contains("\x1b["), "magenta should contain ANSI codes");
|
||||
assert!(
|
||||
output.contains("\x1b["),
|
||||
"magenta should contain ANSI codes"
|
||||
);
|
||||
assert!(output.contains("medium"));
|
||||
|
||||
let output = format!("{}", s.underline.apply_to("path"));
|
||||
assert!(output.contains("\x1b["), "underline should contain ANSI codes");
|
||||
assert!(
|
||||
output.contains("\x1b["),
|
||||
"underline should contain ANSI codes"
|
||||
);
|
||||
assert!(output.contains("path"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -179,7 +179,9 @@ impl ArtifactStore {
|
|||
/// Returns `None` if no `base_dir` is configured.
|
||||
#[must_use]
|
||||
pub fn artifacts_dir(&self) -> Option<PathBuf> {
|
||||
self.base_dir.as_ref().map(|b| b.join("artifacts").join("values"))
|
||||
self.base_dir
|
||||
.as_ref()
|
||||
.map(|b| b.join("artifacts").join("values"))
|
||||
}
|
||||
|
||||
/// Remove all artifacts. Also deletes file-backed data from disk.
|
||||
|
|
|
|||
|
|
@ -52,11 +52,7 @@ const EXCLUDE_DIRS: &[&str] = &[
|
|||
];
|
||||
|
||||
/// Path segments that indicate excluded tool cache directories.
|
||||
const EXCLUDE_SEGMENTS: &[&str] = &[
|
||||
".cache/ms-playwright",
|
||||
"playwright/.cache",
|
||||
".yarn/cache",
|
||||
];
|
||||
const EXCLUDE_SEGMENTS: &[&str] = &[".cache/ms-playwright", "playwright/.cache", ".yarn/cache"];
|
||||
|
||||
/// Maximum size for a single file (10 MB).
|
||||
const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
||||
|
|
@ -197,8 +193,7 @@ pub fn is_asset_candidate(path: &str) -> bool {
|
|||
if let Some(pos) = path.find(segment) {
|
||||
let before_ok = pos == 0 || path.as_bytes()[pos - 1] == b'/';
|
||||
let after_pos = pos + segment.len();
|
||||
let after_ok =
|
||||
after_pos >= path.len() || path.as_bytes()[after_pos] == b'/';
|
||||
let after_ok = after_pos >= path.len() || path.as_bytes()[after_pos] == b'/';
|
||||
if before_ok && after_ok {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -333,9 +328,7 @@ fn normalize_paths(discovered: Vec<DiscoveredFile>, root: &str) -> Vec<Discovere
|
|||
|
||||
/// Take a snapshot of current asset files in the sandbox.
|
||||
/// Returns a fingerprint map of discovered files.
|
||||
pub async fn snapshot(
|
||||
sandbox: &dyn Sandbox,
|
||||
) -> Result<HashMap<String, FileFingerprint>, String> {
|
||||
pub async fn snapshot(sandbox: &dyn Sandbox) -> Result<HashMap<String, FileFingerprint>, String> {
|
||||
let root = sandbox.working_directory();
|
||||
let platform = sandbox.platform();
|
||||
let cmd = build_find_command(root, platform);
|
||||
|
|
@ -454,11 +447,7 @@ mod tests {
|
|||
}
|
||||
|
||||
impl AssetMockSandbox {
|
||||
fn new(
|
||||
files: HashMap<String, String>,
|
||||
exec_stdout: &str,
|
||||
platform: &'static str,
|
||||
) -> Self {
|
||||
fn new(files: HashMap<String, String>, exec_stdout: &str, platform: &'static str) -> Self {
|
||||
Self {
|
||||
files,
|
||||
exec_result: ExecResult {
|
||||
|
|
@ -476,34 +465,85 @@ mod tests {
|
|||
|
||||
#[async_trait::async_trait]
|
||||
impl Sandbox for AssetMockSandbox {
|
||||
async fn read_file(&self, _: &str, _: Option<usize>, _: Option<usize>) -> Result<String, String> {
|
||||
async fn read_file(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<usize>,
|
||||
_: Option<usize>,
|
||||
) -> Result<String, String> {
|
||||
Err("not implemented".into())
|
||||
}
|
||||
async fn write_file(&self, _: &str, _: &str) -> Result<(), String> { Ok(()) }
|
||||
async fn delete_file(&self, _: &str) -> Result<(), String> { Ok(()) }
|
||||
async fn file_exists(&self, _: &str) -> Result<bool, String> { Ok(false) }
|
||||
async fn list_directory(&self, _: &str, _: Option<usize>) -> Result<Vec<arc_agent::sandbox::DirEntry>, String> { Ok(vec![]) }
|
||||
async fn exec_command(&self, _: &str, _: u64, _: Option<&str>, _: Option<&std::collections::HashMap<String, String>>, _: Option<tokio_util::sync::CancellationToken>) -> Result<ExecResult, String> {
|
||||
async fn write_file(&self, _: &str, _: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_file(&self, _: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
async fn file_exists(&self, _: &str) -> Result<bool, String> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn list_directory(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<arc_agent::sandbox::DirEntry>, String> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn exec_command(
|
||||
&self,
|
||||
_: &str,
|
||||
_: u64,
|
||||
_: Option<&str>,
|
||||
_: Option<&std::collections::HashMap<String, String>>,
|
||||
_: Option<tokio_util::sync::CancellationToken>,
|
||||
) -> Result<ExecResult, String> {
|
||||
Ok(self.exec_result.clone())
|
||||
}
|
||||
async fn grep(&self, _: &str, _: &str, _: &arc_agent::sandbox::GrepOptions) -> Result<Vec<String>, String> { Ok(vec![]) }
|
||||
async fn glob(&self, _: &str, _: Option<&str>) -> Result<Vec<String>, String> { Ok(vec![]) }
|
||||
async fn download_file_to_local(&self, remote_path: &str, local_path: &std::path::Path) -> Result<(), String> {
|
||||
let content = self.files.get(remote_path)
|
||||
async fn grep(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: &arc_agent::sandbox::GrepOptions,
|
||||
) -> Result<Vec<String>, String> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn glob(&self, _: &str, _: Option<&str>) -> Result<Vec<String>, String> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn download_file_to_local(
|
||||
&self,
|
||||
remote_path: &str,
|
||||
local_path: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
let content = self
|
||||
.files
|
||||
.get(remote_path)
|
||||
.ok_or_else(|| format!("File not found: {remote_path}"))?;
|
||||
if let Some(parent) = local_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create dirs: {e}"))?;
|
||||
}
|
||||
tokio::fs::write(local_path, content.as_bytes()).await
|
||||
tokio::fs::write(local_path, content.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
async fn initialize(&self) -> Result<(), String> { Ok(()) }
|
||||
async fn cleanup(&self) -> Result<(), String> { Ok(()) }
|
||||
fn working_directory(&self) -> &str { self.working_dir }
|
||||
fn platform(&self) -> &str { self.platform_str }
|
||||
fn os_version(&self) -> String { "Linux 6.1.0".into() }
|
||||
async fn initialize(&self) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
async fn cleanup(&self) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn working_directory(&self) -> &str {
|
||||
self.working_dir
|
||||
}
|
||||
fn platform(&self) -> &str {
|
||||
self.platform_str
|
||||
}
|
||||
fn os_version(&self) -> String {
|
||||
"Linux 6.1.0".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -530,7 +570,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn is_asset_candidate_rejects_excluded_paths() {
|
||||
assert!(!is_asset_candidate(".cache/ms-playwright/chromium/file.txt"));
|
||||
assert!(!is_asset_candidate(
|
||||
".cache/ms-playwright/chromium/file.txt"
|
||||
));
|
||||
assert!(!is_asset_candidate("playwright/.cache/some-file"));
|
||||
assert!(!is_asset_candidate(".yarn/cache/something.zip"));
|
||||
}
|
||||
|
|
@ -729,11 +771,7 @@ mod tests {
|
|||
let mut files = HashMap::new();
|
||||
files.insert("test-results/r.xml".to_string(), "<test/>".to_string());
|
||||
|
||||
let mock = AssetMockSandbox::new(
|
||||
files,
|
||||
"1024\t2000.0\ttest-results/r.xml\n",
|
||||
"linux",
|
||||
);
|
||||
let mock = AssetMockSandbox::new(files, "1024\t2000.0\ttest-results/r.xml\n", "linux");
|
||||
|
||||
let baseline = HashMap::new();
|
||||
let summary = collect_assets(&mock, stage_dir.path(), &baseline, 1000.0)
|
||||
|
|
@ -763,11 +801,7 @@ mod tests {
|
|||
let mut files = HashMap::new();
|
||||
files.insert("test-results/r.xml".to_string(), "<test/>".to_string());
|
||||
|
||||
let mock = AssetMockSandbox::new(
|
||||
files,
|
||||
"1024\t2000.0\ttest-results/r.xml\n",
|
||||
"linux",
|
||||
);
|
||||
let mock = AssetMockSandbox::new(files, "1024\t2000.0\ttest-results/r.xml\n", "linux");
|
||||
|
||||
// Provide a baseline with the same fingerprint
|
||||
let mut baseline = HashMap::new();
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ use async_trait::async_trait;
|
|||
|
||||
use arc_agent::{
|
||||
subagent::{SessionFactory, SubAgentManager},
|
||||
AgentEvent, AnthropicProfile, Sandbox, GeminiProfile, OpenAiProfile,
|
||||
ProviderProfile, Session, SessionConfig, Turn,
|
||||
AgentEvent, AnthropicProfile, GeminiProfile, OpenAiProfile, ProviderProfile, Sandbox, Session,
|
||||
SessionConfig, Turn,
|
||||
};
|
||||
use arc_llm::client::Client;
|
||||
use arc_llm::provider::Provider;
|
||||
|
|
@ -295,7 +295,9 @@ impl CodergenBackend for AgentApiBackend {
|
|||
styles.dim.apply_to(format!("[{node_id}]")),
|
||||
styles.dim.apply_to("\u{25cf}"),
|
||||
styles.bold_cyan.apply_to(tool_name),
|
||||
styles.dim.apply_to(format!("({})", format_tool_args(arguments))),
|
||||
styles
|
||||
.dim
|
||||
.apply_to(format!("({})", format_tool_args(arguments))),
|
||||
);
|
||||
}
|
||||
AgentEvent::Error { error } => {
|
||||
|
|
@ -449,7 +451,12 @@ mod tests {
|
|||
#[test]
|
||||
fn agent_backend_stores_config() {
|
||||
let styles = Box::leak(Box::new(Styles::new(false)));
|
||||
let backend = AgentApiBackend::new("claude-opus-4-6".to_string(), Provider::OpenAi, true, styles);
|
||||
let backend = AgentApiBackend::new(
|
||||
"claude-opus-4-6".to_string(),
|
||||
Provider::OpenAi,
|
||||
true,
|
||||
styles,
|
||||
);
|
||||
assert_eq!(backend.model, "claude-opus-4-6");
|
||||
assert_eq!(backend.provider, Provider::OpenAi);
|
||||
assert!(backend.verbose);
|
||||
|
|
|
|||
|
|
@ -227,10 +227,7 @@ impl AgentCliBackend {
|
|||
}
|
||||
|
||||
/// Detect changed files by comparing git state before and after the CLI run.
|
||||
async fn detect_changed_files(
|
||||
&self,
|
||||
sandbox: &Arc<dyn Sandbox>,
|
||||
) -> Vec<String> {
|
||||
async fn detect_changed_files(&self, sandbox: &Arc<dyn Sandbox>) -> Vec<String> {
|
||||
// Get unstaged changes
|
||||
let diff_result = sandbox
|
||||
.exec_command("git diff --name-only", 30_000, None, None, None)
|
||||
|
|
@ -420,25 +417,13 @@ impl CodergenBackend for BackendRouter {
|
|||
if self.should_use_cli(node) {
|
||||
self.cli_backend
|
||||
.run(
|
||||
node,
|
||||
prompt,
|
||||
context,
|
||||
thread_id,
|
||||
emitter,
|
||||
stage_dir,
|
||||
sandbox,
|
||||
node, prompt, context, thread_id, emitter, stage_dir, sandbox,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
self.api_backend
|
||||
.run(
|
||||
node,
|
||||
prompt,
|
||||
context,
|
||||
thread_id,
|
||||
emitter,
|
||||
stage_dir,
|
||||
sandbox,
|
||||
node, prompt, context, thread_id, emitter, stage_dir, sandbox,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ pub mod backend;
|
|||
pub mod cli_backend;
|
||||
pub mod progress;
|
||||
pub mod run;
|
||||
pub mod runs;
|
||||
pub mod run_config;
|
||||
pub mod runs;
|
||||
pub mod validate;
|
||||
|
||||
use std::path::Path;
|
||||
|
|
@ -164,7 +164,9 @@ pub fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles) {
|
|||
),
|
||||
Severity::Info => eprintln!(
|
||||
"{}",
|
||||
styles.dim.apply_to(format!("info{location}: {} ({})", d.message, d.rule)),
|
||||
styles
|
||||
.dim
|
||||
.apply_to(format!("info{location}: {} ({})", d.message, d.rule)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
@ -183,8 +185,9 @@ pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String
|
|||
total_cost,
|
||||
..
|
||||
} => {
|
||||
let mut s =
|
||||
format!("[WORKFLOW_RUN_COMPLETED] duration={duration_ms}ms artifacts={artifact_count}");
|
||||
let mut s = format!(
|
||||
"[WORKFLOW_RUN_COMPLETED] duration={duration_ms}ms artifacts={artifact_count}"
|
||||
);
|
||||
if let Some(cost) = total_cost {
|
||||
s.push_str(&format!(" total_cost={}", format_cost(*cost)));
|
||||
}
|
||||
|
|
@ -539,7 +542,12 @@ pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String
|
|||
WorkflowRunEvent::StallWatchdogTimeout { node, idle_seconds } => {
|
||||
format!("[STALL_WATCHDOG_TIMEOUT] node={node} idle_seconds={idle_seconds}")
|
||||
}
|
||||
WorkflowRunEvent::AssetsCaptured { node_id, files_copied, total_bytes, files_skipped } => {
|
||||
WorkflowRunEvent::AssetsCaptured {
|
||||
node_id,
|
||||
files_copied,
|
||||
total_bytes,
|
||||
files_skipped,
|
||||
} => {
|
||||
format!("[ASSETS_CAPTURED] node={node_id} files_copied={files_copied} total_bytes={} files_skipped={files_skipped}", HumanBytes(*total_bytes))
|
||||
}
|
||||
};
|
||||
|
|
@ -614,7 +622,8 @@ mod tests {
|
|||
}
|
||||
|
||||
fn test_styles() -> &'static Styles {
|
||||
static STYLES: std::sync::LazyLock<Styles> = std::sync::LazyLock::new(|| Styles::new(false));
|
||||
static STYLES: std::sync::LazyLock<Styles> =
|
||||
std::sync::LazyLock::new(|| Styles::new(false));
|
||||
&STYLES
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,11 +27,20 @@ macro_rules! cached_style {
|
|||
};
|
||||
}
|
||||
|
||||
cached_style!(style_header_running, " {spinner:.dim} {wide_msg} {elapsed:.dim}");
|
||||
cached_style!(
|
||||
style_header_running,
|
||||
" {spinner:.dim} {wide_msg} {elapsed:.dim}"
|
||||
);
|
||||
cached_style!(style_header_done, " {wide_msg} {prefix:.dim}");
|
||||
cached_style!(style_stage_running, " {spinner:.cyan} {wide_msg} {elapsed:.dim}");
|
||||
cached_style!(
|
||||
style_stage_running,
|
||||
" {spinner:.cyan} {wide_msg} {elapsed:.dim}"
|
||||
);
|
||||
cached_style!(style_stage_done, " {wide_msg} {prefix:.dim}");
|
||||
cached_style!(style_tool_running, " {spinner:.dim} {wide_msg} {elapsed:.dim}");
|
||||
cached_style!(
|
||||
style_tool_running,
|
||||
" {spinner:.dim} {wide_msg} {elapsed:.dim}"
|
||||
);
|
||||
cached_style!(style_tool_done, " {wide_msg}");
|
||||
cached_style!(style_static_dim, " {wide_msg:.dim}");
|
||||
cached_style!(style_empty, "");
|
||||
|
|
@ -210,9 +219,7 @@ impl ProgressUI {
|
|||
};
|
||||
self.finish_stage(node_id, name, glyph, &prefix);
|
||||
}
|
||||
WorkflowRunEvent::StageFailed {
|
||||
node_id, name, ..
|
||||
} => {
|
||||
WorkflowRunEvent::StageFailed { node_id, name, .. } => {
|
||||
self.finish_stage(node_id, name, red_cross(), "");
|
||||
}
|
||||
WorkflowRunEvent::Agent { stage, event } => {
|
||||
|
|
@ -415,12 +422,7 @@ impl ProgressUI {
|
|||
}
|
||||
}
|
||||
|
||||
fn on_tool_call_completed(
|
||||
&mut self,
|
||||
stage_node_id: &str,
|
||||
tool_call_id: &str,
|
||||
is_error: bool,
|
||||
) {
|
||||
fn on_tool_call_completed(&mut self, stage_node_id: &str, tool_call_id: &str, is_error: bool) {
|
||||
if let ProgressRenderer::Tty(_) = &self.renderer {
|
||||
if let Some(stage) = self.active_stages.get_mut(stage_node_id) {
|
||||
if let Some(entry) = stage
|
||||
|
|
|
|||
|
|
@ -5,27 +5,25 @@ use std::sync::{Arc, Mutex};
|
|||
use std::time::Instant;
|
||||
|
||||
use anyhow::bail;
|
||||
use arc_agent::{
|
||||
DockerSandboxConfig, DockerSandbox, Sandbox, LocalSandbox,
|
||||
};
|
||||
use arc_agent::{DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox};
|
||||
use arc_util::terminal::Styles;
|
||||
use chrono::{Local, Utc};
|
||||
|
||||
use crate::checkpoint::Checkpoint;
|
||||
use crate::engine::{GitCheckpointMode, WorkflowRunEngine, RunConfig};
|
||||
use crate::engine::{GitCheckpointMode, RunConfig, WorkflowRunEngine};
|
||||
use crate::event::EventEmitter;
|
||||
use crate::handler::default_registry;
|
||||
use crate::interviewer::auto_approve::AutoApproveInterviewer;
|
||||
use crate::interviewer::console::ConsoleInterviewer;
|
||||
use crate::interviewer::Interviewer;
|
||||
use crate::outcome::StageStatus;
|
||||
use crate::workflow::WorkflowBuilder;
|
||||
use crate::validation::Severity;
|
||||
use crate::workflow::WorkflowBuilder;
|
||||
|
||||
use arc_llm::provider::Provider;
|
||||
|
||||
use super::backend::AgentApiBackend;
|
||||
use super::cli_backend::{BackendRouter, AgentCliBackend};
|
||||
use super::cli_backend::{AgentCliBackend, BackendRouter};
|
||||
use super::progress;
|
||||
use super::run_config;
|
||||
use super::run_config::{RunDefaults, WorkflowRunConfig};
|
||||
|
|
@ -33,9 +31,8 @@ use indicatif::HumanDuration;
|
|||
use std::time::Duration;
|
||||
|
||||
use super::{
|
||||
compute_stage_cost, format_cost,
|
||||
format_event_summary, format_tokens_human, print_diagnostics, read_dot_file,
|
||||
SandboxProvider, RunArgs,
|
||||
compute_stage_cost, format_cost, format_event_summary, format_tokens_human, print_diagnostics,
|
||||
read_dot_file, RunArgs, SandboxProvider,
|
||||
};
|
||||
|
||||
/// Return the default model string for a given provider.
|
||||
|
|
@ -67,10 +64,7 @@ fn resolve_model_provider(
|
|||
let toml_provider = run_cfg
|
||||
.and_then(|c| c.llm.as_ref())
|
||||
.and_then(|l| l.provider.as_deref());
|
||||
let defaults_model = run_defaults
|
||||
.llm
|
||||
.as_ref()
|
||||
.and_then(|l| l.model.as_deref());
|
||||
let defaults_model = run_defaults.llm.as_ref().and_then(|l| l.model.as_deref());
|
||||
let defaults_provider = run_defaults
|
||||
.llm
|
||||
.as_ref()
|
||||
|
|
@ -80,23 +74,13 @@ fn resolve_model_provider(
|
|||
let provider = cli_provider
|
||||
.or(toml_provider)
|
||||
.or(defaults_provider)
|
||||
.or_else(|| {
|
||||
graph
|
||||
.attrs
|
||||
.get("default_provider")
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
.or_else(|| graph.attrs.get("default_provider").and_then(|v| v.as_str()))
|
||||
.map(String::from);
|
||||
|
||||
let model = cli_model
|
||||
.or(toml_model)
|
||||
.or(defaults_model)
|
||||
.or_else(|| {
|
||||
graph
|
||||
.attrs
|
||||
.get("default_model")
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
.or_else(|| graph.attrs.get("default_model").and_then(|v| v.as_str()))
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| {
|
||||
let provider_enum = provider
|
||||
|
|
@ -226,7 +210,11 @@ pub async fn run_command(
|
|||
"{} {} ({})",
|
||||
styles.bold.apply_to("Parsed workflow:"),
|
||||
graph.name,
|
||||
styles.dim.apply_to(format!("{} nodes, {} edges", graph.nodes.len(), graph.edges.len())),
|
||||
styles.dim.apply_to(format!(
|
||||
"{} nodes, {} edges",
|
||||
graph.nodes.len(),
|
||||
graph.edges.len()
|
||||
)),
|
||||
);
|
||||
|
||||
let goal = graph.goal();
|
||||
|
|
@ -252,7 +240,16 @@ pub async fn run_command(
|
|||
};
|
||||
|
||||
if args.preflight {
|
||||
return run_preflight(&graph, &run_cfg, &args, &run_defaults, git_clean, sandbox_provider, styles).await;
|
||||
return run_preflight(
|
||||
&graph,
|
||||
&run_cfg,
|
||||
&args,
|
||||
&run_defaults,
|
||||
git_clean,
|
||||
sandbox_provider,
|
||||
styles,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 3. Create logs directory
|
||||
|
|
@ -283,7 +280,10 @@ pub async fn run_command(
|
|||
styles.underline.apply_to(logs_dir.display()),
|
||||
);
|
||||
} else {
|
||||
progress_ui.lock().expect("progress lock poisoned").show_logs_dir(&logs_dir);
|
||||
progress_ui
|
||||
.lock()
|
||||
.expect("progress lock poisoned")
|
||||
.show_logs_dir(&logs_dir);
|
||||
}
|
||||
|
||||
// 3. Build event emitter
|
||||
|
|
@ -333,18 +333,14 @@ pub async fn run_command(
|
|||
envelope.insert(
|
||||
"ts".to_string(),
|
||||
serde_json::Value::String(
|
||||
Utc::now()
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
),
|
||||
);
|
||||
envelope.insert(
|
||||
"run_id".to_string(),
|
||||
serde_json::Value::String(run_id_clone.lock().unwrap().clone()),
|
||||
);
|
||||
envelope.insert(
|
||||
"event".to_string(),
|
||||
serde_json::Value::String(event_name),
|
||||
);
|
||||
envelope.insert("event".to_string(), serde_json::Value::String(event_name));
|
||||
for (k, v) in event_fields {
|
||||
if k != "ts" && k != "run_id" && k != "event" {
|
||||
envelope.insert(k, v);
|
||||
|
|
@ -400,9 +396,9 @@ pub async fn run_command(
|
|||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Git worktree setup failed ({e}), running without worktree.",
|
||||
styles.yellow.apply_to("Warning:"),
|
||||
);
|
||||
"{} Git worktree setup failed ({e}), running without worktree.",
|
||||
styles.yellow.apply_to("Warning:"),
|
||||
);
|
||||
(None, None, None, None, None)
|
||||
}
|
||||
}
|
||||
|
|
@ -435,8 +431,7 @@ pub async fn run_command(
|
|||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Daytona client: {e}"))?;
|
||||
let config = daytona_config.clone().unwrap_or_default();
|
||||
let mut env =
|
||||
crate::daytona_sandbox::DaytonaSandbox::new(daytona_client, config);
|
||||
let mut env = crate::daytona_sandbox::DaytonaSandbox::new(daytona_client, config);
|
||||
let emitter_cb = Arc::clone(&emitter);
|
||||
env.set_event_callback(Arc::new(move |event| {
|
||||
emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event });
|
||||
|
|
@ -475,22 +470,21 @@ pub async fn run_command(
|
|||
});
|
||||
|
||||
// Set up git inside Daytona sandbox (if applicable)
|
||||
let (daytona_run_id, daytona_base_sha, daytona_branch) = if sandbox_provider
|
||||
== SandboxProvider::Daytona
|
||||
{
|
||||
match setup_daytona_git(&*sandbox).await {
|
||||
Ok((rid, base, branch)) => (Some(rid), Some(base), Some(branch)),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Daytona git setup failed ({e}), running without git checkpoints.",
|
||||
styles.yellow.apply_to("Warning:"),
|
||||
);
|
||||
(None, None, None)
|
||||
let (daytona_run_id, daytona_base_sha, daytona_branch) =
|
||||
if sandbox_provider == SandboxProvider::Daytona {
|
||||
match setup_daytona_git(&*sandbox).await {
|
||||
Ok((rid, base, branch)) => (Some(rid), Some(base), Some(branch)),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Daytona git setup failed ({e}), running without git checkpoints.",
|
||||
styles.yellow.apply_to("Warning:"),
|
||||
);
|
||||
(None, None, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
// Run setup commands inside the sandbox (once, not per-stage)
|
||||
if !setup_commands.is_empty() {
|
||||
|
|
@ -664,7 +658,10 @@ pub async fn run_command(
|
|||
// Auto-derive retro (always, cheap) and optionally run retro agent
|
||||
{
|
||||
let (failed, failure_reason) = match &engine_result {
|
||||
Ok(o) => (o.status == StageStatus::Fail, o.failure_reason().map(String::from)),
|
||||
Ok(o) => (
|
||||
o.status == StageStatus::Fail,
|
||||
o.failure_reason().map(String::from),
|
||||
),
|
||||
Err(e) => (true, Some(e.to_string())),
|
||||
};
|
||||
generate_retro(
|
||||
|
|
@ -693,21 +690,18 @@ pub async fn run_command(
|
|||
}
|
||||
|
||||
// 8. Print result
|
||||
eprintln!(
|
||||
"\n{}",
|
||||
styles.bold.apply_to("=== Run Result ==="),
|
||||
);
|
||||
eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="),);
|
||||
|
||||
let status_str = outcome.status.to_string().to_uppercase();
|
||||
let status_color = match outcome.status {
|
||||
StageStatus::Success | StageStatus::PartialSuccess => &styles.bold_green,
|
||||
_ => &styles.bold_red,
|
||||
};
|
||||
eprintln!("Status: {}", status_color.apply_to(&status_str),);
|
||||
eprintln!(
|
||||
"Status: {}",
|
||||
status_color.apply_to(&status_str),
|
||||
"Duration: {}",
|
||||
HumanDuration(Duration::from_millis(run_duration_ms))
|
||||
);
|
||||
eprintln!("Duration: {}", HumanDuration(Duration::from_millis(run_duration_ms)));
|
||||
|
||||
let acc = accumulator.lock().unwrap();
|
||||
let total_tokens = acc.total_input_tokens + acc.total_output_tokens;
|
||||
|
|
@ -747,10 +741,7 @@ pub async fn run_command(
|
|||
eprintln!("Notes: {notes}");
|
||||
}
|
||||
if let Some(failure) = outcome.failure_reason() {
|
||||
eprintln!(
|
||||
"{}",
|
||||
styles.red.apply_to(format!("Failure: {failure}")),
|
||||
);
|
||||
eprintln!("{}", styles.red.apply_to(format!("Failure: {failure}")),);
|
||||
}
|
||||
eprintln!(
|
||||
"{} {}",
|
||||
|
|
@ -988,7 +979,10 @@ async fn run_from_branch(
|
|||
// Auto-derive retro
|
||||
{
|
||||
let (failed, failure_reason) = match &engine_result {
|
||||
Ok(o) => (o.status == StageStatus::Fail, o.failure_reason().map(String::from)),
|
||||
Ok(o) => (
|
||||
o.status == StageStatus::Fail,
|
||||
o.failure_reason().map(String::from),
|
||||
),
|
||||
Err(e) => (true, Some(e.to_string())),
|
||||
};
|
||||
|
||||
|
|
@ -1018,19 +1012,13 @@ async fn run_from_branch(
|
|||
|
||||
let outcome = engine_result?;
|
||||
|
||||
eprintln!(
|
||||
"\n{}",
|
||||
styles.bold.apply_to("=== Run Result ==="),
|
||||
);
|
||||
eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="),);
|
||||
let status_str = outcome.status.to_string().to_uppercase();
|
||||
let status_color = match outcome.status {
|
||||
StageStatus::Success | StageStatus::PartialSuccess => &styles.bold_green,
|
||||
_ => &styles.bold_red,
|
||||
};
|
||||
eprintln!(
|
||||
"Status: {}",
|
||||
status_color.apply_to(&status_str),
|
||||
);
|
||||
eprintln!("Status: {}", status_color.apply_to(&status_str),);
|
||||
eprintln!(
|
||||
"Duration: {}",
|
||||
HumanDuration(Duration::from_millis(run_duration_ms))
|
||||
|
|
@ -1077,16 +1065,14 @@ async fn run_preflight(
|
|||
.map(|env| Arc::new(env) as Arc<dyn Sandbox>)
|
||||
.map_err(|e| format!("Docker sandbox creation failed: {e}"))
|
||||
}
|
||||
SandboxProvider::Daytona => {
|
||||
match daytona_sdk::Client::new().await {
|
||||
Ok(daytona_client) => {
|
||||
let config = daytona_config.unwrap_or_default();
|
||||
let env = crate::daytona_sandbox::DaytonaSandbox::new(daytona_client, config);
|
||||
Ok(Arc::new(env) as Arc<dyn Sandbox>)
|
||||
}
|
||||
Err(e) => Err(format!("Daytona client creation failed: {e}")),
|
||||
SandboxProvider::Daytona => match daytona_sdk::Client::new().await {
|
||||
Ok(daytona_client) => {
|
||||
let config = daytona_config.unwrap_or_default();
|
||||
let env = crate::daytona_sandbox::DaytonaSandbox::new(daytona_client, config);
|
||||
Ok(Arc::new(env) as Arc<dyn Sandbox>)
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("Daytona client creation failed: {e}")),
|
||||
},
|
||||
SandboxProvider::Local => {
|
||||
Ok(Arc::new(LocalSandbox::new(original_cwd.clone())) as Arc<dyn Sandbox>)
|
||||
}
|
||||
|
|
@ -1113,7 +1099,11 @@ async fn run_preflight(
|
|||
// 2. LLM client check
|
||||
let (llm_available, llm_providers) = match arc_llm::client::Client::from_env().await {
|
||||
Ok(c) => {
|
||||
let names = c.provider_names().iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let names = c
|
||||
.provider_names()
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
if names.is_empty() {
|
||||
errors.push("No LLM providers configured (no API keys found)".to_string());
|
||||
(false, names)
|
||||
|
|
@ -1172,25 +1162,16 @@ async fn run_preflight(
|
|||
|
||||
// 7. Print warnings/errors to stderr
|
||||
for err in &errors {
|
||||
eprintln!(
|
||||
"{}: {err}",
|
||||
styles.red.apply_to("error"),
|
||||
);
|
||||
eprintln!("{}: {err}", styles.red.apply_to("error"),);
|
||||
}
|
||||
|
||||
// 8. Final verdict
|
||||
let ok = sandbox_ready && llm_available && provider_valid;
|
||||
if ok {
|
||||
eprintln!(
|
||||
"\n{}",
|
||||
styles.bold_green.apply_to("Preflight: OK"),
|
||||
);
|
||||
eprintln!("\n{}", styles.bold_green.apply_to("Preflight: OK"),);
|
||||
Ok(())
|
||||
} else {
|
||||
eprintln!(
|
||||
"\n{}",
|
||||
styles.bold_red.apply_to("Preflight: FAIL"),
|
||||
);
|
||||
eprintln!("\n{}", styles.bold_red.apply_to("Preflight: FAIL"),);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1252,8 +1233,7 @@ async fn generate_retro(
|
|||
let narrative_result = if dry_run_mode {
|
||||
Ok(crate::retro_agent::dry_run_narrative())
|
||||
} else if let Some(client) = llm_client {
|
||||
crate::retro_agent::run_retro_agent(sandbox, logs_dir, client, provider_enum, model)
|
||||
.await
|
||||
crate::retro_agent::run_retro_agent(sandbox, logs_dir, client, provider_enum, model).await
|
||||
} else {
|
||||
Err(anyhow::anyhow!("No LLM client available"))
|
||||
};
|
||||
|
|
@ -1266,7 +1246,9 @@ async fn generate_retro(
|
|||
eprintln!(
|
||||
"{} {}",
|
||||
styles.dim.apply_to("Retro saved to"),
|
||||
styles.underline.apply_to(format!("{}/retro.json", logs_dir.display())),
|
||||
styles
|
||||
.underline
|
||||
.apply_to(format!("{}/retro.json", logs_dir.display())),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -1292,7 +1274,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn default_model_for_anthropic() {
|
||||
assert_eq!(default_model_for_provider(Provider::Anthropic), "claude-opus-4-6");
|
||||
assert_eq!(
|
||||
default_model_for_provider(Provider::Anthropic),
|
||||
"claude-opus-4-6"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1302,7 +1287,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn default_model_for_gemini() {
|
||||
assert_eq!(default_model_for_provider(Provider::Gemini), "gemini-3.1-pro-preview");
|
||||
assert_eq!(
|
||||
default_model_for_provider(Provider::Gemini),
|
||||
"gemini-3.1-pro-preview"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1317,7 +1305,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn default_model_for_minimax() {
|
||||
assert_eq!(default_model_for_provider(Provider::Minimax), "minimax-m2.5");
|
||||
assert_eq!(
|
||||
default_model_for_provider(Provider::Minimax),
|
||||
"minimax-m2.5"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1367,8 +1358,14 @@ mod tests {
|
|||
fn resolve_model_provider_toml_overrides_graph() {
|
||||
use crate::graph::types::AttrValue;
|
||||
let mut graph = crate::graph::types::Graph::new("test");
|
||||
graph.attrs.insert("default_model".to_string(), AttrValue::String("graph-model".to_string()));
|
||||
graph.attrs.insert("default_provider".to_string(), AttrValue::String("gemini".to_string()));
|
||||
graph.attrs.insert(
|
||||
"default_model".to_string(),
|
||||
AttrValue::String("graph-model".to_string()),
|
||||
);
|
||||
graph.attrs.insert(
|
||||
"default_provider".to_string(),
|
||||
AttrValue::String("gemini".to_string()),
|
||||
);
|
||||
|
||||
let defaults = RunDefaults::default();
|
||||
let cfg = run_config::WorkflowRunConfig {
|
||||
|
|
@ -1393,8 +1390,14 @@ mod tests {
|
|||
fn resolve_model_provider_graph_attrs_used_as_fallback() {
|
||||
use crate::graph::types::AttrValue;
|
||||
let mut graph = crate::graph::types::Graph::new("test");
|
||||
graph.attrs.insert("default_model".to_string(), AttrValue::String("gpt-5.2".to_string()));
|
||||
graph.attrs.insert("default_provider".to_string(), AttrValue::String("openai".to_string()));
|
||||
graph.attrs.insert(
|
||||
"default_model".to_string(),
|
||||
AttrValue::String("gpt-5.2".to_string()),
|
||||
);
|
||||
graph.attrs.insert(
|
||||
"default_provider".to_string(),
|
||||
AttrValue::String("openai".to_string()),
|
||||
);
|
||||
|
||||
let defaults = RunDefaults::default();
|
||||
let (model, provider) = resolve_model_provider(None, None, None, &defaults, &graph);
|
||||
|
|
|
|||
|
|
@ -388,8 +388,7 @@ graph = "p.dot"
|
|||
"#;
|
||||
let err = parse_run_config(toml).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("Unsupported run config version 2"),
|
||||
err.to_string().contains("Unsupported run config version 2"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,10 +82,7 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
|
|||
continue;
|
||||
}
|
||||
|
||||
let dir_name = entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
debug!(dir = %dir_name, "scanning run directory");
|
||||
|
||||
|
|
@ -95,18 +92,12 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
|
|||
debug!(dir = %dir_name, "reading manifest");
|
||||
let manifest: serde_json::Value = serde_json::from_str(&manifest_text)?;
|
||||
|
||||
let run_id = manifest["run_id"]
|
||||
.as_str()
|
||||
.unwrap_or(&dir_name)
|
||||
.to_string();
|
||||
let run_id = manifest["run_id"].as_str().unwrap_or(&dir_name).to_string();
|
||||
let workflow_name = manifest["workflow_name"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let start_time = manifest["start_time"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let start_time = manifest["start_time"].as_str().unwrap_or("").to_string();
|
||||
let labels: HashMap<String, String> = manifest
|
||||
.get("labels")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
|
|
@ -308,10 +299,7 @@ pub fn prune_from(args: &RunsPruneArgs, base: &Path) -> Result<()> {
|
|||
} else {
|
||||
for run in &filtered {
|
||||
debug!(run_id = %run.run_id, "would delete run (dry-run)");
|
||||
println!(
|
||||
"would delete: {} ({})",
|
||||
run.dir_name, run.workflow_name
|
||||
);
|
||||
println!("would delete: {} ({})", run.dir_name, run.workflow_name);
|
||||
}
|
||||
eprintln!(
|
||||
"\n{} run(s) would be deleted. Pass --yes to confirm.",
|
||||
|
|
@ -336,12 +324,18 @@ mod tests {
|
|||
let dir = base.join(dir_name);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
if let Some(m) = manifest {
|
||||
fs::write(dir.join("manifest.json"), serde_json::to_string_pretty(&m).unwrap())
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&m).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
if let Some(f) = final_json {
|
||||
fs::write(dir.join("final.json"), serde_json::to_string_pretty(&f).unwrap())
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("final.json"),
|
||||
serde_json::to_string_pretty(&f).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
if pid_file {
|
||||
fs::write(dir.join("run.pid"), "12345").unwrap();
|
||||
|
|
@ -599,7 +593,10 @@ mod tests {
|
|||
|
||||
prune_from(&args, base).unwrap();
|
||||
assert!(!dir.exists(), "--yes should delete matching directory");
|
||||
assert!(keep_dir.exists(), "non-matching directory should be preserved");
|
||||
assert!(
|
||||
keep_dir.exists(),
|
||||
"non-matching directory should be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use anyhow::bail;
|
||||
use arc_util::terminal::Styles;
|
||||
|
||||
use crate::workflow::WorkflowBuilder;
|
||||
use crate::validation::Severity;
|
||||
use crate::workflow::WorkflowBuilder;
|
||||
|
||||
use super::{print_diagnostics, read_dot_file, ValidateArgs};
|
||||
|
||||
|
|
@ -17,7 +17,9 @@ pub fn validate_command(args: &ValidateArgs, styles: &Styles) -> anyhow::Result<
|
|||
|
||||
eprintln!(
|
||||
"{} ({} nodes, {} edges)",
|
||||
styles.bold.apply_to(format!("Parsed workflow: {}", graph.name)),
|
||||
styles
|
||||
.bold
|
||||
.apply_to(format!("Parsed workflow: {}", graph.name)),
|
||||
graph.nodes.len(),
|
||||
graph.edges.len(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ use std::path::Path;
|
|||
use std::time::Instant;
|
||||
|
||||
use arc_agent::sandbox::{
|
||||
format_lines_numbered, DirEntry, SandboxEventCallback, ExecResult, SandboxEvent,
|
||||
Sandbox, GrepOptions,
|
||||
format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent,
|
||||
SandboxEventCallback,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use rand::Rng;
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ fn millis_u64(d: std::time::Duration) -> u64 {
|
|||
fn classify_outcome(outcome: &Outcome) -> Option<FailureClass> {
|
||||
match outcome.status {
|
||||
StageStatus::Success | StageStatus::PartialSuccess | StageStatus::Skipped => None,
|
||||
StageStatus::Fail | StageStatus::Retry => {
|
||||
outcome.failure_class().or(Some(FailureClass::Deterministic))
|
||||
}
|
||||
StageStatus::Fail | StageStatus::Retry => outcome
|
||||
.failure_class()
|
||||
.or(Some(FailureClass::Deterministic)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -650,11 +650,7 @@ async fn git_diff_remote(sandbox: &dyn Sandbox, base: &str) -> Option<String> {
|
|||
// --- Remote worktree helpers (for Daytona / sandbox environments) ---
|
||||
|
||||
/// Create a branch at a specific SHA inside a remote sandbox.
|
||||
pub async fn git_create_branch_at_remote(
|
||||
sandbox: &dyn Sandbox,
|
||||
name: &str,
|
||||
sha: &str,
|
||||
) -> bool {
|
||||
pub async fn git_create_branch_at_remote(sandbox: &dyn Sandbox, name: &str, sha: &str) -> bool {
|
||||
let cmd = format!("{GIT_REMOTE} branch --force {name} {sha}");
|
||||
matches!(
|
||||
sandbox.exec_command(&cmd, 30_000, None, None, None).await,
|
||||
|
|
@ -663,11 +659,7 @@ pub async fn git_create_branch_at_remote(
|
|||
}
|
||||
|
||||
/// Add a git worktree inside a remote sandbox.
|
||||
pub async fn git_add_worktree_remote(
|
||||
sandbox: &dyn Sandbox,
|
||||
path: &str,
|
||||
branch: &str,
|
||||
) -> bool {
|
||||
pub async fn git_add_worktree_remote(sandbox: &dyn Sandbox, path: &str, branch: &str) -> bool {
|
||||
let cmd = format!("{GIT_REMOTE} worktree add {path} {branch}");
|
||||
matches!(
|
||||
sandbox.exec_command(&cmd, 30_000, None, None, None).await,
|
||||
|
|
@ -703,11 +695,7 @@ pub async fn git_head_sha_remote(sandbox: &dyn Sandbox) -> Option<String> {
|
|||
}
|
||||
|
||||
/// Remove any stale worktree at `path` (best-effort), then add a fresh one.
|
||||
pub async fn git_replace_worktree_remote(
|
||||
sandbox: &dyn Sandbox,
|
||||
path: &str,
|
||||
branch: &str,
|
||||
) -> bool {
|
||||
pub async fn git_replace_worktree_remote(sandbox: &dyn Sandbox, path: &str, branch: &str) -> bool {
|
||||
let _ = git_remove_worktree_remote(sandbox, path).await;
|
||||
git_add_worktree_remote(sandbox, path, branch).await
|
||||
}
|
||||
|
|
@ -939,15 +927,17 @@ impl WorkflowRunEngine {
|
|||
},
|
||||
will_retry: true,
|
||||
});
|
||||
self.services.emitter.emit(&WorkflowRunEvent::StageRetrying {
|
||||
node_id: node.id.clone(),
|
||||
name: node.label().to_string(),
|
||||
index: stage_index,
|
||||
attempt: usize::try_from(attempt).unwrap_or(usize::MAX),
|
||||
max_attempts: usize::try_from(policy.max_attempts)
|
||||
.unwrap_or(usize::MAX),
|
||||
delay_ms: millis_u64(delay),
|
||||
});
|
||||
self.services
|
||||
.emitter
|
||||
.emit(&WorkflowRunEvent::StageRetrying {
|
||||
node_id: node.id.clone(),
|
||||
name: node.label().to_string(),
|
||||
index: stage_index,
|
||||
attempt: usize::try_from(attempt).unwrap_or(usize::MAX),
|
||||
max_attempts: usize::try_from(policy.max_attempts)
|
||||
.unwrap_or(usize::MAX),
|
||||
delay_ms: millis_u64(delay),
|
||||
});
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -965,15 +955,17 @@ impl WorkflowRunEngine {
|
|||
StageStatus::Retry => {
|
||||
if attempt < policy.max_attempts {
|
||||
let delay = policy.backoff.delay_for_attempt(attempt);
|
||||
self.services.emitter.emit(&WorkflowRunEvent::StageRetrying {
|
||||
node_id: node.id.clone(),
|
||||
name: node.label().to_string(),
|
||||
index: stage_index,
|
||||
attempt: usize::try_from(attempt).unwrap_or(usize::MAX),
|
||||
max_attempts: usize::try_from(policy.max_attempts)
|
||||
.unwrap_or(usize::MAX),
|
||||
delay_ms: millis_u64(delay),
|
||||
});
|
||||
self.services
|
||||
.emitter
|
||||
.emit(&WorkflowRunEvent::StageRetrying {
|
||||
node_id: node.id.clone(),
|
||||
name: node.label().to_string(),
|
||||
index: stage_index,
|
||||
attempt: usize::try_from(attempt).unwrap_or(usize::MAX),
|
||||
max_attempts: usize::try_from(policy.max_attempts)
|
||||
.unwrap_or(usize::MAX),
|
||||
delay_ms: millis_u64(delay),
|
||||
});
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -992,7 +984,10 @@ impl WorkflowRunEngine {
|
|||
}
|
||||
}
|
||||
|
||||
Ok((Outcome::fail_classify("max retries exceeded"), policy.max_attempts))
|
||||
Ok((
|
||||
Outcome::fail_classify("max retries exceeded"),
|
||||
policy.max_attempts,
|
||||
))
|
||||
}
|
||||
|
||||
/// Run the workflow. Returns the final outcome.
|
||||
|
|
@ -1002,9 +997,9 @@ impl WorkflowRunEngine {
|
|||
/// Returns an error if no start node is found, a node is missing, or a goal gate fails
|
||||
/// without a retry target.
|
||||
pub async fn run(&self, graph: &Graph, config: &RunConfig) -> Result<Outcome> {
|
||||
let (outcome, _context) =
|
||||
self.run_internal(graph, config, None, None, None, LoopState::default())
|
||||
.await?;
|
||||
let (outcome, _context) = self
|
||||
.run_internal(graph, config, None, None, None, LoopState::default())
|
||||
.await?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
|
|
@ -1077,16 +1072,18 @@ impl WorkflowRunEngine {
|
|||
};
|
||||
self.services.set_git_state(git_state);
|
||||
|
||||
self.services.emitter.emit(&WorkflowRunEvent::WorkflowRunStarted {
|
||||
name: graph.name.clone(),
|
||||
run_id: run_id.clone(),
|
||||
base_sha: config.base_sha.clone(),
|
||||
run_branch: config.run_branch.clone(),
|
||||
worktree_dir: match config.git_checkpoint {
|
||||
Some(GitCheckpointMode::Host(ref p)) => Some(p.display().to_string()),
|
||||
_ => None,
|
||||
},
|
||||
});
|
||||
self.services
|
||||
.emitter
|
||||
.emit(&WorkflowRunEvent::WorkflowRunStarted {
|
||||
name: graph.name.clone(),
|
||||
run_id: run_id.clone(),
|
||||
base_sha: config.base_sha.clone(),
|
||||
run_branch: config.run_branch.clone(),
|
||||
worktree_dir: match config.git_checkpoint {
|
||||
Some(GitCheckpointMode::Host(ref p)) => Some(p.display().to_string()),
|
||||
_ => None,
|
||||
},
|
||||
});
|
||||
self.inform(&format!("Run started: {}", graph.name), "run");
|
||||
|
||||
// Write manifest.json (spec 5.6)
|
||||
|
|
@ -1271,14 +1268,16 @@ impl WorkflowRunEngine {
|
|||
continue;
|
||||
}
|
||||
let duration_ms = millis_u64(run_start.elapsed());
|
||||
let error = ArcError::engine(
|
||||
format!("goal gate unsatisfied for node {failed_node_id} and no retry target")
|
||||
);
|
||||
self.services.emitter.emit(&WorkflowRunEvent::WorkflowRunFailed {
|
||||
error: error.clone(),
|
||||
duration_ms,
|
||||
git_commit_sha: last_git_sha.clone(),
|
||||
});
|
||||
let error = ArcError::engine(format!(
|
||||
"goal gate unsatisfied for node {failed_node_id} and no retry target"
|
||||
));
|
||||
self.services
|
||||
.emitter
|
||||
.emit(&WorkflowRunEvent::WorkflowRunFailed {
|
||||
error: error.clone(),
|
||||
duration_ms,
|
||||
git_commit_sha: last_git_sha.clone(),
|
||||
});
|
||||
return Ok((error.to_fail_outcome(), context));
|
||||
}
|
||||
}
|
||||
|
|
@ -1354,8 +1353,15 @@ impl WorkflowRunEngine {
|
|||
}
|
||||
} else {
|
||||
self.execute_with_retry(
|
||||
node, &context, graph, &config.logs_root, &retry_policy, stage_index, visit,
|
||||
).await?
|
||||
node,
|
||||
&context,
|
||||
graph,
|
||||
&config.logs_root,
|
||||
&retry_policy,
|
||||
stage_index,
|
||||
visit,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
// Gap #5: Track retry count per node
|
||||
node_retries.insert(node.id.clone(), attempts_used);
|
||||
|
|
@ -1386,12 +1392,7 @@ impl WorkflowRunEngine {
|
|||
.failure
|
||||
.as_ref()
|
||||
.and_then(|f| f.failure_signature.as_deref());
|
||||
let sig = FailureSignature::new(
|
||||
&node.id,
|
||||
fc,
|
||||
sig_hint,
|
||||
outcome.failure_reason(),
|
||||
);
|
||||
let sig = FailureSignature::new(&node.id, fc, sig_hint, outcome.failure_reason());
|
||||
if fc.is_signature_tracked() {
|
||||
let count = loop_state
|
||||
.loop_failure_signatures
|
||||
|
|
@ -1421,21 +1422,24 @@ impl WorkflowRunEngine {
|
|||
will_retry: false,
|
||||
});
|
||||
} else {
|
||||
self.services.emitter.emit(&WorkflowRunEvent::StageCompleted {
|
||||
node_id: node.id.clone(),
|
||||
name: node.label().to_string(),
|
||||
index: stage_index,
|
||||
duration_ms: stage_duration_ms,
|
||||
status: outcome.status.to_string(),
|
||||
preferred_label: outcome.preferred_label.clone(),
|
||||
suggested_next_ids: outcome.suggested_next_ids.clone(),
|
||||
usage: outcome.usage.clone(),
|
||||
failure: outcome.failure.clone(),
|
||||
notes: outcome.notes.clone(),
|
||||
files_touched: outcome.files_touched.clone(),
|
||||
attempt: usize::try_from(attempts_used).unwrap_or(usize::MAX),
|
||||
max_attempts: usize::try_from(retry_policy.max_attempts).unwrap_or(usize::MAX),
|
||||
});
|
||||
self.services
|
||||
.emitter
|
||||
.emit(&WorkflowRunEvent::StageCompleted {
|
||||
node_id: node.id.clone(),
|
||||
name: node.label().to_string(),
|
||||
index: stage_index,
|
||||
duration_ms: stage_duration_ms,
|
||||
status: outcome.status.to_string(),
|
||||
preferred_label: outcome.preferred_label.clone(),
|
||||
suggested_next_ids: outcome.suggested_next_ids.clone(),
|
||||
usage: outcome.usage.clone(),
|
||||
failure: outcome.failure.clone(),
|
||||
notes: outcome.notes.clone(),
|
||||
files_touched: outcome.files_touched.clone(),
|
||||
attempt: usize::try_from(attempts_used).unwrap_or(usize::MAX),
|
||||
max_attempts: usize::try_from(retry_policy.max_attempts)
|
||||
.unwrap_or(usize::MAX),
|
||||
});
|
||||
self.inform(&format!("Stage completed: {}", node.label()), &node.id);
|
||||
}
|
||||
|
||||
|
|
@ -1449,8 +1453,7 @@ impl WorkflowRunEngine {
|
|||
|
||||
// Sync artifact files to the sandbox (no-op for local envs)
|
||||
if let Err(e) =
|
||||
sync_artifacts_to_env(&mut outcome.context_updates, &*self.services.sandbox)
|
||||
.await
|
||||
sync_artifacts_to_env(&mut outcome.context_updates, &*self.services.sandbox).await
|
||||
{
|
||||
context.append_log(format!("artifact sync failed: {e}"));
|
||||
}
|
||||
|
|
@ -1505,9 +1508,11 @@ impl WorkflowRunEngine {
|
|||
if let Err(e) = checkpoint.save(&checkpoint_path) {
|
||||
context.append_log(format!("checkpoint save failed: {e}"));
|
||||
} else {
|
||||
self.services.emitter.emit(&WorkflowRunEvent::CheckpointSaved {
|
||||
node_id: node.id.clone(),
|
||||
});
|
||||
self.services
|
||||
.emitter
|
||||
.emit(&WorkflowRunEvent::CheckpointSaved {
|
||||
node_id: node.id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Step 6b: Write shadow branch first, then run branch commit with trailer
|
||||
|
|
@ -1586,12 +1591,14 @@ impl WorkflowRunEngine {
|
|||
if let Err(e) = checkpoint.save(&checkpoint_path) {
|
||||
context.append_log(format!("checkpoint re-save with SHA failed: {e}"));
|
||||
}
|
||||
self.services.emitter.emit(&WorkflowRunEvent::GitCheckpoint {
|
||||
run_id: run_id.clone(),
|
||||
node_id: node.id.clone(),
|
||||
status: outcome.status.to_string(),
|
||||
git_commit_sha: sha.clone(),
|
||||
});
|
||||
self.services
|
||||
.emitter
|
||||
.emit(&WorkflowRunEvent::GitCheckpoint {
|
||||
run_id: run_id.clone(),
|
||||
node_id: node.id.clone(),
|
||||
status: outcome.status.to_string(),
|
||||
git_commit_sha: sha.clone(),
|
||||
});
|
||||
|
||||
// Save diff.patch for this stage
|
||||
let prev = last_git_sha
|
||||
|
|
@ -1632,14 +1639,17 @@ impl WorkflowRunEngine {
|
|||
continue;
|
||||
}
|
||||
let duration_ms = millis_u64(run_start.elapsed());
|
||||
let error = ArcError::engine(
|
||||
format!("stage {} failed with no outgoing fail edge", node.id)
|
||||
);
|
||||
self.services.emitter.emit(&WorkflowRunEvent::WorkflowRunFailed {
|
||||
error: error.clone(),
|
||||
duration_ms,
|
||||
git_commit_sha: last_git_sha.clone(),
|
||||
});
|
||||
let error = ArcError::engine(format!(
|
||||
"stage {} failed with no outgoing fail edge",
|
||||
node.id
|
||||
));
|
||||
self.services
|
||||
.emitter
|
||||
.emit(&WorkflowRunEvent::WorkflowRunFailed {
|
||||
error: error.clone(),
|
||||
duration_ms,
|
||||
git_commit_sha: last_git_sha.clone(),
|
||||
});
|
||||
return Err(error);
|
||||
}
|
||||
break;
|
||||
|
|
@ -2698,9 +2708,10 @@ mod tests {
|
|||
};
|
||||
engine.run(&g, &config).await.unwrap();
|
||||
|
||||
let manifest: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(dir.path().join("manifest.json")).unwrap())
|
||||
.unwrap();
|
||||
let manifest: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(dir.path().join("manifest.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(manifest["labels"]["env"], "test");
|
||||
}
|
||||
|
||||
|
|
@ -2723,9 +2734,10 @@ mod tests {
|
|||
};
|
||||
engine.run(&g, &config).await.unwrap();
|
||||
|
||||
let manifest: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(dir.path().join("manifest.json")).unwrap())
|
||||
.unwrap();
|
||||
let manifest: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(dir.path().join("manifest.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(manifest.get("labels").is_none());
|
||||
}
|
||||
|
||||
|
|
@ -3595,10 +3607,7 @@ mod tests {
|
|||
let result = engine.run(&g, &config).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("(limit 2)"),
|
||||
"expected limit of 2, got: {err}"
|
||||
);
|
||||
assert!(err.contains("(limit 2)"), "expected limit of 2, got: {err}");
|
||||
}
|
||||
|
||||
// --- node_dir visit-count tests ---
|
||||
|
|
@ -4093,10 +4102,8 @@ mod tests {
|
|||
g.nodes.insert("start".to_string(), start);
|
||||
|
||||
let mut work = Node::new("work");
|
||||
work.attrs.insert(
|
||||
"type".to_string(),
|
||||
AttrValue::String("slow".to_string()),
|
||||
);
|
||||
work.attrs
|
||||
.insert("type".to_string(), AttrValue::String("slow".to_string()));
|
||||
g.nodes.insert("work".to_string(), work);
|
||||
|
||||
let mut exit = Node::new("exit");
|
||||
|
|
@ -4214,10 +4221,8 @@ mod tests {
|
|||
g.nodes.insert("start".to_string(), start);
|
||||
|
||||
let mut work = Node::new("work");
|
||||
work.attrs.insert(
|
||||
"type".to_string(),
|
||||
AttrValue::String("slow".to_string()),
|
||||
);
|
||||
work.attrs
|
||||
.insert("type".to_string(), AttrValue::String("slow".to_string()));
|
||||
g.nodes.insert("work".to_string(), work);
|
||||
|
||||
let mut exit = Node::new("exit");
|
||||
|
|
|
|||
|
|
@ -1535,7 +1535,10 @@ mod tests {
|
|||
message: "connection refused".into(),
|
||||
});
|
||||
let outcome = err.to_fail_outcome();
|
||||
assert!(outcome.failure_reason().unwrap().contains("connection refused"));
|
||||
assert!(outcome
|
||||
.failure_reason()
|
||||
.unwrap()
|
||||
.contains("connection refused"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1637,10 +1640,7 @@ mod tests {
|
|||
fn to_fail_outcome_preserves_class() {
|
||||
let err = ArcError::handler("timeout");
|
||||
let outcome = err.to_fail_outcome();
|
||||
assert_eq!(
|
||||
outcome.failure_class(),
|
||||
Some(FailureClass::TransientInfra)
|
||||
);
|
||||
assert_eq!(outcome.failure_class(), Some(FailureClass::TransientInfra));
|
||||
}
|
||||
|
||||
// --- E2E error pipeline tests ---
|
||||
|
|
@ -1709,7 +1709,10 @@ mod tests {
|
|||
|
||||
// Verify wire format
|
||||
assert_eq!(v["type"], "handler");
|
||||
assert!(v["data"]["message"].as_str().unwrap().contains("connection refused"));
|
||||
assert!(v["data"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("connection refused"));
|
||||
assert_eq!(v["data"]["failure_class"], "transient_infra");
|
||||
|
||||
// Round-trip
|
||||
|
|
|
|||
|
|
@ -298,9 +298,7 @@ impl WorkflowRunEvent {
|
|||
} => {
|
||||
debug!(
|
||||
branch_count,
|
||||
join_policy,
|
||||
error_policy,
|
||||
"Parallel execution started"
|
||||
join_policy, error_policy, "Parallel execution started"
|
||||
);
|
||||
}
|
||||
Self::ParallelBranchStarted { branch, index } => {
|
||||
|
|
@ -314,10 +312,7 @@ impl WorkflowRunEvent {
|
|||
} => {
|
||||
debug!(
|
||||
branch,
|
||||
index,
|
||||
duration_ms,
|
||||
status,
|
||||
"Parallel branch completed"
|
||||
index, duration_ms, status, "Parallel branch completed"
|
||||
);
|
||||
}
|
||||
Self::ParallelCompleted {
|
||||
|
|
@ -327,9 +322,7 @@ impl WorkflowRunEvent {
|
|||
} => {
|
||||
debug!(
|
||||
duration_ms,
|
||||
success_count,
|
||||
failure_count,
|
||||
"Parallel execution completed"
|
||||
success_count, failure_count, "Parallel execution completed"
|
||||
);
|
||||
}
|
||||
Self::InterviewStarted {
|
||||
|
|
@ -371,10 +364,7 @@ impl WorkflowRunEvent {
|
|||
"Edge selected"
|
||||
);
|
||||
}
|
||||
Self::LoopRestart {
|
||||
from_node,
|
||||
to_node,
|
||||
} => {
|
||||
Self::LoopRestart { from_node, to_node } => {
|
||||
debug!(from_node, to_node, "Loop restart");
|
||||
}
|
||||
Self::Prompt { stage, text } => {
|
||||
|
|
@ -389,9 +379,7 @@ impl WorkflowRunEvent {
|
|||
} => {
|
||||
warn!(
|
||||
reason,
|
||||
completed_count,
|
||||
pending_count,
|
||||
"Parallel early termination"
|
||||
completed_count, pending_count, "Parallel early termination"
|
||||
);
|
||||
}
|
||||
Self::SubgraphStarted {
|
||||
|
|
@ -408,10 +396,7 @@ impl WorkflowRunEvent {
|
|||
} => {
|
||||
debug!(
|
||||
node_id,
|
||||
steps_executed,
|
||||
status,
|
||||
duration_ms,
|
||||
"Subgraph completed"
|
||||
steps_executed, status, duration_ms, "Subgraph completed"
|
||||
);
|
||||
}
|
||||
Self::SetupStarted { command_count } => {
|
||||
|
|
@ -428,10 +413,7 @@ impl WorkflowRunEvent {
|
|||
} => {
|
||||
debug!(
|
||||
command,
|
||||
index,
|
||||
exit_code,
|
||||
duration_ms,
|
||||
"Setup command completed"
|
||||
index, exit_code, duration_ms, "Setup command completed"
|
||||
);
|
||||
}
|
||||
Self::SetupCompleted { duration_ms } => {
|
||||
|
|
@ -445,10 +427,7 @@ impl WorkflowRunEvent {
|
|||
} => {
|
||||
error!(command, index, exit_code, "Setup command failed");
|
||||
}
|
||||
Self::StallWatchdogTimeout {
|
||||
node,
|
||||
idle_seconds,
|
||||
} => {
|
||||
Self::StallWatchdogTimeout { node, idle_seconds } => {
|
||||
warn!(node, idle_seconds, "Stall watchdog timeout");
|
||||
}
|
||||
Self::AssetsCaptured {
|
||||
|
|
@ -459,10 +438,7 @@ impl WorkflowRunEvent {
|
|||
} => {
|
||||
debug!(
|
||||
node_id,
|
||||
files_copied,
|
||||
total_bytes,
|
||||
files_skipped,
|
||||
"Assets captured"
|
||||
files_copied, total_bytes, files_skipped, "Assets captured"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -510,7 +486,9 @@ fn flatten_agent(inner: serde_json::Value) -> (String, serde_json::Map<String, s
|
|||
return ("Agent".to_string(), serde_json::Map::new());
|
||||
};
|
||||
let stage = agent_fields.remove("stage");
|
||||
let agent_event = agent_fields.remove("event").unwrap_or(serde_json::Value::Null);
|
||||
let agent_event = agent_fields
|
||||
.remove("event")
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
|
||||
match agent_event {
|
||||
serde_json::Value::Object(event_map) => {
|
||||
|
|
@ -591,7 +569,9 @@ fn flatten_sub_agent_event(
|
|||
// Extract the inner event name for dot notation, but keep full inner
|
||||
// event as `nested_event` JSON to avoid field collisions when sub-agents
|
||||
// are themselves nested (SubAgentEvent wrapping SubAgentEvent).
|
||||
let nested_event = sub_fields.remove("event").unwrap_or(serde_json::Value::Null);
|
||||
let nested_event = sub_fields
|
||||
.remove("event")
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let inner_name = match &nested_event {
|
||||
serde_json::Value::Object(map) => map.keys().next().cloned(),
|
||||
serde_json::Value::String(name) => Some(name.clone()),
|
||||
|
|
@ -667,7 +647,9 @@ fn rename_fields(event_name: &str, fields: &mut serde_json::Map<String, serde_js
|
|||
})
|
||||
.or_else(|| {
|
||||
// For newtype string variants: { "data": "..." }
|
||||
error_val.get("data").and_then(|d| d.as_str().map(String::from))
|
||||
error_val
|
||||
.get("data")
|
||||
.and_then(|d| d.as_str().map(String::from))
|
||||
})
|
||||
.unwrap_or_else(|| error_val.to_string());
|
||||
fields.insert("error".to_string(), serde_json::Value::String(display));
|
||||
|
|
@ -948,7 +930,10 @@ mod tests {
|
|||
preferred_label: None,
|
||||
suggested_next_ids: vec![],
|
||||
usage: None,
|
||||
failure: Some(FailureDetail::new("lint errors remain", FailureClass::Deterministic)),
|
||||
failure: Some(FailureDetail::new(
|
||||
"lint errors remain",
|
||||
FailureClass::Deterministic,
|
||||
)),
|
||||
notes: Some("fixed 3 of 5 issues".to_string()),
|
||||
files_touched: vec!["src/main.rs".to_string()],
|
||||
attempt: 2,
|
||||
|
|
|
|||
|
|
@ -431,7 +431,11 @@ impl Graph {
|
|||
|
||||
/// Graph-level `stall_timeout`. Defaults to 600s. Returns `None` when set to zero (disabled).
|
||||
pub fn stall_timeout(&self) -> Option<Duration> {
|
||||
match self.attrs.get("stall_timeout").and_then(AttrValue::as_duration) {
|
||||
match self
|
||||
.attrs
|
||||
.get("stall_timeout")
|
||||
.and_then(AttrValue::as_duration)
|
||||
{
|
||||
Some(d) if d.is_zero() => None,
|
||||
Some(d) => Some(d),
|
||||
None => Some(Duration::from_secs(600)),
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ impl Handler for FanInHandler {
|
|||
) -> Result<Outcome, ArcError> {
|
||||
let results = context.get("parallel.results");
|
||||
let Some(results) = results else {
|
||||
return Ok(Outcome::fail_deterministic("No parallel results to evaluate"));
|
||||
return Ok(Outcome::fail_deterministic(
|
||||
"No parallel results to evaluate",
|
||||
));
|
||||
};
|
||||
|
||||
let prompt = node.prompt().filter(|p| !p.is_empty());
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use async_trait::async_trait;
|
|||
|
||||
use crate::condition::evaluate_condition;
|
||||
use crate::context::Context;
|
||||
use crate::engine::{WorkflowRunEngine, RunConfig};
|
||||
use crate::engine::{RunConfig, WorkflowRunEngine};
|
||||
use crate::error::ArcError;
|
||||
use crate::graph::{Graph, Node};
|
||||
use crate::outcome::{Outcome, StageStatus};
|
||||
|
|
@ -44,10 +44,18 @@ fn parse_duration_str(s: &str) -> Duration {
|
|||
/// Read DOT source from node attributes: inline `stack.child_dot_source` or
|
||||
/// file path `stack.child_dotfile`.
|
||||
fn read_child_dot(node: &Node) -> Result<String, ArcError> {
|
||||
if let Some(dot) = node.attrs.get("stack.child_dot_source").and_then(|v| v.as_str()) {
|
||||
if let Some(dot) = node
|
||||
.attrs
|
||||
.get("stack.child_dot_source")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(dot.to_string());
|
||||
}
|
||||
if let Some(path) = node.attrs.get("stack.child_dotfile").and_then(|v| v.as_str()) {
|
||||
if let Some(path) = node
|
||||
.attrs
|
||||
.get("stack.child_dotfile")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return std::fs::read_to_string(path)
|
||||
.map_err(|e| ArcError::handler(format!("Failed to read child dotfile {path}: {e}")));
|
||||
}
|
||||
|
|
@ -152,10 +160,11 @@ impl Handler for SubWorkflowHandler {
|
|||
|
||||
// Spawn child engine
|
||||
let engine = WorkflowRunEngine::from_services(services);
|
||||
let mut child_handle =
|
||||
tokio::spawn(
|
||||
async move { engine.run_with_context(&child_graph, &child_config, child_context).await },
|
||||
);
|
||||
let mut child_handle = tokio::spawn(async move {
|
||||
engine
|
||||
.run_with_context(&child_graph, &child_config, child_context)
|
||||
.await
|
||||
});
|
||||
|
||||
// Poll loop
|
||||
for cycle in 1..=max_cycles {
|
||||
|
|
@ -269,7 +278,11 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.status, StageStatus::Success);
|
||||
assert!(outcome.notes.as_deref().unwrap().contains("Child completed"));
|
||||
assert!(outcome
|
||||
.notes
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("Child completed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -348,10 +361,9 @@ mod tests {
|
|||
outcome
|
||||
.context_updates
|
||||
.insert("review.result".to_string(), serde_json::json!("approved"));
|
||||
outcome.context_updates.insert(
|
||||
"review.echo".to_string(),
|
||||
serde_json::json!(target),
|
||||
);
|
||||
outcome
|
||||
.context_updates
|
||||
.insert("review.echo".to_string(), serde_json::json!(target));
|
||||
Ok(outcome)
|
||||
}
|
||||
}
|
||||
|
|
@ -500,10 +512,7 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.status, StageStatus::Fail);
|
||||
assert!(outcome
|
||||
.failure_reason()
|
||||
.unwrap()
|
||||
.contains("Max cycles"));
|
||||
assert!(outcome.failure_reason().unwrap().contains("Max cycles"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -82,7 +82,9 @@ impl Sandbox for WorktreeSandbox {
|
|||
remote_path: &str,
|
||||
local_path: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
self.inner.download_file_to_local(remote_path, local_path).await
|
||||
self.inner
|
||||
.download_file_to_local(remote_path, local_path)
|
||||
.await
|
||||
}
|
||||
async fn initialize(&self) -> Result<(), String> {
|
||||
self.inner.initialize().await
|
||||
|
|
@ -277,102 +279,104 @@ impl Handler for ParallelHandler {
|
|||
let target_id = edge.to.clone();
|
||||
let branch_context = context.clone_context();
|
||||
|
||||
let (branch_sandbox, worktree_path): (Arc<dyn Sandbox>, Option<PathBuf>) =
|
||||
if let (Some(ref gs), Some(ref bsha)) = (&git_state, &base_sha) {
|
||||
let branch_key = &target_id;
|
||||
let visit = crate::engine::visit_from_context(&branch_context);
|
||||
let branch_name = format!(
|
||||
"arc/run/parallel/{}/{}/pass{}/{}",
|
||||
gs.run_id,
|
||||
crate::git::sanitize_ref_component(&node.id),
|
||||
visit,
|
||||
crate::git::sanitize_ref_component(branch_key),
|
||||
);
|
||||
let (branch_sandbox, worktree_path): (Arc<dyn Sandbox>, Option<PathBuf>) = if let (
|
||||
Some(ref gs),
|
||||
Some(ref bsha),
|
||||
) =
|
||||
(&git_state, &base_sha)
|
||||
{
|
||||
let branch_key = &target_id;
|
||||
let visit = crate::engine::visit_from_context(&branch_context);
|
||||
let branch_name = format!(
|
||||
"arc/run/parallel/{}/{}/pass{}/{}",
|
||||
gs.run_id,
|
||||
crate::git::sanitize_ref_component(&node.id),
|
||||
visit,
|
||||
crate::git::sanitize_ref_component(branch_key),
|
||||
);
|
||||
|
||||
match &gs.mode {
|
||||
GitCheckpointMode::Host(work_dir) => {
|
||||
let wt_path = logs_root
|
||||
.join("parallel")
|
||||
.join(&node.id)
|
||||
.join(branch_key)
|
||||
.join("worktree");
|
||||
tracing::debug!(branch = %branch_name, path = %wt_path.display(), "Creating worktree for parallel branch");
|
||||
let wd = work_dir.clone();
|
||||
let bn = branch_name.clone();
|
||||
let bs = bsha.clone();
|
||||
let wtp = wt_path.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::git::create_branch_at(&wd, &bn, &bs)?;
|
||||
crate::git::replace_worktree(&wd, &wtp, &bn)?;
|
||||
crate::git::reset_hard(&wtp, &bs)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ArcError::handler(format!("worktree setup join error: {e}"))
|
||||
})??;
|
||||
branch_context.set(
|
||||
"internal.work_dir",
|
||||
serde_json::json!(wt_path.to_string_lossy().as_ref()),
|
||||
);
|
||||
let env: Arc<dyn Sandbox> = Arc::new(
|
||||
arc_agent::LocalSandbox::new(wt_path.clone()),
|
||||
);
|
||||
(env, Some(wt_path))
|
||||
}
|
||||
GitCheckpointMode::Remote(_) => {
|
||||
let wt_path_str = format!(
|
||||
"{}/.arc/logs/{}/parallel/{}/{}",
|
||||
services.sandbox.working_directory(),
|
||||
gs.run_id,
|
||||
node.id,
|
||||
branch_key
|
||||
);
|
||||
let ok = crate::engine::git_create_branch_at_remote(
|
||||
&*services.sandbox,
|
||||
&branch_name,
|
||||
bsha,
|
||||
)
|
||||
.await;
|
||||
if !ok {
|
||||
return Err(ArcError::handler(format!(
|
||||
"failed to create remote branch {branch_name}"
|
||||
)));
|
||||
}
|
||||
let ok = crate::engine::git_replace_worktree_remote(
|
||||
&*services.sandbox,
|
||||
&wt_path_str,
|
||||
&branch_name,
|
||||
)
|
||||
.await;
|
||||
if !ok {
|
||||
return Err(ArcError::handler(format!(
|
||||
"failed to add remote worktree {wt_path_str}"
|
||||
)));
|
||||
}
|
||||
// Reset worktree to the base SHA for a clean start
|
||||
let reset_cmd =
|
||||
format!("{} reset --hard {bsha}", crate::engine::GIT_REMOTE);
|
||||
let reset_result = services
|
||||
.sandbox
|
||||
.exec_command(&reset_cmd, 30_000, Some(&wt_path_str), None, None)
|
||||
.await;
|
||||
if !matches!(reset_result, Ok(ref r) if r.exit_code == 0) {
|
||||
return Err(ArcError::handler(format!(
|
||||
"failed to reset remote worktree {wt_path_str}"
|
||||
)));
|
||||
}
|
||||
branch_context
|
||||
.set("internal.work_dir", serde_json::json!(&wt_path_str));
|
||||
let env: Arc<dyn Sandbox> = Arc::new(WorktreeSandbox {
|
||||
inner: Arc::clone(&services.sandbox),
|
||||
worktree_dir: wt_path_str.clone(),
|
||||
});
|
||||
(env, Some(PathBuf::from(wt_path_str)))
|
||||
}
|
||||
match &gs.mode {
|
||||
GitCheckpointMode::Host(work_dir) => {
|
||||
let wt_path = logs_root
|
||||
.join("parallel")
|
||||
.join(&node.id)
|
||||
.join(branch_key)
|
||||
.join("worktree");
|
||||
tracing::debug!(branch = %branch_name, path = %wt_path.display(), "Creating worktree for parallel branch");
|
||||
let wd = work_dir.clone();
|
||||
let bn = branch_name.clone();
|
||||
let bs = bsha.clone();
|
||||
let wtp = wt_path.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::git::create_branch_at(&wd, &bn, &bs)?;
|
||||
crate::git::replace_worktree(&wd, &wtp, &bn)?;
|
||||
crate::git::reset_hard(&wtp, &bs)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ArcError::handler(format!("worktree setup join error: {e}"))
|
||||
})??;
|
||||
branch_context.set(
|
||||
"internal.work_dir",
|
||||
serde_json::json!(wt_path.to_string_lossy().as_ref()),
|
||||
);
|
||||
let env: Arc<dyn Sandbox> =
|
||||
Arc::new(arc_agent::LocalSandbox::new(wt_path.clone()));
|
||||
(env, Some(wt_path))
|
||||
}
|
||||
} else {
|
||||
(Arc::clone(&services.sandbox), None)
|
||||
};
|
||||
GitCheckpointMode::Remote(_) => {
|
||||
let wt_path_str = format!(
|
||||
"{}/.arc/logs/{}/parallel/{}/{}",
|
||||
services.sandbox.working_directory(),
|
||||
gs.run_id,
|
||||
node.id,
|
||||
branch_key
|
||||
);
|
||||
let ok = crate::engine::git_create_branch_at_remote(
|
||||
&*services.sandbox,
|
||||
&branch_name,
|
||||
bsha,
|
||||
)
|
||||
.await;
|
||||
if !ok {
|
||||
return Err(ArcError::handler(format!(
|
||||
"failed to create remote branch {branch_name}"
|
||||
)));
|
||||
}
|
||||
let ok = crate::engine::git_replace_worktree_remote(
|
||||
&*services.sandbox,
|
||||
&wt_path_str,
|
||||
&branch_name,
|
||||
)
|
||||
.await;
|
||||
if !ok {
|
||||
return Err(ArcError::handler(format!(
|
||||
"failed to add remote worktree {wt_path_str}"
|
||||
)));
|
||||
}
|
||||
// Reset worktree to the base SHA for a clean start
|
||||
let reset_cmd =
|
||||
format!("{} reset --hard {bsha}", crate::engine::GIT_REMOTE);
|
||||
let reset_result = services
|
||||
.sandbox
|
||||
.exec_command(&reset_cmd, 30_000, Some(&wt_path_str), None, None)
|
||||
.await;
|
||||
if !matches!(reset_result, Ok(ref r) if r.exit_code == 0) {
|
||||
return Err(ArcError::handler(format!(
|
||||
"failed to reset remote worktree {wt_path_str}"
|
||||
)));
|
||||
}
|
||||
branch_context.set("internal.work_dir", serde_json::json!(&wt_path_str));
|
||||
let env: Arc<dyn Sandbox> = Arc::new(WorktreeSandbox {
|
||||
inner: Arc::clone(&services.sandbox),
|
||||
worktree_dir: wt_path_str.clone(),
|
||||
});
|
||||
(env, Some(PathBuf::from(wt_path_str)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(Arc::clone(&services.sandbox), None)
|
||||
};
|
||||
|
||||
branch_setups.push(BranchSetup {
|
||||
target_id,
|
||||
|
|
@ -407,8 +411,10 @@ impl Handler for ParallelHandler {
|
|||
let branch_start = Instant::now();
|
||||
|
||||
let Some(target_node) = graph.nodes.get(&setup.target_id) else {
|
||||
let outcome =
|
||||
Outcome::fail_classify(format!("branch target node not found: {}", setup.target_id));
|
||||
let outcome = Outcome::fail_classify(format!(
|
||||
"branch target node not found: {}",
|
||||
setup.target_id
|
||||
));
|
||||
emitter.emit(&WorkflowRunEvent::ParallelBranchCompleted {
|
||||
branch: setup.target_id.clone(),
|
||||
index: setup.branch_index,
|
||||
|
|
@ -572,11 +578,8 @@ impl Handler for ParallelHandler {
|
|||
}
|
||||
GitCheckpointMode::Remote(_) => {
|
||||
let wt_str = wt_path.to_string_lossy().to_string();
|
||||
crate::engine::git_remove_worktree_remote(
|
||||
&*services.sandbox,
|
||||
&wt_str,
|
||||
)
|
||||
.await;
|
||||
crate::engine::git_remove_worktree_remote(&*services.sandbox, &wt_str)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -601,8 +604,7 @@ impl Handler for ParallelHandler {
|
|||
.await;
|
||||
}
|
||||
GitCheckpointMode::Remote(_) => {
|
||||
crate::engine::git_merge_ff_only_remote(&*services.sandbox, sha)
|
||||
.await;
|
||||
crate::engine::git_merge_ff_only_remote(&*services.sandbox, sha).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,10 +197,7 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.status, StageStatus::Fail);
|
||||
assert_eq!(
|
||||
outcome.failure_reason(),
|
||||
Some("No script specified")
|
||||
);
|
||||
assert_eq!(outcome.failure_reason(), Some("No script specified"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -127,7 +127,9 @@ impl Handler for WaitHumanHandler {
|
|||
}
|
||||
|
||||
if choices.is_empty() && freeform_target.is_none() {
|
||||
return Ok(Outcome::fail_deterministic("No outgoing edges for human gate"));
|
||||
return Ok(Outcome::fail_deterministic(
|
||||
"No outgoing edges for human gate",
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Build question
|
||||
|
|
|
|||
|
|
@ -163,11 +163,7 @@ impl Interviewer for ConsoleInterviewer {
|
|||
|
||||
// Non-TTY fallback: line-based stdin reading
|
||||
let s = self.styles;
|
||||
eprintln!(
|
||||
"{} {}",
|
||||
s.bold_cyan.apply_to("?"),
|
||||
question.text,
|
||||
);
|
||||
eprintln!("{} {}", s.bold_cyan.apply_to("?"), question.text,);
|
||||
|
||||
match question.question_type {
|
||||
QuestionType::MultipleChoice | QuestionType::MultiSelect => {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ pub mod handler;
|
|||
pub mod interviewer;
|
||||
pub mod outcome;
|
||||
pub mod parser;
|
||||
pub mod workflow;
|
||||
pub mod preamble;
|
||||
pub mod retro;
|
||||
pub mod retro_agent;
|
||||
pub mod stylesheet;
|
||||
pub mod transform;
|
||||
pub mod validation;
|
||||
pub mod workflow;
|
||||
|
|
|
|||
|
|
@ -600,7 +600,10 @@ mod tests {
|
|||
let completed_nodes = vec!["plan".to_string(), "code".to_string()];
|
||||
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
||||
node_outcomes.insert("plan".to_string(), Outcome::success());
|
||||
node_outcomes.insert("code".to_string(), Outcome::fail_classify("compilation error"));
|
||||
node_outcomes.insert(
|
||||
"code".to_string(),
|
||||
Outcome::fail_classify("compilation error"),
|
||||
);
|
||||
|
||||
let preamble = build_preamble(
|
||||
"compact",
|
||||
|
|
@ -1329,7 +1332,10 @@ mod tests {
|
|||
let context = Context::new();
|
||||
let completed_nodes = vec!["work".to_string()];
|
||||
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
||||
node_outcomes.insert("work".to_string(), Outcome::fail_classify("connection timeout"));
|
||||
node_outcomes.insert(
|
||||
"work".to_string(),
|
||||
Outcome::fail_classify("connection timeout"),
|
||||
);
|
||||
|
||||
let preamble = build_preamble(
|
||||
"summary:high",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::sync::{Arc, Mutex};
|
|||
use std::time::Duration;
|
||||
|
||||
use arc_agent::{
|
||||
AnthropicProfile, Sandbox, GeminiProfile, OpenAiProfile, ProviderProfile, Session,
|
||||
AnthropicProfile, GeminiProfile, OpenAiProfile, ProviderProfile, Sandbox, Session,
|
||||
SessionConfig,
|
||||
};
|
||||
use arc_llm::client::Client;
|
||||
|
|
@ -161,12 +161,7 @@ pub async fn run_retro_agent(
|
|||
..SessionConfig::default()
|
||||
};
|
||||
|
||||
let mut session = Session::new(
|
||||
llm_client.clone(),
|
||||
profile,
|
||||
Arc::clone(sandbox),
|
||||
config,
|
||||
);
|
||||
let mut session = Session::new(llm_client.clone(), profile, Arc::clone(sandbox), config);
|
||||
|
||||
session.initialize().await;
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use arc_workflows::artifact::sync_artifacts_to_env;
|
|||
use arc_workflows::checkpoint::Checkpoint;
|
||||
use arc_workflows::context::Context;
|
||||
use arc_workflows::daytona_sandbox::{DaytonaConfig, DaytonaSandbox};
|
||||
use arc_workflows::engine::{WorkflowRunEngine, RunConfig};
|
||||
use arc_workflows::engine::{RunConfig, WorkflowRunEngine};
|
||||
use arc_workflows::error::ArcError;
|
||||
use arc_workflows::event::EventEmitter;
|
||||
use arc_workflows::graph::{AttrValue, Edge, Graph, Node};
|
||||
|
|
@ -1081,22 +1081,26 @@ async fn daytona_asset_collection() {
|
|||
);
|
||||
|
||||
let mut start = Node::new("start");
|
||||
start
|
||||
.attrs
|
||||
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
|
||||
start.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("Mdiamond".to_string()),
|
||||
);
|
||||
graph.nodes.insert("start".to_string(), start);
|
||||
|
||||
let mut create_assets = Node::new("create_assets");
|
||||
create_assets
|
||||
.attrs
|
||||
.insert("label".to_string(), AttrValue::String("Create Assets".to_string()));
|
||||
create_assets.attrs.insert(
|
||||
"label".to_string(),
|
||||
AttrValue::String("Create Assets".to_string()),
|
||||
);
|
||||
graph
|
||||
.nodes
|
||||
.insert("create_assets".to_string(), create_assets);
|
||||
|
||||
let mut exit = Node::new("exit");
|
||||
exit.attrs
|
||||
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
|
||||
exit.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("Msquare".to_string()),
|
||||
);
|
||||
graph.nodes.insert("exit".to_string(), exit);
|
||||
|
||||
graph.edges.push(Edge::new("start", "create_assets"));
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use arc_util::terminal::Styles;
|
|||
use arc_workflows::checkpoint::Checkpoint;
|
||||
use arc_workflows::cli::backend::AgentApiBackend;
|
||||
use arc_workflows::context::Context;
|
||||
use arc_workflows::engine::{WorkflowRunEngine, RunConfig};
|
||||
use arc_workflows::engine::{RunConfig, WorkflowRunEngine};
|
||||
use arc_workflows::error::ArcError;
|
||||
use arc_workflows::event::{EventEmitter, WorkflowRunEvent};
|
||||
use arc_workflows::graph::{AttrValue, Edge, Graph, Node};
|
||||
|
|
@ -492,7 +492,10 @@ impl Handler for AlwaysFailHandler {
|
|||
_logs_root: &Path,
|
||||
_services: &arc_workflows::handler::EngineServices,
|
||||
) -> Result<Outcome, arc_workflows::error::ArcError> {
|
||||
Ok(Outcome::fail_classify(format!("forced failure for {}", node.id)))
|
||||
Ok(Outcome::fail_classify(format!(
|
||||
"forced failure for {}",
|
||||
node.id
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1249,10 +1252,7 @@ fn make_full_registry(interviewer: Arc<dyn Interviewer>) -> HandlerRegistry {
|
|||
registry.register("conditional", Box::new(ConditionalHandler));
|
||||
registry.register("script", Box::new(ScriptHandler));
|
||||
registry.register("wait.human", Box::new(WaitHumanHandler::new(interviewer)));
|
||||
registry.register(
|
||||
"stack.manager_loop",
|
||||
Box::new(SubWorkflowHandler),
|
||||
);
|
||||
registry.register("stack.manager_loop", Box::new(SubWorkflowHandler));
|
||||
registry
|
||||
}
|
||||
|
||||
|
|
@ -3383,10 +3383,9 @@ async fn manager_loop_context_flows_e2e() {
|
|||
outcome
|
||||
.context_updates
|
||||
.insert("review.result".to_string(), serde_json::json!("approved"));
|
||||
outcome.context_updates.insert(
|
||||
"review.echo".to_string(),
|
||||
serde_json::json!(target),
|
||||
);
|
||||
outcome
|
||||
.context_updates
|
||||
.insert("review.echo".to_string(), serde_json::json!(target));
|
||||
Ok(outcome)
|
||||
}
|
||||
}
|
||||
|
|
@ -3406,18 +3405,18 @@ async fn manager_loop_context_flows_e2e() {
|
|||
_services: &arc_workflows::handler::EngineServices,
|
||||
) -> Result<Outcome, ArcError> {
|
||||
let mut outcome = Outcome::success();
|
||||
outcome
|
||||
.context_updates
|
||||
.insert("review.target".to_string(), serde_json::json!("src/main.rs"));
|
||||
outcome.context_updates.insert(
|
||||
"review.target".to_string(),
|
||||
serde_json::json!("src/main.rs"),
|
||||
);
|
||||
Ok(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
let mut setter = Node::new("setter");
|
||||
setter.attrs.insert(
|
||||
"type".to_string(),
|
||||
AttrValue::String("setter".to_string()),
|
||||
);
|
||||
setter
|
||||
.attrs
|
||||
.insert("type".to_string(), AttrValue::String("setter".to_string()));
|
||||
graph.nodes.insert("setter".to_string(), setter);
|
||||
|
||||
let mut supervisor = Node::new("supervisor");
|
||||
|
|
@ -4876,7 +4875,8 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() {
|
|||
captures: captures_low.clone(),
|
||||
}),
|
||||
);
|
||||
let engine_low = WorkflowRunEngine::new(registry_low, Arc::new(EventEmitter::new()), local_env());
|
||||
let engine_low =
|
||||
WorkflowRunEngine::new(registry_low, Arc::new(EventEmitter::new()), local_env());
|
||||
let config_low = RunConfig {
|
||||
logs_root: dir_low.path().to_path_buf(),
|
||||
cancel_token: None,
|
||||
|
|
@ -4940,7 +4940,8 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() {
|
|||
captures: captures_med.clone(),
|
||||
}),
|
||||
);
|
||||
let engine_med = WorkflowRunEngine::new(registry_med, Arc::new(EventEmitter::new()), local_env());
|
||||
let engine_med =
|
||||
WorkflowRunEngine::new(registry_med, Arc::new(EventEmitter::new()), local_env());
|
||||
let config_med = RunConfig {
|
||||
logs_root: dir_med.path().to_path_buf(),
|
||||
cancel_token: None,
|
||||
|
|
@ -5542,7 +5543,7 @@ mod real_llm {
|
|||
|
||||
use super::local_env;
|
||||
use arc_workflows::checkpoint::Checkpoint;
|
||||
use arc_workflows::engine::{WorkflowRunEngine, RunConfig};
|
||||
use arc_workflows::engine::{RunConfig, WorkflowRunEngine};
|
||||
use arc_workflows::event::EventEmitter;
|
||||
use arc_workflows::graph::{AttrValue, Edge, Graph};
|
||||
use arc_workflows::handler::exit::ExitHandler;
|
||||
|
|
@ -7694,7 +7695,11 @@ impl arc_agent::Sandbox for RemoteMockEnv {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_file_to_local(&self, _: &str, _: &std::path::Path) -> std::result::Result<(), String> {
|
||||
async fn download_file_to_local(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &std::path::Path,
|
||||
) -> std::result::Result<(), String> {
|
||||
Err("not implemented".to_string())
|
||||
}
|
||||
|
||||
|
|
@ -7738,7 +7743,8 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() {
|
|||
registry.register("exit", Box::new(ExitHandler));
|
||||
|
||||
let remote_env = Arc::new(RemoteMockEnv::new("/sandbox"));
|
||||
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), remote_env.clone());
|
||||
let engine =
|
||||
WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), remote_env.clone());
|
||||
let config = RunConfig {
|
||||
logs_root: dir.path().to_path_buf(),
|
||||
cancel_token: None,
|
||||
|
|
@ -7922,7 +7928,7 @@ async fn node_dir_uses_visit_count_on_revisit() {
|
|||
// CLI Backend end-to-end tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use arc_workflows::cli::cli_backend::{BackendRouter, AgentCliBackend};
|
||||
use arc_workflows::cli::cli_backend::{AgentCliBackend, BackendRouter};
|
||||
|
||||
/// A mock sandbox for CLI backend e2e tests.
|
||||
/// Records all exec_command and write_file calls, and returns configurable
|
||||
|
|
@ -8355,8 +8361,7 @@ async fn cli_backend_run_fails_on_nonzero_exit() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn cli_backend_run_fails_on_unparseable_output() {
|
||||
let env: Arc<dyn arc_agent::Sandbox> =
|
||||
Arc::new(CliTestEnv::new("this is not json at all"));
|
||||
let env: Arc<dyn arc_agent::Sandbox> = Arc::new(CliTestEnv::new("this is not json at all"));
|
||||
let backend = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic);
|
||||
|
||||
let node = Node::new("step");
|
||||
|
|
@ -9080,9 +9085,8 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() {
|
|||
let mut emitter = EventEmitter::new();
|
||||
let events = collect_events(&mut emitter);
|
||||
|
||||
let env: Arc<dyn arc_agent::Sandbox> = Arc::new(
|
||||
arc_agent::LocalSandbox::new(worktree_path.clone()),
|
||||
);
|
||||
let env: Arc<dyn arc_agent::Sandbox> =
|
||||
Arc::new(arc_agent::LocalSandbox::new(worktree_path.clone()));
|
||||
let mut registry = HandlerRegistry::new(Box::new(ContextSetterHandler));
|
||||
registry.register("start", Box::new(StartHandler));
|
||||
registry.register("exit", Box::new(ExitHandler));
|
||||
|
|
@ -9260,9 +9264,8 @@ async fn git_checkpoint_host_writes_shadow_branch() {
|
|||
std::fs::write(logs_dir.path().join("graph.dot"), "digraph {}").unwrap();
|
||||
let emitter = EventEmitter::new();
|
||||
|
||||
let env: Arc<dyn arc_agent::Sandbox> = Arc::new(
|
||||
arc_agent::LocalSandbox::new(worktree_path.clone()),
|
||||
);
|
||||
let env: Arc<dyn arc_agent::Sandbox> =
|
||||
Arc::new(arc_agent::LocalSandbox::new(worktree_path.clone()));
|
||||
let mut registry = HandlerRegistry::new(Box::new(ContextSetterHandler));
|
||||
registry.register("start", Box::new(StartHandler));
|
||||
registry.register("exit", Box::new(ExitHandler));
|
||||
|
|
@ -9447,9 +9450,8 @@ async fn parallel_git_branching_host_e2e() {
|
|||
let mut emitter = EventEmitter::new();
|
||||
let events = collect_events(&mut emitter);
|
||||
|
||||
let env: Arc<dyn arc_agent::Sandbox> = Arc::new(
|
||||
arc_agent::LocalSandbox::new(worktree_path.clone()),
|
||||
);
|
||||
let env: Arc<dyn arc_agent::Sandbox> =
|
||||
Arc::new(arc_agent::LocalSandbox::new(worktree_path.clone()));
|
||||
|
||||
let mut registry = HandlerRegistry::new(Box::new(FileWriterHandler));
|
||||
registry.register("start", Box::new(StartHandler));
|
||||
|
|
@ -9700,8 +9702,10 @@ impl Handler for SignatureHintHandler {
|
|||
_logs_root: &Path,
|
||||
_services: &arc_workflows::handler::EngineServices,
|
||||
) -> Result<Outcome, ArcError> {
|
||||
Ok(Outcome::fail_classify("error at line 42 in commit abc123def0")
|
||||
.with_signature(Some("custom-grouping-key")))
|
||||
Ok(
|
||||
Outcome::fail_classify("error at line 42 in commit abc123def0")
|
||||
.with_signature(Some("custom-grouping-key")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -10669,8 +10673,7 @@ impl Handler for ClassifiedFailHandler {
|
|||
if n >= self.succeed_on {
|
||||
return Ok(Outcome::success());
|
||||
}
|
||||
let failure_class: arc_workflows::error::FailureClass =
|
||||
self.failure_class.parse().unwrap();
|
||||
let failure_class: arc_workflows::error::FailureClass = self.failure_class.parse().unwrap();
|
||||
let mut outcome = Outcome::fail_classify("classified failure");
|
||||
if let Some(ref mut f) = outcome.failure {
|
||||
f.failure_class = failure_class;
|
||||
|
|
@ -10706,7 +10709,10 @@ async fn e2e_loop_restart_blocked_for_deterministic_failure() {
|
|||
};
|
||||
|
||||
let result = engine.run(&graph, &config).await;
|
||||
assert!(result.is_err(), "deterministic failure should not loop_restart");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"deterministic failure should not loop_restart"
|
||||
);
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("loop_restart blocked"),
|
||||
|
|
@ -10741,7 +10747,10 @@ async fn e2e_loop_restart_blocked_for_structural_failure() {
|
|||
};
|
||||
|
||||
let result = engine.run(&graph, &config).await;
|
||||
assert!(result.is_err(), "structural failure should not loop_restart");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"structural failure should not loop_restart"
|
||||
);
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("loop_restart blocked"),
|
||||
|
|
@ -10776,7 +10785,10 @@ async fn e2e_loop_restart_blocked_for_budget_exhausted_failure() {
|
|||
};
|
||||
|
||||
let result = engine.run(&graph, &config).await;
|
||||
assert!(result.is_err(), "budget_exhausted failure should not loop_restart");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"budget_exhausted failure should not loop_restart"
|
||||
);
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("loop_restart blocked"),
|
||||
|
|
@ -10846,7 +10858,10 @@ async fn e2e_loop_restart_blocked_for_compilation_loop_failure() {
|
|||
};
|
||||
|
||||
let result = engine.run(&graph, &config).await;
|
||||
assert!(result.is_err(), "compilation_loop failure should not loop_restart");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"compilation_loop failure should not loop_restart"
|
||||
);
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("loop_restart blocked"),
|
||||
|
|
@ -10967,10 +10982,7 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() {
|
|||
let events_clone = events.clone();
|
||||
let mut emitter = EventEmitter::new();
|
||||
emitter.on_event(move |event| {
|
||||
events_clone
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("{event:?}"));
|
||||
events_clone.lock().unwrap().push(format!("{event:?}"));
|
||||
});
|
||||
|
||||
let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), local_env());
|
||||
|
|
@ -11040,7 +11052,10 @@ async fn e2e_stall_watchdog_kept_alive_by_handler_events() {
|
|||
labels: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
let outcome = engine.run(&graph, &config).await.expect("pipeline should succeed");
|
||||
let outcome = engine
|
||||
.run(&graph, &config)
|
||||
.await
|
||||
.expect("pipeline should succeed");
|
||||
assert_eq!(outcome.status, StageStatus::Success);
|
||||
}
|
||||
|
||||
|
|
@ -11056,7 +11071,11 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() {
|
|||
start -> work -> exit
|
||||
}"#;
|
||||
let graph = parse(dot).expect("parse should succeed");
|
||||
assert_eq!(graph.stall_timeout(), None, "zero timeout should disable watchdog");
|
||||
assert_eq!(
|
||||
graph.stall_timeout(),
|
||||
None,
|
||||
"zero timeout should disable watchdog"
|
||||
);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
|
||||
|
|
@ -11077,7 +11096,10 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() {
|
|||
labels: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
let outcome = engine.run(&graph, &config).await.expect("pipeline should succeed");
|
||||
let outcome = engine
|
||||
.run(&graph, &config)
|
||||
.await
|
||||
.expect("pipeline should succeed");
|
||||
assert_eq!(outcome.status, StageStatus::Success);
|
||||
}
|
||||
|
||||
|
|
@ -11228,22 +11250,26 @@ async fn asset_collection_local_sandbox_success() {
|
|||
);
|
||||
|
||||
let mut start = Node::new("start");
|
||||
start
|
||||
.attrs
|
||||
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
|
||||
start.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("Mdiamond".to_string()),
|
||||
);
|
||||
graph.nodes.insert("start".to_string(), start);
|
||||
|
||||
let mut create_assets = Node::new("create_assets");
|
||||
create_assets
|
||||
.attrs
|
||||
.insert("label".to_string(), AttrValue::String("Create Assets".to_string()));
|
||||
create_assets.attrs.insert(
|
||||
"label".to_string(),
|
||||
AttrValue::String("Create Assets".to_string()),
|
||||
);
|
||||
graph
|
||||
.nodes
|
||||
.insert("create_assets".to_string(), create_assets);
|
||||
|
||||
let mut exit = Node::new("exit");
|
||||
exit.attrs
|
||||
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
|
||||
exit.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("Msquare".to_string()),
|
||||
);
|
||||
graph.nodes.insert("exit".to_string(), exit);
|
||||
|
||||
graph.edges.push(Edge::new("start", "create_assets"));
|
||||
|
|
@ -11261,7 +11287,10 @@ async fn asset_collection_local_sandbox_success() {
|
|||
labels: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
|
||||
let outcome = engine
|
||||
.run(&graph, &config)
|
||||
.await
|
||||
.expect("run should succeed");
|
||||
assert_eq!(outcome.status, StageStatus::Success);
|
||||
|
||||
// Check that asset files were collected into the stage directory
|
||||
|
|
@ -11318,11 +11347,7 @@ async fn asset_collection_local_sandbox_on_failure() {
|
|||
registry.register("start", Box::new(StartHandler));
|
||||
registry.register("exit", Box::new(ExitHandler));
|
||||
|
||||
let engine = WorkflowRunEngine::new(
|
||||
registry,
|
||||
Arc::new(EventEmitter::new()),
|
||||
sandbox.clone(),
|
||||
);
|
||||
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), sandbox.clone());
|
||||
|
||||
let mut graph = Graph::new("AssetCollectionFailTest");
|
||||
graph.attrs.insert(
|
||||
|
|
@ -11331,22 +11356,26 @@ async fn asset_collection_local_sandbox_on_failure() {
|
|||
);
|
||||
|
||||
let mut start = Node::new("start");
|
||||
start
|
||||
.attrs
|
||||
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
|
||||
start.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("Mdiamond".to_string()),
|
||||
);
|
||||
graph.nodes.insert("start".to_string(), start);
|
||||
|
||||
let mut create_assets = Node::new("create_assets");
|
||||
create_assets
|
||||
.attrs
|
||||
.insert("label".to_string(), AttrValue::String("Create Assets".to_string()));
|
||||
create_assets.attrs.insert(
|
||||
"label".to_string(),
|
||||
AttrValue::String("Create Assets".to_string()),
|
||||
);
|
||||
graph
|
||||
.nodes
|
||||
.insert("create_assets".to_string(), create_assets);
|
||||
|
||||
let mut exit = Node::new("exit");
|
||||
exit.attrs
|
||||
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
|
||||
exit.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("Msquare".to_string()),
|
||||
);
|
||||
graph.nodes.insert("exit".to_string(), exit);
|
||||
|
||||
graph.edges.push(Edge::new("start", "create_assets"));
|
||||
|
|
@ -11364,7 +11393,10 @@ async fn asset_collection_local_sandbox_on_failure() {
|
|||
labels: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
|
||||
let outcome = engine
|
||||
.run(&graph, &config)
|
||||
.await
|
||||
.expect("run should succeed");
|
||||
// The pipeline completes (handler returned Fail, not an error), but assets should still be collected
|
||||
assert_eq!(outcome.status, StageStatus::Fail);
|
||||
|
||||
|
|
@ -11404,11 +11436,7 @@ async fn asset_collection_docker_sandbox() {
|
|||
registry.register("start", Box::new(StartHandler));
|
||||
registry.register("exit", Box::new(ExitHandler));
|
||||
|
||||
let engine = WorkflowRunEngine::new(
|
||||
registry,
|
||||
Arc::new(EventEmitter::new()),
|
||||
sandbox.clone(),
|
||||
);
|
||||
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), sandbox.clone());
|
||||
|
||||
let mut graph = Graph::new("DockerAssetTest");
|
||||
graph.attrs.insert(
|
||||
|
|
@ -11417,22 +11445,26 @@ async fn asset_collection_docker_sandbox() {
|
|||
);
|
||||
|
||||
let mut start = Node::new("start");
|
||||
start
|
||||
.attrs
|
||||
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
|
||||
start.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("Mdiamond".to_string()),
|
||||
);
|
||||
graph.nodes.insert("start".to_string(), start);
|
||||
|
||||
let mut create_assets = Node::new("create_assets");
|
||||
create_assets
|
||||
.attrs
|
||||
.insert("label".to_string(), AttrValue::String("Create Assets".to_string()));
|
||||
create_assets.attrs.insert(
|
||||
"label".to_string(),
|
||||
AttrValue::String("Create Assets".to_string()),
|
||||
);
|
||||
graph
|
||||
.nodes
|
||||
.insert("create_assets".to_string(), create_assets);
|
||||
|
||||
let mut exit = Node::new("exit");
|
||||
exit.attrs
|
||||
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
|
||||
exit.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("Msquare".to_string()),
|
||||
);
|
||||
graph.nodes.insert("exit".to_string(), exit);
|
||||
|
||||
graph.edges.push(Edge::new("start", "create_assets"));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue