mecha10-cli 0.6.3

Mecha10 CLI tool
Documentation
//! Node-oriented spawning for `ProcessService`
//!
//! Higher-level spawning that resolves what a "node" actually is and how to
//! run it: via `mecha10-node-runner` (framework dev mode), or directly in
//! standalone mode (framework nodes via `BinaryManager`, or local project
//! nodes built and run in-place).

use super::ProcessService;
use crate::paths;
use anyhow::{Context, Result};
use mecha10_cli_core::utils::strip_node_scope;
use std::collections::HashMap;

impl ProcessService {
    /// Spawn a node using mecha10-node-runner
    ///
    /// This is the simplified spawning method for Phase 2+ of Node Lifecycle Architecture.
    /// It delegates all complexity (binary resolution, model pulling, env setup) to node-runner.
    ///
    /// # Returns
    ///
    /// Process ID of the spawned node-runner instance
    ///
    /// # Errors
    ///
    /// Returns an error if the node-runner cannot be spawned
    ///
    /// # Configuration
    ///
    /// The node-runner reads configuration from the node's config file (e.g., `configs/nodes/{node_name}.json`)
    /// and supports the following runtime settings:
    ///
    /// ```json
    /// {
    ///   "runtime": {
    ///     "restart_policy": "on-failure",  // never, on-failure, always
    ///     "max_retries": 3,
    ///     "backoff_secs": 1
    ///   },
    ///   "depends_on": ["camera", "lidar"],
    ///   "startup_timeout_secs": 30
    /// }
    /// ```
    ///
    /// To enable dependency checking, use: `mecha10-node-runner --wait-for-deps <node-name>`
    ///
    /// # Arguments
    ///
    /// * `node_identifier` - Full node identifier (e.g., `@mecha10/simulator`, `my-node`)
    /// * `project_name` - Name of the current project, forwarded to node-runner as `--project`.
    ///   Without this, node-runner falls back to guessing the project name from its own
    ///   current working directory's basename (see its `resolve_node_execution`) to find the
    ///   monorepo project binary at `target/{release,debug}/{project_name}` - a guess that
    ///   silently breaks whenever the working directory's name doesn't match the actual
    ///   Cargo package/binary name (e.g. a checkout cloned into a differently-named directory).
    ///   That misresolution is what made bundled framework nodes (`@mecha10/*`) appear to fail
    ///   under node-runner: with no monorepo binary found under the guessed name, node-runner
    ///   falls through to its bundled-node fallback, which shells out to the globally installed
    ///   `mecha10` CLI as `mecha10 node <name>` - a subcommand the `mecha10` CLI binary
    ///   (`packages/cli`) doesn't define, so that fallback always fails too. Passing the real
    ///   project name explicitly removes the guess entirely.
    pub fn spawn_node_runner(
        &mut self,
        node_identifier: &str,
        project_name: &str,
        project_env: Option<HashMap<String, String>>,
    ) -> Result<u32> {
        // Simply spawn: mecha10-node-runner --project <project_name> <node-name>
        // The node-runner handles:
        // - Binary path resolution (monorepo vs local)
        // - Model pulling (if node needs AI models)
        // - Environment setup
        // - Log redirection
        // - Restart policies (from config)
        // - Health monitoring
        // - Dependency checking (if --wait-for-deps enabled)
        // - Actual node execution

        // Resolve path to mecha10-node-runner binary
        let runner_path = Self::resolve_node_runner_path();

        // Build environment - include project vars for config substitution
        let env = project_env.unwrap_or_default();

        // Register with full identifier (e.g., @mecha10/listener) for consistent tracking
        let args = node_runner_args(project_name, node_identifier);
        self.spawn_with_env(node_identifier, &runner_path, &args, env)
    }

    /// Spawn a node directly (standalone mode)
    ///
    /// This is used when mecha10-node-runner is not available (standalone projects
    /// installed from crates.io). Handles both framework nodes (via BinaryManager)
    /// and local project nodes (built and run directly).
    ///
    /// Resolution strategy for framework nodes:
    /// 1. Check ~/.mecha10/bin/ for pre-downloaded binary
    /// 2. If not found, attempt to download from GitHub releases
    /// 3. If download fails, fall back to cargo install
    ///
    /// # Arguments
    ///
    /// * `node_name` - Name of the node to run
    /// * `project_name` - Name of the project (used to find the binary)
    /// * `project_env` - Optional additional environment variables from project config
    ///
    /// # Returns
    ///
    /// Process ID of the spawned node process
    pub fn spawn_node_direct(
        &mut self,
        node_identifier: &str,
        _project_name: &str,
        project_env: Option<HashMap<String, String>>,
    ) -> Result<u32> {
        // Extract node name from full identifier (e.g., @mecha10/listener -> listener)
        let node_name = strip_node_scope(node_identifier);

        // Check if this is a local project node by looking at mecha10.json
        let is_local_node = self.is_local_project_node(node_name);

        let mut env = HashMap::new();
        // Use full identifier for NODE_NAME so health reports and dashboard are consistent
        env.insert("NODE_NAME".to_string(), node_identifier.to_string());
        if let Ok(rust_log) = std::env::var("RUST_LOG") {
            env.insert("RUST_LOG".to_string(), rust_log);
        }

        // Add project environment variables (REDIS_URL, control plane, robot info, etc.)
        if let Some(project_vars) = project_env {
            tracing::debug!(
                "🔀 spawn_node_direct for '{}': merging {} project vars",
                node_name,
                project_vars.len()
            );
            for (key, value) in &project_vars {
                tracing::debug!(
                    "  Adding: {} = {}",
                    key,
                    if key == "ROBOT_API_KEY" { "<redacted>" } else { value }
                );
            }
            env.extend(project_vars);
        } else {
            tracing::warn!("⚠️  spawn_node_direct for '{}': No project_env provided!", node_name);
        }

        tracing::debug!(
            "🏁 spawn_node_direct for '{}': final env has {} vars, is_local: {}",
            node_name,
            env.len(),
            is_local_node
        );

        if is_local_node {
            // Local project node: build and run the binary directly
            // Register with full identifier (e.g., my-node) for consistent tracking
            self.spawn_local_node_with_identifier(node_identifier, node_name, env)
        } else {
            // Framework node: resolve binary via BinaryManager
            // This will download from GitHub releases if not cached
            self.spawn_framework_node(node_identifier, node_name, env)
        }
    }

