use crate::commands::{RunCommand, RunRemoteArgs, RunRlArgs, RunRobotArgs};
use crate::config_validation::{validate_configs, ConfigKind};
use crate::context::CliContext;
use crate::handlers::dev::ops::inject_locomotion_limits_env;
use crate::paths;
use crate::services::credentials::CredentialsService;
use crate::services::publish_status::check_published_version;
use crate::services::rl_bundle;
use crate::ui::style;
use anyhow::{Context, Result};
use mecha10_cli_core::types::EnvironmentsConfig;
use mecha10_core::enum_str_method;
use std::collections::HashMap;
use std::process::Command;
#[derive(Debug, Clone, Copy)]
pub enum RunTarget {
Robot,
Remote,
Rl,
}
enum_str_method!(
pub(crate) fn display_name(RunTarget) -> &'static str {
Robot => "Robot",
Remote => "Remote",
Rl => "RL",
}
);
enum_str_method!(
pub(crate) fn description(RunTarget) -> &'static str {
Robot => "edge/on-robot nodes",
Remote => "cloud/remote server nodes",
Rl => "trained RL policy nodes (excludes the simulator)",
}
);
pub async fn handle_run(ctx: &mut CliContext, command: &RunCommand) -> Result<()> {
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,
RunCommand::Rl(args) if args.download_bundle_only => download_rl_bundle_only(ctx, args).await,
RunCommand::Rl(args) => run_rl_target(ctx, args).await,
}
}
async fn download_rl_bundle_only(ctx: &mut CliContext, args: &RunRlArgs) -> Result<()> {
let robot_id = args
.robot_id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("--robot-id is required together with --download-bundle-only"))?;
println!();
println!("📦 Downloading RL project bundle for robot {}...", robot_id);
println!();
let credentials_service = CredentialsService::new();
let credentials = credentials_service.load()?.ok_or_else(|| {
anyhow::anyhow!(
"Not logged in. Set the MECHA10_API_KEY env var (non-interactive/service contexts) or run \
`mecha10 auth login` first."
)
})?;
let control_plane_url = EnvironmentsConfig::default().control_plane_url();
let archive_bytes =
rl_bundle::download_rl_bundle(&control_plane_url, robot_id, &credentials.api_key, &credentials.user_id)
.await
.context("Failed to download RL project bundle")?;
rl_bundle::unpack_rl_bundle_archive(&archive_bytes, &ctx.working_dir)
.context("Failed to unpack RL project bundle")?;
println!(
"✅ RL project bundle downloaded and unpacked into {}",
ctx.working_dir.display()
);
println!();
Ok(())
}
async fn run_robot_target(ctx: &mut CliContext, args: &RunRobotArgs) -> Result<()> {
let target = RunTarget::Robot;
println!();
println!(
"{}",
style::heading(format!("🚀 Running Mecha10 Project - {} Target", target.display_name()))
);
println!("{}", style::separator(47));
println!();
if !ctx.is_project_initialized() {
println!("{}", style::warn("⚠️ Not in a Mecha10 project directory"));
println!();
return Err(anyhow::anyhow!("Not in a Mecha10 project"));
}
validate_configs(&ctx.working_dir, &[ConfigKind::ProjectAndNodes], None)
.context("pre-flight config validation failed for `mecha10 run robot`")?;
let project = ctx.project()?;
let project_name = project.name()?.to_string();
println!("Project: {}", project_name);
println!("Target: {} ({})", target.display_name(), target.description());
println!();
let redis_url = ctx.redis_url().to_string();
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();
let credentials_service = CredentialsService::new();
let api_key = if ctx.is_ci() {
credentials_service.get_api_key().ok().flatten()
} else {
mecha10_auth::ensure_authenticated_interactive(&credentials_service)
.await
.ok()
.map(|creds| creds.api_key)
};
match &api_key {
Some(_) => println!("Auth: ✓ (logged in)"),
None => println!("Auth: ⚠ Not logged in - run 'mecha10 auth login'"),
}
println!("Control: {}", control_plane_url);
println!("Relay: {}", relay_url);
println!("Redis: {}", redis_url);
println!("Robot: {}", robot_id);
println!();
let requested_nodes = parse_requested_nodes(args.nodes.as_deref());
let available_node_names = config.nodes.get_node_names();
if !requested_nodes.is_empty() {
crate::dev::validation::validate_node_names(&requested_nodes, &available_node_names)?;
}
let node_identifiers: Vec<String> = crate::dev::node_selection::get_nodes_to_run(&requested_nodes, &config)
.into_iter()
.map(|n| n.identifier)
.collect();
if node_identifiers.is_empty() {
println!("{}", style::error("❌ No nodes configured to run"));
println!();
return Err(anyhow::anyhow!("No nodes configured for the robot target"));
}
println!(
"Nodes: {}",
node_identifiers
.iter()
.map(|n| mecha10_cli_core::utils::strip_node_scope(n))
.collect::<Vec<_>>()
.join(", ")
);
println!();
let mut env: HashMap<String, String> = mecha10_core::config::robot_process_env(
&control_plane_url,
&relay_url,
&redis_url,
&robot_id,
api_key.as_deref(),
)
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
env.insert(
"MECHA10_ENV".to_string(),
config.environments.current_env_name().to_string(),
);
env.insert("LOG_LEVEL".to_string(), ctx.log_level.to_string().to_lowercase());
inject_locomotion_limits_env(&config, &mut env);
let use_node_runner = crate::services::ProcessService::is_node_runner_available();
if !use_node_runner {
println!(" (standalone mode - mecha10-node-runner not found, spawning nodes directly)");
println!();
}
println!(
"Starting {} node(s) via {}...",
node_identifiers.len(),
if use_node_runner {
"mecha10-node-runner"
} else {
"direct spawn"
}
);
println!();
let process_service = ctx.process();
let mut pids = HashMap::new();
let mut failures = Vec::new();
for node_identifier in &node_identifiers {
let result = if use_node_runner {
process_service.spawn_node_runner(node_identifier, &project_name, Some(env.clone()))
} else {
process_service.spawn_node_direct(node_identifier, &project_name, Some(env.clone()))
};
match result {
Ok(pid) => {
println!("{}", style::success(format!(" ✅ {} (PID: {})", node_identifier, pid)));
pids.insert(node_identifier.clone(), pid);
}
Err(e) => {
println!(
"{}",
style::error(format!(" ❌ {} failed to start: {}", node_identifier, e))
);
failures.push((node_identifier.clone(), e.to_string()));
}
}
}
println!();
if pids.is_empty() {
return Err(anyhow::anyhow!("Failed to start any robot nodes"));
}
if !failures.is_empty() {
println!(
"⚠️ {} node(s) failed to start: {}",
failures.len(),
failures.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>().join(", ")
);
println!();
}
println!("Robot running. Press Ctrl+C to stop.");
println!();
wait_for_all_nodes_to_exit_or_ctrl_c(process_service).await;
process_service.cleanup();
println!();
println!(
"{}",
style::success(format!("✅ {} target stopped", target.display_name()))
);
Ok(())
}
async fn wait_for_all_nodes_to_exit_or_ctrl_c(process_service: &mut crate::services::ProcessService) {
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => return,
_ = tokio::time::sleep(std::time::Duration::from_millis(500)) => {
let status = process_service.get_status();
if !status.is_empty() && status.values().all(|s| s != "running") {
return;
}
}
}
}
}
fn parse_requested_nodes(nodes_arg: Option<&str>) -> Vec<String> {
nodes_arg
.map(|s| {
s.split(',')
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect()
})
.unwrap_or_default()
}
async fn run_remote_target(ctx: &mut CliContext, _args: &RunRemoteArgs) -> Result<()> {
let target = RunTarget::Remote;
println!();
println!(
"{}",
style::heading(format!(
"🐳 Running Mecha10 Project - {} Target (Docker)",
target.display_name()
))
);
println!("{}", style::separator(56));
println!();
if !ctx.is_project_initialized() {
println!("{}", style::warn("⚠️ Not in a Mecha10 project directory"));
println!();
return Err(anyhow::anyhow!("Not in a Mecha10 project"));
}
validate_configs(&ctx.working_dir, &[ConfigKind::ProjectAndNodes], None)
.context("pre-flight config validation failed for `mecha10 run remote`")?;
let project = ctx.project()?;
let project_name = project.name()?;
println!("Project: {}", project_name);
println!("Target: {} ({})", target.display_name(), target.description());
println!();
let compose_file = ctx.working_dir.join("docker").join("docker-compose.remote.yml");
if !compose_file.exists() {
println!(
"{}",
style::error(format!("❌ 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());
let env_file = ctx.working_dir.join(paths::env::FILE);
if !env_file.exists() {
return Err(anyhow::anyhow!(
"{} not found.\nCopy {} to {} and fill in your project's values first.",
env_file.display(),
paths::env::EXAMPLE,
paths::env::FILE
));
}
let credentials_service = CredentialsService::new();
let api_key = if ctx.is_ci() {
credentials_service.get_api_key().ok().flatten()
} else {
mecha10_auth::ensure_authenticated_interactive(&credentials_service)
.await
.ok()
.map(|creds| creds.api_key)
};
match &api_key {
Some(_) => println!("Auth: ✓ (logged in)"),
None => println!("Auth: ⚠ Not logged in - run 'mecha10 auth login'"),
}
let api_key = api_key.unwrap_or_default();
let project_config = ctx.load_project_config().await?;
let robot_id = project_config.robot.id.clone();
println!("Robot: {}", robot_id);
println!();
warn_if_remote_version_not_published(
&project_config.environments.control_plane_url(),
&project_config.name,
&project_config.version,
&credentials_service,
)
.await;
println!("Starting Docker containers...");
println!();
println!("Mode: Detached (background)");
println!();
let env_file_str = env_file.to_string_lossy().to_string();
let mut compose_args = vec![
"compose",
"--env-file",
&env_file_str,
"-f",
"docker/docker-compose.remote.yml",
];
let needs_ollama = mecha10_core::ollama::project_needs_ollama(&ctx.working_dir).unwrap_or(false);
if needs_ollama {
compose_args.extend(["--profile", "ollama"]);
}
compose_args.extend(["up", "--build", "-d"]);
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);
if std::env::var("MECHA10_REMOTE_SIM").is_err() {
let needs_sim = project_config
.targets
.nodes_for("remote")
.iter()
.any(|node| node.name() == "@mecha10/simulator");
if needs_sim {
cmd.env("MECHA10_REMOTE_SIM", "1");
}
}
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!("{}", style::success("✅ 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!(
"{}",
style::error(format!("❌ Docker compose exited with error: {:?}", status.code()))
);
}
Ok(())
}
async fn run_rl_target(ctx: &mut CliContext, _args: &RunRlArgs) -> Result<()> {
let target = RunTarget::Rl;
println!();
println!(
"{}",
style::heading(format!(
"🐳 Running Mecha10 Project - {} Target (Docker)",
target.display_name()
))
);
println!("{}", style::separator(56));
println!();
if !ctx.is_project_initialized() {
println!("{}", style::warn("⚠️ Not in a Mecha10 project directory"));
println!();
return Err(anyhow::anyhow!("Not in a Mecha10 project"));
}
validate_configs(&ctx.working_dir, &[ConfigKind::ProjectAndNodes], None)
.context("pre-flight config validation failed for `mecha10 run rl`")?;
let project = ctx.project()?;
let project_name = project.name()?;
println!("Project: {}", project_name);
println!("Target: {} ({})", target.display_name(), target.description());
println!();
let compose_file = ctx.working_dir.join("docker").join("docker-compose.rl.yml");
if !compose_file.exists() {
println!(
"{}",
style::error(format!("❌ Docker compose file not found: {}", compose_file.display()))
);
println!();
println!("Expected: docker/docker-compose.rl.yml");
println!("Run 'mecha10 init' to create a new project with Docker support.");
return Err(anyhow::anyhow!("docker-compose.rl.yml not found"));
}
println!("Compose: {}", compose_file.display());
let env_file = ctx.working_dir.join(paths::env::FILE);
if !env_file.exists() {
return Err(anyhow::anyhow!(
"{} not found.\nCopy {} to {} and fill in your project's values first.",
env_file.display(),
paths::env::EXAMPLE,
paths::env::FILE
));
}
let credentials_service = CredentialsService::new();
let api_key = if ctx.is_ci() {
credentials_service.get_api_key().ok().flatten()
} else {
mecha10_auth::ensure_authenticated_interactive(&credentials_service)
.await
.ok()
.map(|creds| creds.api_key)
};
match &api_key {
Some(_) => println!("Auth: ✓ (logged in)"),
None => println!("Auth: ⚠ Not logged in - run 'mecha10 auth login'"),
}
let api_key = api_key.unwrap_or_default();
let project_config = ctx.load_project_config().await?;
let robot_id = project_config.robot.id.clone();
println!("Robot: {}", robot_id);
println!();
println!("Starting Docker containers...");
println!();
println!("Mode: Detached (background)");
println!();
let env_file_str = env_file.to_string_lossy().to_string();
let compose_args = [
"compose",
"--env-file",
&env_file_str,
"-f",
"docker/docker-compose.rl.yml",
"up",
"--build",
"-d",
];
let mut locomotion_env = HashMap::new();
inject_locomotion_limits_env(&project_config, &mut locomotion_env);
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);
for (key, value) in &locomotion_env {
cmd.env(key, value);
}
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!("{}", style::success("✅ RL containers started in background"));
println!();
println!("View logs: docker compose -f docker/docker-compose.rl.yml logs -f");
println!("Stop: docker compose -f docker/docker-compose.rl.yml down");
} else {
println!(
"{}",
style::error(format!("❌ Docker compose exited with error: {:?}", status.code()))
);
}
Ok(())
}
async fn warn_if_remote_version_not_published(
control_plane_url: &str,
name: &str,
version: &str,
credentials_service: &CredentialsService,
) {
let credentials = credentials_service.load().unwrap_or(None);
let Some(credentials) = credentials else {
return;
};
let status = match check_published_version(
control_plane_url,
name,
version,
"remote",
Some(&credentials.api_key),
Some(&credentials.user_id),
)
.await
{
Ok(status) => status,
Err(_) => return,
};
if !status.exists {
println!(
"⚠️ Version {} has not been published for remote - this is a local build, not the latest deployed version.",
version
);
println!();
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn parses_none_as_no_filter() {
assert_eq!(parse_requested_nodes(None), Vec::<String>::new());
}
#[test]
fn parses_single_node_name() {
assert_eq!(parse_requested_nodes(Some("teleop")), vec!["teleop".to_string()]);
}
#[test]
fn parses_comma_separated_node_names() {
assert_eq!(
parse_requested_nodes(Some("teleop,simulator,camera")),
vec!["teleop".to_string(), "simulator".to_string(), "camera".to_string()]
);
}
#[test]
fn trims_whitespace_around_node_names() {
assert_eq!(
parse_requested_nodes(Some(" teleop , simulator ")),
vec!["teleop".to_string(), "simulator".to_string()]
);
}
#[test]
fn drops_empty_segments_from_stray_commas() {
assert_eq!(
parse_requested_nodes(Some("teleop,,simulator,")),
vec!["teleop".to_string(), "simulator".to_string()]
);
}
#[test]
fn empty_string_parses_as_no_filter() {
assert_eq!(parse_requested_nodes(Some("")), Vec::<String>::new());
}
#[test]
fn run_preflight_validation_rejects_an_invalid_project_config() {
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("mecha10.json"),
serde_json::json!({"name": "proj", "version": "1.0.0", "logging": {"disk_logging_enabled": false}})
.to_string(),
)
.unwrap();
let err = validate_configs(dir.path(), &[ConfigKind::ProjectAndNodes], None).unwrap_err();
assert!(
err.to_string().contains("environments.<env>.logging"),
"expected the legacy top-level `logging` block to be rejected, got: {err}"
);
}
#[tokio::test]
async fn warn_if_remote_version_not_published_is_a_noop_without_credentials() {
let dir = tempdir().unwrap();
let credentials_service = CredentialsService::with_path(dir.path().join("credentials.json"));
warn_if_remote_version_not_published("https://api.example.com", "my-robot", "1.2.3", &credentials_service)
.await;
}
}