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 | |
| parent | 1ce281adb89683a5fccfd153706383b14b944f32 (diff) | |
| parent | dcbf8c834626870a43b633b099f409d69d4f9b87 (diff) | |
| download | soryu-bfa3af9ef16fd5e255bdb606a99a5ebb535ba7cc.tar.gz soryu-bfa3af9ef16fd5e255bdb606a99a5ebb535ba7cc.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')
| -rw-r--r-- | makima/src/bin/makima.rs | 216 | ||||
| -rw-r--r-- | makima/src/daemon/api/chain.rs | 52 | ||||
| -rw-r--r-- | makima/src/daemon/api/client.rs | 42 | ||||
| -rw-r--r-- | makima/src/daemon/api/mod.rs | 1 | ||||
| -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 | ||||
| -rw-r--r-- | makima/src/daemon/cli/chain.rs | 107 | ||||
| -rw-r--r-- | makima/src/daemon/cli/mod.rs | 52 | ||||
| -rw-r--r-- | makima/src/daemon/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 356 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 548 | ||||
| -rw-r--r-- | makima/src/server/handlers/chains.rs | 609 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 17 |
16 files changed, 3210 insertions, 11 deletions
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index af9832b..2037b47 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use makima::daemon::api::{ApiClient, CreateContractRequest}; use makima::daemon::cli::{ - Cli, CliConfig, Commands, ConfigCommand, ContractCommand, SupervisorCommand, ViewArgs, + Cli, CliConfig, Commands, ConfigCommand, ContractCommand, ChainCommand, + SupervisorCommand, ViewArgs, }; use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion}; use makima::daemon::config::{DaemonConfig, RepoEntry}; @@ -30,6 +31,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { Commands::Contract(cmd) => run_contract(cmd).await, Commands::View(args) => run_view(args).await, Commands::Config(cmd) => run_config(cmd).await, + Commands::Chain(cmd) => run_chain(cmd).await, } } @@ -793,6 +795,218 @@ async fn run_config(cmd: ConfigCommand) -> Result<(), Box<dyn std::error::Error } } +/// Run chain commands. +async fn run_chain( + cmd: ChainCommand, +) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + use makima::daemon::chain::{parse_chain_file, validate_dag, ChainRunner}; + + match cmd { + ChainCommand::Run(args) => { + eprintln!("Loading chain from: {}", args.file.display()); + + // Load and validate chain + let chain = parse_chain_file(&args.file)?; + validate_dag(&chain)?; + + if args.dry_run { + eprintln!("\n=== DRY RUN - No changes will be made ===\n"); + } + + let runner = ChainRunner::new(args.common.api_url.clone(), args.common.api_key.clone()); + + // Show execution order + let order = runner.get_execution_order(&chain)?; + eprintln!("Execution order:"); + for (i, name) in order.iter().enumerate() { + eprintln!(" {}. {}", i + 1, name); + } + eprintln!(); + + // Show visualization + eprintln!("{}", runner.visualize_dag(&chain)); + + if args.dry_run { + eprintln!("\n=== DRY RUN COMPLETE ==="); + let request = runner.to_create_request(&chain); + println!("{}", serde_json::to_string_pretty(&request)?); + } else { + // Create chain via API + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let request = runner.to_create_request(&chain); + let result = client.create_chain(request).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + } + ChainCommand::Status(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client.get_chain(args.chain_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + ChainCommand::List(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client.list_chains(args.status.as_deref(), args.limit).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + ChainCommand::Contracts(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client.get_chain_contracts(args.chain_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + ChainCommand::Graph(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client.get_chain_graph(args.chain_id).await?; + + // Get the graph data + if args.with_status { + // Enhanced ASCII visualization with status + if let Some(nodes) = result.0.get("nodes").and_then(|v| v.as_array()) { + let mut by_depth: std::collections::HashMap<i32, Vec<(&str, &str)>> = + std::collections::HashMap::new(); + + for node in nodes { + let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let status = node + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("pending"); + let depth = node.get("depth").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + by_depth.entry(depth).or_default().push((name, status)); + } + + let chain_name = result + .0 + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Chain"); + println!("Chain: {}", chain_name); + println!(); + + let max_depth = by_depth.keys().max().copied().unwrap_or(0); + for depth in 0..=max_depth { + if let Some(contracts) = by_depth.get(&depth) { + let indent = " ".repeat(depth as usize); + for (name, status) in contracts { + let status_icon = match *status { + "completed" | "done" => "\u{2713}", + "active" | "running" | "in_progress" => "\u{21bb}", + "failed" | "error" => "\u{2717}", + _ => "\u{25cb}", + }; + println!("{}[{}] {} {}", indent, name, status_icon, status); + } + if depth < max_depth { + println!("{} |", indent); + println!("{} v", indent); + } + } + } + } + } else { + // Simple JSON output + println!("{}", serde_json::to_string_pretty(&result.0)?); + } + } + ChainCommand::Validate(args) => { + eprintln!("Validating chain file: {}", args.file.display()); + + match parse_chain_file(&args.file) { + Ok(chain) => { + match validate_dag(&chain) { + Ok(()) => { + eprintln!("\u{2713} Chain definition is valid"); + eprintln!(" Name: {}", chain.name); + eprintln!(" Contracts: {}", chain.contracts.len()); + + // Show any warnings + for contract in &chain.contracts { + if contract.tasks.is_none() || contract.tasks.as_ref().map(|t| t.is_empty()).unwrap_or(true) { + eprintln!(" \u{26a0} Contract '{}' has no tasks", contract.name); + } + } + + println!(r#"{{"valid": true, "name": "{}", "contractCount": {}}}"#, + chain.name, chain.contracts.len()); + } + Err(e) => { + eprintln!("\u{2717} DAG validation failed: {}", e); + println!(r#"{{"valid": false, "error": "{}"}}"#, e); + std::process::exit(1); + } + } + } + Err(e) => { + eprintln!("\u{2717} Parse error: {}", e); + println!(r#"{{"valid": false, "error": "{}"}}"#, e); + std::process::exit(1); + } + } + } + ChainCommand::Preview(args) => { + eprintln!("Previewing chain: {}", args.file.display()); + + let chain = parse_chain_file(&args.file)?; + validate_dag(&chain)?; + + let runner = ChainRunner::new(String::new(), String::new()); + + // Show chain info + println!("Chain: {}", chain.name); + if let Some(desc) = &chain.description { + println!("Description: {}", desc); + } + if let Some(repo) = &chain.repository_url { + println!("Repository: {}", repo); + } + println!(); + + // Show execution order + let order = runner.get_execution_order(&chain)?; + println!("Execution Order:"); + for (i, name) in order.iter().enumerate() { + let contract = chain.contracts.iter().find(|c| c.name == *name).unwrap(); + let deps = contract + .depends_on + .as_ref() + .map(|d| d.join(", ")) + .unwrap_or_else(|| "(none)".to_string()); + let task_count = contract.tasks.as_ref().map(|t| t.len()).unwrap_or(0); + println!( + " {}. {} [type: {}, tasks: {}, depends: {}]", + i + 1, + name, + contract.contract_type, + task_count, + deps + ); + } + println!(); + + // Show DAG visualization + println!("{}", runner.visualize_dag(&chain)); + + // Show loop config if enabled + if let Some(lc) = &chain.loop_config { + if lc.enabled { + println!("\nLoop Configuration:"); + println!(" Max iterations: {}", lc.max_iterations); + if let Some(check) = &lc.progress_check { + println!(" Progress check: {}", check); + } + } + } + } + ChainCommand::Archive(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + eprintln!("Archiving chain {}...", args.chain_id); + let result = client.archive_chain(args.chain_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + } + + Ok(()) +} + /// Load contracts from API async fn load_contracts(client: &ApiClient) -> Result<Vec<ListItem>, Box<dyn std::error::Error + Send + Sync>> { let result = client.list_contracts().await?; 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, °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); + } +} 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; diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index cef0a22..45ddb52 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -1446,6 +1446,9 @@ pub struct Contract { /// Use `get_phase_config()` to get the parsed PhaseConfig. #[serde(skip_serializing_if = "Option::is_none")] pub phase_config: Option<serde_json::Value>, + /// Chain ID if this contract is part of a chain (DAG of contracts) + #[serde(skip_serializing_if = "Option::is_none")] + pub chain_id: Option<Uuid>, pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, @@ -2586,6 +2589,359 @@ pub struct HeartbeatHistoryQuery { } // ============================================================================= +// Chains (DAG of contracts for multi-contract orchestration) +// ============================================================================= + +/// Chain status determines the overall state of the chain +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum ChainStatus { + /// Chain is actively running + Active, + /// All contracts completed successfully + Completed, + /// Chain was manually archived + Archived, +} + +impl Default for ChainStatus { + fn default() -> Self { + ChainStatus::Active + } +} + +impl std::fmt::Display for ChainStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ChainStatus::Active => write!(f, "active"), + ChainStatus::Completed => write!(f, "completed"), + ChainStatus::Archived => write!(f, "archived"), + } + } +} + +impl std::str::FromStr for ChainStatus { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "active" => Ok(ChainStatus::Active), + "completed" => Ok(ChainStatus::Completed), + "archived" => Ok(ChainStatus::Archived), + _ => Err(format!("Invalid chain status: {}", s)), + } + } +} + +/// Chain - a directed acyclic graph (DAG) of contracts +/// Fits Makima's control theme - she controls through invisible chains +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Chain { + pub id: Uuid, + pub owner_id: Uuid, + pub name: String, + pub description: Option<String>, + pub status: String, + /// Whether loop mode is enabled for iterative execution + #[serde(default)] + pub loop_enabled: bool, + /// Maximum loop iterations (default: 10) + pub loop_max_iterations: Option<i32>, + /// Current loop iteration count + pub loop_current_iteration: Option<i32>, + /// Progress check prompt/criteria for evaluating loop completion + pub loop_progress_check: Option<String>, + /// Repository URL for contracts in this chain (optional) + pub repository_url: Option<String>, + /// Local path for contracts in this chain (optional) + pub local_path: Option<String>, + /// Version for optimistic locking + pub version: i32, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +impl Chain { + /// Parse status string to ChainStatus enum + pub fn status_enum(&self) -> Result<ChainStatus, String> { + self.status.parse() + } +} + +/// Chain contract link - links contracts to chains with DAG dependency info +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainContract { + pub id: Uuid, + pub chain_id: Uuid, + pub contract_id: Uuid, + /// Contract IDs this contract depends on (DAG edges) + #[sqlx(default)] + pub depends_on: Vec<Uuid>, + /// Order for display/processing (topological sort order) + pub order_index: i32, + /// X position for GUI editor + pub editor_x: Option<f64>, + /// Y position for GUI editor + pub editor_y: Option<f64>, + pub created_at: DateTime<Utc>, +} + +/// Chain event for audit trail +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainEvent { + pub id: Uuid, + pub chain_id: Uuid, + pub event_type: String, + pub contract_id: Option<Uuid>, + #[sqlx(json)] + pub event_data: Option<serde_json::Value>, + pub created_at: DateTime<Utc>, +} + +/// Summary of a chain for list views +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainSummary { + pub id: Uuid, + pub name: String, + pub description: Option<String>, + pub status: String, + pub loop_enabled: bool, + pub loop_current_iteration: Option<i32>, + pub contract_count: i64, + pub completed_count: i64, + pub version: i32, + pub created_at: DateTime<Utc>, +} + +/// Chain with contracts for detail view +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainWithContracts { + #[serde(flatten)] + pub chain: Chain, + pub contracts: Vec<ChainContractDetail>, +} + +/// Contract detail within a chain (includes contract info + chain link info) +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainContractDetail { + pub chain_contract_id: Uuid, + pub contract_id: Uuid, + pub contract_name: String, + pub contract_status: String, + pub contract_phase: String, + #[sqlx(default)] + pub depends_on: Vec<Uuid>, + pub order_index: i32, + pub editor_x: Option<f64>, + pub editor_y: Option<f64>, +} + +/// DAG graph structure for visualization +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainGraphResponse { + pub chain_id: Uuid, + pub chain_name: String, + pub chain_status: String, + pub nodes: Vec<ChainGraphNode>, + pub edges: Vec<ChainGraphEdge>, +} + +/// Node in chain DAG graph +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainGraphNode { + pub id: Uuid, + pub contract_id: Uuid, + pub name: String, + pub status: String, + pub phase: String, + pub x: f64, + pub y: f64, +} + +/// Edge in chain DAG graph +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainGraphEdge { + pub from: Uuid, + pub to: Uuid, +} + +/// Response for chain list endpoint +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainListResponse { + pub chains: Vec<ChainSummary>, + pub total: i64, +} + +/// Request payload for creating a new chain +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateChainRequest { + /// Name of the chain + pub name: String, + /// Optional description + pub description: Option<String>, + /// Repository URL for contracts in this chain + pub repository_url: Option<String>, + /// Local path for contracts in this chain + pub local_path: Option<String>, + /// Enable loop mode for iterative execution + #[serde(default)] + pub loop_enabled: Option<bool>, + /// Maximum loop iterations (default: 10) + pub loop_max_iterations: Option<i32>, + /// Progress check prompt for evaluating loop completion + pub loop_progress_check: Option<String>, + /// Contracts to create within this chain + pub contracts: Option<Vec<CreateChainContractRequest>>, +} + +/// Request to create a contract within a chain +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateChainContractRequest { + /// Name of the contract + pub name: String, + /// Optional description + pub description: Option<String>, + /// Contract type + #[serde(default)] + pub contract_type: Option<String>, + /// Initial phase + pub initial_phase: Option<String>, + /// Phases for the contract + pub phases: Option<Vec<String>>, + /// Names of contracts this depends on (resolved to IDs) + pub depends_on: Option<Vec<String>>, + /// Tasks to create in this contract + pub tasks: Option<Vec<CreateChainTaskRequest>>, + /// Deliverables for this contract + pub deliverables: Option<Vec<CreateChainDeliverableRequest>>, + /// Position in GUI editor + pub editor_x: Option<f64>, + pub editor_y: Option<f64>, +} + +/// Task definition within a chain contract +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateChainTaskRequest { + pub name: String, + pub plan: String, +} + +/// Deliverable definition within a chain contract +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateChainDeliverableRequest { + pub id: String, + pub name: String, + pub priority: Option<String>, +} + +/// Request to update an existing chain +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateChainRequest { + pub name: Option<String>, + pub description: Option<String>, + pub status: Option<String>, + pub loop_enabled: Option<bool>, + pub loop_max_iterations: Option<i32>, + pub loop_progress_check: Option<String>, + /// Version for optimistic locking + pub version: Option<i32>, +} + +/// Request to add a contract to a chain +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddContractToChainRequest { + /// Existing contract ID to add + pub contract_id: Option<Uuid>, + /// Or create a new contract with this definition + pub new_contract: Option<CreateChainContractRequest>, + /// Contract IDs this depends on + pub depends_on: Option<Vec<Uuid>>, + /// Position in GUI editor + pub editor_x: Option<f64>, + pub editor_y: Option<f64>, +} + +/// Editor data model for GUI chain editor +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainEditorData { + pub id: Option<Uuid>, + pub name: String, + pub description: Option<String>, + pub repository_url: Option<String>, + pub local_path: Option<String>, + pub loop_enabled: bool, + pub loop_max_iterations: Option<i32>, + pub loop_progress_check: Option<String>, + pub nodes: Vec<ChainEditorNode>, + pub edges: Vec<ChainEditorEdge>, +} + +/// Node in chain editor +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainEditorNode { + pub id: String, + pub x: f64, + pub y: f64, + pub contract: ChainEditorContract, +} + +/// Contract data in chain editor node +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainEditorContract { + pub name: String, + pub description: Option<String>, + #[serde(rename = "type")] + pub contract_type: String, + pub phases: Vec<String>, + pub tasks: Vec<ChainEditorTask>, + pub deliverables: Vec<ChainEditorDeliverable>, +} + +/// Task in chain editor +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainEditorTask { + pub name: String, + pub plan: String, +} + +/// Deliverable in chain editor +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainEditorDeliverable { + pub id: String, + pub name: String, + pub priority: String, +} + +/// Edge in chain editor +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainEditorEdge { + pub from: String, + pub to: String, +} + +// ============================================================================= // Unit Tests // ============================================================================= diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 2ecbc4a..48b0714 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -6,15 +6,19 @@ use sqlx::PgPool; use uuid::Uuid; use super::models::{ - CheckpointPatch, CheckpointPatchInfo, Contract, ContractChatConversation, - ContractChatMessageRecord, ContractEvent, ContractRepository, ContractSummary, - ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot, CreateContractRequest, - CreateFileRequest, CreateTaskRequest, CreateTemplateRequest, Daemon, DaemonTaskAssignment, - DaemonWithCapacity, DeliverableDefinition, File, FileSummary, FileVersion, HistoryEvent, - HistoryQueryFilters, MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, - PhaseConfig, PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, - Task, TaskCheckpoint, TaskEvent, TaskSummary, UpdateContractRequest, UpdateFileRequest, - UpdateTaskRequest, UpdateTemplateRequest, + AddContractToChainRequest, Chain, ChainContract, ChainContractDetail, ChainEditorContract, + ChainEditorData, ChainEditorDeliverable, ChainEditorEdge, ChainEditorNode, ChainEditorTask, + ChainEvent, ChainGraphEdge, ChainGraphNode, ChainGraphResponse, ChainSummary, + ChainWithContracts, CheckpointPatch, CheckpointPatchInfo, Contract, + ContractChatConversation, ContractChatMessageRecord, ContractEvent, ContractRepository, + ContractSummary, ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot, + CreateChainRequest, CreateContractRequest, CreateFileRequest, CreateTaskRequest, + CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, + DeliverableDefinition, File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters, + MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, PhaseDefinition, + SupervisorHeartbeatRecord, SupervisorState, Task, TaskCheckpoint, TaskEvent, TaskSummary, + UpdateChainRequest, UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, + UpdateTemplateRequest, }; /// Repository error types. @@ -4896,3 +4900,529 @@ pub async fn sync_supervisor_state( .fetch_optional(pool) .await } + +// ============================================================================= +// Chain Operations (DAG of contracts for multi-contract orchestration) +// ============================================================================= + +/// Create a new chain for a specific owner. +pub async fn create_chain_for_owner( + pool: &PgPool, + owner_id: Uuid, + req: CreateChainRequest, +) -> Result<Chain, sqlx::Error> { + let loop_enabled = req.loop_enabled.unwrap_or(false); + let loop_max_iterations = req.loop_max_iterations.unwrap_or(10); + + sqlx::query_as::<_, Chain>( + r#" + INSERT INTO chains (owner_id, name, description, repository_url, local_path, loop_enabled, loop_max_iterations, loop_progress_check) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + "#, + ) + .bind(owner_id) + .bind(&req.name) + .bind(&req.description) + .bind(&req.repository_url) + .bind(&req.local_path) + .bind(loop_enabled) + .bind(loop_max_iterations) + .bind(&req.loop_progress_check) + .fetch_one(pool) + .await +} + +/// Get a chain by ID, scoped to owner. +pub async fn get_chain_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, +) -> Result<Option<Chain>, sqlx::Error> { + sqlx::query_as::<_, Chain>( + r#" + SELECT * + FROM chains + WHERE id = $1 AND owner_id = $2 + "#, + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await +} + +/// Get a chain by ID (no owner check - for internal use). +pub async fn get_chain(pool: &PgPool, id: Uuid) -> Result<Option<Chain>, sqlx::Error> { + sqlx::query_as::<_, Chain>( + r#" + SELECT * + FROM chains + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await +} + +/// List chains for a specific owner. +pub async fn list_chains_for_owner( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Vec<ChainSummary>, sqlx::Error> { + sqlx::query_as::<_, ChainSummary>( + r#" + SELECT + c.id, + c.name, + c.description, + c.status, + c.loop_enabled, + c.loop_current_iteration, + COUNT(DISTINCT cc.contract_id) as contract_count, + COUNT(DISTINCT CASE WHEN con.status = 'completed' THEN cc.contract_id END) as completed_count, + c.version, + c.created_at + FROM chains c + LEFT JOIN chain_contracts cc ON cc.chain_id = c.id + LEFT JOIN contracts con ON con.id = cc.contract_id + WHERE c.owner_id = $1 + GROUP BY c.id + ORDER BY c.created_at DESC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Update a chain. +pub async fn update_chain_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, + req: UpdateChainRequest, +) -> Result<Chain, RepositoryError> { + // First get current version if optimistic locking requested + if let Some(expected_version) = req.version { + let current: Option<(i32,)> = sqlx::query_as( + "SELECT version FROM chains WHERE id = $1 AND owner_id = $2", + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await?; + + if let Some((actual_version,)) = current { + if actual_version != expected_version { + return Err(RepositoryError::VersionConflict { + expected: expected_version, + actual: actual_version, + }); + } + } + } + + let result = sqlx::query_as::<_, Chain>( + r#" + UPDATE chains + SET + name = COALESCE($3, name), + description = COALESCE($4, description), + status = COALESCE($5, status), + loop_enabled = COALESCE($6, loop_enabled), + loop_max_iterations = COALESCE($7, loop_max_iterations), + loop_progress_check = COALESCE($8, loop_progress_check), + version = version + 1, + updated_at = NOW() + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(id) + .bind(owner_id) + .bind(&req.name) + .bind(&req.description) + .bind(&req.status) + .bind(req.loop_enabled) + .bind(req.loop_max_iterations) + .bind(&req.loop_progress_check) + .fetch_one(pool) + .await?; + + Ok(result) +} + +/// Delete (archive) a chain. +pub async fn delete_chain_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + UPDATE chains + SET status = 'archived', updated_at = NOW() + WHERE id = $1 AND owner_id = $2 + "#, + ) + .bind(id) + .bind(owner_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Add a contract to a chain. +pub async fn add_contract_to_chain( + pool: &PgPool, + chain_id: Uuid, + contract_id: Uuid, + depends_on: Vec<Uuid>, + order_index: i32, + editor_x: Option<f64>, + editor_y: Option<f64>, +) -> Result<ChainContract, sqlx::Error> { + // Also update the contract's chain_id + sqlx::query("UPDATE contracts SET chain_id = $1 WHERE id = $2") + .bind(chain_id) + .bind(contract_id) + .execute(pool) + .await?; + + sqlx::query_as::<_, ChainContract>( + r#" + INSERT INTO chain_contracts (chain_id, contract_id, depends_on, order_index, editor_x, editor_y) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (chain_id, contract_id) DO UPDATE SET + depends_on = EXCLUDED.depends_on, + order_index = EXCLUDED.order_index, + editor_x = EXCLUDED.editor_x, + editor_y = EXCLUDED.editor_y + RETURNING * + "#, + ) + .bind(chain_id) + .bind(contract_id) + .bind(&depends_on) + .bind(order_index) + .bind(editor_x) + .bind(editor_y) + .fetch_one(pool) + .await +} + +/// Remove a contract from a chain. +pub async fn remove_contract_from_chain( + pool: &PgPool, + chain_id: Uuid, + contract_id: Uuid, +) -> Result<bool, sqlx::Error> { + // Clear the contract's chain_id + sqlx::query("UPDATE contracts SET chain_id = NULL WHERE id = $1 AND chain_id = $2") + .bind(contract_id) + .bind(chain_id) + .execute(pool) + .await?; + + let result = sqlx::query( + r#" + DELETE FROM chain_contracts + WHERE chain_id = $1 AND contract_id = $2 + "#, + ) + .bind(chain_id) + .bind(contract_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// List contracts in a chain with their details. +pub async fn list_chain_contracts( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Vec<ChainContractDetail>, sqlx::Error> { + sqlx::query_as::<_, ChainContractDetail>( + r#" + SELECT + cc.id as chain_contract_id, + cc.contract_id, + c.name as contract_name, + c.status as contract_status, + c.phase as contract_phase, + cc.depends_on, + cc.order_index, + cc.editor_x, + cc.editor_y + FROM chain_contracts cc + JOIN contracts c ON c.id = cc.contract_id + WHERE cc.chain_id = $1 + ORDER BY cc.order_index ASC + "#, + ) + .bind(chain_id) + .fetch_all(pool) + .await +} + +/// Get chain with all contracts for detail view. +pub async fn get_chain_with_contracts( + pool: &PgPool, + chain_id: Uuid, + owner_id: Uuid, +) -> Result<Option<ChainWithContracts>, sqlx::Error> { + let chain = get_chain_for_owner(pool, chain_id, owner_id).await?; + + match chain { + Some(chain) => { + let contracts = list_chain_contracts(pool, chain_id).await?; + Ok(Some(ChainWithContracts { chain, contracts })) + } + None => Ok(None), + } +} + +/// Get chain graph structure for visualization. +pub async fn get_chain_graph( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Option<ChainGraphResponse>, sqlx::Error> { + let chain = get_chain(pool, chain_id).await?; + + match chain { + Some(chain) => { + let contracts = list_chain_contracts(pool, chain_id).await?; + + let nodes: Vec<ChainGraphNode> = contracts + .iter() + .map(|c| ChainGraphNode { + id: c.chain_contract_id, + contract_id: c.contract_id, + name: c.contract_name.clone(), + status: c.contract_status.clone(), + phase: c.contract_phase.clone(), + x: c.editor_x.unwrap_or(0.0), + y: c.editor_y.unwrap_or(0.0), + }) + .collect(); + + let mut edges: Vec<ChainGraphEdge> = Vec::new(); + for contract in &contracts { + for dep_id in &contract.depends_on { + // Find the chain_contract_id for this dependency + if let Some(dep) = contracts.iter().find(|c| c.contract_id == *dep_id) { + edges.push(ChainGraphEdge { + from: dep.chain_contract_id, + to: contract.chain_contract_id, + }); + } + } + } + + Ok(Some(ChainGraphResponse { + chain_id: chain.id, + chain_name: chain.name, + chain_status: chain.status, + nodes, + edges, + })) + } + None => Ok(None), + } +} + +/// Record a chain event. +pub async fn record_chain_event( + pool: &PgPool, + chain_id: Uuid, + event_type: &str, + contract_id: Option<Uuid>, + event_data: Option<serde_json::Value>, +) -> Result<ChainEvent, sqlx::Error> { + sqlx::query_as::<_, ChainEvent>( + r#" + INSERT INTO chain_events (chain_id, event_type, contract_id, event_data) + VALUES ($1, $2, $3, $4) + RETURNING * + "#, + ) + .bind(chain_id) + .bind(event_type) + .bind(contract_id) + .bind(event_data) + .fetch_one(pool) + .await +} + +/// List chain events. +pub async fn list_chain_events( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Vec<ChainEvent>, sqlx::Error> { + sqlx::query_as::<_, ChainEvent>( + r#" + SELECT * + FROM chain_events + WHERE chain_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(chain_id) + .fetch_all(pool) + .await +} + +/// Increment chain loop iteration. +pub async fn increment_chain_loop(pool: &PgPool, chain_id: Uuid) -> Result<Chain, sqlx::Error> { + sqlx::query_as::<_, Chain>( + r#" + UPDATE chains + SET loop_current_iteration = COALESCE(loop_current_iteration, 0) + 1, + updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(chain_id) + .fetch_one(pool) + .await +} + +/// Mark a chain as completed. +pub async fn complete_chain(pool: &PgPool, chain_id: Uuid) -> Result<Chain, sqlx::Error> { + sqlx::query_as::<_, Chain>( + r#" + UPDATE chains + SET status = 'completed', + updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(chain_id) + .fetch_one(pool) + .await +} + +/// Get contracts in a chain that have no pending dependencies (ready to start). +/// Returns contracts where all depends_on contracts are completed. +pub async fn get_ready_chain_contracts( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Vec<ChainContractDetail>, sqlx::Error> { + sqlx::query_as::<_, ChainContractDetail>( + r#" + SELECT + cc.id as chain_contract_id, + cc.contract_id, + c.name as contract_name, + c.status as contract_status, + c.phase as contract_phase, + cc.depends_on, + cc.order_index, + cc.editor_x, + cc.editor_y + FROM chain_contracts cc + JOIN contracts c ON c.id = cc.contract_id + WHERE cc.chain_id = $1 + AND c.status = 'active' + AND ( + -- No dependencies + cc.depends_on IS NULL + OR array_length(cc.depends_on, 1) IS NULL + OR array_length(cc.depends_on, 1) = 0 + -- Or all dependencies completed + OR NOT EXISTS ( + SELECT 1 + FROM unnest(cc.depends_on) AS dep_id + JOIN contracts dep ON dep.id = dep_id + WHERE dep.status != 'completed' + ) + ) + ORDER BY cc.order_index ASC + "#, + ) + .bind(chain_id) + .fetch_all(pool) + .await +} + +/// Check if all contracts in a chain are completed. +pub async fn is_chain_complete(pool: &PgPool, chain_id: Uuid) -> Result<bool, sqlx::Error> { + let result: (i64,) = sqlx::query_as( + r#" + SELECT COUNT(*) + FROM chain_contracts cc + JOIN contracts c ON c.id = cc.contract_id + WHERE cc.chain_id = $1 + AND c.status != 'completed' + "#, + ) + .bind(chain_id) + .fetch_one(pool) + .await?; + + Ok(result.0 == 0) +} + +/// Get chain editor data for the GUI editor. +pub async fn get_chain_editor_data( + pool: &PgPool, + chain_id: Uuid, + owner_id: Uuid, +) -> Result<Option<ChainEditorData>, sqlx::Error> { + let chain = get_chain_for_owner(pool, chain_id, owner_id).await?; + + match chain { + Some(chain) => { + let contracts = list_chain_contracts(pool, chain_id).await?; + + // Build nodes + let nodes: Vec<ChainEditorNode> = contracts + .iter() + .map(|c| ChainEditorNode { + id: c.contract_id.to_string(), + x: c.editor_x.unwrap_or(0.0), + y: c.editor_y.unwrap_or(0.0), + contract: ChainEditorContract { + name: c.contract_name.clone(), + description: None, // Would need to join with full contract data + contract_type: "simple".to_string(), + phases: vec!["plan".to_string(), "execute".to_string()], + tasks: vec![], + deliverables: vec![], + }, + }) + .collect(); + + // Build edges + let edges: Vec<ChainEditorEdge> = contracts + .iter() + .flat_map(|c| { + c.depends_on.iter().map(move |dep_id| ChainEditorEdge { + from: dep_id.to_string(), + to: c.contract_id.to_string(), + }) + }) + .collect(); + + Ok(Some(ChainEditorData { + id: Some(chain.id), + name: chain.name, + description: chain.description, + repository_url: chain.repository_url, + local_path: chain.local_path, + loop_enabled: chain.loop_enabled, + loop_max_iterations: chain.loop_max_iterations, + loop_progress_check: chain.loop_progress_check, + nodes, + edges, + })) + } + None => Ok(None), + } +} diff --git a/makima/src/server/handlers/chains.rs b/makima/src/server/handlers/chains.rs new file mode 100644 index 0000000..136a868 --- /dev/null +++ b/makima/src/server/handlers/chains.rs @@ -0,0 +1,609 @@ +//! HTTP handlers for chain CRUD operations. +//! +//! Chains are DAGs (directed acyclic graphs) of contracts for multi-contract orchestration. + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::Deserialize; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::db::models::{ + ChainContractDetail, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary, + ChainWithContracts, CreateChainRequest, UpdateChainRequest, +}; +use crate::db::repository::{self, RepositoryError}; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +// ============================================================================= +// Query Parameters +// ============================================================================= + +/// Query parameters for listing chains. +#[derive(Debug, Deserialize, ToSchema)] +pub struct ListChainsQuery { + /// Filter by status (active, completed, archived) + pub status: Option<String>, + /// Maximum number of results + #[serde(default = "default_limit")] + pub limit: i32, + /// Offset for pagination + #[serde(default)] + pub offset: i32, +} + +fn default_limit() -> i32 { + 50 +} + +// ============================================================================= +// Response Types +// ============================================================================= + +/// Response for listing chains. +#[derive(Debug, serde::Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainListResponse { + pub chains: Vec<ChainSummary>, + pub total: i64, +} + +// ============================================================================= +// Handlers +// ============================================================================= + +/// List chains for the authenticated user. +/// +/// GET /api/v1/chains +#[utoipa::path( + get, + path = "/api/v1/chains", + responses( + (status = 200, description = "List of chains", body = ChainListResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn list_chains( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Query(query): Query<ListChainsQuery>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::list_chains_for_owner(pool, auth.owner_id).await { + Ok(mut chains) => { + // Apply filters + if let Some(status) = &query.status { + chains.retain(|c| c.status == *status); + } + // Apply pagination + let total = chains.len() as i64; + let chains: Vec<_> = chains + .into_iter() + .skip(query.offset as usize) + .take(query.limit as usize) + .collect(); + Json(ChainListResponse { chains, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list chains: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Create a new chain with contracts. +/// +/// POST /api/v1/chains +#[utoipa::path( + post, + path = "/api/v1/chains", + request_body = CreateChainRequest, + responses( + (status = 201, description = "Chain created"), + (status = 400, description = "Invalid request", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn create_chain( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Json(req): Json<CreateChainRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Validate the request + if req.name.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("VALIDATION_ERROR", "Chain name cannot be empty")), + ) + .into_response(); + } + + match repository::create_chain_for_owner(pool, auth.owner_id, req).await { + Ok(chain) => (StatusCode::CREATED, Json(chain)).into_response(), + Err(e) => { + tracing::error!("Failed to create chain: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get a chain by ID. +/// +/// GET /api/v1/chains/{id} +#[utoipa::path( + get, + path = "/api/v1/chains/{id}", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "Chain with contracts", body = ChainWithContracts), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn get_chain( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_chain_with_contracts(pool, chain_id, auth.owner_id).await { + Ok(Some(chain)) => Json(chain).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get chain: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Update a chain. +/// +/// PUT /api/v1/chains/{id} +#[utoipa::path( + put, + path = "/api/v1/chains/{id}", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + request_body = UpdateChainRequest, + responses( + (status = 200, description = "Chain updated"), + (status = 400, description = "Invalid request", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 409, description = "Version conflict", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn update_chain( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, + Json(req): Json<UpdateChainRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::update_chain_for_owner(pool, chain_id, auth.owner_id, req).await { + Ok(chain) => Json(chain).into_response(), + Err(RepositoryError::VersionConflict { expected, actual }) => ( + StatusCode::CONFLICT, + Json(ApiError::new( + "VERSION_CONFLICT", + format!("Version conflict: expected {}, found {}", expected, actual), + )), + ) + .into_response(), + Err(RepositoryError::Database(e)) => { + // Check if it's a "row not found" error + let error_str = e.to_string(); + if error_str.contains("no rows") || error_str.contains("RowNotFound") { + ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response() + } else { + tracing::error!("Failed to update chain: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } + } +} + +/// Delete (archive) a chain. +/// +/// DELETE /api/v1/chains/{id} +#[utoipa::path( + delete, + path = "/api/v1/chains/{id}", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "Chain archived"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn delete_chain( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::delete_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(true) => Json(serde_json::json!({"archived": true})).into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete chain: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get contracts in a chain. +/// +/// GET /api/v1/chains/{id}/contracts +#[utoipa::path( + get, + path = "/api/v1/chains/{id}/contracts", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "List of contracts in chain", body = Vec<ChainContractDetail>), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn get_chain_contracts( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::list_chain_contracts(pool, chain_id).await { + Ok(contracts) => Json(contracts).into_response(), + Err(e) => { + tracing::error!("Failed to list chain contracts: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get chain DAG structure for visualization. +/// +/// GET /api/v1/chains/{id}/graph +#[utoipa::path( + get, + path = "/api/v1/chains/{id}/graph", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "Chain graph structure", body = ChainGraphResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn get_chain_graph( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership first + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::get_chain_graph(pool, chain_id).await { + Ok(Some(graph)) => Json(graph).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get chain graph: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get chain events. +/// +/// GET /api/v1/chains/{id}/events +#[utoipa::path( + get, + path = "/api/v1/chains/{id}/events", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "Chain events", body = Vec<ChainEvent>), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn get_chain_events( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::list_chain_events(pool, chain_id).await { + Ok(events) => Json(events).into_response(), + Err(e) => { + tracing::error!("Failed to list chain events: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get chain editor data. +/// +/// GET /api/v1/chains/{id}/editor +#[utoipa::path( + get, + path = "/api/v1/chains/{id}/editor", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "Chain editor data", body = ChainEditorData), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn get_chain_editor( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_chain_editor_data(pool, chain_id, auth.owner_id).await { + Ok(Some(editor_data)) => Json(editor_data).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get chain editor data: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index ae370c9..5e172bc 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -1,6 +1,7 @@ //! HTTP and WebSocket request handlers. pub mod api_keys; +pub mod chains; pub mod chat; pub mod contract_chat; pub mod contract_daemon; diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 12651d9..553797f 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chains, chat, contract_chat, contract_daemon, contract_discuss, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -214,6 +214,21 @@ pub fn make_router(state: SharedState) -> Router { ) // Timeline endpoint (unified history for user) .route("/timeline", get(history::get_timeline)) + // Chain endpoints (multi-contract orchestration) + .route( + "/chains", + get(chains::list_chains).post(chains::create_chain), + ) + .route( + "/chains/{id}", + get(chains::get_chain) + .put(chains::update_chain) + .delete(chains::delete_chain), + ) + .route("/chains/{id}/contracts", get(chains::get_chain_contracts)) + .route("/chains/{id}/graph", get(chains::get_chain_graph)) + .route("/chains/{id}/events", get(chains::get_chain_events)) + .route("/chains/{id}/editor", get(chains::get_chain_editor)) // Contract type templates (built-in only) .route("/contract-types", get(templates::list_contract_types)) // Settings endpoints |
