diff options
| author | soryu <soryu@soryu.co> | 2026-02-03 23:19:40 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-03 23:19:40 +0000 |
| commit | bfa3af9ef16fd5e255bdb606a99a5ebb535ba7cc (patch) | |
| tree | 53da855b4ca61a5c0856fc15112daa7a3748c637 /makima/src/daemon/chain | |
| parent | 1ce281adb89683a5fccfd153706383b14b944f32 (diff) | |
| parent | dcbf8c834626870a43b633b099f409d69d4f9b87 (diff) | |
| download | soryu-makima/discuss-contract-feature.tar.gz soryu-makima/discuss-contract-feature.zip | |
fix: Resolve merge conflict in server/mod.rsmakima/discuss-contract-feature
Combine imports from both branches - include both chains and contract_discuss handlers.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'makima/src/daemon/chain')
| -rw-r--r-- | makima/src/daemon/chain/dag.rs | 450 | ||||
| -rw-r--r-- | makima/src/daemon/chain/mod.rs | 13 | ||||
| -rw-r--r-- | makima/src/daemon/chain/parser.rs | 392 | ||||
| -rw-r--r-- | makima/src/daemon/chain/runner.rs | 364 |
4 files changed, 1219 insertions, 0 deletions
diff --git a/makima/src/daemon/chain/dag.rs b/makima/src/daemon/chain/dag.rs new file mode 100644 index 0000000..7ba5904 --- /dev/null +++ b/makima/src/daemon/chain/dag.rs @@ -0,0 +1,450 @@ +//! DAG validation and traversal for chain contracts. +//! +//! Provides cycle detection and topological sorting for contract dependencies. + +use std::collections::{HashMap, HashSet, VecDeque}; +use thiserror::Error; + +use super::parser::ChainDefinition; + +/// Error type for DAG operations. +#[derive(Error, Debug)] +pub enum DagError { + #[error("Cycle detected in dependency graph: {0}")] + CycleDetected(String), + + #[error("Unknown contract in dependency: {0}")] + UnknownContract(String), +} + +/// Validates that the chain definition forms a valid DAG (no cycles). +/// +/// Uses depth-first search with color marking to detect cycles. +/// Returns Ok(()) if valid, or an error describing the cycle. +pub fn validate_dag(chain: &ChainDefinition) -> Result<(), DagError> { + // Build adjacency list from contract dependencies + let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new(); + let contract_names: HashSet<&str> = chain.contracts.iter().map(|c| c.name.as_str()).collect(); + + for contract in &chain.contracts { + let deps: Vec<&str> = contract + .depends_on + .as_ref() + .map(|d| d.iter().map(|s| s.as_str()).collect()) + .unwrap_or_default(); + + // Validate all dependencies exist + for dep in &deps { + if !contract_names.contains(dep) { + return Err(DagError::UnknownContract(format!( + "Contract '{}' depends on unknown contract '{}'", + contract.name, dep + ))); + } + } + + adjacency.insert(contract.name.as_str(), deps); + } + + // Color-based DFS for cycle detection + // White (0): not visited, Gray (1): in progress, Black (2): completed + let mut color: HashMap<&str, u8> = HashMap::new(); + for name in &contract_names { + color.insert(name, 0); + } + + // Track path for cycle reporting + fn dfs<'a>( + node: &'a str, + adjacency: &HashMap<&'a str, Vec<&'a str>>, + color: &mut HashMap<&'a str, u8>, + path: &mut Vec<&'a str>, + ) -> Result<(), DagError> { + color.insert(node, 1); // Mark as in-progress + path.push(node); + + if let Some(deps) = adjacency.get(node) { + for dep in deps { + match color.get(dep) { + Some(1) => { + // Found cycle - dep is in current path + let cycle_start = path.iter().position(|&n| n == *dep).unwrap(); + let cycle: Vec<_> = path[cycle_start..].to_vec(); + return Err(DagError::CycleDetected(format!( + "{} -> {}", + cycle.join(" -> "), + dep + ))); + } + Some(0) => { + // Not visited - recurse + dfs(dep, adjacency, color, path)?; + } + _ => { + // Already completed - skip + } + } + } + } + + color.insert(node, 2); // Mark as completed + path.pop(); + Ok(()) + } + + // Run DFS from each unvisited node + for name in &contract_names { + if color.get(name) == Some(&0) { + let mut path = Vec::new(); + dfs(name, &adjacency, &mut color, &mut path)?; + } + } + + Ok(()) +} + +/// Returns contracts in topological order (dependencies before dependents). +/// +/// Uses Kahn's algorithm for topological sorting. +pub fn topological_sort(chain: &ChainDefinition) -> Result<Vec<&str>, DagError> { + // Validate first + validate_dag(chain)?; + + // Build in-degree map and adjacency list + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new(); + + for contract in &chain.contracts { + in_degree.entry(contract.name.as_str()).or_insert(0); + dependents.entry(contract.name.as_str()).or_default(); + + if let Some(deps) = &contract.depends_on { + for dep in deps { + *in_degree.entry(contract.name.as_str()).or_insert(0) += 1; + dependents + .entry(dep.as_str()) + .or_default() + .push(contract.name.as_str()); + } + } + } + + // Kahn's algorithm + let mut queue: VecDeque<&str> = VecDeque::new(); + let mut result: Vec<&str> = Vec::new(); + + // Start with nodes that have no dependencies + for (name, °ree) in &in_degree { + if degree == 0 { + queue.push_back(name); + } + } + + while let Some(node) = queue.pop_front() { + result.push(node); + + if let Some(deps) = dependents.get(node) { + for dep in deps { + if let Some(degree) = in_degree.get_mut(dep) { + *degree -= 1; + if *degree == 0 { + queue.push_back(dep); + } + } + } + } + } + + Ok(result) +} + +/// Returns contracts that are ready to run (have no unmet dependencies). +/// +/// Takes a set of completed contract names and returns contracts that +/// can now be started. +pub fn get_ready_contracts<'a>( + chain: &'a ChainDefinition, + completed: &HashSet<&str>, +) -> Vec<&'a str> { + chain + .contracts + .iter() + .filter(|c| { + // Already completed? Skip + if completed.contains(c.name.as_str()) { + return false; + } + + // Check if all dependencies are met + match &c.depends_on { + None => true, // No dependencies + Some(deps) => deps.iter().all(|d| completed.contains(d.as_str())), + } + }) + .map(|c| c.name.as_str()) + .collect() +} + +/// Get the depth of each contract in the DAG (for layout purposes). +/// +/// Root nodes (no dependencies) have depth 0. +/// Each dependent has depth = max(dependency depths) + 1. +pub fn get_contract_depths(chain: &ChainDefinition) -> HashMap<&str, usize> { + let mut depths: HashMap<&str, usize> = HashMap::new(); + + // Multiple passes to handle dependencies + let max_iterations = chain.contracts.len(); + for _ in 0..max_iterations { + let mut changed = false; + + for contract in &chain.contracts { + let new_depth = match &contract.depends_on { + None => 0, + Some(deps) => { + if deps.iter().all(|d| depths.contains_key(d.as_str())) { + deps.iter() + .filter_map(|d| depths.get(d.as_str())) + .max() + .copied() + .unwrap_or(0) + + 1 + } else { + continue; // Dependencies not yet computed + } + } + }; + + if depths.get(contract.name.as_str()) != Some(&new_depth) { + depths.insert(contract.name.as_str(), new_depth); + changed = true; + } + } + + if !changed { + break; + } + } + + depths +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::daemon::chain::parser::parse_chain_yaml; + + #[test] + fn test_valid_dag() { + let yaml = r#" +name: Valid DAG +contracts: + - name: A + tasks: + - name: Task + plan: "Do A" + - name: B + depends_on: [A] + tasks: + - name: Task + plan: "Do B" + - name: C + depends_on: [A] + tasks: + - name: Task + plan: "Do C" + - name: D + depends_on: [B, C] + tasks: + - name: Task + plan: "Do D" +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + assert!(validate_dag(&chain).is_ok()); + } + + #[test] + fn test_simple_cycle() { + let yaml = r#" +name: Simple Cycle +contracts: + - name: A + depends_on: [B] + tasks: + - name: Task + plan: "Do A" + - name: B + depends_on: [A] + tasks: + - name: Task + plan: "Do B" +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + let result = validate_dag(&chain); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Cycle detected")); + } + + #[test] + fn test_longer_cycle() { + let yaml = r#" +name: Longer Cycle +contracts: + - name: A + depends_on: [C] + tasks: + - name: Task + plan: "Do A" + - name: B + depends_on: [A] + tasks: + - name: Task + plan: "Do B" + - name: C + depends_on: [B] + tasks: + - name: Task + plan: "Do C" +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + let result = validate_dag(&chain); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Cycle detected")); + } + + #[test] + fn test_topological_sort() { + let yaml = r#" +name: Topo Test +contracts: + - name: A + tasks: + - name: Task + plan: "Do A" + - name: B + depends_on: [A] + tasks: + - name: Task + plan: "Do B" + - name: C + depends_on: [A] + tasks: + - name: Task + plan: "Do C" + - name: D + depends_on: [B, C] + tasks: + - name: Task + plan: "Do D" +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + let sorted = topological_sort(&chain).unwrap(); + + // A must come before B, C; B and C must come before D + let pos_a = sorted.iter().position(|&n| n == "A").unwrap(); + let pos_b = sorted.iter().position(|&n| n == "B").unwrap(); + let pos_c = sorted.iter().position(|&n| n == "C").unwrap(); + let pos_d = sorted.iter().position(|&n| n == "D").unwrap(); + + assert!(pos_a < pos_b); + assert!(pos_a < pos_c); + assert!(pos_b < pos_d); + assert!(pos_c < pos_d); + } + + #[test] + fn test_get_ready_contracts() { + let yaml = r#" +name: Ready Test +contracts: + - name: A + tasks: + - name: Task + plan: "Do A" + - name: B + depends_on: [A] + tasks: + - name: Task + plan: "Do B" + - name: C + tasks: + - name: Task + plan: "Do C" +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + + // Initially A and C are ready (no dependencies) + let completed = HashSet::new(); + let mut ready = get_ready_contracts(&chain, &completed); + ready.sort(); + assert_eq!(ready, vec!["A", "C"]); + + // After A completes, B becomes ready + let mut completed = HashSet::new(); + completed.insert("A"); + let ready = get_ready_contracts(&chain, &completed); + assert!(ready.contains(&"B")); + assert!(ready.contains(&"C")); // C still ready if not started + } + + #[test] + fn test_get_contract_depths() { + let yaml = r#" +name: Depth Test +contracts: + - name: A + tasks: + - name: Task + plan: "Do A" + - name: B + depends_on: [A] + tasks: + - name: Task + plan: "Do B" + - name: C + depends_on: [B] + tasks: + - name: Task + plan: "Do C" +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + let depths = get_contract_depths(&chain); + + assert_eq!(depths.get("A"), Some(&0)); + assert_eq!(depths.get("B"), Some(&1)); + assert_eq!(depths.get("C"), Some(&2)); + } + + #[test] + fn test_diamond_dependency_depths() { + let yaml = r#" +name: Diamond Test +contracts: + - name: A + tasks: + - name: Task + plan: "Do A" + - name: B + depends_on: [A] + tasks: + - name: Task + plan: "Do B" + - name: C + depends_on: [A] + tasks: + - name: Task + plan: "Do C" + - name: D + depends_on: [B, C] + tasks: + - name: Task + plan: "Do D" +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + let depths = get_contract_depths(&chain); + + assert_eq!(depths.get("A"), Some(&0)); + assert_eq!(depths.get("B"), Some(&1)); + assert_eq!(depths.get("C"), Some(&1)); + assert_eq!(depths.get("D"), Some(&2)); + } +} diff --git a/makima/src/daemon/chain/mod.rs b/makima/src/daemon/chain/mod.rs new file mode 100644 index 0000000..5588a27 --- /dev/null +++ b/makima/src/daemon/chain/mod.rs @@ -0,0 +1,13 @@ +//! Chain module - DAG-based multi-contract orchestration. +//! +//! Chains are directed acyclic graphs (DAGs) of contracts that work together +//! to achieve a larger goal. Each contract can depend on others, and contracts +//! run in parallel when no dependencies exist. + +pub mod dag; +pub mod parser; +pub mod runner; + +pub use dag::{validate_dag, DagError}; +pub use parser::{parse_chain_file, ChainDefinition, ParseError}; +pub use runner::{ChainRunner, RunnerError}; diff --git a/makima/src/daemon/chain/parser.rs b/makima/src/daemon/chain/parser.rs new file mode 100644 index 0000000..0f16710 --- /dev/null +++ b/makima/src/daemon/chain/parser.rs @@ -0,0 +1,392 @@ +//! Chain YAML parser. +//! +//! Parses chain definition files in YAML format into structured data +//! that can be used to create chains and contracts. + +use serde::{Deserialize, Serialize}; +use std::path::Path; +use thiserror::Error; + +/// Error type for chain parsing operations. +#[derive(Error, Debug)] +pub enum ParseError { + #[error("Failed to read chain file: {0}")] + IoError(#[from] std::io::Error), + + #[error("Failed to parse YAML: {0}")] + YamlError(#[from] serde_yaml::Error), + + #[error("Validation error: {0}")] + ValidationError(String), +} + +/// Chain definition parsed from YAML. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainDefinition { + /// Name of the chain + pub name: String, + /// Optional description + pub description: Option<String>, + /// Repository URL (optional - contracts may have their own repos) + #[serde(alias = "repo")] + pub repository_url: Option<String>, + /// Local path for repository + pub local_path: Option<String>, + /// Contracts in this chain + pub contracts: Vec<ContractDefinition>, + /// Loop configuration + #[serde(rename = "loop")] + pub loop_config: Option<LoopConfig>, +} + +/// Contract definition within a chain. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractDefinition { + /// Name of the contract + pub name: String, + /// Optional description + pub description: Option<String>, + /// Contract type (defaults to "simple") + #[serde(rename = "type", default = "default_contract_type")] + pub contract_type: String, + /// Phases for this contract + pub phases: Option<Vec<String>>, + /// Names of contracts this depends on (DAG edges) + pub depends_on: Option<Vec<String>>, + /// Tasks to create in this contract + pub tasks: Option<Vec<TaskDefinition>>, + /// Deliverables for this contract + pub deliverables: Option<Vec<DeliverableDefinition>>, +} + +fn default_contract_type() -> String { + "simple".to_string() +} + +/// Task definition within a contract. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskDefinition { + /// Name of the task + pub name: String, + /// Plan/instructions for the task + pub plan: String, +} + +/// Deliverable definition within a contract. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeliverableDefinition { + /// Unique identifier for the deliverable + pub id: String, + /// Name of the deliverable + pub name: String, + /// Priority level (defaults to "required") + #[serde(default = "default_priority")] + pub priority: String, +} + +fn default_priority() -> String { + "required".to_string() +} + +/// Loop configuration for chain iteration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoopConfig { + /// Whether loop is enabled + #[serde(default)] + pub enabled: bool, + /// Maximum number of iterations + #[serde(default = "default_max_iterations")] + pub max_iterations: i32, + /// Progress check prompt/criteria + pub progress_check: Option<String>, +} + +fn default_max_iterations() -> i32 { + 10 +} + +impl ChainDefinition { + /// Validate the chain definition. + pub fn validate(&self) -> Result<(), ParseError> { + // Check for empty name + if self.name.trim().is_empty() { + return Err(ParseError::ValidationError( + "Chain name cannot be empty".to_string(), + )); + } + + // Check for at least one contract + if self.contracts.is_empty() { + return Err(ParseError::ValidationError( + "Chain must have at least one contract".to_string(), + )); + } + + // Collect all contract names for dependency validation + let contract_names: std::collections::HashSet<_> = + self.contracts.iter().map(|c| c.name.as_str()).collect(); + + // Check for duplicate contract names + if contract_names.len() != self.contracts.len() { + return Err(ParseError::ValidationError( + "Duplicate contract names found".to_string(), + )); + } + + // Validate each contract + for contract in &self.contracts { + contract.validate(&contract_names)?; + } + + Ok(()) + } +} + +impl ContractDefinition { + /// Validate the contract definition. + pub fn validate( + &self, + valid_contract_names: &std::collections::HashSet<&str>, + ) -> Result<(), ParseError> { + // Check for empty name + if self.name.trim().is_empty() { + return Err(ParseError::ValidationError( + "Contract name cannot be empty".to_string(), + )); + } + + // Validate dependencies exist + if let Some(deps) = &self.depends_on { + for dep in deps { + if !valid_contract_names.contains(dep.as_str()) { + return Err(ParseError::ValidationError(format!( + "Contract '{}' depends on unknown contract '{}'", + self.name, dep + ))); + } + // Self-dependency check + if dep == &self.name { + return Err(ParseError::ValidationError(format!( + "Contract '{}' cannot depend on itself", + self.name + ))); + } + } + } + + // Validate tasks + if let Some(tasks) = &self.tasks { + for task in tasks { + if task.name.trim().is_empty() { + return Err(ParseError::ValidationError(format!( + "Task name cannot be empty in contract '{}'", + self.name + ))); + } + if task.plan.trim().is_empty() { + return Err(ParseError::ValidationError(format!( + "Task '{}' in contract '{}' has empty plan", + task.name, self.name + ))); + } + } + } + + Ok(()) + } +} + +/// Parse a chain definition from a YAML file. +pub fn parse_chain_file<P: AsRef<Path>>(path: P) -> Result<ChainDefinition, ParseError> { + let content = std::fs::read_to_string(path)?; + parse_chain_yaml(&content) +} + +/// Parse a chain definition from a YAML string. +pub fn parse_chain_yaml(yaml: &str) -> Result<ChainDefinition, ParseError> { + let definition: ChainDefinition = serde_yaml::from_str(yaml)?; + definition.validate()?; + Ok(definition) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_chain() { + let yaml = r#" +name: Test Chain +description: A test chain +contracts: + - name: Research + type: simple + tasks: + - name: Analyze + plan: "Analyze the codebase" + - name: Implement + type: simple + depends_on: [Research] + tasks: + - name: Build + plan: "Build the feature" +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + assert_eq!(chain.name, "Test Chain"); + assert_eq!(chain.contracts.len(), 2); + assert_eq!(chain.contracts[0].name, "Research"); + assert_eq!(chain.contracts[1].name, "Implement"); + assert_eq!( + chain.contracts[1].depends_on, + Some(vec!["Research".to_string()]) + ); + } + + #[test] + fn test_parse_chain_with_loop() { + let yaml = r#" +name: Iterative Chain +contracts: + - name: Phase1 + tasks: + - name: Task1 + plan: "Do something" +loop: + enabled: true + max_iterations: 5 + progress_check: "Check if goals are met" +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + assert!(chain.loop_config.is_some()); + let loop_config = chain.loop_config.unwrap(); + assert!(loop_config.enabled); + assert_eq!(loop_config.max_iterations, 5); + } + + #[test] + fn test_parse_chain_with_deliverables() { + let yaml = r#" +name: Feature Chain +contracts: + - name: Research + tasks: + - name: Survey + plan: "Survey existing code" + deliverables: + - id: analysis + name: Codebase Analysis + priority: required +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + let deliverables = chain.contracts[0].deliverables.as_ref().unwrap(); + assert_eq!(deliverables.len(), 1); + assert_eq!(deliverables[0].id, "analysis"); + } + + #[test] + fn test_validation_empty_name() { + let yaml = r#" +name: "" +contracts: + - name: Phase1 + tasks: + - name: Task1 + plan: "Do something" +"#; + let result = parse_chain_yaml(yaml); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("name cannot be empty")); + } + + #[test] + fn test_validation_no_contracts() { + let yaml = r#" +name: Empty Chain +contracts: [] +"#; + let result = parse_chain_yaml(yaml); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("at least one contract")); + } + + #[test] + fn test_validation_unknown_dependency() { + let yaml = r#" +name: Bad Chain +contracts: + - name: Phase1 + depends_on: [NonExistent] + tasks: + - name: Task1 + plan: "Do something" +"#; + let result = parse_chain_yaml(yaml); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("unknown contract")); + } + + #[test] + fn test_validation_self_dependency() { + let yaml = r#" +name: Self Ref Chain +contracts: + - name: Phase1 + depends_on: [Phase1] + tasks: + - name: Task1 + plan: "Do something" +"#; + let result = parse_chain_yaml(yaml); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("cannot depend on itself")); + } + + #[test] + fn test_validation_duplicate_names() { + let yaml = r#" +name: Dup Chain +contracts: + - name: Phase1 + tasks: + - name: Task1 + plan: "Do something" + - name: Phase1 + tasks: + - name: Task2 + plan: "Do another thing" +"#; + let result = parse_chain_yaml(yaml); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Duplicate contract names")); + } + + #[test] + fn test_repo_alias() { + let yaml = r#" +name: Repo Chain +repo: https://github.com/user/project +contracts: + - name: Phase1 + tasks: + - name: Task1 + plan: "Work on repo" +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + assert_eq!( + chain.repository_url, + Some("https://github.com/user/project".to_string()) + ); + } +} diff --git a/makima/src/daemon/chain/runner.rs b/makima/src/daemon/chain/runner.rs new file mode 100644 index 0000000..9c6f6b4 --- /dev/null +++ b/makima/src/daemon/chain/runner.rs @@ -0,0 +1,364 @@ +//! Chain runner - creates and orchestrates contracts from chain definitions. +//! +//! Handles the lifecycle of a chain: +//! 1. Parse chain definition +//! 2. Validate DAG +//! 3. Create chain record +//! 4. Create contracts in dependency order +//! 5. Monitor and trigger dependent contracts + +use std::collections::HashMap; +use std::path::Path; +use thiserror::Error; + +use super::dag::{topological_sort, validate_dag, DagError}; +use super::parser::{parse_chain_file, ChainDefinition, ParseError}; +use crate::db::models::{ + CreateChainContractRequest, CreateChainDeliverableRequest, CreateChainRequest, + CreateChainTaskRequest, +}; + +/// Error type for chain runner operations. +#[derive(Error, Debug)] +pub enum RunnerError { + #[error("Parse error: {0}")] + Parse(#[from] ParseError), + + #[error("DAG error: {0}")] + Dag(#[from] DagError), + + #[error("API error: {0}")] + Api(String), + + #[error("Contract creation failed: {0}")] + ContractCreation(String), +} + +/// Chain runner for creating and managing chains. +pub struct ChainRunner { + /// Base API URL + api_url: String, + /// API key for authentication + api_key: String, +} + +impl ChainRunner { + /// Create a new chain runner. + pub fn new(api_url: String, api_key: String) -> Self { + Self { api_url, api_key } + } + + /// Load and validate a chain from a YAML file. + pub fn load_chain<P: AsRef<Path>>(&self, path: P) -> Result<ChainDefinition, RunnerError> { + let chain = parse_chain_file(path)?; + validate_dag(&chain)?; + Ok(chain) + } + + /// Convert a chain definition to a CreateChainRequest for API submission. + pub fn to_create_request(&self, chain: &ChainDefinition) -> CreateChainRequest { + let contracts: Vec<CreateChainContractRequest> = chain + .contracts + .iter() + .map(|c| CreateChainContractRequest { + name: c.name.clone(), + description: c.description.clone(), + contract_type: Some(c.contract_type.clone()), + initial_phase: None, + phases: c.phases.clone(), + depends_on: c.depends_on.clone(), + tasks: c.tasks.as_ref().map(|tasks| { + tasks + .iter() + .map(|t| CreateChainTaskRequest { + name: t.name.clone(), + plan: t.plan.clone(), + }) + .collect() + }), + deliverables: c.deliverables.as_ref().map(|dels| { + dels.iter() + .map(|d| CreateChainDeliverableRequest { + id: d.id.clone(), + name: d.name.clone(), + priority: Some(d.priority.clone()), + }) + .collect() + }), + editor_x: None, + editor_y: None, + }) + .collect(); + + let (loop_enabled, loop_max_iterations, loop_progress_check) = + match &chain.loop_config { + Some(lc) => ( + Some(lc.enabled), + Some(lc.max_iterations), + lc.progress_check.clone(), + ), + None => (None, None, None), + }; + + CreateChainRequest { + name: chain.name.clone(), + description: chain.description.clone(), + repository_url: chain.repository_url.clone(), + local_path: chain.local_path.clone(), + loop_enabled, + loop_max_iterations, + loop_progress_check, + contracts: Some(contracts), + } + } + + /// Get contracts in topological order (for display/debugging). + pub fn get_execution_order<'a>( + &self, + chain: &'a ChainDefinition, + ) -> Result<Vec<&'a str>, RunnerError> { + Ok(topological_sort(chain)?) + } + + /// Generate ASCII visualization of the chain DAG. + pub fn visualize_dag(&self, chain: &ChainDefinition) -> String { + use super::dag::get_contract_depths; + + let depths = get_contract_depths(chain); + let mut lines: Vec<String> = vec![]; + + lines.push(format!("Chain: {}", chain.name)); + if let Some(desc) = &chain.description { + lines.push(format!(" {}", desc)); + } + lines.push(String::new()); + + // Group contracts by depth + let mut by_depth: HashMap<usize, Vec<&str>> = HashMap::new(); + for contract in &chain.contracts { + let depth = depths.get(contract.name.as_str()).copied().unwrap_or(0); + by_depth.entry(depth).or_default().push(&contract.name); + } + + // Find max depth + let max_depth = by_depth.keys().max().copied().unwrap_or(0); + + // Build visualization + for depth in 0..=max_depth { + if let Some(contracts) = by_depth.get(&depth) { + let contract_strs: Vec<String> = contracts + .iter() + .map(|name| format!("[{}]", name)) + .collect(); + + let indent = " ".repeat(depth); + lines.push(format!("{}{}", indent, contract_strs.join(" "))); + + // Draw arrows to next level + if depth < max_depth { + if let Some(next_contracts) = by_depth.get(&(depth + 1)) { + // Find which contracts connect to the next level + for next in next_contracts { + let next_contract = chain + .contracts + .iter() + .find(|c| c.name.as_str() == *next) + .unwrap(); + + if let Some(deps) = &next_contract.depends_on { + for dep in deps { + if contracts.contains(&dep.as_str()) { + let arrow_indent = " ".repeat(depth); + lines.push(format!("{} │", arrow_indent)); + lines.push(format!("{} ▼", arrow_indent)); + } + } + } + } + } + } + } + } + + lines.join("\n") + } +} + +/// Compute editor positions for contracts based on DAG layout. +/// +/// Returns a map of contract name to (x, y) positions suitable for +/// the GUI editor. +pub fn compute_editor_positions(chain: &ChainDefinition) -> HashMap<String, (f64, f64)> { + use super::dag::get_contract_depths; + + let depths = get_contract_depths(chain); + let mut positions: HashMap<String, (f64, f64)> = HashMap::new(); + + // Group by depth + let mut by_depth: HashMap<usize, Vec<&str>> = HashMap::new(); + for contract in &chain.contracts { + let depth = depths.get(contract.name.as_str()).copied().unwrap_or(0); + by_depth.entry(depth).or_default().push(&contract.name); + } + + // Compute positions: x based on depth, y based on index within depth + let x_spacing = 250.0; + let y_spacing = 150.0; + + for (depth, contracts) in &by_depth { + let x = (*depth as f64) * x_spacing + 100.0; + for (i, name) in contracts.iter().enumerate() { + let y = (i as f64) * y_spacing + 100.0; + positions.insert(name.to_string(), (x, y)); + } + } + + positions +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::daemon::chain::parser::parse_chain_yaml; + + #[test] + fn test_to_create_request() { + let yaml = r#" +name: Test Chain +description: A test chain +repo: https://github.com/test/repo +contracts: + - name: Research + type: simple + phases: [plan, execute] + tasks: + - name: Analyze + plan: "Analyze the codebase" + deliverables: + - id: analysis + name: Analysis Doc + priority: required + - name: Implement + depends_on: [Research] + tasks: + - name: Build + plan: "Build the feature" +loop: + enabled: true + max_iterations: 5 + progress_check: "Check completion" +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + let runner = ChainRunner::new("http://localhost".to_string(), "key".to_string()); + let request = runner.to_create_request(&chain); + + assert_eq!(request.name, "Test Chain"); + assert_eq!(request.description, Some("A test chain".to_string())); + assert_eq!( + request.repository_url, + Some("https://github.com/test/repo".to_string()) + ); + assert_eq!(request.loop_enabled, Some(true)); + assert_eq!(request.loop_max_iterations, Some(5)); + + let contracts = request.contracts.unwrap(); + assert_eq!(contracts.len(), 2); + assert_eq!(contracts[0].name, "Research"); + assert_eq!(contracts[0].phases, Some(vec!["plan".to_string(), "execute".to_string()])); + assert_eq!( + contracts[1].depends_on, + Some(vec!["Research".to_string()]) + ); + } + + #[test] + fn test_get_execution_order() { + let yaml = r#" +name: Order Test +contracts: + - name: C + depends_on: [B] + tasks: + - name: Task + plan: "Do C" + - name: A + tasks: + - name: Task + plan: "Do A" + - name: B + depends_on: [A] + tasks: + - name: Task + plan: "Do B" +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + let runner = ChainRunner::new("http://localhost".to_string(), "key".to_string()); + let order = runner.get_execution_order(&chain).unwrap(); + + let pos_a = order.iter().position(|&n| n == "A").unwrap(); + let pos_b = order.iter().position(|&n| n == "B").unwrap(); + let pos_c = order.iter().position(|&n| n == "C").unwrap(); + + assert!(pos_a < pos_b); + assert!(pos_b < pos_c); + } + + #[test] + fn test_visualize_dag() { + let yaml = r#" +name: Visual Test +description: Test visualization +contracts: + - name: A + tasks: + - name: Task + plan: "Do A" + - name: B + depends_on: [A] + tasks: + - name: Task + plan: "Do B" +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + let runner = ChainRunner::new("http://localhost".to_string(), "key".to_string()); + let viz = runner.visualize_dag(&chain); + + assert!(viz.contains("Chain: Visual Test")); + assert!(viz.contains("[A]")); + assert!(viz.contains("[B]")); + } + + #[test] + fn test_compute_editor_positions() { + let yaml = r#" +name: Position Test +contracts: + - name: A + tasks: + - name: Task + plan: "Do A" + - name: B + depends_on: [A] + tasks: + - name: Task + plan: "Do B" + - name: C + depends_on: [A] + tasks: + - name: Task + plan: "Do C" +"#; + let chain = parse_chain_yaml(yaml).unwrap(); + let positions = compute_editor_positions(&chain); + + // A should be at depth 0 (x = 100) + let (a_x, _) = positions.get("A").unwrap(); + assert_eq!(*a_x, 100.0); + + // B and C should be at depth 1 (x = 350) + let (b_x, _) = positions.get("B").unwrap(); + let (c_x, _) = positions.get("C").unwrap(); + assert_eq!(*b_x, 350.0); + assert_eq!(*c_x, 350.0); + } +} |
