From 8aa56285f47e934829f03e70b30f431ebec5a81d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 1 Mar 2026 12:11:24 -0500 Subject: [PATCH] Rename remaining attractor references to arc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AttractorError → ArcError across 30 source files - .attractor/ → .arc/ for artifacts and skills paths - ATTRACTOR_NODE_ID → ARC_NODE_ID env var - attractor-rust → arc in Cargo.toml repository URLs - attractor-spec.md → arc-spec.md with content updates - Update server banner, test comments, README examples, and docs Co-Authored-By: Claude Opus 4.6 --- crates/arc-agent/Cargo.toml | 2 +- crates/arc-agent/src/skills.rs | 10 +- crates/arc-api/src/serve.rs | 2 +- crates/arc-api/src/server.rs | 2 +- crates/arc-cli/tests/cli.rs | 6 +- crates/arc-git-storage/Cargo.toml | 2 +- crates/arc-llm/Cargo.toml | 2 +- crates/arc-workflows/Cargo.toml | 2 +- crates/arc-workflows/README.md | 30 +- crates/arc-workflows/src/artifact.rs | 24 +- crates/arc-workflows/src/checkpoint.rs | 6 +- crates/arc-workflows/src/cli/backend.rs | 21 +- crates/arc-workflows/src/cli/cli_backend.rs | 20 +- crates/arc-workflows/src/cli/run.rs | 4 +- crates/arc-workflows/src/cli/task_config.rs | 4 +- crates/arc-workflows/src/condition.rs | 53 +- crates/arc-workflows/src/daytona_env.rs | 12 +- crates/arc-workflows/src/engine.rs | 164 +++++- crates/arc-workflows/src/error.rs | 515 +++++++++++++++++- crates/arc-workflows/src/git.rs | 8 +- crates/arc-workflows/src/graph/types.rs | 8 +- crates/arc-workflows/src/handler/codergen.rs | 36 +- .../arc-workflows/src/handler/conditional.rs | 4 +- crates/arc-workflows/src/handler/exit.rs | 4 +- crates/arc-workflows/src/handler/fan_in.rs | 8 +- .../arc-workflows/src/handler/manager_loop.rs | 10 +- crates/arc-workflows/src/handler/mod.rs | 20 +- crates/arc-workflows/src/handler/parallel.rs | 8 +- crates/arc-workflows/src/handler/script.rs | 10 +- crates/arc-workflows/src/handler/start.rs | 4 +- .../arc-workflows/src/handler/sub_pipeline.rs | 8 +- .../arc-workflows/src/handler/wait_human.rs | 4 +- .../src/interviewer/recording.rs | 14 +- crates/arc-workflows/src/parser/mod.rs | 8 +- crates/arc-workflows/src/parser/semantic.rs | 4 +- crates/arc-workflows/src/pipeline.rs | 8 +- crates/arc-workflows/src/stylesheet.rs | 22 +- crates/arc-workflows/src/validation/mod.rs | 8 +- .../tests/daytona_integration.rs | 12 +- crates/arc-workflows/tests/integration.rs | 54 +- docs/spec-structure.md | 4 +- docs/specs/README.md | 12 +- docs/specs/{attractor-spec.md => arc-spec.md} | 14 +- 43 files changed, 909 insertions(+), 264 deletions(-) rename docs/specs/{attractor-spec.md => arc-spec.md} (98%) diff --git a/crates/arc-agent/Cargo.toml b/crates/arc-agent/Cargo.toml index 6eba64abc..0c80fbcae 100644 --- a/crates/arc-agent/Cargo.toml +++ b/crates/arc-agent/Cargo.toml @@ -4,7 +4,7 @@ edition.workspace = true version.workspace = true license.workspace = true description = "A programmable agentic loop for coding agents" -repository = "https://github.com/brynary/attractor-rust" +repository = "https://github.com/brynary/arc" readme = "README.md" keywords = ["llm", "ai", "agent", "coding"] categories = ["api-bindings"] diff --git a/crates/arc-agent/src/skills.rs b/crates/arc-agent/src/skills.rs index 2b60940d5..8aecdf5ad 100644 --- a/crates/arc-agent/src/skills.rs +++ b/crates/arc-agent/src/skills.rs @@ -209,11 +209,11 @@ pub fn default_skill_dirs(home_dir: Option<&str>, git_root: Option<&str>) -> Vec let mut dirs = Vec::new(); if let Some(home) = home_dir { - dirs.push(format!("{home}/.attractor/skills")); + dirs.push(format!("{home}/.arc/skills")); } if let Some(root) = git_root { - dirs.push(format!("{root}/.attractor/skills")); + dirs.push(format!("{root}/.arc/skills")); dirs.push(format!("{root}/skills")); } @@ -521,8 +521,8 @@ name: trimmed assert_eq!( dirs, vec![ - "/home/user/.attractor/skills", - "/repo/.attractor/skills", + "/home/user/.arc/skills", + "/repo/.arc/skills", "/repo/skills", ] ); @@ -531,7 +531,7 @@ name: trimmed #[test] fn default_dirs_without_git_root() { let dirs = default_skill_dirs(Some("/home/user"), None); - assert_eq!(dirs, vec!["/home/user/.attractor/skills"]); + assert_eq!(dirs, vec!["/home/user/.arc/skills"]); } // --- make_use_skill_tool tests --- diff --git a/crates/arc-api/src/serve.rs b/crates/arc-api/src/serve.rs index 798d873dc..4f125f53f 100644 --- a/crates/arc-api/src/serve.rs +++ b/crates/arc-api/src/serve.rs @@ -116,7 +116,7 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: let listener = TcpListener::bind(&addr).await?; eprintln!( - "{bold}Attractor server listening on {green}{addr}{reset}", + "{bold}Arc server listening on {green}{addr}{reset}", bold = styles.bold, green = styles.green, reset = styles.reset, diff --git a/crates/arc-api/src/server.rs b/crates/arc-api/src/server.rs index 64adaf973..7e7e9a6cc 100644 --- a/crates/arc-api/src/server.rs +++ b/crates/arc-api/src/server.rs @@ -245,7 +245,7 @@ async fn start_pipeline( Ok(_) => { pipeline.status = PipelineStatus::Completed; } - Err(arc_workflows::error::AttractorError::Cancelled) => { + Err(arc_workflows::error::ArcError::Cancelled) => { pipeline.status = PipelineStatus::Cancelled; } Err(e) => { diff --git a/crates/arc-cli/tests/cli.rs b/crates/arc-cli/tests/cli.rs index e4a9fe7e5..f479cec7b 100644 --- a/crates/arc-cli/tests/cli.rs +++ b/crates/arc-cli/tests/cli.rs @@ -328,7 +328,7 @@ fn agent_invalid_permissions_value() { .stderr(predicate::str::contains("invalid value")); } -// == Attractor: validate ====================================================== +// == Arc: validate ====================================================== #[test] fn validate_simple() { @@ -392,7 +392,7 @@ fn validate_invalid() { .failure(); } -// == Attractor: serve ========================================================= +// == Arc: serve ========================================================= #[test] fn serve_help() { @@ -407,7 +407,7 @@ fn serve_help() { .stdout(predicate::str::contains("--provider")); } -// == Attractor: run --dry-run ================================================= +// == Arc: run --dry-run ================================================= #[test] fn dry_run_simple() { diff --git a/crates/arc-git-storage/Cargo.toml b/crates/arc-git-storage/Cargo.toml index 5c20d59c4..e8bed8923 100644 --- a/crates/arc-git-storage/Cargo.toml +++ b/crates/arc-git-storage/Cargo.toml @@ -4,7 +4,7 @@ edition.workspace = true version.workspace = true license.workspace = true description = "Store structured data in git without touching the working directory" -repository = "https://github.com/brynary/attractor-rust" +repository = "https://github.com/brynary/arc" [lib] doctest = false diff --git a/crates/arc-llm/Cargo.toml b/crates/arc-llm/Cargo.toml index 5a4296cb9..cd467d186 100644 --- a/crates/arc-llm/Cargo.toml +++ b/crates/arc-llm/Cargo.toml @@ -4,7 +4,7 @@ edition.workspace = true version.workspace = true license.workspace = true description = "A unified client library for multiple LLM providers" -repository = "https://github.com/brynary/attractor-rust" +repository = "https://github.com/brynary/arc" readme = "README.md" keywords = ["llm", "ai", "openai", "anthropic"] categories = ["api-bindings"] diff --git a/crates/arc-workflows/Cargo.toml b/crates/arc-workflows/Cargo.toml index 1325a8057..d1d84daa6 100644 --- a/crates/arc-workflows/Cargo.toml +++ b/crates/arc-workflows/Cargo.toml @@ -4,7 +4,7 @@ edition.workspace = true version.workspace = true license.workspace = true description = "A DOT-based pipeline runner for multi-stage AI workflows" -repository = "https://github.com/brynary/attractor-rust" +repository = "https://github.com/brynary/arc" keywords = ["llm", "ai", "pipeline", "workflow", "dot"] categories = ["development-tools"] readme = "README.md" diff --git a/crates/arc-workflows/README.md b/crates/arc-workflows/README.md index 9cd18d003..a92acd024 100644 --- a/crates/arc-workflows/README.md +++ b/crates/arc-workflows/README.md @@ -1,4 +1,4 @@ -# attractor +# arc-workflows A DOT-based pipeline runner for multi-stage AI workflows. Define workflows as Graphviz `digraph` files and execute them with pluggable handlers, conditional routing, human-in-the-loop gates, parallel branching, retry policies, and checkpoint-based recovery. @@ -41,7 +41,7 @@ digraph MyPipeline { ### Parsing and Validating a Pipeline ```rust -use attractor::pipeline::prepare_pipeline; +use arc_workflows::pipeline::prepare_pipeline; let dot_source = r#"digraph Simple { graph [goal="Run tests"] @@ -62,13 +62,13 @@ assert_eq!(graph.goal(), "Run tests"); ### Running a Pipeline ```rust -use attractor::engine::{PipelineEngine, RunConfig}; -use attractor::event::EventEmitter; -use attractor::handler::HandlerRegistry; -use attractor::handler::start::StartHandler; -use attractor::handler::exit::ExitHandler; -use attractor::handler::codergen::CodergenHandler; -use attractor::pipeline::prepare_pipeline; +use arc_workflows::engine::{PipelineEngine, RunConfig}; +use arc_workflows::event::EventEmitter; +use arc_workflows::handler::HandlerRegistry; +use arc_workflows::handler::start::StartHandler; +use arc_workflows::handler::exit::ExitHandler; +use arc_workflows::handler::codergen::CodergenHandler; +use arc_workflows::pipeline::prepare_pipeline; let graph = prepare_pipeline(dot_source).unwrap(); @@ -90,11 +90,11 @@ let config = RunConfig { Implement the `Handler` trait to add custom node behavior: ```rust -use attractor::handler::Handler; -use attractor::context::Context; -use attractor::graph::{Graph, Node}; -use attractor::outcome::Outcome; -use attractor::error::AttractorError; +use arc_workflows::handler::Handler; +use arc_workflows::context::Context; +use arc_workflows::graph::{Graph, Node}; +use arc_workflows::outcome::Outcome; +use arc_workflows::error::ArcError; use async_trait::async_trait; use std::path::Path; @@ -108,7 +108,7 @@ impl Handler for MyHandler { context: &Context, graph: &Graph, logs_root: &Path, - ) -> Result { + ) -> Result { // Custom logic here Ok(Outcome::success()) } diff --git a/crates/arc-workflows/src/artifact.rs b/crates/arc-workflows/src/artifact.rs index 8d28a53bb..2d688af23 100644 --- a/crates/arc-workflows/src/artifact.rs +++ b/crates/arc-workflows/src/artifact.rs @@ -8,7 +8,7 @@ use serde_json::Value; use arc_agent::ExecutionEnvironment; -use crate::error::{AttractorError, Result}; +use crate::error::{ArcError, Result}; /// Threshold above which artifacts are stored on disk instead of in memory (100KB). const FILE_BACKING_THRESHOLD: usize = 100 * 1024; @@ -66,7 +66,7 @@ impl ArtifactStore { let id = id.into(); let name = name.into(); let serialized = serde_json::to_string(&data) - .map_err(|e| AttractorError::Engine(format!("artifact serialize failed: {e}")))?; + .map_err(|e| ArcError::Engine(format!("artifact serialize failed: {e}")))?; let size_bytes = serialized.len(); let is_file_backed = size_bytes > FILE_BACKING_THRESHOLD && self.base_dir.is_some(); @@ -112,7 +112,7 @@ impl ArtifactStore { let guard = self.artifacts.read().expect("artifact lock poisoned"); let (_, stored) = guard .get(id) - .ok_or_else(|| AttractorError::Engine(format!("artifact not found: {id}")))?; + .ok_or_else(|| ArcError::Engine(format!("artifact not found: {id}")))?; match stored { StoredData::InMemory(v) => Ok(v.clone()), @@ -120,12 +120,12 @@ impl ArtifactStore { let path = path.clone(); drop(guard); let data = std::fs::read_to_string(&path).map_err(|e| { - AttractorError::Engine(format!( + ArcError::Engine(format!( "failed to read file-backed artifact {id}: {e}" )) })?; serde_json::from_str(&data).map_err(|e| { - AttractorError::Engine(format!( + ArcError::Engine(format!( "failed to deserialize file-backed artifact {id}: {e}" )) }) @@ -254,7 +254,7 @@ pub fn format_artifact_reference(path: &str) -> String { /// /// For each `file://` pointer in `updates`, checks whether the file is accessible /// in `env`. If not, reads the local file and uploads it via `env.write_file`, -/// placing it at `{working_directory}/.attractor/artifacts/{filename}`. The pointer +/// placing it at `{working_directory}/.arc/artifacts/{filename}`. The pointer /// is rewritten to reference the remote path. /// /// # Errors @@ -274,14 +274,14 @@ pub async fn sync_artifacts_to_env( Ok(true) => continue, Ok(false) => {} Err(e) => { - return Err(AttractorError::Engine(format!( + return Err(ArcError::Engine(format!( "failed to check artifact existence: {e}" ))); } } let content = std::fs::read_to_string(&local_path).map_err(|e| { - AttractorError::Engine(format!("failed to read local artifact {local_path}: {e}")) + ArcError::Engine(format!("failed to read local artifact {local_path}: {e}")) })?; let filename = std::path::Path::new(&local_path) @@ -290,12 +290,12 @@ pub async fn sync_artifacts_to_env( .unwrap_or("artifact.json"); let remote_path = format!( - "{}/.attractor/artifacts/{filename}", + "{}/.arc/artifacts/{filename}", env.working_directory() ); env.write_file(&remote_path, &content).await.map_err(|e| { - AttractorError::Engine(format!("failed to write artifact to remote env: {e}")) + ArcError::Engine(format!("failed to write artifact to remote env: {e}")) })?; *value = Value::String(format!("{ARTIFACT_POINTER_PREFIX}{remote_path}")); @@ -592,14 +592,14 @@ mod tests { assert_eq!(written.len(), 1); assert_eq!( written[0].0, - "/workspace/.attractor/artifacts/response.plan.json" + "/workspace/.arc/artifacts/response.plan.json" ); assert_eq!(written[0].1, r#""hello from artifact""#); let new_pointer = updates["response.plan"].as_str().unwrap(); assert_eq!( new_pointer, - "file:///workspace/.attractor/artifacts/response.plan.json" + "file:///workspace/.arc/artifacts/response.plan.json" ); } diff --git a/crates/arc-workflows/src/checkpoint.rs b/crates/arc-workflows/src/checkpoint.rs index 79f4a8572..c3e720723 100644 --- a/crates/arc-workflows/src/checkpoint.rs +++ b/crates/arc-workflows/src/checkpoint.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::context::Context; -use crate::error::{AttractorError, Result}; +use crate::error::{ArcError, Result}; use crate::outcome::Outcome; /// Serializable snapshot of execution state for crash recovery and resume. @@ -59,7 +59,7 @@ impl Checkpoint { /// Returns an error if serialization or file writing fails. pub fn save(&self, path: &Path) -> Result<()> { let json = serde_json::to_string_pretty(self) - .map_err(|e| AttractorError::Checkpoint(format!("serialize failed: {e}")))?; + .map_err(|e| ArcError::Checkpoint(format!("serialize failed: {e}")))?; std::fs::write(path, json)?; Ok(()) } @@ -72,7 +72,7 @@ impl Checkpoint { pub fn load(path: &Path) -> Result { let data = std::fs::read_to_string(path)?; let checkpoint: Self = serde_json::from_str(&data) - .map_err(|e| AttractorError::Checkpoint(format!("deserialize failed: {e}")))?; + .map_err(|e| ArcError::Checkpoint(format!("deserialize failed: {e}")))?; Ok(checkpoint) } } diff --git a/crates/arc-workflows/src/cli/backend.rs b/crates/arc-workflows/src/cli/backend.rs index 3aca438c2..04975bdaf 100644 --- a/crates/arc-workflows/src/cli/backend.rs +++ b/crates/arc-workflows/src/cli/backend.rs @@ -13,7 +13,7 @@ use arc_llm::provider::Provider; use arc_util::terminal::Styles; use crate::context::Context; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::graph::Node; use crate::handler::codergen::{CodergenBackend, CodergenResult}; use crate::outcome::StageUsage; @@ -51,10 +51,10 @@ impl AgentBackend { &self, node: &Node, execution_env: &Arc, - ) -> Result { + ) -> Result { let client = Client::from_env() .await - .map_err(|e| AttractorError::Handler(format!("Failed to create LLM client: {e}")))?; + .map_err(|e| ArcError::Handler(format!("Failed to create LLM client: {e}")))?; let mut profile = self.build_profile(); @@ -120,10 +120,10 @@ impl CodergenBackend for AgentBackend { node: &Node, prompt: &str, stage_dir: &std::path::Path, - ) -> Result { + ) -> Result { let client = Client::from_env() .await - .map_err(|e| AttractorError::Handler(format!("Failed to create LLM client: {e}")))?; + .map_err(|e| ArcError::Handler(format!("Failed to create LLM client: {e}")))?; let model = node.llm_model().unwrap_or(&self.model); let provider = node @@ -157,7 +157,7 @@ impl CodergenBackend for AgentBackend { let response = client .complete(&request) .await - .map_err(|e| AttractorError::Handler(format!("one_shot LLM call failed: {e}")))?; + .map_err(ArcError::Llm)?; if let Ok(json) = serde_json::to_string_pretty(&response) { let _ = tokio::fs::write(stage_dir.join("api_response.json"), json).await; @@ -199,7 +199,7 @@ impl CodergenBackend for AgentBackend { emitter: &Arc, stage_dir: &std::path::Path, execution_env: &Arc, - ) -> Result { + ) -> Result { let fidelity = context.get_string("internal.fidelity", ""); let reuse_key = if fidelity == "full" { thread_id.map(String::from) @@ -344,7 +344,12 @@ impl CodergenBackend for AgentBackend { } let result = session.process_input(prompt).await.map_err(|e| { - AttractorError::Handler(format!("Agent session failed: {e}")) + use arc_agent::AgentError; + match e { + AgentError::Llm(sdk_err) => ArcError::Llm(sdk_err), + AgentError::Aborted => ArcError::Cancelled, + other => ArcError::Handler(format!("Agent session failed: {other}")), + } }); // On error, drop the session (don't cache failed state). diff --git a/crates/arc-workflows/src/cli/cli_backend.rs b/crates/arc-workflows/src/cli/cli_backend.rs index 50ebf5dcf..fe136e455 100644 --- a/crates/arc-workflows/src/cli/cli_backend.rs +++ b/crates/arc-workflows/src/cli/cli_backend.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use arc_llm::provider::Provider; use crate::context::Context; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::event::EventEmitter; use crate::graph::Node; use crate::handler::codergen::{CodergenBackend, CodergenResult}; @@ -260,16 +260,16 @@ impl CodergenBackend for CliBackend { _emitter: &Arc, stage_dir: &Path, execution_env: &Arc, - ) -> Result { + ) -> Result { // 1. Snapshot git state before the CLI run let files_before = self.detect_changed_files(execution_env).await; // 2. Write prompt to temp file - let prompt_path = "/tmp/attractor_cli_prompt.txt"; + let prompt_path = "/tmp/arc_cli_prompt.txt"; execution_env .write_file(prompt_path, prompt) .await - .map_err(|e| AttractorError::Handler(format!("Failed to write prompt file: {e}")))?; + .map_err(|e| ArcError::Handler(format!("Failed to write prompt file: {e}")))?; // 3. Build and execute CLI command let model = node.llm_model().unwrap_or(&self.model); @@ -293,7 +293,7 @@ impl CodergenBackend for CliBackend { let result = execution_env .exec_command(&command, 600_000, None, None, None) .await - .map_err(|e| AttractorError::Handler(format!("CLI command failed: {e}")))?; + .map_err(|e| ArcError::Handler(format!("CLI command failed: {e}")))?; if let Ok(json) = serde_json::to_string_pretty(&serde_json::json!({ "exit_code": result.exit_code, @@ -305,7 +305,7 @@ impl CodergenBackend for CliBackend { } if result.exit_code != 0 { - return Err(AttractorError::Handler(format!( + return Err(ArcError::Handler(format!( "CLI command exited with code {}: {}", result.exit_code, result.stderr.chars().take(500).collect::() @@ -314,7 +314,7 @@ impl CodergenBackend for CliBackend { // 4. Parse the CLI output let parsed = parse_cli_response(provider, &result.stdout).ok_or_else(|| { - AttractorError::Handler("Failed to parse CLI output".to_string()) + ArcError::Handler("Failed to parse CLI output".to_string()) })?; // 5. Detect changed files @@ -387,7 +387,7 @@ impl CodergenBackend for BackendRouter { emitter: &Arc, stage_dir: &Path, execution_env: &Arc, - ) -> Result { + ) -> Result { if self.should_use_cli(node) { self.cli_backend .run(node, prompt, context, thread_id, emitter, stage_dir, execution_env) @@ -404,7 +404,7 @@ impl CodergenBackend for BackendRouter { node: &Node, prompt: &str, stage_dir: &Path, - ) -> Result { + ) -> Result { // CLI backend doesn't support one_shot, always route to API self.api_backend.one_shot(node, prompt, stage_dir).await } @@ -619,7 +619,7 @@ mod tests { _emitter: &Arc, _stage_dir: &Path, _execution_env: &Arc, - ) -> Result { + ) -> Result { Ok(CodergenResult::Text { text: "stub".to_string(), usage: None, diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index b4ea64203..dec6cb58f 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -126,7 +126,7 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu let logs_dir = args.logs_dir.unwrap_or_else(|| { let base = dirs::home_dir() .expect("could not determine home directory") - .join(".attractor") + .join(".arc") .join("logs"); base.join(format!( "arc-run-{}", @@ -732,7 +732,7 @@ async fn run_from_branch(args: RunArgs, run_branch: &str, styles: &'static Style let logs_dir = args.logs_dir.unwrap_or_else(|| { let base = dirs::home_dir() .expect("could not determine home directory") - .join(".attractor") + .join(".arc") .join("logs"); base.join(format!( "arc-resume-{}", diff --git a/crates/arc-workflows/src/cli/task_config.rs b/crates/arc-workflows/src/cli/task_config.rs index 65260be7c..f2a82a5f1 100644 --- a/crates/arc-workflows/src/cli/task_config.rs +++ b/crates/arc-workflows/src/cli/task_config.rs @@ -244,7 +244,7 @@ environment = "daytona" auto_stop_interval = 60 [execution.daytona.sandbox.labels] -project = "attractor" +project = "arc" [execution.daytona.snapshot] name = "my-snapshot" @@ -260,7 +260,7 @@ dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update" let daytona = execution.daytona.unwrap(); assert_eq!(daytona.sandbox.auto_stop_interval, Some(60)); let labels = daytona.sandbox.labels.unwrap(); - assert_eq!(labels["project"], "attractor"); + assert_eq!(labels["project"], "arc"); let snapshot = daytona.snapshot.unwrap(); assert_eq!(snapshot.name, "my-snapshot"); diff --git a/crates/arc-workflows/src/condition.rs b/crates/arc-workflows/src/condition.rs index 7dddfc872..2e45378db 100644 --- a/crates/arc-workflows/src/condition.rs +++ b/crates/arc-workflows/src/condition.rs @@ -3,7 +3,7 @@ /// Grammar: `ConditionExpr ::= Clause ('&&' Clause)*`, `Clause ::= Key Op Literal`, /// `Op ::= '=' | '!='`. use crate::context::Context; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::outcome::Outcome; #[derive(Debug, Clone, PartialEq, Eq)] @@ -20,7 +20,7 @@ enum Op { Truthy, } -fn parse_clauses(expr: &str) -> Result, AttractorError> { +fn parse_clauses(expr: &str) -> Result, ArcError> { let expr = expr.trim(); if expr.is_empty() { return Ok(Vec::new()); @@ -34,7 +34,7 @@ fn parse_clauses(expr: &str) -> Result, AttractorError> { let key = part[..pos].trim().to_string(); let value = part[pos + 2..].trim().to_string(); if key.is_empty() { - return Err(AttractorError::Parse(format!( + return Err(ArcError::Parse(format!( "empty key in condition clause: {part:?}" ))); } @@ -47,7 +47,7 @@ fn parse_clauses(expr: &str) -> Result, AttractorError> { let key = part[..pos].trim().to_string(); let value = part[pos + 1..].trim().to_string(); if key.is_empty() { - return Err(AttractorError::Parse(format!( + return Err(ArcError::Parse(format!( "empty key in condition clause: {part:?}" ))); } @@ -60,7 +60,7 @@ fn parse_clauses(expr: &str) -> Result, AttractorError> { // Bare key: truthiness check let key = part.to_string(); if key.is_empty() { - return Err(AttractorError::Parse(format!( + return Err(ArcError::Parse(format!( "empty key in condition clause: {part:?}" ))); } @@ -79,7 +79,7 @@ fn parse_clauses(expr: &str) -> Result, AttractorError> { /// # Errors /// /// Returns an error if the expression contains invalid syntax. -pub fn parse_condition(expr: &str) -> Result<(), AttractorError> { +pub fn parse_condition(expr: &str) -> Result<(), ArcError> { parse_clauses(expr)?; Ok(()) } @@ -323,4 +323,45 @@ mod tests { &context )); } + + #[test] + fn context_failure_class_matches_when_set() { + let outcome = make_outcome(StageStatus::Fail); + let context = Context::new(); + context.set("failure_class", serde_json::json!("budget_exhausted")); + assert!(evaluate_condition( + "context.failure_class=budget_exhausted", + &outcome, + &context + )); + } + + #[test] + fn context_failure_class_not_equals_on_success() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + context.set("failure_class", serde_json::json!("")); + assert!(evaluate_condition( + "context.failure_class!=transient_infra", + &outcome, + &context + )); + } + + #[test] + fn context_failure_class_combined_with_outcome() { + let outcome = make_outcome(StageStatus::Fail); + let context = Context::new(); + context.set("failure_class", serde_json::json!("transient_infra")); + assert!(evaluate_condition( + "outcome=fail && context.failure_class=transient_infra", + &outcome, + &context + )); + assert!(!evaluate_condition( + "outcome=fail && context.failure_class=deterministic", + &outcome, + &context + )); + } } diff --git a/crates/arc-workflows/src/daytona_env.rs b/crates/arc-workflows/src/daytona_env.rs index 77aff30d8..1bd532e28 100644 --- a/crates/arc-workflows/src/daytona_env.rs +++ b/crates/arc-workflows/src/daytona_env.rs @@ -722,24 +722,24 @@ mod tests { #[test] fn ssh_url_to_https_converts_git_at_syntax() { assert_eq!( - ssh_url_to_https("git@github.com:brynary/attractor-rust.git"), - "https://github.com/brynary/attractor-rust.git" + ssh_url_to_https("git@github.com:brynary/arc.git"), + "https://github.com/brynary/arc.git" ); } #[test] fn ssh_url_to_https_converts_ssh_protocol() { assert_eq!( - ssh_url_to_https("ssh://git@github.com/brynary/attractor-rust.git"), - "https://github.com/brynary/attractor-rust.git" + ssh_url_to_https("ssh://git@github.com/brynary/arc.git"), + "https://github.com/brynary/arc.git" ); } #[test] fn ssh_url_to_https_passes_through_https() { assert_eq!( - ssh_url_to_https("https://github.com/brynary/attractor-rust.git"), - "https://github.com/brynary/attractor-rust.git" + ssh_url_to_https("https://github.com/brynary/arc.git"), + "https://github.com/brynary/arc.git" ); } diff --git a/crates/arc-workflows/src/engine.rs b/crates/arc-workflows/src/engine.rs index f94b965fe..a64d785c4 100644 --- a/crates/arc-workflows/src/engine.rs +++ b/crates/arc-workflows/src/engine.rs @@ -16,7 +16,7 @@ use crate::artifact::{offload_large_values, sync_artifacts_to_env, ArtifactStore use crate::checkpoint::Checkpoint; use crate::condition::evaluate_condition; use crate::context::Context; -use crate::error::{AttractorError, Result}; +use crate::error::{ArcError, FailureClass, Result, classify_failure_reason}; use crate::event::{EventEmitter, PipelineEvent}; use crate::graph::{Edge, Graph, Node}; use crate::handler::{EngineServices, HandlerRegistry}; @@ -29,6 +29,37 @@ fn millis_u64(d: std::time::Duration) -> u64 { u64::try_from(d.as_millis()).unwrap_or(u64::MAX) } +/// Classify the failure mode of a completed outcome. +/// +/// Returns `None` for `Success`, `PartialSuccess`, and `Skipped` outcomes. +/// For failures, checks (in priority order): +/// 1. Handler hint in `context_updates["failure_class"]` +/// 2. String heuristics on `failure_reason` +/// 3. Default to `Deterministic` +#[must_use] +fn classify_outcome(outcome: &Outcome) -> Option { + match outcome.status { + StageStatus::Success | StageStatus::PartialSuccess | StageStatus::Skipped => None, + StageStatus::Fail | StageStatus::Retry => { + // Check handler hint in context_updates + if let Some(hint) = outcome.context_updates.get("failure_class") { + if let Some(s) = hint.as_str() { + if let Ok(fc) = s.parse::() { + return Some(fc); + } + } + } + + // Fall back to string heuristics on failure_reason + if let Some(ref reason) = outcome.failure_reason { + return Some(classify_failure_reason(reason)); + } + + Some(FailureClass::Deterministic) + } + } +} + // --- Retry policy types --- /// Configuration for exponential backoff between retry attempts. @@ -699,7 +730,7 @@ impl PipelineEngine { let panic_dir = node_dir(logs_root, &node.id, visit); let _ = std::fs::create_dir_all(&panic_dir); let _ = std::fs::write(panic_dir.join("panic.txt"), &msg); - Err(AttractorError::Handler(msg)) + Err(ArcError::Handler(msg)) } } }; @@ -716,7 +747,7 @@ impl PipelineEngine { error: e.to_string(), will_retry: true, failure_reason: None, - failure_class: Some("transient".into()), + failure_class: Some(e.failure_class().to_string()), }); self.services.emitter.emit(&PipelineEvent::StageRetrying { name: node.label().to_string(), @@ -728,7 +759,12 @@ impl PipelineEngine { tokio::time::sleep(delay).await; continue; } - return Ok((Outcome::fail(e.to_string()), attempt)); + let mut fail_outcome = Outcome::fail(e.to_string()); + fail_outcome.context_updates.insert( + "failure_class".to_string(), + serde_json::json!(e.failure_class().to_string()), + ); + return Ok((fail_outcome, attempt)); } }; @@ -917,7 +953,7 @@ impl PipelineEngine { let start_node = graph .find_start_node() - .ok_or_else(|| AttractorError::Engine("no start node found".to_string()))?; + .ok_or_else(|| ArcError::Engine("no start node found".to_string()))?; current_node_id = start_node.id.clone(); } @@ -931,19 +967,19 @@ impl PipelineEngine { // Check for cancellation before processing each node if let Some(ref token) = config.cancel_token { if token.load(Ordering::Relaxed) { - return Err(AttractorError::Cancelled); + return Err(ArcError::Cancelled); } } let node = graph.nodes.get(¤t_node_id).ok_or_else(|| { - AttractorError::Engine(format!("node not found: {current_node_id}")) + ArcError::Engine(format!("node not found: {current_node_id}")) })?; // Always track visit count (used for stage directory naming) let count = node_visits.entry(current_node_id.clone()).or_insert(0); *count += 1; if max_node_visits > 0 && *count > max_node_visits { - return Err(AttractorError::Engine(format!( + return Err(ArcError::Engine(format!( "node \"{}\" exceeded max visit limit of {max_node_visits}", current_node_id ))); @@ -1057,6 +1093,8 @@ impl PipelineEngine { let stage_duration_ms = millis_u64(stage_start.elapsed()); + let outcome_failure_class = classify_outcome(&outcome); + if outcome.status == StageStatus::Fail { self.services.emitter.emit(&PipelineEvent::StageFailed { name: node.label().to_string(), @@ -1068,7 +1106,7 @@ impl PipelineEngine { .to_string(), will_retry: false, failure_reason: outcome.failure_reason.clone(), - failure_class: Some("terminal".into()), + failure_class: outcome_failure_class.map(|fc| fc.to_string()), }); } else { self.services.emitter.emit(&PipelineEvent::StageCompleted { @@ -1084,7 +1122,7 @@ impl PipelineEngine { 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), - failure_class: None, + failure_class: outcome_failure_class.map(|fc| fc.to_string()), }); self.inform( &format!("Stage completed: {}", node.label()), @@ -1117,6 +1155,12 @@ impl PipelineEngine { // Step 4: Apply context updates from outcome context.apply_updates(&outcome.context_updates); context.set("outcome", serde_json::json!(outcome.status.to_string())); + context.set( + "failure_class", + serde_json::json!( + outcome_failure_class.map_or(String::new(), |fc| fc.to_string()) + ), + ); if let Some(ref pref) = outcome.preferred_label { context.set("preferred_label", serde_json::json!(pref)); } @@ -1258,7 +1302,7 @@ impl PipelineEngine { duration_ms, git_commit_sha: last_git_sha.clone(), }); - return Err(AttractorError::Engine(error_msg)); + return Err(ArcError::Engine(error_msg)); } break; } @@ -1352,7 +1396,7 @@ mod tests { _graph: &Graph, _logs_root: &Path, _services: &crate::handler::EngineServices, - ) -> std::result::Result { + ) -> std::result::Result { Ok(Outcome::fail("always fails")) } } @@ -1371,7 +1415,7 @@ mod tests { _graph: &Graph, _logs_root: &Path, _services: &crate::handler::EngineServices, - ) -> std::result::Result { + ) -> std::result::Result { tokio::time::sleep(Duration::from_millis(self.sleep_ms)).await; Ok(Outcome::success()) } @@ -2785,7 +2829,7 @@ mod tests { }; let result = engine.run(&g, &config).await; assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), AttractorError::Cancelled)); + assert!(matches!(result.unwrap_err(), ArcError::Cancelled)); } #[tokio::test] @@ -2848,7 +2892,7 @@ mod tests { // The engine should detect cancellation at the next loop iteration // after the slow handler completes assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), AttractorError::Cancelled)); + assert!(matches!(result.unwrap_err(), ArcError::Cancelled)); } // --- max_node_visits tests --- @@ -3015,7 +3059,7 @@ mod tests { _graph: &Graph, _logs_root: &Path, _services: &crate::handler::EngineServices, - ) -> std::result::Result { + ) -> std::result::Result { panic!("test panic message"); } } @@ -3068,4 +3112,92 @@ mod tests { "panic.txt should contain the panic message, got: {content}" ); } + + // --- classify_outcome tests --- + + #[test] + fn classify_outcome_returns_none_for_success() { + assert!(classify_outcome(&Outcome::success()).is_none()); + } + + #[test] + fn classify_outcome_returns_none_for_skipped() { + assert!(classify_outcome(&Outcome::skipped()).is_none()); + } + + #[test] + fn classify_outcome_returns_none_for_partial_success() { + let outcome = Outcome { + status: StageStatus::PartialSuccess, + ..Outcome::success() + }; + assert!(classify_outcome(&outcome).is_none()); + } + + #[test] + fn classify_outcome_respects_handler_hint() { + let mut outcome = Outcome::fail("some error"); + outcome.context_updates.insert( + "failure_class".to_string(), + serde_json::json!("budget_exhausted"), + ); + assert_eq!( + classify_outcome(&outcome), + Some(FailureClass::BudgetExhausted) + ); + } + + #[test] + fn classify_outcome_ignores_invalid_handler_hint() { + let mut outcome = Outcome::fail("timeout occurred"); + outcome.context_updates.insert( + "failure_class".to_string(), + serde_json::json!("not_a_valid_class"), + ); + // Falls through to string heuristics on failure_reason + assert_eq!( + classify_outcome(&outcome), + Some(FailureClass::TransientInfra) + ); + } + + #[test] + fn classify_outcome_uses_failure_reason_heuristics() { + let outcome = Outcome::fail("rate limited by provider"); + assert_eq!( + classify_outcome(&outcome), + Some(FailureClass::TransientInfra) + ); + } + + #[test] + fn classify_outcome_defaults_to_deterministic() { + let outcome = Outcome::fail("something went wrong"); + assert_eq!( + classify_outcome(&outcome), + Some(FailureClass::Deterministic) + ); + } + + #[test] + fn classify_outcome_fail_no_reason_is_deterministic() { + let outcome = Outcome { + status: StageStatus::Fail, + failure_reason: None, + ..Outcome::success() + }; + assert_eq!( + classify_outcome(&outcome), + Some(FailureClass::Deterministic) + ); + } + + #[test] + fn classify_outcome_retry_status_uses_heuristics() { + let outcome = Outcome::retry("connection refused"); + assert_eq!( + classify_outcome(&outcome), + Some(FailureClass::TransientInfra) + ); + } } \ No newline at end of file diff --git a/crates/arc-workflows/src/error.rs b/crates/arc-workflows/src/error.rs index 1c7c6dc71..178eb4d84 100644 --- a/crates/arc-workflows/src/error.rs +++ b/crates/arc-workflows/src/error.rs @@ -1,7 +1,132 @@ +use std::fmt; +use std::str::FromStr; + +use arc_llm::error::{ProviderErrorKind, SdkError}; +use serde::{Deserialize, Serialize}; use thiserror::Error; +/// Classification of failure modes for pipeline edge conditions. +/// +/// Pipeline authors can write edge conditions like `context.failure_class=budget_exhausted` +/// to route execution based on the nature of the failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FailureClass { + /// Temporary infrastructure failure (rate limit, timeout, network, 5xx). + TransientInfra, + /// Permanent failure (auth, bad config, code bug). + Deterministic, + /// Context length, token/turn limit, quota exceeded. + BudgetExhausted, + /// Reserved for future loop detection. + CompilationLoop, + /// User/system cancellation. + Canceled, + /// Reserved for future scope enforcement. + Structural, +} + +impl fmt::Display for FailureClass { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::TransientInfra => "transient_infra", + Self::Deterministic => "deterministic", + Self::BudgetExhausted => "budget_exhausted", + Self::CompilationLoop => "compilation_loop", + Self::Canceled => "canceled", + Self::Structural => "structural", + }; + write!(f, "{s}") + } +} + +impl FromStr for FailureClass { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + match s { + "transient_infra" => Ok(Self::TransientInfra), + "deterministic" => Ok(Self::Deterministic), + "budget_exhausted" => Ok(Self::BudgetExhausted), + "compilation_loop" => Ok(Self::CompilationLoop), + "canceled" => Ok(Self::Canceled), + "structural" => Ok(Self::Structural), + other => Err(format!("unknown failure class: {other}")), + } + } +} + +/// Classify an `SdkError` into a `FailureClass` based on its structure. +#[must_use] +pub fn classify_sdk_error(err: &SdkError) -> FailureClass { + match err { + SdkError::Provider { kind, .. } => match kind { + ProviderErrorKind::RateLimit | ProviderErrorKind::Server => { + FailureClass::TransientInfra + } + ProviderErrorKind::ContextLength | ProviderErrorKind::QuotaExceeded => { + FailureClass::BudgetExhausted + } + ProviderErrorKind::Authentication + | ProviderErrorKind::AccessDenied + | ProviderErrorKind::NotFound + | ProviderErrorKind::InvalidRequest + | ProviderErrorKind::ContentFilter => FailureClass::Deterministic, + }, + SdkError::RequestTimeout { .. } | SdkError::Network { .. } | SdkError::Stream { .. } => { + FailureClass::TransientInfra + } + SdkError::Abort { .. } => FailureClass::Canceled, + SdkError::InvalidToolCall { .. } + | SdkError::NoObjectGenerated { .. } + | SdkError::Configuration { .. } + | SdkError::UnsupportedToolChoice { .. } => FailureClass::Deterministic, + } +} + +/// Classify a failure reason string using heuristics. +/// +/// This is the fallback when structured error information is not available +/// (e.g. for `Handler(String)` or `Engine(String)` errors). +#[must_use] +pub fn classify_failure_reason(reason: &str) -> FailureClass { + let lower = reason.to_lowercase(); + + if lower.contains("cancel") || lower.contains("abort") { + return FailureClass::Canceled; + } + + if lower.contains("turn limit") + || lower.contains("token limit") + || lower.contains("context length") + || lower.contains("budget") + || lower.contains("quota exceeded") + { + return FailureClass::BudgetExhausted; + } + + if lower.contains("scope violation") { + return FailureClass::Structural; + } + + if lower.contains("timeout") + || lower.contains("timed out") + || lower.contains("rate limit") + || lower.contains("rate limited") + || lower.contains("connection refused") + || lower.contains("connection reset") + || lower.contains("503") + || lower.contains("502") + || lower.contains("500") + { + return FailureClass::TransientInfra; + } + + FailureClass::Deterministic +} + #[derive(Error, Debug, Clone)] -pub enum AttractorError { +pub enum ArcError { #[error("Parse error: {0}")] Parse(String), @@ -14,6 +139,9 @@ pub enum AttractorError { #[error("Handler error: {0}")] Handler(String), + #[error("LLM error: {0}")] + Llm(SdkError), + #[error("Checkpoint error: {0}")] Checkpoint(String), @@ -27,17 +155,18 @@ pub enum AttractorError { Cancelled, } -impl AttractorError { +impl ArcError { /// Whether this error category is retryable (transient) or terminal. /// /// Retryable: Handler (transient handler failures), Engine (could be transient), - /// Io (network/disk issues are often transient). + /// Io (network/disk issues are often transient), Llm (delegates to SdkError). /// Terminal: Parse, Validation, Stylesheet (configuration errors), /// Checkpoint (storage integrity), Cancelled (explicit cancellation). #[must_use] - pub const fn is_retryable(&self) -> bool { + pub fn is_retryable(&self) -> bool { match self { Self::Handler(_) | Self::Engine(_) | Self::Io(_) => true, + Self::Llm(sdk_err) => sdk_err.retryable(), Self::Parse(_) | Self::Validation(_) | Self::Stylesheet(_) @@ -45,61 +174,82 @@ impl AttractorError { | Self::Cancelled => false, } } + + /// Classify this error into a `FailureClass`. + #[must_use] + pub fn failure_class(&self) -> FailureClass { + match self { + Self::Cancelled => FailureClass::Canceled, + Self::Llm(sdk_err) => classify_sdk_error(sdk_err), + Self::Io(_) => FailureClass::TransientInfra, + Self::Parse(_) | Self::Validation(_) | Self::Stylesheet(_) | Self::Checkpoint(_) => { + FailureClass::Deterministic + } + Self::Handler(msg) | Self::Engine(msg) => classify_failure_reason(msg), + } + } } -impl From for AttractorError { +impl From for ArcError { fn from(err: std::io::Error) -> Self { Self::Io(err.to_string()) } } -pub type Result = std::result::Result; +impl From for ArcError { + fn from(err: SdkError) -> Self { + Self::Llm(err) + } +} + +pub type Result = std::result::Result; #[cfg(test)] mod tests { use super::*; + use arc_llm::error::ProviderErrorDetail; #[test] fn parse_error_display() { - let err = AttractorError::Parse("unexpected token".to_string()); + let err = ArcError::Parse("unexpected token".to_string()); assert_eq!(err.to_string(), "Parse error: unexpected token"); } #[test] fn validation_error_display() { - let err = AttractorError::Validation("missing start node".to_string()); + let err = ArcError::Validation("missing start node".to_string()); assert_eq!(err.to_string(), "Validation error: missing start node"); } #[test] fn engine_error_display() { - let err = AttractorError::Engine("no outgoing edge".to_string()); + let err = ArcError::Engine("no outgoing edge".to_string()); assert_eq!(err.to_string(), "Engine error: no outgoing edge"); } #[test] fn handler_error_display() { - let err = AttractorError::Handler("LLM call failed".to_string()); + let err = ArcError::Handler("LLM call failed".to_string()); assert_eq!(err.to_string(), "Handler error: LLM call failed"); } #[test] fn checkpoint_error_display() { - let err = AttractorError::Checkpoint("file not found".to_string()); + let err = ArcError::Checkpoint("file not found".to_string()); assert_eq!(err.to_string(), "Checkpoint error: file not found"); } #[test] fn io_error_display() { - let err = AttractorError::Io("permission denied".to_string()); + let err = ArcError::Io("permission denied".to_string()); assert_eq!(err.to_string(), "I/O error: permission denied"); } #[test] fn io_error_from_std() { let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found"); - let err = AttractorError::from(io_err); - assert!(matches!(err, AttractorError::Io(_))); + let err = ArcError::from(io_err); + assert!(matches!(err, ArcError::Io(_))); assert!(err.to_string().contains("not found")); } @@ -108,33 +258,350 @@ mod tests { let ok: Result = Ok(42); assert!(ok.is_ok()); - let err: Result = Err(AttractorError::Parse("bad".to_string())); + let err: Result = Err(ArcError::Parse("bad".to_string())); assert!(err.is_err()); } #[test] fn cancelled_error_display() { - let err = AttractorError::Cancelled; + let err = ArcError::Cancelled; assert_eq!(err.to_string(), "Pipeline cancelled"); } #[test] fn cancelled_is_not_retryable() { - assert!(!AttractorError::Cancelled.is_retryable()); + assert!(!ArcError::Cancelled.is_retryable()); } #[test] fn is_retryable_terminal_errors() { - assert!(!AttractorError::Parse("bad".to_string()).is_retryable()); - assert!(!AttractorError::Validation("bad".to_string()).is_retryable()); - assert!(!AttractorError::Stylesheet("bad".to_string()).is_retryable()); - assert!(!AttractorError::Checkpoint("bad".to_string()).is_retryable()); + assert!(!ArcError::Parse("bad".to_string()).is_retryable()); + assert!(!ArcError::Validation("bad".to_string()).is_retryable()); + assert!(!ArcError::Stylesheet("bad".to_string()).is_retryable()); + assert!(!ArcError::Checkpoint("bad".to_string()).is_retryable()); } #[test] fn is_retryable_transient_errors() { - assert!(AttractorError::Handler("timeout".to_string()).is_retryable()); - assert!(AttractorError::Engine("transient".to_string()).is_retryable()); - assert!(AttractorError::Io("connection reset".to_string()).is_retryable()); + assert!(ArcError::Handler("timeout".to_string()).is_retryable()); + assert!(ArcError::Engine("transient".to_string()).is_retryable()); + assert!(ArcError::Io("connection reset".to_string()).is_retryable()); + } + + // --- FailureClass Display/FromStr/serde tests --- + + #[test] + fn failure_class_display_all_values() { + assert_eq!(FailureClass::TransientInfra.to_string(), "transient_infra"); + assert_eq!(FailureClass::Deterministic.to_string(), "deterministic"); + assert_eq!(FailureClass::BudgetExhausted.to_string(), "budget_exhausted"); + assert_eq!(FailureClass::CompilationLoop.to_string(), "compilation_loop"); + assert_eq!(FailureClass::Canceled.to_string(), "canceled"); + assert_eq!(FailureClass::Structural.to_string(), "structural"); + } + + #[test] + fn failure_class_from_str_all_values() { + assert_eq!( + "transient_infra".parse::().unwrap(), + FailureClass::TransientInfra + ); + assert_eq!( + "deterministic".parse::().unwrap(), + FailureClass::Deterministic + ); + assert_eq!( + "budget_exhausted".parse::().unwrap(), + FailureClass::BudgetExhausted + ); + assert_eq!( + "compilation_loop".parse::().unwrap(), + FailureClass::CompilationLoop + ); + assert_eq!( + "canceled".parse::().unwrap(), + FailureClass::Canceled + ); + assert_eq!( + "structural".parse::().unwrap(), + FailureClass::Structural + ); + } + + #[test] + fn failure_class_from_str_invalid() { + assert!("unknown".parse::().is_err()); + } + + #[test] + fn failure_class_serde_roundtrip() { + let values = [ + FailureClass::TransientInfra, + FailureClass::Deterministic, + FailureClass::BudgetExhausted, + FailureClass::CompilationLoop, + FailureClass::Canceled, + FailureClass::Structural, + ]; + for fc in values { + let json = serde_json::to_string(&fc).unwrap(); + let parsed: FailureClass = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, fc); + } + } + + // --- Llm variant tests --- + + #[test] + fn llm_error_display() { + let sdk_err = SdkError::Network { + message: "connection refused".into(), + }; + let err = ArcError::Llm(sdk_err); + assert_eq!(err.to_string(), "LLM error: Network error: connection refused"); + } + + #[test] + fn llm_error_retryable_delegates_to_sdk() { + let retryable = ArcError::Llm(SdkError::Network { + message: "timeout".into(), + }); + assert!(retryable.is_retryable()); + + let non_retryable = ArcError::Llm(SdkError::Configuration { + message: "bad config".into(), + }); + assert!(!non_retryable.is_retryable()); + } + + #[test] + fn llm_error_from_sdk_error() { + let sdk_err = SdkError::Stream { + message: "broken pipe".into(), + }; + let err = ArcError::from(sdk_err); + assert!(matches!(err, ArcError::Llm(_))); + } + + // --- failure_class() method tests --- + + #[test] + fn failure_class_cancelled() { + assert_eq!( + ArcError::Cancelled.failure_class(), + FailureClass::Canceled + ); + } + + #[test] + fn failure_class_io() { + assert_eq!( + ArcError::Io("disk full".into()).failure_class(), + FailureClass::TransientInfra + ); + } + + #[test] + fn failure_class_parse() { + assert_eq!( + ArcError::Parse("bad syntax".into()).failure_class(), + FailureClass::Deterministic + ); + } + + #[test] + fn failure_class_handler_with_timeout() { + assert_eq!( + ArcError::Handler("request timed out".into()).failure_class(), + FailureClass::TransientInfra + ); + } + + #[test] + fn failure_class_handler_deterministic() { + assert_eq!( + ArcError::Handler("invalid configuration".into()).failure_class(), + FailureClass::Deterministic + ); + } + + #[test] + fn failure_class_llm_rate_limit() { + let err = ArcError::Llm(SdkError::Provider { + kind: ProviderErrorKind::RateLimit, + detail: Box::new(ProviderErrorDetail::new("too fast", "openai")), + }); + assert_eq!(err.failure_class(), FailureClass::TransientInfra); + } + + #[test] + fn failure_class_llm_context_length() { + let err = ArcError::Llm(SdkError::Provider { + kind: ProviderErrorKind::ContextLength, + detail: Box::new(ProviderErrorDetail::new("too long", "openai")), + }); + assert_eq!(err.failure_class(), FailureClass::BudgetExhausted); + } + + #[test] + fn failure_class_llm_auth() { + let err = ArcError::Llm(SdkError::Provider { + kind: ProviderErrorKind::Authentication, + detail: Box::new(ProviderErrorDetail::new("bad key", "openai")), + }); + assert_eq!(err.failure_class(), FailureClass::Deterministic); + } + + #[test] + fn failure_class_llm_abort() { + let err = ArcError::Llm(SdkError::Abort { + message: "user cancelled".into(), + }); + assert_eq!(err.failure_class(), FailureClass::Canceled); + } + + #[test] + fn failure_class_llm_timeout() { + let err = ArcError::Llm(SdkError::RequestTimeout { + message: "timed out".into(), + }); + assert_eq!(err.failure_class(), FailureClass::TransientInfra); + } + + // --- classify_sdk_error tests --- + + #[test] + fn classify_sdk_rate_limit() { + let err = SdkError::Provider { + kind: ProviderErrorKind::RateLimit, + detail: Box::new(ProviderErrorDetail::new("too fast", "openai")), + }; + assert_eq!(classify_sdk_error(&err), FailureClass::TransientInfra); + } + + #[test] + fn classify_sdk_server() { + let err = SdkError::Provider { + kind: ProviderErrorKind::Server, + detail: Box::new(ProviderErrorDetail::new("500", "openai")), + }; + assert_eq!(classify_sdk_error(&err), FailureClass::TransientInfra); + } + + #[test] + fn classify_sdk_context_length() { + let err = SdkError::Provider { + kind: ProviderErrorKind::ContextLength, + detail: Box::new(ProviderErrorDetail::new("too long", "openai")), + }; + assert_eq!(classify_sdk_error(&err), FailureClass::BudgetExhausted); + } + + #[test] + fn classify_sdk_quota_exceeded() { + let err = SdkError::Provider { + kind: ProviderErrorKind::QuotaExceeded, + detail: Box::new(ProviderErrorDetail::new("out of quota", "openai")), + }; + assert_eq!(classify_sdk_error(&err), FailureClass::BudgetExhausted); + } + + #[test] + fn classify_sdk_auth() { + let err = SdkError::Provider { + kind: ProviderErrorKind::Authentication, + detail: Box::new(ProviderErrorDetail::new("bad key", "openai")), + }; + assert_eq!(classify_sdk_error(&err), FailureClass::Deterministic); + } + + #[test] + fn classify_sdk_request_timeout() { + let err = SdkError::RequestTimeout { + message: "timed out".into(), + }; + assert_eq!(classify_sdk_error(&err), FailureClass::TransientInfra); + } + + #[test] + fn classify_sdk_abort() { + let err = SdkError::Abort { + message: "cancelled".into(), + }; + assert_eq!(classify_sdk_error(&err), FailureClass::Canceled); + } + + #[test] + fn classify_sdk_invalid_tool_call() { + let err = SdkError::InvalidToolCall { + message: "bad tool".into(), + }; + assert_eq!(classify_sdk_error(&err), FailureClass::Deterministic); + } + + // --- classify_failure_reason tests --- + + #[test] + fn classify_reason_timeout() { + assert_eq!( + classify_failure_reason("request timed out after 30s"), + FailureClass::TransientInfra + ); + } + + #[test] + fn classify_reason_rate_limit() { + assert_eq!( + classify_failure_reason("rate limited by provider"), + FailureClass::TransientInfra + ); + } + + #[test] + fn classify_reason_connection_refused() { + assert_eq!( + classify_failure_reason("connection refused"), + FailureClass::TransientInfra + ); + } + + #[test] + fn classify_reason_turn_limit() { + assert_eq!( + classify_failure_reason("exceeded turn limit of 10"), + FailureClass::BudgetExhausted + ); + } + + #[test] + fn classify_reason_token_limit() { + assert_eq!( + classify_failure_reason("token limit reached"), + FailureClass::BudgetExhausted + ); + } + + #[test] + fn classify_reason_cancel() { + assert_eq!( + classify_failure_reason("operation cancelled by user"), + FailureClass::Canceled + ); + } + + #[test] + fn classify_reason_scope_violation() { + assert_eq!( + classify_failure_reason("scope violation detected"), + FailureClass::Structural + ); + } + + #[test] + fn classify_reason_default_deterministic() { + assert_eq!( + classify_failure_reason("invalid configuration parameter"), + FailureClass::Deterministic + ); } } diff --git a/crates/arc-workflows/src/git.rs b/crates/arc-workflows/src/git.rs index 95c490822..fe22406f7 100644 --- a/crates/arc-workflows/src/git.rs +++ b/crates/arc-workflows/src/git.rs @@ -7,10 +7,10 @@ use arc_git_storage::trailerlink::{self, Trailer}; use git2::{Repository, Signature}; use crate::checkpoint::Checkpoint; -use crate::error::{AttractorError, Result}; +use crate::error::{ArcError, Result}; -fn git_error(msg: impl Into) -> AttractorError { - AttractorError::Engine(msg.into()) +fn git_error(msg: impl Into) -> ArcError { + ArcError::Engine(msg.into()) } /// Assert the working directory is a clean git repo (no uncommitted changes). @@ -250,7 +250,7 @@ impl MetadataStore { match Self::read_file(repo_path, run_id, "checkpoint.json")? { Some(bytes) => { let cp: Checkpoint = serde_json::from_slice(&bytes) - .map_err(|e| AttractorError::Checkpoint(format!("deserialize failed: {e}")))?; + .map_err(|e| ArcError::Checkpoint(format!("deserialize failed: {e}")))?; Ok(Some(cp)) } None => Ok(None), diff --git a/crates/arc-workflows/src/graph/types.rs b/crates/arc-workflows/src/graph/types.rs index 635765741..3d440a830 100644 --- a/crates/arc-workflows/src/graph/types.rs +++ b/crates/arc-workflows/src/graph/types.rs @@ -3,7 +3,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; -use crate::error::AttractorError; +use crate::error::ArcError; /// Whether a codergen node runs as a multi-turn agent loop or a single LLM call. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -13,11 +13,11 @@ pub enum CodergenMode { } impl CodergenMode { - pub fn parse(s: &str) -> Result { + pub fn parse(s: &str) -> Result { match s { "agent_loop" => Ok(Self::AgentLoop), "one_shot" => Ok(Self::OneShot), - other => Err(AttractorError::Validation(format!( + other => Err(ArcError::Validation(format!( "invalid codergen_mode: {other:?} (expected \"agent_loop\" or \"one_shot\")" ))), } @@ -231,7 +231,7 @@ impl Node { } /// Returns the codergen mode for this node. Defaults to `AgentLoop` when absent. - pub fn codergen_mode(&self) -> Result { + pub fn codergen_mode(&self) -> Result { match self.str_attr("codergen_mode") { Some(s) => CodergenMode::parse(s), None => Ok(CodergenMode::AgentLoop), diff --git a/crates/arc-workflows/src/handler/codergen.rs b/crates/arc-workflows/src/handler/codergen.rs index 83aa7cdea..73378f95b 100644 --- a/crates/arc-workflows/src/handler/codergen.rs +++ b/crates/arc-workflows/src/handler/codergen.rs @@ -5,7 +5,7 @@ use arc_agent::ExecutionEnvironment; use async_trait::async_trait; use crate::context::Context; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::event::EventEmitter; use crate::graph::{CodergenMode, Graph, Node}; use crate::outcome::{Outcome, StageUsage}; @@ -36,7 +36,7 @@ pub trait CodergenBackend: Send + Sync { emitter: &Arc, stage_dir: &Path, execution_env: &Arc, - ) -> Result; + ) -> Result; /// Run a single LLM call with no tools (one_shot mode). async fn one_shot( @@ -44,8 +44,8 @@ pub trait CodergenBackend: Send + Sync { _node: &Node, _prompt: &str, _stage_dir: &Path, - ) -> Result { - Err(AttractorError::Validation( + ) -> Result { + Err(ArcError::Validation( "one_shot mode not supported by this backend".into(), )) } @@ -180,7 +180,7 @@ fn run_hook(command: &str, node_id: &str, work_dir: Option<&Path>) -> bool { let mut cmd = std::process::Command::new("sh"); cmd.arg("-c") .arg(command) - .env("ATTRACTOR_NODE_ID", node_id); + .env("ARC_NODE_ID", node_id); if let Some(wd) = work_dir { cmd.current_dir(wd); } @@ -199,7 +199,7 @@ impl Handler for CodergenHandler { graph: &Graph, logs_root: &Path, services: &EngineServices, - ) -> Result { + ) -> Result { // 1. Build prompt (prepend fidelity preamble if present) let raw_prompt = node .prompt() @@ -570,7 +570,7 @@ mod tests { _emitter: &Arc, _stage_dir: &Path, _execution_env: &Arc, - ) -> Result { + ) -> Result { *self.captured_thread_id.lock().unwrap() = Some(thread_id.map(String::from)); Ok(CodergenResult::Text { text: "ok".to_string(), usage: None, files_touched: Vec::new() }) @@ -618,7 +618,7 @@ mod tests { _emitter: &Arc, _stage_dir: &Path, _execution_env: &Arc, - ) -> Result { + ) -> Result { *self.captured_thread_id.lock().unwrap() = Some(thread_id.map(String::from)); Ok(CodergenResult::Text { text: "ok".to_string(), usage: None, files_touched: Vec::new() }) @@ -661,8 +661,8 @@ mod tests { _emitter: &Arc, _stage_dir: &Path, _execution_env: &Arc, - ) -> Result { - Err(AttractorError::Handler("Request timed out".to_string())) + ) -> Result { + Err(ArcError::Handler("Request timed out".to_string())) } } @@ -775,7 +775,7 @@ Some text in between. _emitter: &Arc, _stage_dir: &Path, _execution_env: &Arc, - ) -> Result { + ) -> Result { panic!("run() should not be called in one_shot mode"); } @@ -784,7 +784,7 @@ Some text in between. _node: &Node, _prompt: &str, _stage_dir: &Path, - ) -> Result { + ) -> Result { Ok(CodergenResult::Text { text: "one-shot response".to_string(), usage: None, @@ -879,8 +879,8 @@ Some text in between. _emitter: &Arc, _stage_dir: &Path, _execution_env: &Arc, - ) -> Result { - Err(AttractorError::Validation("bad config".to_string())) + ) -> Result { + Err(ArcError::Validation("bad config".to_string())) } } @@ -917,7 +917,7 @@ Some text in between. _emitter: &Arc, _stage_dir: &std::path::Path, _execution_env: &Arc, - ) -> Result { + ) -> Result { *self.captured_prompt.lock().unwrap() = Some(prompt.to_string()); Ok(CodergenResult::Text { text: "ok".to_string(), @@ -985,7 +985,7 @@ Some text in between. _emitter: &Arc, _stage_dir: &std::path::Path, _execution_env: &Arc, - ) -> Result { + ) -> Result { *self.captured_prompt.lock().unwrap() = Some(prompt.to_string()); Ok(CodergenResult::Text { text: "ok".to_string(), @@ -1039,7 +1039,7 @@ Some text in between. _emitter: &Arc, _stage_dir: &std::path::Path, _execution_env: &Arc, - ) -> Result { + ) -> Result { panic!("run() should not be called in one_shot mode"); } @@ -1048,7 +1048,7 @@ Some text in between. _node: &Node, prompt: &str, _stage_dir: &std::path::Path, - ) -> Result { + ) -> Result { *self.captured_prompt.lock().unwrap() = Some(prompt.to_string()); Ok(CodergenResult::Text { text: "classified".to_string(), diff --git a/crates/arc-workflows/src/handler/conditional.rs b/crates/arc-workflows/src/handler/conditional.rs index f429bd0d4..18889cf1d 100644 --- a/crates/arc-workflows/src/handler/conditional.rs +++ b/crates/arc-workflows/src/handler/conditional.rs @@ -3,7 +3,7 @@ use std::path::Path; use async_trait::async_trait; use crate::context::Context; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::graph::{Graph, Node}; use crate::outcome::Outcome; @@ -22,7 +22,7 @@ impl Handler for ConditionalHandler { _graph: &Graph, _logs_root: &Path, _services: &EngineServices, - ) -> Result { + ) -> Result { let mut outcome = Outcome::success(); outcome.notes = Some(format!("Conditional node evaluated: {}", node.id)); Ok(outcome) diff --git a/crates/arc-workflows/src/handler/exit.rs b/crates/arc-workflows/src/handler/exit.rs index 1e8ec8597..677b06b11 100644 --- a/crates/arc-workflows/src/handler/exit.rs +++ b/crates/arc-workflows/src/handler/exit.rs @@ -3,7 +3,7 @@ use std::path::Path; use async_trait::async_trait; use crate::context::Context; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::graph::{Graph, Node}; use crate::outcome::Outcome; @@ -21,7 +21,7 @@ impl Handler for ExitHandler { _graph: &Graph, _logs_root: &Path, _services: &EngineServices, - ) -> Result { + ) -> Result { Ok(Outcome::success()) } } diff --git a/crates/arc-workflows/src/handler/fan_in.rs b/crates/arc-workflows/src/handler/fan_in.rs index ea0914f61..f3cfc783d 100644 --- a/crates/arc-workflows/src/handler/fan_in.rs +++ b/crates/arc-workflows/src/handler/fan_in.rs @@ -5,7 +5,7 @@ use arc_agent::ExecutionEnvironment; use async_trait::async_trait; use crate::context::Context; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::event::EventEmitter; use crate::graph::{Graph, Node}; use crate::outcome::Outcome; @@ -34,7 +34,7 @@ impl Handler for FanInHandler { _graph: &Graph, logs_root: &Path, services: &EngineServices, - ) -> Result { + ) -> Result { let results = context.get("parallel.results"); let Some(results) = results else { return Ok(Outcome::fail("No parallel results to evaluate")); @@ -159,7 +159,7 @@ async fn llm_evaluate( node_id: &str, emitter: &Arc, execution_env: &Arc, -) -> Result { +) -> Result { let results_text = serde_json::to_string_pretty(results) .unwrap_or_else(|_| results.to_string()); @@ -380,7 +380,7 @@ mod tests { _emitter: &Arc, _stage_dir: &std::path::Path, _execution_env: &Arc, - ) -> Result { + ) -> Result { // Return text that contains the ID "branch_b" Ok(CodergenResult::Text { text: "The best candidate is branch_b".to_string(), usage: None, files_touched: Vec::new() }) } diff --git a/crates/arc-workflows/src/handler/manager_loop.rs b/crates/arc-workflows/src/handler/manager_loop.rs index 08ecb19fe..d6a9b3eb5 100644 --- a/crates/arc-workflows/src/handler/manager_loop.rs +++ b/crates/arc-workflows/src/handler/manager_loop.rs @@ -5,7 +5,7 @@ use async_trait::async_trait; use crate::condition::evaluate_condition; use crate::context::Context; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::graph::{Graph, Node}; use crate::outcome::{Outcome, StageStatus}; @@ -20,15 +20,15 @@ pub trait ChildObserver: Send + Sync { _dotfile: &str, _workdir: &str, _context: &Context, - ) -> Result<(), AttractorError> { + ) -> Result<(), ArcError> { Ok(()) } /// Ingest child telemetry into the context. - async fn observe(&self, context: &Context) -> Result<(), AttractorError>; + async fn observe(&self, context: &Context) -> Result<(), ArcError>; /// Optionally steer the child pipeline (e.g., write intervention instructions). - async fn steer(&self, context: &Context, node: &Node) -> Result<(), AttractorError>; + async fn steer(&self, context: &Context, node: &Node) -> Result<(), ArcError>; } /// Orchestrates observe/steer/wait cycles over a child pipeline. @@ -74,7 +74,7 @@ impl Handler for ManagerLoopHandler { _graph: &Graph, _logs_root: &Path, _services: &EngineServices, - ) -> Result { + ) -> Result { let poll_interval = node .attrs .get("manager.poll_interval") diff --git a/crates/arc-workflows/src/handler/mod.rs b/crates/arc-workflows/src/handler/mod.rs index 10c832055..7e8c8481f 100644 --- a/crates/arc-workflows/src/handler/mod.rs +++ b/crates/arc-workflows/src/handler/mod.rs @@ -17,7 +17,7 @@ use arc_agent::ExecutionEnvironment; use async_trait::async_trait; use crate::context::Context; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::event::EventEmitter; use crate::graph::{shape_to_handler_type, Graph, Node}; use crate::interviewer::Interviewer; @@ -40,11 +40,11 @@ pub trait Handler: Send + Sync { graph: &Graph, logs_root: &Path, services: &EngineServices, - ) -> Result; + ) -> Result; /// Determines whether an error should be retried. /// Default implementation retries transient errors only. - fn should_retry(&self, err: &AttractorError) -> bool { + fn should_retry(&self, err: &ArcError) -> bool { err.is_retryable() } } @@ -147,7 +147,7 @@ mod tests { _graph: &Graph, _logs_root: &Path, _services: &EngineServices, - ) -> Result { + ) -> Result { Ok(Outcome::success()) } } @@ -211,8 +211,8 @@ mod tests { let handler = TestHandler { _name: "test".to_string(), }; - assert!(handler.should_retry(&AttractorError::Handler("timeout".to_string()))); - assert!(!handler.should_retry(&AttractorError::Parse("bad".to_string()))); + assert!(handler.should_retry(&ArcError::Handler("timeout".to_string()))); + assert!(!handler.should_retry(&ArcError::Parse("bad".to_string()))); } struct NeverRetryHandler; @@ -226,11 +226,11 @@ mod tests { _graph: &Graph, _logs_root: &Path, _services: &EngineServices, - ) -> Result { + ) -> Result { Ok(Outcome::success()) } - fn should_retry(&self, _err: &AttractorError) -> bool { + fn should_retry(&self, _err: &ArcError) -> bool { false } } @@ -238,8 +238,8 @@ mod tests { #[test] fn custom_should_retry_override() { let handler = NeverRetryHandler; - assert!(!handler.should_retry(&AttractorError::Handler("timeout".to_string()))); - assert!(!handler.should_retry(&AttractorError::Io("connection reset".to_string()))); + assert!(!handler.should_retry(&ArcError::Handler("timeout".to_string()))); + assert!(!handler.should_retry(&ArcError::Io("connection reset".to_string()))); } #[test] diff --git a/crates/arc-workflows/src/handler/parallel.rs b/crates/arc-workflows/src/handler/parallel.rs index a4f1e58d8..33f0bf7bf 100644 --- a/crates/arc-workflows/src/handler/parallel.rs +++ b/crates/arc-workflows/src/handler/parallel.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use tokio::sync::Semaphore; use crate::context::Context; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::event::PipelineEvent; use crate::graph::{Graph, Node}; use crate::outcome::{Outcome, StageStatus}; @@ -99,7 +99,7 @@ impl Handler for ParallelHandler { graph: &Graph, logs_root: &Path, services: &EngineServices, - ) -> Result { + ) -> Result { let parallel_start = Instant::now(); let branches = graph.outgoing_edges(&node.id); if branches.is_empty() { @@ -147,7 +147,7 @@ impl Handler for ParallelHandler { let handle = tokio::spawn(async move { let _permit = sem.acquire().await.map_err(|e| { - AttractorError::Handler(format!("semaphore error: {e}")) + ArcError::Handler(format!("semaphore error: {e}")) })?; emitter.emit(&PipelineEvent::ParallelBranchStarted { @@ -187,7 +187,7 @@ impl Handler for ParallelHandler { status: outcome.status.to_string(), }); - Ok::(BranchResult { + Ok::(BranchResult { id: target_id, outcome, }) diff --git a/crates/arc-workflows/src/handler/script.rs b/crates/arc-workflows/src/handler/script.rs index 463ed85ae..a68c17637 100644 --- a/crates/arc-workflows/src/handler/script.rs +++ b/crates/arc-workflows/src/handler/script.rs @@ -3,7 +3,7 @@ use std::path::Path; use async_trait::async_trait; use crate::context::Context; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::graph::{Graph, Node}; use crate::outcome::Outcome; @@ -25,7 +25,7 @@ impl Handler for ScriptHandler { _graph: &Graph, logs_root: &Path, _services: &EngineServices, - ) -> Result { + ) -> Result { let script = node .attrs .get("script") @@ -94,7 +94,7 @@ impl Handler for ScriptHandler { ) .await?; - return Err(AttractorError::Handler(format!( + return Err(ArcError::Handler(format!( "Script timed out after {}ms: {script}", timeout_dur.as_millis() ))); @@ -158,7 +158,7 @@ impl Handler for ScriptHandler { Ok(outcome) } } - Err(e) => Err(AttractorError::Handler(format!("Failed to spawn script: {e}"))), + Err(e) => Err(ArcError::Handler(format!("Failed to spawn script: {e}"))), } } } @@ -647,7 +647,7 @@ mod tests { // // Pragmatic approach: verify the error construction matches what the // handler produces. The timeout test covers the other Err path. - let err = AttractorError::Handler(format!("Failed to spawn script: {}", "No such file")); + let err = ArcError::Handler(format!("Failed to spawn script: {}", "No such file")); assert!(err.to_string().contains("Failed to spawn script")); } diff --git a/crates/arc-workflows/src/handler/start.rs b/crates/arc-workflows/src/handler/start.rs index 269864eaf..9c7c5aa7c 100644 --- a/crates/arc-workflows/src/handler/start.rs +++ b/crates/arc-workflows/src/handler/start.rs @@ -3,7 +3,7 @@ use std::path::Path; use async_trait::async_trait; use crate::context::Context; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::graph::{Graph, Node}; use crate::outcome::Outcome; @@ -21,7 +21,7 @@ impl Handler for StartHandler { _graph: &Graph, _logs_root: &Path, _services: &EngineServices, - ) -> Result { + ) -> Result { Ok(Outcome::success()) } } diff --git a/crates/arc-workflows/src/handler/sub_pipeline.rs b/crates/arc-workflows/src/handler/sub_pipeline.rs index ba308066c..f1e864883 100644 --- a/crates/arc-workflows/src/handler/sub_pipeline.rs +++ b/crates/arc-workflows/src/handler/sub_pipeline.rs @@ -5,7 +5,7 @@ use async_trait::async_trait; use crate::context::Context; use crate::engine::select_edge; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::event::PipelineEvent; use crate::graph::{Graph, Node}; use crate::outcome::Outcome; @@ -36,7 +36,7 @@ impl Handler for SubPipelineHandler { _graph: &Graph, logs_root: &Path, services: &EngineServices, - ) -> Result { + ) -> Result { // 1. Get DOT source from node attribute let dot_source = match node.attrs.get("sub_pipeline.dot_source").and_then(|v| v.as_str()) { Some(s) if !s.is_empty() => s, @@ -246,7 +246,7 @@ mod tests { _graph: &Graph, _logs_root: &Path, _services: &EngineServices, - ) -> Result { + ) -> Result { context.set("sub.result", serde_json::json!("from_sub")); Ok(Outcome::success()) } @@ -305,7 +305,7 @@ mod tests { _graph: &Graph, _logs_root: &Path, _services: &EngineServices, - ) -> Result { + ) -> Result { Ok(Outcome::fail("sub-pipeline failure")) } } diff --git a/crates/arc-workflows/src/handler/wait_human.rs b/crates/arc-workflows/src/handler/wait_human.rs index bd330d717..8f914d0bb 100644 --- a/crates/arc-workflows/src/handler/wait_human.rs +++ b/crates/arc-workflows/src/handler/wait_human.rs @@ -5,7 +5,7 @@ use std::time::Instant; use async_trait::async_trait; use crate::context::Context; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::event::{EventEmitter, PipelineEvent}; use crate::graph::{Graph, Node}; use crate::interviewer::{ @@ -106,7 +106,7 @@ impl Handler for WaitHumanHandler { graph: &Graph, _logs_root: &Path, _services: &EngineServices, - ) -> Result { + ) -> Result { // 1. Derive choices from outgoing edges let edges = graph.outgoing_edges(&node.id); let mut freeform_target: Option = None; diff --git a/crates/arc-workflows/src/interviewer/recording.rs b/crates/arc-workflows/src/interviewer/recording.rs index c842fdd0d..b853429e2 100644 --- a/crates/arc-workflows/src/interviewer/recording.rs +++ b/crates/arc-workflows/src/interviewer/recording.rs @@ -4,7 +4,7 @@ use std::sync::Mutex; use async_trait::async_trait; use super::{Answer, Interviewer, Question}; -use crate::error::AttractorError; +use crate::error::ArcError; /// Wraps another interviewer and records all question-answer pairs. pub struct RecordingInterviewer { @@ -32,26 +32,26 @@ impl RecordingInterviewer { /// /// # Errors /// Returns an error if serialization fails. - pub fn to_json(&self) -> Result { + pub fn to_json(&self) -> Result { let recordings = self.recordings(); serde_json::to_string_pretty(&recordings) - .map_err(|e| AttractorError::Io(e.to_string())) + .map_err(|e| ArcError::Io(e.to_string())) } /// Deserializes recordings from a JSON string. /// /// # Errors /// Returns an error if deserialization fails. - pub fn from_json(json: &str) -> Result, AttractorError> { + pub fn from_json(json: &str) -> Result, ArcError> { serde_json::from_str(json) - .map_err(|e| AttractorError::Io(e.to_string())) + .map_err(|e| ArcError::Io(e.to_string())) } /// Saves recordings to a file as JSON. /// /// # Errors /// Returns an error if serialization or file writing fails. - pub fn save_to_file(&self, path: &Path) -> Result<(), AttractorError> { + pub fn save_to_file(&self, path: &Path) -> Result<(), ArcError> { let json = self.to_json()?; std::fs::write(path, json)?; Ok(()) @@ -61,7 +61,7 @@ impl RecordingInterviewer { /// /// # Errors /// Returns an error if file reading or deserialization fails. - pub fn load_from_file(path: &Path) -> Result, AttractorError> { + pub fn load_from_file(path: &Path) -> Result, ArcError> { let json = std::fs::read_to_string(path)?; Self::from_json(&json) } diff --git a/crates/arc-workflows/src/parser/mod.rs b/crates/arc-workflows/src/parser/mod.rs index ebf0ac2ba..ae8777bc7 100644 --- a/crates/arc-workflows/src/parser/mod.rs +++ b/crates/arc-workflows/src/parser/mod.rs @@ -3,7 +3,7 @@ pub mod grammar; pub mod lexer; pub mod semantic; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::graph::types::Graph; /// Parse a DOT source string into a semantic `Graph`. @@ -15,14 +15,14 @@ use crate::graph::types::Graph; /// /// Returns an error if the input is not valid DOT syntax or contains /// trailing content after the graph definition. -pub fn parse(input: &str) -> Result { +pub fn parse(input: &str) -> Result { let stripped = lexer::strip_comments(input); let (rest, dot_graph) = grammar::parse_dot_graph(&stripped) - .map_err(|e| AttractorError::Parse(format!("grammar error: {e}")))?; + .map_err(|e| ArcError::Parse(format!("grammar error: {e}")))?; let remaining = rest.trim(); if !remaining.is_empty() { - return Err(AttractorError::Parse(format!( + return Err(ArcError::Parse(format!( "unexpected trailing content: {:?}", &remaining[..remaining.len().min(50)] ))); diff --git a/crates/arc-workflows/src/parser/semantic.rs b/crates/arc-workflows/src/parser/semantic.rs index 6873abe25..64e19e23c 100644 --- a/crates/arc-workflows/src/parser/semantic.rs +++ b/crates/arc-workflows/src/parser/semantic.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::time::Duration; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::graph::types::{AttrValue, Edge, Graph, Node}; use crate::parser::ast::{AstValue, AttrBlock, DotGraph, Statement}; @@ -239,7 +239,7 @@ impl SemanticState { /// # Errors /// /// Returns an error if the AST cannot be converted to a valid graph. -pub fn ast_to_graph(dot: &DotGraph) -> Result { +pub fn ast_to_graph(dot: &DotGraph) -> Result { let mut state = SemanticState::new(dot.name.clone()); let empty = HashMap::new(); state.process_statements(&dot.statements, None, &empty, &empty); diff --git a/crates/arc-workflows/src/pipeline.rs b/crates/arc-workflows/src/pipeline.rs index ae3e28450..4953e3e15 100644 --- a/crates/arc-workflows/src/pipeline.rs +++ b/crates/arc-workflows/src/pipeline.rs @@ -1,4 +1,4 @@ -use crate::error::AttractorError; +use crate::error::ArcError; use crate::graph::Graph; use crate::transform::{StylesheetApplicationTransform, Transform, VariableExpansionTransform}; use crate::validation::{self, Diagnostic}; @@ -28,7 +28,7 @@ impl PipelineBuilder { /// # Errors /// /// Returns an error if parsing or validation fails. - pub fn prepare(&self, dot_source: &str) -> Result<(Graph, Vec), AttractorError> { + pub fn prepare(&self, dot_source: &str) -> Result<(Graph, Vec), ArcError> { let mut graph = crate::parser::parse(dot_source)?; // Built-in transforms (PreambleTransform moved to engine execution time) @@ -56,7 +56,7 @@ impl Default for PipelineBuilder { /// # Errors /// /// Returns an error if parsing fails or if validation produces Error-severity diagnostics. -pub fn prepare_pipeline(dot_source: &str) -> Result { +pub fn prepare_pipeline(dot_source: &str) -> Result { let builder = PipelineBuilder::new(); let (graph, diagnostics) = builder.prepare(dot_source)?; @@ -66,7 +66,7 @@ pub fn prepare_pipeline(dot_source: &str) -> Result { .collect(); if !errors.is_empty() { let messages: Vec = errors.iter().map(|d| d.message.clone()).collect(); - return Err(AttractorError::Validation(messages.join("; "))); + return Err(ArcError::Validation(messages.join("; "))); } Ok(graph) diff --git a/crates/arc-workflows/src/stylesheet.rs b/crates/arc-workflows/src/stylesheet.rs index 313ca707e..7227f985e 100644 --- a/crates/arc-workflows/src/stylesheet.rs +++ b/crates/arc-workflows/src/stylesheet.rs @@ -1,4 +1,4 @@ -use crate::error::AttractorError; +use crate::error::ArcError; use crate::graph::types::{AttrValue, Graph}; /// A parsed stylesheet selector. @@ -51,7 +51,7 @@ pub struct Stylesheet { /// # Errors /// /// Returns an error if the input contains invalid stylesheet syntax. -pub fn parse_stylesheet(input: &str) -> Result { +pub fn parse_stylesheet(input: &str) -> Result { let input = input.trim(); if input.is_empty() { return Ok(Stylesheet { rules: Vec::new() }); @@ -65,7 +65,7 @@ pub fn parse_stylesheet(input: &str) -> Result { let selector = parse_selector(&mut remaining)?; if !remaining.starts_with('{') { - return Err(AttractorError::Stylesheet(format!( + return Err(ArcError::Stylesheet(format!( "expected '{{' after selector, got: {:?}", &remaining[..remaining.len().min(20)] ))); @@ -84,7 +84,7 @@ pub fn parse_stylesheet(input: &str) -> Result { Ok(Stylesheet { rules }) } -fn parse_selector(remaining: &mut &str) -> Result { +fn parse_selector(remaining: &mut &str) -> Result { if remaining.starts_with('*') { *remaining = remaining[1..].trim(); Ok(Selector::Universal) @@ -94,7 +94,7 @@ fn parse_selector(remaining: &mut &str) -> Result { .find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-') .unwrap_or(remaining.len()); if end == 0 { - return Err(AttractorError::Stylesheet( + return Err(ArcError::Stylesheet( "expected identifier after '#'".into(), )); } @@ -107,7 +107,7 @@ fn parse_selector(remaining: &mut &str) -> Result { .find(|c: char| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-') .unwrap_or(remaining.len()); if end == 0 { - return Err(AttractorError::Stylesheet( + return Err(ArcError::Stylesheet( "expected class name after '.'".into(), )); } @@ -120,7 +120,7 @@ fn parse_selector(remaining: &mut &str) -> Result { .find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-') .unwrap_or(remaining.len()); if end == 0 { - return Err(AttractorError::Stylesheet(format!( + return Err(ArcError::Stylesheet(format!( "expected selector ('*', '#id', '.class', or shape name), got: {:?}", &remaining[..remaining.len().min(20)] ))); @@ -131,11 +131,11 @@ fn parse_selector(remaining: &mut &str) -> Result { } } -fn parse_declarations(remaining: &mut &str) -> Result, AttractorError> { +fn parse_declarations(remaining: &mut &str) -> Result, ArcError> { let mut declarations = Vec::new(); while !remaining.starts_with('}') { if remaining.is_empty() { - return Err(AttractorError::Stylesheet( + return Err(ArcError::Stylesheet( "unexpected end of stylesheet, expected '}'".into(), )); } @@ -151,7 +151,7 @@ fn parse_declarations(remaining: &mut &str) -> Result, Attracto *remaining = remaining[prop_end..].trim(); if !remaining.starts_with(':') { - return Err(AttractorError::Stylesheet(format!( + return Err(ArcError::Stylesheet(format!( "expected ':' after property name '{property}'" ))); } @@ -164,7 +164,7 @@ fn parse_declarations(remaining: &mut &str) -> Result, Attracto *remaining = remaining[val_end..].trim(); if value.is_empty() { - return Err(AttractorError::Stylesheet(format!( + return Err(ArcError::Stylesheet(format!( "empty value for property '{property}'" ))); } diff --git a/crates/arc-workflows/src/validation/mod.rs b/crates/arc-workflows/src/validation/mod.rs index e41e105bc..c245852bb 100644 --- a/crates/arc-workflows/src/validation/mod.rs +++ b/crates/arc-workflows/src/validation/mod.rs @@ -2,7 +2,7 @@ pub mod rules; use serde::{Deserialize, Serialize}; -use crate::error::AttractorError; +use crate::error::ArcError; use crate::graph::Graph; /// Severity level for validation diagnostics. @@ -48,11 +48,11 @@ pub fn validate(graph: &Graph, extra_rules: &[&dyn LintRule]) -> Vec /// diagnostics are found. /// /// # Errors -/// Returns `AttractorError::Validation` if any Error-severity diagnostics are found. +/// Returns `ArcError::Validation` if any Error-severity diagnostics are found. pub fn validate_or_raise( graph: &Graph, extra_rules: &[&dyn LintRule], -) -> Result, AttractorError> { +) -> Result, ArcError> { let diagnostics = validate(graph, extra_rules); let errors: Vec<&Diagnostic> = diagnostics .iter() @@ -60,7 +60,7 @@ pub fn validate_or_raise( .collect(); if !errors.is_empty() { let messages: Vec = errors.iter().map(|d| d.message.clone()).collect(); - return Err(AttractorError::Validation(messages.join("; "))); + return Err(ArcError::Validation(messages.join("; "))); } Ok(diagnostics) } diff --git a/crates/arc-workflows/tests/daytona_integration.rs b/crates/arc-workflows/tests/daytona_integration.rs index e71b1d02f..fc7fc8c82 100644 --- a/crates/arc-workflows/tests/daytona_integration.rs +++ b/crates/arc-workflows/tests/daytona_integration.rs @@ -1,7 +1,7 @@ //! Integration tests for `DaytonaExecutionEnvironment`. //! //! These tests require a `DAYTONA_API_KEY` environment variable and network access. -//! Run with: `cargo test --package attractor -- --ignored daytona` +//! Run with: `cargo test --package arc-workflows -- --ignored daytona` use std::collections::HashMap; use std::path::Path; @@ -13,7 +13,7 @@ use arc_workflows::checkpoint::Checkpoint; use arc_workflows::context::Context; use arc_workflows::daytona_env::{DaytonaConfig, DaytonaExecutionEnvironment}; use arc_workflows::engine::{PipelineEngine, RunConfig}; -use arc_workflows::error::AttractorError; +use arc_workflows::error::ArcError; use arc_workflows::event::EventEmitter; use arc_workflows::graph::{AttrValue, Edge, Graph, Node}; use arc_workflows::handler::exit::ExitHandler; @@ -179,7 +179,7 @@ async fn daytona_artifact_sync_uploads_and_rewrites_pointer() { // Pointer should be rewritten to the Daytona working directory let new_pointer = updates["response.plan"].as_str().unwrap(); let expected_prefix = format!( - "file://{}/.attractor/artifacts/", + "file://{}/.arc/artifacts/", env.working_directory() ); assert!( @@ -220,7 +220,7 @@ impl Handler for LargeOutputHandler { _graph: &Graph, _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, - ) -> Result { + ) -> Result { let mut outcome = Outcome::success(); let large_value = "x".repeat(150 * 1024); outcome.context_updates.insert( @@ -289,7 +289,7 @@ async fn daytona_pipeline_artifact_offload_and_sync() { .expect("context should have response.big_output"); let pointer_str = pointer_value.as_str().expect("pointer should be a string"); let expected_prefix = format!( - "file://{}/.attractor/artifacts/", + "file://{}/.arc/artifacts/", env.working_directory() ); assert!( @@ -336,7 +336,7 @@ impl Handler for FileWriterHandler { _graph: &Graph, _logs_root: &Path, services: &arc_workflows::handler::EngineServices, - ) -> Result { + ) -> Result { let content = format!("output from {}", node.id); let cmd = format!("echo '{content}' > {}.txt", node.id); let _ = services.execution_env.exec_command(&cmd, 10_000, None, None, None).await; diff --git a/crates/arc-workflows/tests/integration.rs b/crates/arc-workflows/tests/integration.rs index f7174e99c..085f55948 100644 --- a/crates/arc-workflows/tests/integration.rs +++ b/crates/arc-workflows/tests/integration.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use arc_workflows::checkpoint::Checkpoint; use arc_workflows::context::Context; use arc_workflows::engine::{PipelineEngine, RunConfig}; -use arc_workflows::error::AttractorError; +use arc_workflows::error::ArcError; use arc_workflows::event::{EventEmitter, PipelineEvent}; use arc_workflows::graph::{AttrValue, Edge, Graph, Node}; use arc_workflows::handler::codergen::{CodergenBackend, CodergenHandler, CodergenResult}; @@ -460,7 +460,7 @@ impl Handler for AlwaysFailHandler { _graph: &Graph, _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, - ) -> Result { + ) -> Result { Ok(Outcome::fail(format!("forced failure for {}", node.id))) } } @@ -574,7 +574,7 @@ async fn goal_gate_routes_to_retry_target_when_present() { _graph: &Graph, _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, - ) -> Result { + ) -> Result { let count = self .call_count .fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -896,7 +896,7 @@ async fn retry_on_failure_then_succeed() { _graph: &Graph, _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, - ) -> Result { + ) -> Result { let count = self .call_count .fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -1104,7 +1104,7 @@ impl CodergenBackend for MockCodergenBackend { _emitter: &Arc, _stage_dir: &std::path::Path, _execution_env: &Arc, - ) -> Result { + ) -> Result { Ok(CodergenResult::Text { text: format!( "Response for {}: processed prompt '{}'", @@ -1136,7 +1136,7 @@ impl Handler for CounterHandler { _graph: &Graph, _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, - ) -> Result { + ) -> Result { let count = self .call_count .fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -1160,7 +1160,7 @@ impl Handler for LargeOutputHandler { _graph: &Graph, _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, - ) -> Result { + ) -> Result { let mut outcome = Outcome::success(); // 150KB string — well above the 100KB artifact threshold let large_value = "x".repeat(150 * 1024); @@ -1184,7 +1184,7 @@ impl Handler for ContextSetterHandler { _graph: &Graph, _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, - ) -> Result { + ) -> Result { let mut outcome = Outcome::success(); outcome .context_updates @@ -1947,7 +1947,7 @@ async fn branching_loop_back_on_failure() { _graph: &Graph, _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, - ) -> Result { + ) -> Result { let count = self .call_count .fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -2263,7 +2263,7 @@ async fn scenario_node_retries_on_retry_status() { _graph: &Graph, _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, - ) -> Result { + ) -> Result { let count = self .call_count .fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -2508,7 +2508,7 @@ async fn manager_loop_stop_condition_satisfied_e2e() { _graph: &Graph, _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, - ) -> Result { + ) -> Result { let mut outcome = Outcome::success(); outcome .context_updates @@ -2980,7 +2980,7 @@ async fn custom_handler_registration_and_execution() { _graph: &Graph, _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, - ) -> Result { + ) -> Result { let mut outcome = Outcome::success(); outcome .context_updates @@ -3184,7 +3184,7 @@ async fn manager_loop_with_child_observer_e2e() { _dotfile: &str, _workdir: &str, _context: &arc_workflows::context::Context, - ) -> Result<(), arc_workflows::error::AttractorError> { + ) -> Result<(), arc_workflows::error::ArcError> { self.launch_count.fetch_add(1, Ordering::SeqCst); Ok(()) } @@ -3192,7 +3192,7 @@ async fn manager_loop_with_child_observer_e2e() { async fn observe( &self, context: &arc_workflows::context::Context, - ) -> Result<(), arc_workflows::error::AttractorError> { + ) -> Result<(), arc_workflows::error::ArcError> { let cycle = self.observe_count.fetch_add(1, Ordering::SeqCst); if cycle >= 2 { context.set( @@ -3211,7 +3211,7 @@ async fn manager_loop_with_child_observer_e2e() { &self, _context: &arc_workflows::context::Context, _node: &arc_workflows::graph::Node, - ) -> Result<(), arc_workflows::error::AttractorError> { + ) -> Result<(), arc_workflows::error::ArcError> { Ok(()) } } @@ -3496,7 +3496,7 @@ impl Handler for FidelityCapturingHandler { _graph: &Graph, _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, - ) -> Result { + ) -> Result { let fidelity = context.get_string("internal.fidelity", "none"); self.captures .fidelities @@ -4993,7 +4993,7 @@ mod real_llm { use async_trait::async_trait; use arc_workflows::context::Context; - use arc_workflows::error::AttractorError; + use arc_workflows::error::ArcError; use arc_workflows::graph::Node; use arc_workflows::handler::codergen::{CodergenBackend, CodergenHandler, CodergenResult}; @@ -5016,7 +5016,7 @@ mod real_llm { _emitter: &Arc, _stage_dir: &std::path::Path, _execution_env: &Arc, - ) -> Result { + ) -> Result { let request = Request { model: self.model.clone(), messages: vec![Message::user(prompt)], @@ -5036,7 +5036,7 @@ mod real_llm { .client .complete(&request) .await - .map_err(|e| AttractorError::Handler(e.to_string()))?; + .map_err(|e| ArcError::Handler(e.to_string()))?; Ok(CodergenResult::Text { text: response.text(), usage: None, files_touched: Vec::new() }) } } @@ -6485,11 +6485,11 @@ async fn tool_hooks_node_level_overrides_graph_level() { #[tokio::test] async fn tool_hooks_pre_receives_node_id_env_var() { - // Use a pre-hook that writes the ATTRACTOR_NODE_ID env var to a file + // Use a pre-hook that writes the ARC_NODE_ID env var to a file let dir = tempfile::tempdir().unwrap(); let marker_path = dir.path().join("node_id.txt"); let hook_cmd = format!( - "echo $ATTRACTOR_NODE_ID > {}", + "echo $ARC_NODE_ID > {}", marker_path.display() ); @@ -6524,7 +6524,7 @@ async fn tool_hooks_pre_receives_node_id_env_var() { assert_eq!( written.trim(), "my_step", - "ATTRACTOR_NODE_ID should contain the node id" + "ARC_NODE_ID should contain the node id" ); } @@ -6566,7 +6566,7 @@ static TEST_STYLES: Styles = Styles::new(false); #[tokio::test] #[ignore = "requires ANTHROPIC_API_KEY"] -async fn attractor_e2e_with_real_llm() { +async fn arc_e2e_with_real_llm() { dotenvy::dotenv().ok(); let dir = tempfile::tempdir().unwrap(); @@ -7057,7 +7057,7 @@ async fn artifact_pointers_rewritten_for_remote_execution_env() { .expect("context should have response.big_output"); let pointer_str = pointer_value.as_str().expect("pointer should be a string"); assert!( - pointer_str.starts_with("file:///sandbox/.attractor/artifacts/"), + pointer_str.starts_with("file:///sandbox/.arc/artifacts/"), "pointer should reference remote path, got: {pointer_str}" ); @@ -7094,7 +7094,7 @@ async fn node_dir_uses_visit_count_on_revisit() { _graph: &Graph, _logs_root: &Path, _services: &arc_workflows::handler::EngineServices, - ) -> Result { + ) -> Result { let n = self.call_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); if n == 0 { Ok(Outcome::fail("first attempt fails")) @@ -7338,7 +7338,7 @@ async fn cli_backend_run_writes_prompt_and_calls_exec() { // Verify prompt was written let written = test_env.recorded_written_files(); assert_eq!(written.len(), 1, "should write exactly one file (the prompt)"); - assert_eq!(written[0].0, "/tmp/attractor_cli_prompt.txt"); + assert_eq!(written[0].0, "/tmp/arc_cli_prompt.txt"); assert_eq!(written[0].1, "Fix the authentication bug"); // Verify the CLI command was called @@ -7346,7 +7346,7 @@ async fn cli_backend_run_writes_prompt_and_calls_exec() { let cli_cmd = commands.iter().find(|c| c.contains("claude")).expect("should call claude CLI"); assert!(cli_cmd.contains("-p"), "should use pipe mode"); assert!(cli_cmd.contains("claude-opus-4-6"), "should use correct model"); - assert!(cli_cmd.contains("/tmp/attractor_cli_prompt.txt"), "should reference prompt file"); + assert!(cli_cmd.contains("/tmp/arc_cli_prompt.txt"), "should reference prompt file"); // Verify parsed response match result { diff --git a/docs/spec-structure.md b/docs/spec-structure.md index ddd565fe6..9da3e24f2 100644 --- a/docs/spec-structure.md +++ b/docs/spec-structure.md @@ -1,6 +1,6 @@ # Spec Format Reference -Common format shared by `specs/unified-llm-spec.md`, `specs/coding-agent-loop-spec.md` (agent crate), and `specs/attractor-spec.md`. +Common format shared by `specs/unified-llm-spec.md`, `specs/coding-agent-loop-spec.md` (agent crate), and `specs/arc-spec.md`. --- @@ -38,7 +38,7 @@ An ASCII art box diagram showing the layers/components and how they connect. All ### 4.5 Relationship to Companion Specs -When the spec depends on another (agent depends on unified-llm; attractor depends on agent), it states this explicitly with the types it imports and how the layering works. +When the spec depends on another (agent depends on unified-llm; arc depends on agent), it states this explicitly with the types it imports and how the layering works. ## 5. Core Technical Sections diff --git a/docs/specs/README.md b/docs/specs/README.md index 50b578930..7fb3b088d 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -1,21 +1,21 @@ -# Attractor +# Arc -This repository contains [NLSpecs](#terminology) to build your own version of Attractor to create your own software factory. +This repository contains [NLSpecs](#terminology) to build your own version of Arc to create your own software factory. -Although bringing your own agentic loop and unified LLM SDK is not required to build your own Attractor, we highly recommend controlling the stack so you have a strong foundation. +Although bringing your own agentic loop and unified LLM SDK is not required to build your own Arc, we highly recommend controlling the stack so you have a strong foundation. ## Specs -- [Attractor Specification](./attractor-spec.md) +- [Arc Specification](./arc-spec.md) - [Agent Specification](./coding-agent-loop-spec.md) - [Unified LLM Client Specification](./unified-llm-spec.md) -## Building Attractor +## Building Arc Supply the following prompt to a modern coding agent (Claude Code, Codex, OpenCode, Amp, Cursor, etc): ``` -codeagent> Implement Attractor as described by https://factory.strongdm.ai/ +codeagent> Implement Arc as described by https://factory.strongdm.ai/ ``` ## Terminology diff --git a/docs/specs/attractor-spec.md b/docs/specs/arc-spec.md similarity index 98% rename from docs/specs/attractor-spec.md rename to docs/specs/arc-spec.md index 210737df0..b1b49d1b3 100644 --- a/docs/specs/attractor-spec.md +++ b/docs/specs/arc-spec.md @@ -1,4 +1,4 @@ -# Attractor Specification +# Arc Specification A DOT-based pipeline runner that uses directed graphs (defined in Graphviz DOT syntax) to orchestrate multi-stage AI workflows. Each node in the graph is an AI task (LLM call, human review, conditional branch, parallel fan-out, etc.) and edges define the flow between them. @@ -26,7 +26,7 @@ A DOT-based pipeline runner that uses directed graphs (defined in Graphviz DOT s AI-powered software workflows -- code generation, code review, testing, deployment planning -- often require multiple LLM calls chained together with conditional logic, human approvals, and parallel execution. Without a structured orchestration layer, developers either write fragile imperative scripts or build ad-hoc state machines that are difficult to visualize, version, or debug. -Attractor solves this by letting pipeline authors define multi-stage AI workflows as directed graphs using Graphviz DOT syntax. The graph is the workflow: nodes are tasks, edges are transitions, and attributes configure behavior. The result is a declarative, visual, version-controllable pipeline definition that an execution engine can traverse deterministically. +Arc solves this by letting pipeline authors define multi-stage AI workflows as directed graphs using Graphviz DOT syntax. The graph is the workflow: nodes are tasks, edges are transitions, and attributes configure behavior. The result is a declarative, visual, version-controllable pipeline definition that an execution engine can traverse deterministically. ### 1.2 Why DOT Syntax @@ -53,11 +53,11 @@ For reference on DOT syntax, see the Graphviz DOT language specification: https: ### 1.4 Layering and LLM Backends -Attractor defines the orchestration layer: graph definition, traversal, state management, and extensibility. It does NOT require any specific LLM integration. The codergen handler (Section 4.5) needs a way to call an LLM and get a response -- how you provide that is up to you. +Arc defines the orchestration layer: graph definition, traversal, state management, and extensibility. It does NOT require any specific LLM integration. The codergen handler (Section 4.5) needs a way to call an LLM and get a response -- how you provide that is up to you. The codergen handler takes a backend that conforms to the `CodergenBackend` interface (Section 4.5). What that backend does internally is entirely up to the implementor -- use the companion [Agent](./coding-agent-loop-spec.md) and [Unified LLM Client](./unified-llm-spec.md) specs, spawn CLI agents (Claude Code, Codex, Gemini CLI) in subprocesses, run agents in tmux panes with a manager attaching to them, call an LLM API directly, or anything else. The pipeline definition (the DOT file) does not change regardless of backend choice. -Attractor pipelines are driven by an event stream (Section 9.6). TUI, web, and IDE frontends consume events and submit human-in-the-loop answers. The pipeline engine is headless; the presentation layer is separate. +Arc pipelines are driven by an event stream (Section 9.6). TUI, web, and IDE frontends consume events and submit human-in-the-loop answers. The pipeline engine is headless; the presentation layer is separate. --- @@ -65,7 +65,7 @@ Attractor pipelines are driven by an event stream (Section 9.6). TUI, web, and I ### 2.1 Supported Subset -Attractor accepts a strict subset of the Graphviz DOT language. The restrictions exist for predictability: one graph per file, directed edges only, no HTML labels, and typed attributes with defaults. +Arc accepts a strict subset of the Graphviz DOT language. The restrictions exist for predictability: one graph per file, directed edges only, no HTML labels, and typed attributes with defaults. ### 2.2 BNF-Style Grammar @@ -1277,7 +1277,7 @@ Each pipeline execution produces a directory tree for logging, checkpoints, and ### 6.1 Interviewer Interface -All human interaction in Attractor goes through an Interviewer interface. This abstraction allows the pipeline to present questions to a human and receive answers through any frontend: CLI, web UI, Slack bot, or a programmatic queue for testing. +All human interaction in Arc goes through an Interviewer interface. This abstraction allows the pipeline to present questions to a human and receive answers through any frontend: CLI, web UI, Slack bot, or a programmatic queue for testing. ``` INTERFACE Interviewer: @@ -1624,7 +1624,7 @@ Custom transforms run after built-in transforms. Order of custom transforms foll ### 9.4 Pipeline Composition -Attractor supports combining multiple DOT graphs through: +Arc supports combining multiple DOT graphs through: **Sub-pipeline nodes:** A node whose handler runs an entire sub-graph as its execution. The manager loop handler (Section 4.11) is an example of this pattern.