mecha10-cli 0.6.3

Mecha10 CLI tool
Documentation
//! Run command handler
//!
//! Runs the built project binary for a specific target.

use crate::commands::{RunCommand, RunRemoteArgs, RunRobotArgs};
use crate::context::CliContext;
use crate::handlers::dev::ops::maybe_inject_teleop_locomotion_env;
use crate::services::credentials::CredentialsService;
use anyhow::{Context, Result};
use mecha10_core::enum_str_method;
use std::collections::HashMap;
use std::process::Command;

/// Run target type
#[derive(Debug, Clone, Copy)]
pub enum RunTarget {
    /// Robot nodes (edge/on-robot)
    Robot,
    /// Remote nodes (cloud/remote server)
    Remote,
}

enum_str_method!(
    /// Get display name for this target
    fn display_name(RunTarget) -> &'static str {
        Robot => "Robot",
        Remote => "Remote",
    }
);

enum_str_method!(
    /// Get description for this target
    fn description(RunTarget) -> &'static str {
        Robot => "edge/on-robot nodes",
        Remote => "cloud/remote server nodes",
    }
);

/// Handle the run command
pub async fn handle_run(ctx: &mut CliContext, command: &RunCommand) -> Result<()> {
    // Regenerate `websocket-bridge`'s relay topic allowlist from other nodes' own configs
    // (LAB relay-topics-core-deriver) before running, so the local config a spawned
    // binary/container reads already reflects the current derived allowlist.
    mecha10_core::relay_topics::regenerate_websocket_bridge_relay_config(&ctx.working_dir)
        .context("Failed to regenerate websocket-bridge relay topics")?;

    match command {
        RunCommand::Robot(args) => run_robot_target(ctx, args).await,
        RunCommand::Remote(args) => run_remote_target(ctx, args).await,
    }
}

/// Run the robot target (binary execution)
async fn run_robot_target(ctx: &mut CliContext, _args: &RunRobotArgs) -> Result<()> {
    let target = RunTarget::Robot;

    println!();
    println!("🚀 Running Mecha10 Project - {} Target", target.display_name());
    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
    println!();

    // Verify we're in a project
    if !ctx.is_project_initialized() {
        println!("⚠️  Not in a Mecha10 project directory");
        println!();
        return Err(anyhow::anyhow!("Not in a Mecha10 project"));
    }

    // Detect project to ensure it's valid
    let project = ctx.project()?;
    let project_name = project.name()?;
    println!("Project: {}", project_name);
    println!("Target:  {} ({})", target.display_name(), target.description());

    let build_profile = "release";
    println!("Profile: {}", build_profile);
    println!();

    // Get Redis URL from context
    let redis_url = ctx.redis_url().to_string();
    println!("Redis:   {}", redis_url);
    println!();

    // Build binary path
    let binary_path = ctx.working_dir.join("target").join(build_profile).join(project_name);

    if !binary_path.exists() {
        println!("❌ Binary not found: {}", binary_path.display());
        println!();
        println!("Build first with:");
        println!("  mecha10 build robot");
        return Err(anyhow::anyhow!("Binary not found"));
    }

    println!("Binary:  {}", binary_path.display());
    println!();

    // Get control plane URL and robot ID from config
    let config = ctx.load_project_config().await?;
    let control_plane_url = config.environments.control_plane_url();
    let relay_url = config.environments.relay_url();
    let robot_id = config.robot.id.clone();

    // Get API key from credentials (user must be logged in)
    let credentials_service = CredentialsService::new();
    let api_key = match credentials_service.get_api_key() {
        Ok(Some(key)) => {
            println!("Auth:    ✓ (logged in)");
            key
        }
        _ => {
            println!("Auth:    ⚠ Not logged in - run 'mecha10 auth login'");
            String::new()
        }
    };

    println!("Control: {}", control_plane_url);
    println!("Relay:   {}", relay_url);
    println!("Robot:   {}", robot_id);
    println!();

    // Run the binary with proper environment
    println!("Starting {}...", target.display_name());
    println!();

    let mut cmd = Command::new(&binary_path);
    cmd.arg("run")
        .current_dir(&ctx.working_dir)
        .env("MECHA10_REDIS_URL", &redis_url)
        .env("MECHA10_ENV", config.environments.current_env_name())
        .env("CONTROL_PLANE_URL", &control_plane_url)
        .env("WEBRTC_RELAY_URL", &relay_url)
        .env("ROBOT_ID", &robot_id)
        .env("ROBOT_API_KEY", &api_key)
        .env("LOG_LEVEL", ctx.log_level.to_string().to_lowercase());

    // Locomotion speed limits (mandatory) and optional acceleration override -> simulator
    // subprocess's ctrlrange/forcerange (LAB-1977/1978/1985), same injection `mecha10 dev` does
    // for its own spawn paths (see `ops::spawn::maybe_inject_teleop_locomotion_env` docs). Without
    // this, `mecha10 run robot` execs the binary with neither
    // `MECHA10_LOCOMOTION_MAX_LINEAR_SPEED` nor `_MAX_ANGULAR_SPEED` set, and mecha10-rl's `serve`
    // fails fast at simulator startup - this was the root bug this fix addresses. No-op (nothing
    // inserted) when there's no `simulator` node in this project's `mecha10.json`.
    cmd.envs(&locomotion_env_for_nodes(&config.nodes.get_node_names()));

    // Inherit stdio for interactive output
    cmd.stdin(std::process::Stdio::inherit())
        .stdout(std::process::Stdio::inherit())
        .stderr(std::process::Stdio::inherit());

    let status = cmd.status()?;

    println!();

    if status.success() {
        println!("✅ {} target stopped gracefully", target.display_name());
    } else {
        println!(
            "❌ {} target exited with error: {:?}",
            target.display_name(),
            status.code()
        );
    }

    Ok(())
}

