Add TOML-based task config for attractor run

Support `attractor run task.toml` as an alternative to `.dot` files.
Auto-detects format by file extension. TOML bundles pipeline config
(graph path, model, setup commands, working directory) into a single
file with precedence: CLI flag > TOML > DOT graph attrs > defaults.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 8744d57d6053
This commit is contained in:
Bryan Helmkamp 2026-02-26 13:37:08 -05:00
parent 5198a29a62
commit 0f9e31204f
6 changed files with 289 additions and 10 deletions

1
Cargo.lock generated
View file

@ -215,6 +215,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"toml",
"tower",
"uuid",
]

View file

@ -33,3 +33,4 @@ git2 = "0.19"
walkdir = "2"
regex = "1"
aho-corasick = "1"
toml = "0.8"

View file

@ -38,6 +38,7 @@ async-trait.workspace = true
futures.workspace = true
chrono = { workspace = true, features = ["serde"] }
nom = "7"
toml.workspace = true
dirs = "6"
dialoguer.workspace = true
axum = { version = "0.8", optional = true }

View file

@ -2,6 +2,7 @@ pub mod backend;
pub mod run;
#[cfg(feature = "server")]
pub mod serve;
pub mod task_config;
pub mod validate;
use std::path::Path;
@ -24,7 +25,7 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Command {
/// Launch a pipeline from a .dot file
/// Launch a pipeline from a .dot or .toml task file
Run(RunArgs),
/// Parse and validate a pipeline without executing
Validate(ValidateArgs),
@ -35,7 +36,7 @@ pub enum Command {
#[derive(Args)]
pub struct RunArgs {
/// Path to the .dot pipeline file
/// Path to a .dot pipeline file or .toml task config
pub pipeline: PathBuf,
/// Log/artifact directory

View file

@ -17,6 +17,7 @@ use crate::pipeline::PipelineBuilder;
use crate::validation::Severity;
use super::backend::AgentBackend;
use super::task_config;
use super::{compute_stage_cost, format_cost, format_duration_human, format_event_detail, format_event_summary, format_tokens_human, print_diagnostics, read_dot_file, RunArgs};
/// Accumulates token usage and cost across all pipeline stages.
@ -37,8 +38,33 @@ struct CostAccumulator {
///
/// Returns an error if the pipeline cannot be read, parsed, validated, or executed.
pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Result<()> {
// 0. Load task config if TOML, resolve DOT path, run setup
let (dot_path, task_cfg) = if args.pipeline.extension().is_some_and(|ext| ext == "toml") {
let cfg = task_config::load_task_config(&args.pipeline)?;
let dot = task_config::resolve_graph_path(&args.pipeline, &cfg.graph);
(dot, Some(cfg))
} else {
(args.pipeline.clone(), None)
};
if let Some(ref cfg) = task_cfg {
if let Some(ref dir) = cfg.directory {
std::env::set_current_dir(dir)
.map_err(|e| anyhow::anyhow!("Failed to set working directory to {dir}: {e}"))?;
}
if let Some(ref setup) = cfg.setup {
let cwd = std::env::current_dir()?;
eprintln!(
"{dim}Running {} setup command(s)…{reset}",
setup.commands.len(),
dim = styles.dim, reset = styles.reset,
);
task_config::run_setup(setup, &cwd).await?;
}
}
// 1. Parse and validate pipeline
let source = read_dot_file(&args.pipeline)?;
let source = read_dot_file(&dot_path)?;
let (graph, diagnostics) = PipelineBuilder::new().prepare(&source)?;
eprintln!(
@ -74,6 +100,11 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu
tokio::fs::create_dir_all(&logs_dir).await?;
tokio::fs::write(logs_dir.join("graph.dot"), &source).await?;
tokio::fs::write(logs_dir.join("run.pid"), std::process::id().to_string()).await?;
if args.pipeline.extension().is_some_and(|ext| ext == "toml") {
if let Ok(toml_contents) = tokio::fs::read(&args.pipeline).await {
tokio::fs::write(logs_dir.join("task.toml"), toml_contents).await?;
}
}
if args.verbose >= 1 {
eprintln!(
@ -208,16 +239,30 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu
}
};
let provider = args.provider.or_else(|| {
graph
.attrs
.get("default_provider")
.and_then(|v| v.as_str())
.map(String::from)
});
let toml_model = task_cfg
.as_ref()
.and_then(|c| c.llm.as_ref())
.and_then(|l| l.model.clone());
let toml_provider = task_cfg
.as_ref()
.and_then(|c| c.llm.as_ref())
.and_then(|l| l.provider.clone());
// Precedence: CLI flag > TOML > DOT graph attrs > defaults
let provider = args
.provider
.or(toml_provider)
.or_else(|| {
graph
.attrs
.get("default_provider")
.and_then(|v| v.as_str())
.map(String::from)
});
let model = args
.model
.or(toml_model)
.or_else(|| {
graph
.attrs

View file

@ -0,0 +1,230 @@
use std::path::{Path, PathBuf};
use anyhow::{bail, Context};
use serde::Deserialize;
const SUPPORTED_VERSION: u32 = 1;
#[derive(Debug, Deserialize)]
pub struct TaskConfig {
pub version: u32,
pub task: String,
pub graph: String,
pub directory: Option<String>,
pub llm: Option<LlmConfig>,
pub setup: Option<SetupConfig>,
}
#[derive(Debug, Deserialize)]
pub struct LlmConfig {
pub model: Option<String>,
pub provider: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SetupConfig {
pub commands: Vec<String>,
pub timeout_ms: Option<u64>,
}
/// Load and validate a task config from a TOML file.
///
/// The `graph` path in the returned config is resolved relative to the
/// TOML file's parent directory.
pub fn load_task_config(path: &Path) -> anyhow::Result<TaskConfig> {
let contents =
std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
let config = parse_task_config(&contents)?;
Ok(config)
}
/// Resolve the graph path relative to the TOML file's parent directory.
pub fn resolve_graph_path(toml_path: &Path, graph: &str) -> PathBuf {
let graph_path = Path::new(graph);
if graph_path.is_absolute() {
graph_path.to_path_buf()
} else {
toml_path
.parent()
.unwrap_or(Path::new("."))
.join(graph_path)
}
}
fn parse_task_config(contents: &str) -> anyhow::Result<TaskConfig> {
let config: TaskConfig =
toml::from_str(contents).context("Failed to parse task config TOML")?;
if config.version != SUPPORTED_VERSION {
bail!(
"Unsupported task config version {}. Only version {SUPPORTED_VERSION} is supported.",
config.version
);
}
Ok(config)
}
/// Run setup commands sequentially in the given directory.
///
/// Each command gets the full `timeout_ms` budget. Commands are executed
/// via `sh -c` so shell features (pipes, redirects, etc.) work.
pub async fn run_setup(setup: &SetupConfig, directory: &Path) -> anyhow::Result<()> {
let timeout = std::time::Duration::from_millis(setup.timeout_ms.unwrap_or(300_000));
for cmd in &setup.commands {
let fut = tokio::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.current_dir(directory)
.output();
let output = tokio::time::timeout(timeout, fut)
.await
.with_context(|| format!("Setup command timed out after {}ms: {cmd}", timeout.as_millis()))?
.with_context(|| format!("Failed to execute setup command: {cmd}"))?;
if !output.status.success() {
let code = output
.status
.code()
.map_or("unknown".to_string(), |c| c.to_string());
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Setup command failed (exit code {code}): {cmd}\n{stderr}");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_toml() {
let toml = r#"
version = 1
task = "Run tests"
graph = "pipeline.dot"
"#;
let config = parse_task_config(toml).unwrap();
assert_eq!(config.version, 1);
assert_eq!(config.task, "Run tests");
assert_eq!(config.graph, "pipeline.dot");
assert!(config.directory.is_none());
assert!(config.llm.is_none());
assert!(config.setup.is_none());
}
#[test]
fn parse_full_toml() {
let toml = r#"
version = 1
task = "Full workflow"
graph = "pipeline.dot"
directory = "/tmp/repo"
[llm]
model = "claude-haiku"
provider = "anthropic"
[setup]
commands = ["pip install -r requirements.txt", "npm install"]
timeout_ms = 60000
"#;
let config = parse_task_config(toml).unwrap();
assert_eq!(config.task, "Full workflow");
assert_eq!(config.directory.as_deref(), Some("/tmp/repo"));
let llm = config.llm.unwrap();
assert_eq!(llm.model.as_deref(), Some("claude-haiku"));
assert_eq!(llm.provider.as_deref(), Some("anthropic"));
let setup = config.setup.unwrap();
assert_eq!(setup.commands.len(), 2);
assert_eq!(setup.timeout_ms, Some(60000));
}
#[test]
fn unsupported_version_rejected() {
let toml = r#"
version = 2
task = "x"
graph = "p.dot"
"#;
let err = parse_task_config(toml).unwrap_err();
assert!(
err.to_string().contains("Unsupported task config version 2"),
"unexpected error: {err}"
);
}
#[test]
fn graph_path_resolved_relative_to_toml() {
let toml_path = Path::new("/tmp/sub/task.toml");
let resolved = resolve_graph_path(toml_path, "p.dot");
assert_eq!(resolved, PathBuf::from("/tmp/sub/p.dot"));
}
#[test]
fn graph_path_absolute_unchanged() {
let toml_path = Path::new("/tmp/sub/task.toml");
let resolved = resolve_graph_path(toml_path, "/other/pipeline.dot");
assert_eq!(resolved, PathBuf::from("/other/pipeline.dot"));
}
#[test]
fn missing_required_fields() {
let no_task = r#"
version = 1
graph = "p.dot"
"#;
assert!(parse_task_config(no_task).is_err());
let no_graph = r#"
version = 1
task = "x"
"#;
assert!(parse_task_config(no_graph).is_err());
}
#[tokio::test]
async fn run_setup_succeeds() {
let dir = tempfile::tempdir().unwrap();
let setup = SetupConfig {
commands: vec!["echo hello".to_string()],
timeout_ms: None,
};
run_setup(&setup, dir.path()).await.unwrap();
}
#[tokio::test]
async fn run_setup_fails_on_nonzero_exit() {
let dir = tempfile::tempdir().unwrap();
let setup = SetupConfig {
commands: vec!["false".to_string()],
timeout_ms: None,
};
let err = run_setup(&setup, dir.path()).await.unwrap_err();
assert!(
err.to_string().contains("exit code"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn run_setup_timeout() {
let dir = tempfile::tempdir().unwrap();
let setup = SetupConfig {
commands: vec!["sleep 10".to_string()],
timeout_ms: Some(100),
};
let err = run_setup(&setup, dir.path()).await.unwrap_err();
assert!(
err.to_string().contains("timed out"),
"unexpected error: {err}"
);
}
}