summaryrefslogtreecommitdiff
path: root/makima/src/daemon
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/daemon')
-rw-r--r--makima/src/daemon/api/chain.rs52
-rw-r--r--makima/src/daemon/api/client.rs42
-rw-r--r--makima/src/daemon/api/mod.rs1
-rw-r--r--makima/src/daemon/chain/dag.rs450
-rw-r--r--makima/src/daemon/chain/mod.rs13
-rw-r--r--makima/src/daemon/chain/parser.rs392
-rw-r--r--makima/src/daemon/chain/runner.rs364
-rw-r--r--makima/src/daemon/cli/chain.rs107
-rw-r--r--makima/src/daemon/cli/mod.rs52
-rw-r--r--makima/src/daemon/mod.rs1
10 files changed, 1474 insertions, 0 deletions
diff --git a/makima/src/daemon/api/chain.rs b/makima/src/daemon/api/chain.rs
new file mode 100644
index 0000000..7f7826f
--- /dev/null
+++ b/makima/src/daemon/api/chain.rs
@@ -0,0 +1,52 @@
+//! Chain API methods.
+
+use uuid::Uuid;
+
+use super::client::{ApiClient, ApiError};
+use super::supervisor::JsonValue;
+use crate::db::models::CreateChainRequest;
+
+impl ApiClient {
+ /// Create a new chain with contracts.
+ pub async fn create_chain(&self, req: CreateChainRequest) -> Result<JsonValue, ApiError> {
+ self.post("/api/v1/chains", &req).await
+ }
+
+ /// List all chains for the authenticated user.
+ pub async fn list_chains(
+ &self,
+ status: Option<&str>,
+ limit: i32,
+ ) -> Result<JsonValue, ApiError> {
+ let mut params = Vec::new();
+ if let Some(s) = status {
+ params.push(format!("status={}", s));
+ }
+ params.push(format!("limit={}", limit));
+ let query_string = format!("?{}", params.join("&"));
+ self.get(&format!("/api/v1/chains{}", query_string)).await
+ }
+
+ /// Get a chain by ID.
+ pub async fn get_chain(&self, chain_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/chains/{}", chain_id)).await
+ }
+
+ /// Get contracts in a chain.
+ pub async fn get_chain_contracts(&self, chain_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/chains/{}/contracts", chain_id))
+ .await
+ }
+
+ /// Get chain DAG structure for visualization.
+ pub async fn get_chain_graph(&self, chain_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/chains/{}/graph", chain_id))
+ .await
+ }
+
+ /// Archive a chain.
+ pub async fn archive_chain(&self, chain_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.delete_with_response(&format!("/api/v1/chains/{}", chain_id))
+ .await
+ }
+}
diff --git a/makima/src/daemon/api/client.rs b/makima/src/daemon/api/client.rs
index 4ba4778..dbf3101 100644
--- a/makima/src/daemon/api/client.rs
+++ b/makima/src/daemon/api/client.rs
@@ -276,6 +276,48 @@ impl ApiClient {
Err(last_error.unwrap())
}
+ /// Make a DELETE request with response and retry.
+ pub async fn delete_with_response<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
+ let url = format!("{}{}", self.base_url, path);
+ let mut last_error = None;
+
+ for attempt in 0..MAX_RETRIES {
+ if attempt > 0 {
+ tokio::time::sleep(Self::backoff_delay(attempt - 1)).await;
+ }
+
+ let result = self.client
+ .delete(&url)
+ .header("X-Makima-Tool-Key", &self.api_key)
+ .header("X-Makima-API-Key", &self.api_key)
+ .send()
+ .await;
+
+ match result {
+ Ok(response) => {
+ match self.handle_response(response).await {
+ Ok(value) => return Ok(value),
+ Err(e) if Self::is_retryable(&e) && attempt < MAX_RETRIES - 1 => {
+ last_error = Some(e);
+ continue;
+ }
+ Err(e) => return Err(e),
+ }
+ }
+ Err(e) => {
+ let error = ApiError::Request(e);
+ if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 {
+ last_error = Some(error);
+ continue;
+ }
+ return Err(error);
+ }
+ }
+ }
+
+ Err(last_error.unwrap())
+ }
+
/// Handle API response.
async fn handle_response<T: DeserializeOwned>(
&self,
diff --git a/makima/src/daemon/api/mod.rs b/makima/src/daemon/api/mod.rs
index 49d80e0..7868907 100644
--- a/makima/src/daemon/api/mod.rs
+++ b/makima/src/daemon/api/mod.rs
@@ -1,5 +1,6 @@
//! HTTP API client for makima CLI commands.
+pub mod chain;
pub mod client;
pub mod contract;
pub mod supervisor;
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, &degree) 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);
+ }
+}
diff --git a/makima/src/daemon/cli/chain.rs b/makima/src/daemon/cli/chain.rs
new file mode 100644
index 0000000..1d7c167
--- /dev/null
+++ b/makima/src/daemon/cli/chain.rs
@@ -0,0 +1,107 @@
+//! Chain CLI commands for multi-contract orchestration.
+//!
+//! Provides commands for creating, managing, and visualizing chains
+//! (DAGs of contracts).
+
+use clap::Args;
+use std::path::PathBuf;
+use uuid::Uuid;
+
+/// Common arguments for chain commands requiring API access.
+#[derive(Args, Debug, Clone)]
+pub struct ChainArgs {
+ /// API URL
+ #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp", global = true)]
+ pub api_url: String,
+
+ /// API key for authentication
+ #[arg(long, env = "MAKIMA_API_KEY", global = true)]
+ pub api_key: String,
+}
+
+/// Arguments for the `run` command (create chain from YAML file).
+#[derive(Args, Debug)]
+pub struct RunArgs {
+ #[command(flatten)]
+ pub common: ChainArgs,
+
+ /// Path to the chain YAML file
+ pub file: PathBuf,
+
+ /// Don't actually create the chain, just validate and show what would be created
+ #[arg(long)]
+ pub dry_run: bool,
+}
+
+/// Arguments for the `status` command.
+#[derive(Args, Debug)]
+pub struct StatusArgs {
+ #[command(flatten)]
+ pub common: ChainArgs,
+
+ /// Chain ID
+ pub chain_id: Uuid,
+}
+
+/// Arguments for the `list` command.
+#[derive(Args, Debug)]
+pub struct ListArgs {
+ #[command(flatten)]
+ pub common: ChainArgs,
+
+ /// Filter by status (active, completed, archived)
+ #[arg(long)]
+ pub status: Option<String>,
+
+ /// Limit number of results
+ #[arg(long, default_value = "50")]
+ pub limit: i32,
+}
+
+/// Arguments for the `contracts` command.
+#[derive(Args, Debug)]
+pub struct ContractsArgs {
+ #[command(flatten)]
+ pub common: ChainArgs,
+
+ /// Chain ID
+ pub chain_id: Uuid,
+}
+
+/// Arguments for the `graph` command (ASCII DAG visualization).
+#[derive(Args, Debug)]
+pub struct GraphArgs {
+ #[command(flatten)]
+ pub common: ChainArgs,
+
+ /// Chain ID
+ pub chain_id: Uuid,
+
+ /// Show contract status in nodes
+ #[arg(long)]
+ pub with_status: bool,
+}
+
+/// Arguments for the `validate` command.
+#[derive(Args, Debug)]
+pub struct ValidateArgs {
+ /// Path to the chain YAML file
+ pub file: PathBuf,
+}
+
+/// Arguments for the `preview` command.
+#[derive(Args, Debug)]
+pub struct PreviewArgs {
+ /// Path to the chain YAML file
+ pub file: PathBuf,
+}
+
+/// Arguments for the `archive` command.
+#[derive(Args, Debug)]
+pub struct ArchiveArgs {
+ #[command(flatten)]
+ pub common: ChainArgs,
+
+ /// Chain ID
+ pub chain_id: Uuid,
+}
diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs
index 0805edd..035a784 100644
--- a/makima/src/daemon/cli/mod.rs
+++ b/makima/src/daemon/cli/mod.rs
@@ -1,5 +1,6 @@
//! Command-line interface for the makima CLI.
+pub mod chain;
pub mod config;
pub mod contract;
pub mod daemon;
@@ -9,6 +10,7 @@ pub mod view;
use clap::{Parser, Subcommand};
+pub use chain::ChainArgs;
pub use config::CliConfig;
pub use contract::ContractArgs;
pub use daemon::DaemonArgs;
@@ -58,6 +60,14 @@ pub enum Commands {
/// Saves configuration to ~/.makima/config.toml for use by CLI commands.
#[command(subcommand)]
Config(ConfigCommand),
+
+ /// Chain commands for multi-contract orchestration
+ ///
+ /// Chains are DAGs (directed acyclic graphs) of contracts that work together
+ /// to achieve a larger goal. Contracts can depend on each other, and run
+ /// in parallel when no dependencies exist.
+ #[command(subcommand)]
+ Chain(ChainCommand),
}
/// Config subcommands for CLI configuration.
@@ -196,6 +206,48 @@ pub enum ContractCommand {
CreateFile(contract::CreateFileArgs),
}
+/// Chain subcommands for multi-contract orchestration.
+#[derive(Subcommand, Debug)]
+pub enum ChainCommand {
+ /// Create a chain from a YAML file
+ ///
+ /// Parses the chain definition, validates the DAG, and creates
+ /// contracts in the correct dependency order.
+ Run(chain::RunArgs),
+
+ /// Get chain status and progress
+ Status(chain::StatusArgs),
+
+ /// List all chains
+ List(chain::ListArgs),
+
+ /// List contracts in a chain
+ Contracts(chain::ContractsArgs),
+
+ /// Display ASCII DAG visualization
+ ///
+ /// Shows the chain structure as an ASCII graph with
+ /// contracts as nodes and dependencies as edges.
+ Graph(chain::GraphArgs),
+
+ /// Validate a chain YAML file without creating
+ ///
+ /// Checks syntax, validates the DAG (no cycles), and
+ /// reports any errors.
+ Validate(chain::ValidateArgs),
+
+ /// Preview what would be created from a chain file
+ ///
+ /// Shows execution order and contract details without
+ /// actually creating anything.
+ Preview(chain::PreviewArgs),
+
+ /// Archive a chain
+ ///
+ /// Marks the chain as archived. Does not delete contracts.
+ Archive(chain::ArchiveArgs),
+}
+
impl Cli {
/// Parse command-line arguments
pub fn parse_args() -> Self {
diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs
index f5793d6..62da20e 100644
--- a/makima/src/daemon/mod.rs
+++ b/makima/src/daemon/mod.rs
@@ -8,6 +8,7 @@
//! - `makima view` - Interactive TUI browser for tasks, contracts, and files
pub mod api;
+pub mod chain;
pub mod cli;
pub mod config;
pub mod db;