/// Resolve the locomotion env vars (`MECHA10_LOCOMOTION_MAX_LINEAR_SPEED`/`_MAX_ANGULAR_SPEED`,
/// and an optional `MECHA10_LOCOMOTION_ACCELERATION`) to inject into the spawned project binary's
/// `Command`, given this project's configured node short names. Split out from
/// `run_robot_target` so it's unit testable without a real project directory, built binary, or
/// credentials on disk - same rationale as `ops::spawn`'s `inject_teleop_locomotion_env` taking
/// its inputs as plain parameters instead of reaching into global/process state directly.
fn locomotion_env_for_nodes(node_names: &[String]) -> HashMap<String, String> {
    let mut env = HashMap::new();
    maybe_inject_teleop_locomotion_env(node_names, &mut env);
    env
}

/// Run the remote target (docker compose)
async fn run_remote_target(ctx: &mut CliContext, _args: &RunRemoteArgs) -> Result<()> {
    let target = RunTarget::Remote;

    println!();
    println!("🐳 Running Mecha10 Project - {} Target (Docker)", target.display_name());
    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
    println!();

    // Verify we're in a project
    if !ctx.is_project_initialized() {
        println!("⚠️  Not in a Mecha10 project directory");
        println!();
        return Err(anyhow::anyhow!("Not in a Mecha10 project"));
    }

    // Detect project to ensure it's valid
    let project = ctx.project()?;
    let project_name = project.name()?;
    println!("Project: {}", project_name);
    println!("Target:  {} ({})", target.display_name(), target.description());
    println!();

    // Check for docker-compose.remote.yml
    let compose_file = ctx.working_dir.join("docker").join("docker-compose.remote.yml");
    if !compose_file.exists() {
        println!("❌ Docker compose file not found: {}", compose_file.display());
        println!();
        println!("Expected: docker/docker-compose.remote.yml");
        println!("Run 'mecha10 init' to create a new project with Docker support.");
        return Err(anyhow::anyhow!("docker-compose.remote.yml not found"));
    }

    println!("Compose: {}", compose_file.display());

    // Get control plane URL and robot ID from config
    let config = ctx.load_project_config().await?;
    let control_plane_url = config.environments.control_plane_url();
    let robot_id = config.robot.id.clone();

    // Get API key from credentials (user must be logged in)
    let credentials_service = CredentialsService::new();
    let api_key = match credentials_service.get_api_key() {
        Ok(Some(key)) => {
            println!("Auth:    ✓ (logged in)");
            key
        }
        _ => {
            println!("Auth:    ⚠ Not logged in - run 'mecha10 auth login'");
            String::new()
        }
    };

    println!("Control: {}", control_plane_url);
    println!("Robot:   {}", robot_id);
    println!();

    // Run docker compose
    println!("Starting Docker containers...");
    println!();

    // Build command args
    let mut compose_args = vec!["compose", "-f", "docker/docker-compose.remote.yml", "up", "--build"];

    // Always run detached (background)
    compose_args.push("-d");
    println!("Mode:    Detached (background)");
    println!();

    // Get GITHUB_TOKEN from environment for private repo builds
    let github_token = std::env::var("GITHUB_TOKEN").unwrap_or_default();
    if !github_token.is_empty() {
        println!("GitHub:  ✓ (token found)");
    } else {
        println!("GitHub:  ⚠ No GITHUB_TOKEN - will fail if repo is private");
    }

    let mut cmd = Command::new("docker");
    cmd.args(&compose_args)
        .current_dir(&ctx.working_dir)
        .env("ROBOT_ID", &robot_id)
        .env("ROBOT_API_KEY", &api_key)
        .env("CONTROL_PLANE_URL", &control_plane_url)
        .env("GITHUB_TOKEN", &github_token)
        .env("LOG_LEVEL", ctx.log_level.to_string().to_lowercase());

    // Inherit stdio for interactive output
    cmd.stdin(std::process::Stdio::inherit())
        .stdout(std::process::Stdio::inherit())
        .stderr(std::process::Stdio::inherit());

    let status = cmd.status()?;

    println!();

    if status.success() {
        println!("✅ Remote containers started in background");
        println!();
        println!("View logs:  docker compose -f docker/docker-compose.remote.yml logs -f");
        println!("Stop:       docker compose -f docker/docker-compose.remote.yml down");
    } else {
        println!("❌ Docker compose exited with error: {:?}", status.code());
    }

    Ok(())
}

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

    // Regression coverage for the root bug this ticket fixes: `mecha10 run robot` execs the
    // built project binary directly and previously never called into the locomotion env
    // injection `mecha10 dev` uses, so a `simulator` node would always fail fast at startup
    // (mecha10-rl's `serve`, LAB-1978, requires these vars). `run_robot_target` itself can't be
    // exercised directly here (it needs a real project directory, built binary, and credentials
    // on disk) - `locomotion_env_for_nodes` is the exact same call `run_robot_target` makes, just
    // with the node-name list passed in directly instead of derived from a loaded
    // `ProjectConfig`.

    #[test]
    fn sets_locomotion_speed_vars_when_simulator_node_configured() {
        let env = locomotion_env_for_nodes(&["simulator".to_string()]);
        assert!(env.contains_key("MECHA10_LOCOMOTION_MAX_LINEAR_SPEED"));
        assert!(env.contains_key("MECHA10_LOCOMOTION_MAX_ANGULAR_SPEED"));
    }

    #[test]
    fn sets_locomotion_speed_vars_for_simulator_and_teleop_nodes() {
        let env = locomotion_env_for_nodes(&["simulator".to_string(), "teleop".to_string()]);
        assert!(env.contains_key("MECHA10_LOCOMOTION_MAX_LINEAR_SPEED"));
        assert!(env.contains_key("MECHA10_LOCOMOTION_MAX_ANGULAR_SPEED"));
    }

    #[test]
    fn does_not_set_locomotion_vars_without_simulator_node() {
        let env = locomotion_env_for_nodes(&["teleop".to_string()]);
        assert!(!env.contains_key("MECHA10_LOCOMOTION_MAX_LINEAR_SPEED"));
        assert!(!env.contains_key("MECHA10_LOCOMOTION_MAX_ANGULAR_SPEED"));
        assert!(!env.contains_key("MECHA10_LOCOMOTION_ACCELERATION"));
    }
}