use crate::context::CliContext;
use crate::dev::node_selection::NodeToRun;
use crate::paths;
use crate::services::dev::SimulationPaths;
use crate::services::CredentialsService;
use crate::services::DockerService;
use crate::services::ModelService;
use crate::types::ProjectConfig;
use crate::ui::{LogBuffer, TeleopUi};
use anyhow::Result;
use serde::Deserialize;
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
pub struct SimulatorLaunchConfig<'a> {
pub godot_path: &'a Option<String>,
pub sim_paths: &'a Option<SimulationPaths>,
#[allow(dead_code)] pub project_name: &'a str,
pub redis_url: &'a str,
}
pub async fn start_docker_services(ctx: &mut CliContext, config: &ProjectConfig, project_name: &str) -> Result<()> {
println!("🐳 Starting project services...");
let proj = ctx.project()?;
let compose_path = proj.root().join(&config.docker.compose_file);
if compose_path.exists() {
let docker = DockerService::with_compose_file(compose_path.to_string_lossy().to_string());
match docker.start_project_services(&config.docker.robot, project_name).await {
Ok(true) => {
println!("✅ Services started");
println!();
}
Ok(false) => {
println!(" Services already running");
println!();
}
Err(e) => {
println!("⚠️ Warning: Failed to start services: {}", e);
println!(" You can manually start them with: docker compose -f docker/docker-compose.yml up -d");
println!();
}
}
}
Ok(())
}
pub async fn setup_redis_connection(ctx: &mut CliContext) -> Result<()> {
println!("Checking Redis connection...");
let redis_url = ctx.redis_url().to_string();
let redis = ctx.redis()?;
match redis.get_connection().await {
Ok(mut conn) => {
println!("✅ Redis connected: {}", redis_url);
println!("🧹 Flushing Redis for clean dev session...");
let dev_service = ctx.dev();
dev_service.flush_redis(&mut conn).await?;
println!("✅ Redis flushed");
}
Err(e) => {
println!("❌ Redis connection failed: {}", e);
println!();
println!("Please ensure Redis is running:");
println!(" docker compose -f docker/docker-compose.yml up -d redis");
println!();
return Err(anyhow::anyhow!("Redis connection required for dev mode"));
}
}
println!();
Ok(())
}
pub async fn start_headless_simulation(
ctx: &mut CliContext,
sim_paths: &SimulationPaths,
_config: &ProjectConfig,
) -> Result<()> {
let project_root = ctx.project()?.root().to_path_buf();
let compose_path = project_root.join(paths::docker::COMPOSE_FILE);
if !compose_path.exists() {
return Err(anyhow::anyhow!(
"{} not found. Headless simulation requires Docker Compose.",
paths::docker::COMPOSE_FILE
));
}
let docker_service = DockerService::with_compose_file(compose_path.to_string_lossy().to_string());
if let Err(e) = docker_service.check_installation() {
return Err(anyhow::anyhow!(
"Docker not available: {}. Install Docker or set headless: false in simulation config.",
e
));
}
let simulation_mount_path = if let Ok(framework_path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
std::path::PathBuf::from(framework_path)
} else {
let assets_service = crate::services::SimulationAssetsService::new();
match assets_service.ensure_assets().await {
Ok(assets_path) => assets_path,
Err(e) => {
return Err(anyhow::anyhow!(
"Failed to get simulation assets: {}. Set MECHA10_FRAMEWORK_PATH for local development or check internet connection.",
e
));
}
}
};
std::env::set_var("MECHA10_SIMULATION_PATH", &simulation_mount_path);
println!(" Simulation assets: {}", simulation_mount_path.display());
let container_godot_path = if std::env::var("MECHA10_FRAMEWORK_PATH").is_ok() {
std::path::PathBuf::from(paths::container::FRAMEWORK_GODOT_PROJECT)
} else {
std::path::PathBuf::from(paths::container::FRAMEWORK_GODOT_PROJECT_LEGACY)
};
let container_model_config = sim_paths.model_config_path.as_ref().map(|p| {
let relative = p.strip_prefix(&project_root).unwrap_or(p);
std::path::PathBuf::from(paths::container::PROJECT_ROOT).join(relative)
});
let container_env_config = sim_paths.environment_config_path.as_ref().map(|p| {
let relative = p.strip_prefix(&project_root).unwrap_or(p);
std::path::PathBuf::from(paths::container::PROJECT_ROOT).join(relative)
});
let dev_service = ctx.dev();
let args = dev_service.build_godot_args(
&container_godot_path,
&sim_paths.model_path,
&sim_paths.environment_path,
container_model_config.as_ref(),
container_env_config.as_ref(),
false, Some(&sim_paths.networking),
);
match docker_service
.compose_run_service("simulation", true, true, &args)
.await
{
Ok(_) => {
println!("✅ Simulation container started");
println!(" View logs: docker compose logs -f simulation");
println!();
Ok(())
}
Err(e) => Err(anyhow::anyhow!(
"Failed to start simulation container: {}. Check: docker compose logs simulation",
e
)),
}
}
#[derive(Deserialize, Default)]
struct NodeModelConfig {
model: Option<ModelInfo>,
}
#[derive(Deserialize)]
struct ModelInfo {
name: Option<String>,
}
#[derive(Deserialize)]
struct EnvironmentNodeConfig {
dev: Option<NodeModelConfig>,
production: Option<NodeModelConfig>,
}
fn parse_node_config(content: &str) -> Option<NodeModelConfig> {
let profile = std::env::var("MECHA10_ENVIRONMENT").unwrap_or_else(|_| "dev".to_string());
if let Ok(env_config) = serde_json::from_str::<EnvironmentNodeConfig>(content) {
let config = match profile.as_str() {
"production" | "prod" => env_config.production,
_ => env_config.dev,
};
return config;
}
serde_json::from_str(content).ok()
}
async fn ensure_node_models_available(node_names: &[String]) -> Result<()> {
let model_service = match ModelService::with_models_dir("models".into()) {
Ok(service) => service,
Err(e) => {
eprintln!("⚠️ Warning: Could not initialize model service: {}", e);
return Ok(());
}
};
for node_name in node_names {
let short_name = node_name
.strip_prefix("@mecha10/")
.or_else(|| node_name.strip_prefix("@local/"))
.unwrap_or(node_name);
let config_paths = vec![
format!("configs/nodes/@mecha10/{}/config.json", short_name),
format!("configs/nodes/{}/config.json", short_name), format!("configs/nodes/@local/{}/config.json", short_name), ];
for config_path in &config_paths {
let path = PathBuf::from(config_path);
if !path.exists() {
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let config = match parse_node_config(&content) {
Some(c) => c,
None => continue,
};
if let Some(model_info) = config.model {
if let Some(model_name) = model_info.name {
let model_path = PathBuf::from(format!("models/{}/model.onnx", model_name));
if !model_path.exists() {
println!("📦 Downloading model '{}' for node '{}'...", model_name, node_name);
match model_service.pull(&model_name, None).await {
Ok(_) => println!("✅ Model '{}' ready", model_name),
Err(e) => {
eprintln!("⚠️ Warning: Failed to download model '{}': {}", model_name, e);
eprintln!(" You can manually download with: mecha10 models pull {}", model_name);
}
}
}
}
}
break; }
}
Ok(())
}
pub fn spawn_nodes_by_name(
ctx: &mut CliContext,
node_names: &[String],
project_name: &str,
project_config: &ProjectConfig,
log_buffer: Option<&LogBuffer>,
) -> Result<HashMap<String, u32>> {
use crate::services::ProcessService;
println!("Starting nodes...");
println!();
let shared_process = ctx.shared_process();
let mut process_service = shared_process.lock().unwrap();
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("/tmp/command-listener.log")
.and_then(|mut f| {
use std::io::Write;
writeln!(
f,
"[spawn_nodes_by_name] Using shared ProcessService, ptr={:p}",
&*process_service
)
});
let use_node_runner = ProcessService::is_node_runner_available();
if let Err(e) = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(ensure_node_models_available(node_names))
}) {
eprintln!("⚠️ Warning: Failed to check/download models: {}", e);
}
if !use_node_runner {
println!(" (standalone mode - spawning nodes directly)");
println!();
}
let project_env = {
let mut env = HashMap::new();
env.insert(
"CONTROL_PLANE_URL".to_string(),
project_config.environments.control_plane_url(),
);
env.insert("ROBOT_ID".to_string(), project_config.robot.id.clone());
let credentials_service = CredentialsService::new();
if let Ok(Some(api_key)) = credentials_service.get_api_key() {
env.insert("ROBOT_API_KEY".to_string(), api_key.clone());
tracing::debug!("🔑 Loaded API key from credentials: {}", &api_key[..20]);
} else {
tracing::warn!("⚠️ No API key found in credentials - did you run 'mecha10 auth login'?");
}
env.insert("WEBRTC_RELAY_URL".to_string(), project_config.environments.relay_url());
env.insert("REDIS_URL".to_string(), project_config.environments.redis_url());
tracing::debug!("📋 Environment variables to inject:");
tracing::debug!(
" CONTROL_PLANE_URL: {}",
env.get("CONTROL_PLANE_URL").unwrap_or(&"<missing>".to_string())
);
tracing::debug!(
" ROBOT_ID: {}",
env.get("ROBOT_ID").unwrap_or(&"<missing>".to_string())
);
tracing::debug!(
" ROBOT_API_KEY: {}",
if env.contains_key("ROBOT_API_KEY") {
"<present>"
} else {
"<missing>"
}
);
tracing::debug!(
" WEBRTC_RELAY_URL: {}",
env.get("WEBRTC_RELAY_URL").unwrap_or(&"<missing>".to_string())
);
tracing::debug!(
" REDIS_URL: {}",
env.get("REDIS_URL").unwrap_or(&"<missing>".to_string())
);
Some(env)
};
let mut pids = HashMap::new();
for node_name in node_names {
let is_local_node = node_name.starts_with("@local/") || !node_name.starts_with('@');
let should_use_node_runner = use_node_runner && is_local_node;
tracing::debug!(
"🚀 Spawning node: {} (use_node_runner: {}, is_local: {})",
node_name,
should_use_node_runner,
is_local_node
);
let result = if should_use_node_runner {
process_service.spawn_node_runner(node_name, project_env.clone())
} else {
process_service.spawn_node_direct(node_name, project_name, project_env.clone())
};
match result {
Ok(pid) => {
pids.insert(node_name.clone(), pid);
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("/tmp/command-listener.log")
.and_then(|mut f| {
use std::io::Write;
writeln!(f, "[spawn_nodes_by_name] Spawned '{}' with PID {}", node_name, pid)
});
if let Some(buffer) = log_buffer {
buffer.push(format!("✅ STARTED: {} (PID: {})", node_name, pid));
}
}
Err(e) => {
eprintln!("Failed to spawn {}: {}", node_name, e);
if let Some(buffer) = log_buffer {
buffer.push(format!("ERROR: Failed to start {}: {}", node_name, e));
}
}
}
}
for (node, pid) in &pids {
println!(" ✅ {} (PID: {})", node, pid);
}
println!();
Ok(pids)
}
pub fn build_nodes(ctx: &mut CliContext) -> Result<()> {
use crate::services::ProcessService;
if ProcessService::is_framework_dev_mode() {
println!("🔧 Framework dev mode: Building required packages from source...");
let process_service = ctx.process();
println!("Building mecha10-node-runner from framework...");
process_service.build_from_framework("mecha10-node-runner", true)?;
process_service.build_project_packages(true)?;
println!("✅ Build complete");
println!();
} else {
println!("⚡ Using bundled nodes (no build required)");
println!();
}
build_local_project_nodes()?;
Ok(())
}
fn build_local_project_nodes() -> Result<()> {
use crate::types::NodeSource;
use std::process::Command;
let config_path = std::path::Path::new(paths::PROJECT_CONFIG);
if !config_path.exists() {
return Ok(()); }
let config_content = std::fs::read_to_string(config_path)?;
let config: ProjectConfig = serde_json::from_str(&config_content)?;
let local_nodes: Vec<_> = config
.nodes
.get_node_specs()
.into_iter()
.filter(|spec| spec.source == NodeSource::Project)
.collect();
if local_nodes.is_empty() {
return Ok(());
}
println!("🔨 Pre-building local project nodes...");
for node_spec in &local_nodes {
let node_name = &node_spec.name;
let node_dir = std::path::Path::new("nodes").join(node_name);
if !node_dir.exists() {
tracing::warn!("Local node directory not found: nodes/{}", node_name);
continue;
}
println!(" Building {}...", node_name);
let output = Command::new("cargo")
.args(["build", "-p", node_name])
.output()
.map_err(|e| anyhow::anyhow!("Failed to run cargo build for {}: {}", node_name, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!(
"Failed to build local node '{}': {}",
node_name,
stderr
));
}
}
println!("✅ Local nodes built");
println!();
Ok(())
}
pub async fn run_watch_mode(ctx: &mut CliContext, _pids: HashMap<String, u32>) -> Result<()> {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
println!("Watch mode enabled (file changes will trigger rebuild)");
println!();
println!("Press Ctrl+C to stop all nodes");
println!();
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
})?;
let process_service = ctx.process();
while running.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_secs(1)).await;
let status = process_service.get_status();
for (node, node_status) in &status {
if !node_status.contains("running") {
println!("⚠️ {} stopped unexpectedly", node);
}
}
}
println!();
println!("Stopping all nodes...");
process_service.cleanup();
println!("✅ All nodes stopped");
Ok(())
}
pub fn cleanup(ctx: &mut CliContext) -> Result<()> {
use std::time::Duration;
use tracing::warn;
println!();
println!("Stopping all nodes in dependency order...");
let shared_process = ctx.shared_process();
let mut process_service = shared_process.lock().unwrap();
let shutdown_order = process_service.get_shutdown_order();
if shutdown_order.is_empty() {
println!("✅ No nodes to stop");
return Ok(());
}
for node in shutdown_order {
println!(" Stopping {}...", node);
if let Err(e) = process_service.stop_with_timeout(&node, Duration::from_secs(10)) {
warn!("Failed to gracefully stop {}: {}", node, e);
if let Err(kill_err) = process_service.force_kill(&node) {
warn!("Failed to force kill {}: {}", node, kill_err);
} else {
println!(" ✅ {} stopped (force killed)", node);
}
} else {
println!(" ✅ {} stopped", node);
}
}
println!("✅ All nodes stopped");
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_mode_change(
ctx: &mut CliContext,
lifecycle: &mut crate::dev::lifecycle_adapter::CliLifecycleManager,
target_mode: &str,
pids: &mut HashMap<String, u32>,
project_name: &str,
project_config: &ProjectConfig,
nodes_to_run: &[NodeToRun],
log_buffer: Option<&LogBuffer>,
) -> Result<()> {
println!();
println!("⚡ Switching to {} mode...", target_mode);
if let Some(buffer) = log_buffer {
buffer.push(format!("MODE: Switching to {} mode", target_mode));
}
let diff = lifecycle.change_mode(target_mode)?;
if !diff.stop.is_empty() {
println!("🛑 Stopping: {}", diff.stop.join(", "));
let shared_process = ctx.shared_process();
let mut process_service = shared_process.lock().unwrap();
for node_name in &diff.stop {
if pids.remove(node_name).is_some() {
if let Err(e) = process_service.stop(node_name) {
eprintln!(" Warning: Failed to stop {}: {}", node_name, e);
if let Some(buffer) = log_buffer {
buffer.push(format!("ERROR: Failed to stop {}: {}", node_name, e));
}
} else if let Some(buffer) = log_buffer {
buffer.push(format!("🛑 STOPPED: {}", node_name));
}
}
}
lifecycle.mark_nodes_stopped(&diff.stop);
}
if !diff.start.is_empty() {
println!("✅ Starting: {}", diff.start.join(", "));
let new_pids = spawn_nodes_by_name(ctx, &diff.start, project_name, project_config, log_buffer)?;
let actually_started: Vec<String> = new_pids.keys().cloned().collect();
pids.extend(new_pids);
lifecycle.mark_nodes_running(&actually_started);
}
println!("✓ Mode changed to: {}", target_mode);
println!();
if let Some(buffer) = log_buffer {
buffer.push(format!("MODE: Successfully changed to {} mode", target_mode));
}
let running: Vec<String> = pids.keys().cloned().collect();
crate::ui::dev_banners::print_mode_status(target_mode, &running, nodes_to_run);
Ok(())
}
pub struct InteractiveModeConfig<'a> {
pub project_config: &'a ProjectConfig,
pub nodes_to_run: &'a [NodeToRun],
pub godot_path: &'a Option<String>,
pub sim_paths: &'a Option<SimulationPaths>,
pub project_name: &'a str,
}
pub struct InteractiveModeState {
pub pids: HashMap<String, u32>,
pub lifecycle: Option<crate::dev::lifecycle_adapter::CliLifecycleManager>,
pub log_buffer: LogBuffer,
}
pub async fn run_interactive_mode(
ctx: &mut CliContext,
config: InteractiveModeConfig<'_>,
state: &mut InteractiveModeState,
) -> Result<()> {
use crate::ui::DevModeTui;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io::stdout;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
})?;
let redis_url = ctx.redis_url().to_string();
enable_raw_mode()?;
let mut stdout_handle = stdout();
execute!(stdout_handle, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout_handle);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let current_mode = state
.lifecycle
.as_ref()
.map(|l| l.current_mode().to_string())
.unwrap_or_else(|| "startup".to_string());
let running_nodes: Vec<String> = state.pids.keys().cloned().collect();
let dashboard_url = config.project_config.environments.dashboard_url();
let mut dev_tui = DevModeTui::new(state.log_buffer.clone(), current_mode, running_nodes, dashboard_url);
let result = loop {
if !running.load(Ordering::SeqCst) {
break Ok(());
}
if let Err(e) = terminal.draw(|f| dev_tui.draw(f)) {
break Err(e.into());
}
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key_event) = event::read()? {
if matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('x'))
&& key_event.modifiers.contains(KeyModifiers::CONTROL)
{
running.store(false, Ordering::SeqCst);
break Ok(());
}
if let Some(c) = dev_tui.handle_key(key_event) {
match c {
's' => {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
print!("\x1B[2J\x1B[1;1H"); std::io::stdout().flush()?;
println!("╔════════════════════════════════════════════════════════════╗");
println!("║ SWITCHING TO SIMULATION MODE ║");
println!("╚════════════════════════════════════════════════════════════╝");
println!();
if let Some(ref mut lifecycle_mgr) = state.lifecycle {
if lifecycle_mgr.available_modes().contains(&"simulation") {
handle_mode_change(
ctx,
lifecycle_mgr,
"simulation",
&mut state.pids,
config.project_name,
config.project_config,
config.nodes_to_run,
Some(&state.log_buffer),
)
.await?;
let launch_config = SimulatorLaunchConfig {
godot_path: config.godot_path,
sim_paths: config.sim_paths,
project_name: config.project_name,
redis_url: &redis_url,
};
start_simulator(
ctx,
config.project_config,
config.nodes_to_run,
&mut state.pids,
&launch_config,
&running,
&state.log_buffer,
)
.await?;
} else {
println!("⚠️ No 'simulation' mode configured in lifecycle");
state
.log_buffer
.push("MODE: No 'simulation' mode in lifecycle config".to_string());
}
} else {
println!("❌ Lifecycle configuration is required. Please add a 'lifecycle' section to mecha10.json");
state.log_buffer.push("MODE: No lifecycle config found".to_string());
}
enable_raw_mode()?;
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
terminal.clear()?;
let current_mode = state
.lifecycle
.as_ref()
.map(|l| l.current_mode().to_string())
.unwrap_or_else(|| "startup".to_string());
let running_nodes: Vec<String> = state.pids.keys().cloned().collect();
dev_tui.update_status(current_mode, running_nodes);
}
't' => {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
print!("\x1B[2J\x1B[1;1H"); std::io::stdout().flush()?;
println!("╔════════════════════════════════════════════════════════════╗");
println!("║ SWITCHING TO TELEOP MODE ║");
println!("╚════════════════════════════════════════════════════════════╝");
println!();
if let Some(ref mut lifecycle_mgr) = state.lifecycle {
if lifecycle_mgr.available_modes().contains(&"teleop") {
handle_mode_change(
ctx,
lifecycle_mgr,
"teleop",
&mut state.pids,
config.project_name,
config.project_config,
config.nodes_to_run,
Some(&state.log_buffer),
)
.await?;
enter_teleop_mode(
config.project_config,
&state.pids,
&redis_url,
&running,
&state.log_buffer,
)
.await?;
} else {
enter_teleop_mode(
config.project_config,
&state.pids,
&redis_url,
&running,
&state.log_buffer,
)
.await?;
}
} else {
println!("❌ Lifecycle configuration is required. Please add a 'lifecycle' section to mecha10.json");
}
enable_raw_mode()?;
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
terminal.clear()?;
let current_mode = state
.lifecycle
.as_ref()
.map(|l| l.current_mode().to_string())
.unwrap_or_else(|| "startup".to_string());
let running_nodes: Vec<String> = state.pids.keys().cloned().collect();
dev_tui.update_status(current_mode, running_nodes);
}
'x' => {
let current_mode = state
.lifecycle
.as_ref()
.map(|l| l.current_mode().to_lowercase())
.unwrap_or_default();
if current_mode != "simulation" {
continue;
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
print!("\x1B[2J\x1B[1;1H");
std::io::stdout().flush()?;
println!("╔════════════════════════════════════════════════════════════╗");
println!("║ STOPPING SIMULATION MODE ║");
println!("╚════════════════════════════════════════════════════════════╝");
println!();
state.log_buffer.push("MODE: Stopping simulation mode...".to_string());
println!("🛑 Stopping simulator...");
if let Some(ref sim_paths) = config.sim_paths {
if sim_paths.headless {
let project_root = ctx.project().map(|p| p.root().to_path_buf()).ok();
if let Some(root) = project_root {
let compose_path = root.join(paths::docker::COMPOSE_FILE);
if compose_path.exists() {
let docker_service = crate::services::DockerService::with_compose_file(
compose_path.to_string_lossy().to_string(),
);
if let Err(e) = docker_service.compose_stop(Some("simulation")).await {
eprintln!(" Warning: Failed to stop simulation container: {}", e);
} else {
println!(" ✅ Simulation container stopped");
state.log_buffer.push("🛑 STOPPED: Simulation container".to_string());
}
}
}
} else {
let process_service = ctx.process();
if let Err(e) = process_service.stop("godot-simulator") {
eprintln!(" Warning: Failed to stop Godot: {}", e);
} else {
println!(" ✅ Godot simulator stopped");
state.log_buffer.push("🛑 STOPPED: Godot simulator".to_string());
}
}
} else {
let process_service = ctx.process();
let _ = process_service.stop("godot-simulator");
}
if let Some(ref mut lifecycle_mgr) = state.lifecycle {
let default_mode = config
.project_config
.lifecycle
.as_ref()
.map(|l| l.default_mode.clone())
.unwrap_or_else(|| "dev".to_string());
if lifecycle_mgr.available_modes().contains(&default_mode.as_str()) {
handle_mode_change(
ctx,
lifecycle_mgr,
&default_mode,
&mut state.pids,
config.project_name,
config.project_config,
config.nodes_to_run,
Some(&state.log_buffer),
)
.await?;
}
}
enable_raw_mode()?;
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
terminal.clear()?;
let current_mode = state
.lifecycle
.as_ref()
.map(|l| l.current_mode().to_string())
.unwrap_or_else(|| "startup".to_string());
let running_nodes: Vec<String> = state.pids.keys().cloned().collect();
dev_tui.update_status(current_mode, running_nodes);
}
_ => {}
}
}
}
}
};
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
cleanup(ctx)?;
result
}
pub async fn start_simulator(
ctx: &mut CliContext,
config: &ProjectConfig,
_nodes_to_run: &[NodeToRun],
pids: &mut HashMap<String, u32>,
launch_config: &SimulatorLaunchConfig<'_>,
running: &Arc<AtomicBool>,
log_buffer: &LogBuffer,
) -> Result<()> {
{
let dev_service = ctx.dev();
if dev_service.is_port_in_use(11008)? {
println!();
println!("⚠️ Simulation already running on port 11008");
log_buffer.push("SIMULATOR: Already running on port 11008".to_string());
println!();
println!("Only one simulation instance can run at a time.");
println!("Stop the running simulation before starting a new one.");
println!();
return Ok(());
}
}
println!();
println!("🎮 Launching simulator...");
log_buffer.push("SIMULATOR: Launching Godot...".to_string());
println!();
if let Some(ref godot_exe) = launch_config.godot_path {
if let Some(ref sim_paths) = launch_config.sim_paths {
println!(" Model: {}", sim_paths.model_path.display());
println!(" Environment: {}", sim_paths.environment_path.display());
if sim_paths.headless {
println!(" Mode: Headless (Docker + Xvfb)");
println!();
let project_root = ctx.project()?.root().to_path_buf();
let dev_service = ctx.dev();
let compose_path = project_root.join(paths::docker::COMPOSE_FILE);
if !compose_path.exists() {
eprintln!(
"❌ {} not found at {}",
paths::docker::COMPOSE_FILE,
compose_path.display()
);
eprintln!(" Headless mode requires Docker Compose configuration.");
return Ok(());
}
let docker_service = DockerService::with_compose_file(compose_path.to_string_lossy().to_string());
if let Err(e) = docker_service.check_installation() {
eprintln!("❌ Docker not available: {}", e);
eprintln!(" Install Docker or set headless: false in simulation config.");
return Ok(());
}
println!("🐳 Starting simulation container...");
let simulation_mount_path = if let Ok(framework_path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
std::path::PathBuf::from(framework_path)
} else {
let assets_service = crate::services::SimulationAssetsService::new();
match assets_service.ensure_assets().await {
Ok(assets_path) => assets_path,
Err(e) => {
eprintln!("❌ Failed to get simulation assets: {}", e);
eprintln!();
eprintln!("Options:");
eprintln!(" 1. Set MECHA10_FRAMEWORK_PATH for local development");
eprintln!(" 2. Check your internet connection for asset download");
return Ok(());
}
}
};
std::env::set_var("MECHA10_SIMULATION_PATH", &simulation_mount_path);
println!(" Simulation assets: {}", simulation_mount_path.display());
let container_godot_path = if std::env::var("MECHA10_FRAMEWORK_PATH").is_ok() {
std::path::PathBuf::from(paths::container::FRAMEWORK_GODOT_PROJECT)
} else {
std::path::PathBuf::from(paths::container::FRAMEWORK_GODOT_PROJECT_LEGACY)
};
let container_model_config = sim_paths.model_config_path.as_ref().map(|p| {
let relative = p.strip_prefix(&project_root).unwrap_or(p);
std::path::PathBuf::from(paths::container::PROJECT_ROOT).join(relative)
});
let container_env_config = sim_paths.environment_config_path.as_ref().map(|p| {
let relative = p.strip_prefix(&project_root).unwrap_or(p);
std::path::PathBuf::from(paths::container::PROJECT_ROOT).join(relative)
});
let args = dev_service.build_godot_args(
&container_godot_path,
&sim_paths.model_path,
&sim_paths.environment_path,
container_model_config.as_ref(),
container_env_config.as_ref(),
false, Some(&sim_paths.networking),
);
match docker_service
.compose_run_service("simulation", true, true, &args)
.await
{
Ok(_) => {
println!("✅ Simulation container started");
println!(" View logs: docker compose logs -f simulation");
println!();
check_and_enter_teleop(config, pids, launch_config.redis_url, running, log_buffer).await?;
}
Err(e) => {
eprintln!("❌ Failed to start simulation container: {}", e);
eprintln!(" Check: docker compose logs simulation");
}
}
} else {
println!(" Mode: Native Godot");
println!();
let dev_service = ctx.dev();
let godot_project_path = dev_service.get_godot_project_path();
let godot_project_path = if !godot_project_path.exists() {
println!("📦 Simulation assets not found locally, attempting download...");
let assets_service = crate::services::SimulationAssetsService::new();
match assets_service.ensure_assets().await {
Ok(assets_path) => {
let downloaded_godot_path = assets_path.join("godot-project");
if downloaded_godot_path.exists() {
println!("✅ Using downloaded simulation assets");
downloaded_godot_path
} else {
eprintln!("❌ Downloaded assets don't contain godot-project");
eprintln!();
eprintln!("Options:");
eprintln!(" 1. Set MECHA10_FRAMEWORK_PATH environment variable:");
eprintln!(" export MECHA10_FRAMEWORK_PATH=/path/to/mecha10");
eprintln!();
eprintln!(" 2. Use Docker-based headless simulation:");
eprintln!(" Set 'headless: true' in configs/dev/simulation/config.json");
eprintln!();
return Ok(());
}
}
Err(e) => {
eprintln!("❌ Failed to download simulation assets: {}", e);
eprintln!();
eprintln!("The CLI was unable to locate or download the Godot simulation project.");
eprintln!();
eprintln!("Options:");
eprintln!(" 1. Set MECHA10_FRAMEWORK_PATH environment variable:");
eprintln!(" export MECHA10_FRAMEWORK_PATH=/path/to/mecha10");
eprintln!();
eprintln!(" 2. Use Docker-based headless simulation:");
eprintln!(" Set 'headless: true' in configs/dev/simulation/config.json");
eprintln!();
return Ok(());
}
}
} else {
godot_project_path
};
let args = dev_service.build_godot_args(
&godot_project_path,
&sim_paths.model_path,
&sim_paths.environment_path,
sim_paths.model_config_path.as_ref(),
sim_paths.environment_config_path.as_ref(),
false, Some(&sim_paths.networking),
);
let process_service = ctx.process();
use std::process::{Command, Stdio};
let mut cmd = Command::new(godot_exe);
cmd.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Ok(framework_path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
cmd.env("MECHA10_FRAMEWORK_PATH", framework_path);
}
match cmd.spawn() {
Ok(child) => {
let pid = child.id();
println!("✅ Simulator launched (PID: {})", pid);
log_buffer.push(format!("SIMULATOR: ✅ Godot launched (PID: {})", pid));
println!();
process_service.manager().track("godot-simulator".to_string(), child);
check_and_enter_teleop(config, pids, launch_config.redis_url, running, log_buffer).await?;
}
Err(e) => {
eprintln!("❌ Failed to launch simulator: {}", e);
log_buffer.push(format!("SIMULATOR: ❌ Failed to launch: {}", e));
}
}
}
} else {
print_no_simulation_config();
log_buffer.push("SIMULATOR: ❌ No simulation configuration in mecha10.json".to_string());
}
} else {
print_godot_not_found();
log_buffer.push("SIMULATOR: ❌ Godot not found - install Godot 4.x".to_string());
}
println!();
Ok(())
}
pub async fn enter_teleop_mode(
config: &ProjectConfig,
pids: &HashMap<String, u32>,
redis_url: &str,
running: &Arc<AtomicBool>,
log_buffer: &LogBuffer,
) -> Result<()> {
let has_teleop = config.nodes.contains("teleop");
let teleop_running = pids.contains_key("teleop");
if !has_teleop {
println!("\n⚠️ Teleop node not found in project");
println!();
println!("To add teleop to your project:");
println!(" mecha10 add teleop");
println!();
println!("The teleop coordination node manages multiple");
println!("control sources with priority arbitration.");
println!();
} else if !teleop_running {
println!("\n⚠️ Teleop node not running");
println!();
println!("Start the teleop node by adding it to dev nodes:");
println!(" mecha10 dev teleop");
println!();
} else {
println!("\n🎮 Entering teleop control mode...\n");
let dashboard_url = config.environments.dashboard_url();
let mut teleop_ui = TeleopUi::new(redis_url.to_string(), log_buffer.clone(), dashboard_url);
match teleop_ui.run_with_interrupt(running.clone()).await {
Ok(()) => {
println!("\n✅ Exited teleop mode\n");
}
Err(e) => {
eprintln!("\n❌ Teleop UI error: {}\n", e);
}
}
}
Ok(())
}
async fn check_and_enter_teleop(
config: &ProjectConfig,
pids: &HashMap<String, u32>,
redis_url: &str,
running: &Arc<AtomicBool>,
log_buffer: &LogBuffer,
) -> Result<()> {
let has_teleop = config.nodes.contains("teleop");
let teleop_running = pids.contains_key("teleop");
if has_teleop && teleop_running {
println!("🎮 Entering teleop control mode...");
println!();
let dashboard_url = config.environments.dashboard_url();
let mut teleop_ui = TeleopUi::new(redis_url.to_string(), log_buffer.clone(), dashboard_url);
match teleop_ui.run_with_interrupt(running.clone()).await {
Ok(()) => {
println!();
println!("✅ Exited teleop mode");
println!();
}
Err(e) => {
eprintln!();
eprintln!("❌ Teleop UI error: {}", e);
println!();
}
}
} else if !has_teleop {
println!("💡 Tip: Add teleop node to control the robot");
println!(" mecha10 add teleop");
println!();
} else if !teleop_running {
println!("💡 Tip: Start teleop node to control the robot");
println!(" mecha10 dev teleop");
println!();
}
Ok(())
}
fn print_no_simulation_config() {
println!("⚠️ No simulation configuration found in mecha10.json");
println!();
println!("Add simulation config to mecha10.json:");
println!(r#" "simulation": {{"#);
println!(r#" "model": "@mecha10/simulation-models/rover","#);
println!(r#" "environment": "@mecha10/simulation-environments/basic_arena""#);
println!(r#" }}"#);
}
fn print_godot_not_found() {
println!("⚠️ Godot not found");
println!();
println!("Make sure Godot is installed and in PATH.");
println!("Or install it with: mecha10 setup");
}