    /// Spawn a framework node using BinaryManager for binary resolution
    ///
    /// This method resolves the binary path using BinaryManager, which:
    /// 1. Checks the local cache (~/.mecha10/bin/)
    /// 2. Downloads from GitHub releases if needed
    /// 3. Falls back to cargo install for unsupported platforms
    fn spawn_framework_node(
        &mut self,
        node_identifier: &str,
        node_name: &str,
        env: HashMap<String, String>,
    ) -> Result<u32> {
        use super::super::binary_manager::BinaryManager;

        // Try to get version from project config
        let version = self.get_node_version_from_config(node_identifier);

        // Try to resolve binary via BinaryManager
        let binary_manager = BinaryManager::new()?;

        // Use block_in_place to run async code in sync context
        let binary_path = tokio::task::block_in_place(|| {
            tokio::runtime::Handle::current()
                .block_on(binary_manager.resolve_with_version(node_name, version.as_deref()))
        })?;

        tracing::info!(
            "🚀 Spawning framework node '{}' from {}",
            node_name,
            binary_path.display()
        );

        // Spawn the binary directly
        self.spawn_with_env(node_identifier, &binary_path.to_string_lossy(), &[], env)
    }

    /// Spawn a local project node with full identifier for tracking
    ///
    /// Builds the node with cargo and runs the binary directly.
    /// Registers the process with the full identifier (e.g., my-node) for consistent tracking.
    fn spawn_local_node_with_identifier(
        &mut self,
        node_identifier: &str,
        node_name: &str,
        env: HashMap<String, String>,
    ) -> Result<u32> {
        use std::process::Command;

        // First, build the node
        tracing::info!("🔨 Building local node: {}", node_name);

        let build_output = Command::new("cargo")
            .args(["build", "-p", node_name])
            .output()
            .context("Failed to run cargo build")?;

        if !build_output.status.success() {
            let stderr = String::from_utf8_lossy(&build_output.stderr);
            anyhow::bail!("Failed to build node '{}': {}", node_name, stderr);
        }

        tracing::info!("✅ Built local node: {}", node_name);

        // Run the binary from target/debug/<name>
        let binary_path = paths::target_path("debug", node_name);

        if !std::path::Path::new(&binary_path).exists() {
            anyhow::bail!(
                "Binary not found at '{}'. Build may have failed or the package name differs from node name.",
                binary_path
            );
        }

        // Register with full identifier for consistent tracking
        self.spawn_with_env(node_identifier, &binary_path, &[], env)
    }
}

/// Build the argument list passed to `mecha10-node-runner` for a given node.
///
/// Split out from [`ProcessService::spawn_node_runner`] as a pure function so the
/// `--project` wiring (see that method's doc comment for why it matters - without it,
/// node-runner silently misresolves bundled framework nodes) is unit testable without
/// spawning a real subprocess.
fn node_runner_args<'a>(project_name: &'a str, node_identifier: &'a str) -> [&'a str; 3] {
    // node-runner only needs the short name (e.g. `@mecha10/simulator` -> `simulator`) as
    // its positional argument.
    ["--project", project_name, strip_node_scope(node_identifier)]
}

#[cfg(test)]
mod tests {
    use super::*;

    // ---- node_runner_args --------------------------------------------------------------

    #[test]
    fn node_runner_args_passes_project_name_and_strips_bundled_node_scope() {
        let args = node_runner_args("rover-robot", "@mecha10/simulator");
        assert_eq!(args, ["--project", "rover-robot", "simulator"]);
    }

    #[test]
    fn node_runner_args_passes_project_name_and_strips_local_node_scope() {
        let args = node_runner_args("rover-robot", "@local/my-node");
        assert_eq!(args, ["--project", "rover-robot", "my-node"]);
    }

    #[test]
    fn node_runner_args_leaves_unscoped_node_name_untouched() {
        let args = node_runner_args("rover-robot", "my-node");
        assert_eq!(args, ["--project", "rover-robot", "my-node"]);
    }
}