mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
Rename remaining attractor references to arc
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
99ad7ffe29
commit
8aa56285f4
43 changed files with 909 additions and 264 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
// Custom logic here
|
||||
Ok(Outcome::success())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<dyn ExecutionEnvironment>,
|
||||
) -> Result<Session, AttractorError> {
|
||||
) -> Result<Session, ArcError> {
|
||||
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<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
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<crate::event::EventEmitter>,
|
||||
stage_dir: &std::path::Path,
|
||||
execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -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<EventEmitter>,
|
||||
stage_dir: &Path,
|
||||
execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
// 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::<String>()
|
||||
|
|
@ -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<EventEmitter>,
|
||||
stage_dir: &Path,
|
||||
execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
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<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
// 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<EventEmitter>,
|
||||
_stage_dir: &Path,
|
||||
_execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
Ok(CodergenResult::Text {
|
||||
text: "stub".to_string(),
|
||||
usage: None,
|
||||
|
|
|
|||
|
|
@ -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-{}",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<Vec<Clause>, AttractorError> {
|
||||
fn parse_clauses(expr: &str) -> Result<Vec<Clause>, ArcError> {
|
||||
let expr = expr.trim();
|
||||
if expr.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
|
|
@ -34,7 +34,7 @@ fn parse_clauses(expr: &str) -> Result<Vec<Clause>, 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<Vec<Clause>, 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<Vec<Clause>, 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<Vec<Clause>, 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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<FailureClass> {
|
||||
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::<FailureClass>() {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> std::result::Result<Outcome, ArcError> {
|
||||
Ok(Outcome::fail("always fails"))
|
||||
}
|
||||
}
|
||||
|
|
@ -1371,7 +1415,7 @@ mod tests {
|
|||
_graph: &Graph,
|
||||
_logs_root: &Path,
|
||||
_services: &crate::handler::EngineServices,
|
||||
) -> std::result::Result<Outcome, AttractorError> {
|
||||
) -> std::result::Result<Outcome, ArcError> {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> std::result::Result<Outcome, ArcError> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Self, Self::Err> {
|
||||
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<std::io::Error> for AttractorError {
|
||||
impl From<std::io::Error> for ArcError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
Self::Io(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AttractorError>;
|
||||
impl From<SdkError> for ArcError {
|
||||
fn from(err: SdkError) -> Self {
|
||||
Self::Llm(err)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ArcError>;
|
||||
|
||||
#[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<i32> = Ok(42);
|
||||
assert!(ok.is_ok());
|
||||
|
||||
let err: Result<i32> = Err(AttractorError::Parse("bad".to_string()));
|
||||
let err: Result<i32> = 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::<FailureClass>().unwrap(),
|
||||
FailureClass::TransientInfra
|
||||
);
|
||||
assert_eq!(
|
||||
"deterministic".parse::<FailureClass>().unwrap(),
|
||||
FailureClass::Deterministic
|
||||
);
|
||||
assert_eq!(
|
||||
"budget_exhausted".parse::<FailureClass>().unwrap(),
|
||||
FailureClass::BudgetExhausted
|
||||
);
|
||||
assert_eq!(
|
||||
"compilation_loop".parse::<FailureClass>().unwrap(),
|
||||
FailureClass::CompilationLoop
|
||||
);
|
||||
assert_eq!(
|
||||
"canceled".parse::<FailureClass>().unwrap(),
|
||||
FailureClass::Canceled
|
||||
);
|
||||
assert_eq!(
|
||||
"structural".parse::<FailureClass>().unwrap(),
|
||||
FailureClass::Structural
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_class_from_str_invalid() {
|
||||
assert!("unknown".parse::<FailureClass>().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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>) -> AttractorError {
|
||||
AttractorError::Engine(msg.into())
|
||||
fn git_error(msg: impl Into<String>) -> 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),
|
||||
|
|
|
|||
|
|
@ -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<Self, AttractorError> {
|
||||
pub fn parse(s: &str) -> Result<Self, ArcError> {
|
||||
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<CodergenMode, AttractorError> {
|
||||
pub fn codergen_mode(&self) -> Result<CodergenMode, ArcError> {
|
||||
match self.str_attr("codergen_mode") {
|
||||
Some(s) => CodergenMode::parse(s),
|
||||
None => Ok(CodergenMode::AgentLoop),
|
||||
|
|
|
|||
|
|
@ -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<EventEmitter>,
|
||||
stage_dir: &Path,
|
||||
execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError>;
|
||||
) -> Result<CodergenResult, ArcError>;
|
||||
|
||||
/// 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<CodergenResult, AttractorError> {
|
||||
Err(AttractorError::Validation(
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
// 1. Build prompt (prepend fidelity preamble if present)
|
||||
let raw_prompt = node
|
||||
.prompt()
|
||||
|
|
@ -570,7 +570,7 @@ mod tests {
|
|||
_emitter: &Arc<EventEmitter>,
|
||||
_stage_dir: &Path,
|
||||
_execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
*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<EventEmitter>,
|
||||
_stage_dir: &Path,
|
||||
_execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
*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<EventEmitter>,
|
||||
_stage_dir: &Path,
|
||||
_execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
Err(AttractorError::Handler("Request timed out".to_string()))
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
Err(ArcError::Handler("Request timed out".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -775,7 +775,7 @@ Some text in between.
|
|||
_emitter: &Arc<EventEmitter>,
|
||||
_stage_dir: &Path,
|
||||
_execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
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<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
Ok(CodergenResult::Text {
|
||||
text: "one-shot response".to_string(),
|
||||
usage: None,
|
||||
|
|
@ -879,8 +879,8 @@ Some text in between.
|
|||
_emitter: &Arc<EventEmitter>,
|
||||
_stage_dir: &Path,
|
||||
_execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
Err(AttractorError::Validation("bad config".to_string()))
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
Err(ArcError::Validation("bad config".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -917,7 +917,7 @@ Some text in between.
|
|||
_emitter: &Arc<EventEmitter>,
|
||||
_stage_dir: &std::path::Path,
|
||||
_execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
*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<EventEmitter>,
|
||||
_stage_dir: &std::path::Path,
|
||||
_execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
*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<EventEmitter>,
|
||||
_stage_dir: &std::path::Path,
|
||||
_execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
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<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
*self.captured_prompt.lock().unwrap() = Some(prompt.to_string());
|
||||
Ok(CodergenResult::Text {
|
||||
text: "classified".to_string(),
|
||||
|
|
|
|||
|
|
@ -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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
let mut outcome = Outcome::success();
|
||||
outcome.notes = Some(format!("Conditional node evaluated: {}", node.id));
|
||||
Ok(outcome)
|
||||
|
|
|
|||
|
|
@ -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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
Ok(Outcome::success())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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<EventEmitter>,
|
||||
execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<Candidate, AttractorError> {
|
||||
) -> Result<Candidate, ArcError> {
|
||||
let results_text = serde_json::to_string_pretty(results)
|
||||
.unwrap_or_else(|_| results.to_string());
|
||||
|
||||
|
|
@ -380,7 +380,7 @@ mod tests {
|
|||
_emitter: &Arc<EventEmitter>,
|
||||
_stage_dir: &std::path::Path,
|
||||
_execution_env: &Arc<dyn ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
// 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() })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
let poll_interval = node
|
||||
.attrs
|
||||
.get("manager.poll_interval")
|
||||
|
|
|
|||
|
|
@ -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<Outcome, AttractorError>;
|
||||
) -> Result<Outcome, ArcError>;
|
||||
|
||||
/// 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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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, AttractorError>(BranchResult {
|
||||
Ok::<BranchResult, ArcError>(BranchResult {
|
||||
id: target_id,
|
||||
outcome,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
Ok(Outcome::success())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
// 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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
Ok(Outcome::fail("sub-pipeline failure"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
// 1. Derive choices from outgoing edges
|
||||
let edges = graph.outgoing_edges(&node.id);
|
||||
let mut freeform_target: Option<String> = None;
|
||||
|
|
|
|||
|
|
@ -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<String, AttractorError> {
|
||||
pub fn to_json(&self) -> Result<String, ArcError> {
|
||||
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<Vec<(Question, Answer)>, AttractorError> {
|
||||
pub fn from_json(json: &str) -> Result<Vec<(Question, Answer)>, 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<Vec<(Question, Answer)>, AttractorError> {
|
||||
pub fn load_from_file(path: &Path) -> Result<Vec<(Question, Answer)>, ArcError> {
|
||||
let json = std::fs::read_to_string(path)?;
|
||||
Self::from_json(&json)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Graph, AttractorError> {
|
||||
pub fn parse(input: &str) -> Result<Graph, ArcError> {
|
||||
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)]
|
||||
)));
|
||||
|
|
|
|||
|
|
@ -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<Graph, AttractorError> {
|
||||
pub fn ast_to_graph(dot: &DotGraph) -> Result<Graph, ArcError> {
|
||||
let mut state = SemanticState::new(dot.name.clone());
|
||||
let empty = HashMap::new();
|
||||
state.process_statements(&dot.statements, None, &empty, &empty);
|
||||
|
|
|
|||
|
|
@ -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<Diagnostic>), AttractorError> {
|
||||
pub fn prepare(&self, dot_source: &str) -> Result<(Graph, Vec<Diagnostic>), 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<Graph, AttractorError> {
|
||||
pub fn prepare_pipeline(dot_source: &str) -> Result<Graph, ArcError> {
|
||||
let builder = PipelineBuilder::new();
|
||||
let (graph, diagnostics) = builder.prepare(dot_source)?;
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ pub fn prepare_pipeline(dot_source: &str) -> Result<Graph, AttractorError> {
|
|||
.collect();
|
||||
if !errors.is_empty() {
|
||||
let messages: Vec<String> = errors.iter().map(|d| d.message.clone()).collect();
|
||||
return Err(AttractorError::Validation(messages.join("; ")));
|
||||
return Err(ArcError::Validation(messages.join("; ")));
|
||||
}
|
||||
|
||||
Ok(graph)
|
||||
|
|
|
|||
|
|
@ -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<Stylesheet, AttractorError> {
|
||||
pub fn parse_stylesheet(input: &str) -> Result<Stylesheet, ArcError> {
|
||||
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<Stylesheet, AttractorError> {
|
|||
|
||||
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<Stylesheet, AttractorError> {
|
|||
Ok(Stylesheet { rules })
|
||||
}
|
||||
|
||||
fn parse_selector(remaining: &mut &str) -> Result<Selector, AttractorError> {
|
||||
fn parse_selector(remaining: &mut &str) -> Result<Selector, ArcError> {
|
||||
if remaining.starts_with('*') {
|
||||
*remaining = remaining[1..].trim();
|
||||
Ok(Selector::Universal)
|
||||
|
|
@ -94,7 +94,7 @@ fn parse_selector(remaining: &mut &str) -> Result<Selector, AttractorError> {
|
|||
.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<Selector, AttractorError> {
|
|||
.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<Selector, AttractorError> {
|
|||
.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<Selector, AttractorError> {
|
|||
}
|
||||
}
|
||||
|
||||
fn parse_declarations(remaining: &mut &str) -> Result<Vec<Declaration>, AttractorError> {
|
||||
fn parse_declarations(remaining: &mut &str) -> Result<Vec<Declaration>, 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<Vec<Declaration>, 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<Vec<Declaration>, 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}'"
|
||||
)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Diagnostic>
|
|||
/// 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<Vec<Diagnostic>, AttractorError> {
|
||||
) -> Result<Vec<Diagnostic>, 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<String> = errors.iter().map(|d| d.message.clone()).collect();
|
||||
return Err(AttractorError::Validation(messages.join("; ")));
|
||||
return Err(ArcError::Validation(messages.join("; ")));
|
||||
}
|
||||
Ok(diagnostics)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<Outcome, arc_workflows::error::AttractorError> {
|
||||
) -> Result<Outcome, arc_workflows::error::ArcError> {
|
||||
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<Outcome, arc_workflows::error::AttractorError> {
|
||||
) -> Result<Outcome, arc_workflows::error::ArcError> {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
let count = self
|
||||
.call_count
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
|
|
@ -1104,7 +1104,7 @@ impl CodergenBackend for MockCodergenBackend {
|
|||
_emitter: &Arc<EventEmitter>,
|
||||
_stage_dir: &std::path::Path,
|
||||
_execution_env: &Arc<dyn arc_agent::ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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<Outcome, AttractorError> {
|
||||
) -> Result<Outcome, ArcError> {
|
||||
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<EventEmitter>,
|
||||
_stage_dir: &std::path::Path,
|
||||
_execution_env: &Arc<dyn arc_agent::ExecutionEnvironment>,
|
||||
) -> Result<CodergenResult, AttractorError> {
|
||||
) -> Result<CodergenResult, ArcError> {
|
||||
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<Outcome, arc_workflows::error::AttractorError> {
|
||||
) -> Result<Outcome, arc_workflows::error::ArcError> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
Loading…
Add table
Reference in a new issue