summaryrefslogblamecommitdiff
path: root/makima/src/daemon/chain/runner.rs
blob: 9c6f6b4ccec900287bdd0e460c67e161ba789733 (plain) (tree)











































































































































































































































































































































































                                                                                               
//! 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);
    }
}