mecha10-cli 0.8.0

Mecha10 CLI tool
Documentation
//! Dev session service
//!
//! Manages the complete lifecycle of a development mode session.

use crate::commands::dev::DevArgs;
use crate::context::CliContext;
use crate::dev::lifecycle_adapter::CliLifecycleManager;
use crate::dev::node_selection::NodeToRun;
use crate::types::ProjectConfig;
use crate::ui::{dev_banners, LogBuffer};
use anyhow::{Context, Result};

use super::command_listener::{build_command_listener, CommandListenerConfig};
use super::ops;
use ops::NodeRegistry;

pub struct DevSession {
    pub project_name: String,
    #[allow(dead_code)]
    pub project_version: String,
    pub config: ProjectConfig,
    pub nodes: Vec<NodeToRun>,
    pub node_registry: NodeRegistry,
    pub should_build: bool,
    pub watch_mode: bool,
    pub cli_nodes_specified: bool,
    pub lifecycle: Option<CliLifecycleManager>,
    pub log_buffer: LogBuffer,
}

impl DevSession {
    /// Initialize a new dev session: loads project configuration, validates the project,
    /// selects nodes to run, and resolves simulation paths.
    pub async fn initialize(ctx: &mut CliContext, args: &DevArgs) -> Result<Self> {
        dev_banners::print_header();

        let project_root = std::env::current_dir()?;
        crate::dev::validation::check_project_initialized(&project_root)?;

        mecha10_core::relay_topics::regenerate_websocket_bridge_relay_config(&project_root)
            .context("Failed to regenerate websocket-bridge relay topics")?;

        use crate::services::DevService;
        let (project_name, project_version, config) = DevService::load_project_info(ctx).await?;

        dev_banners::print_project_info(&project_name, &project_version);

        let nodes = crate::dev::node_selection::get_nodes_to_run(&args.nodes, &config);
        if !args.nodes.is_empty() {
            let available = crate::dev::node_selection::get_available_node_names(&config);
            crate::dev::validation::validate_node_names(&args.nodes, &available)?;
        }

        if nodes.is_empty() {
            dev_banners::print_no_nodes()?;
            return Err(anyhow::anyhow!("No nodes configured to run"));
        }

        dev_banners::print_nodes(&nodes);

        let lifecycle = CliLifecycleManager::from_project_config(&config);
        ensure_dev_mode_configured(lifecycle.as_ref())?;
        if let Some(ref lifecycle_mgr) = lifecycle {
            tracing::info!("Lifecycle management enabled. Mode: {}", lifecycle_mgr.current_mode());
        }

        let log_buffer = LogBuffer::new(1000);

        if args.sim_visible {
            std::env::set_var(ops::SIM_VISIBLE_ENV_VAR, "1");
        }

        Ok(Self {
            project_name,
            project_version,
            config,
            nodes,
            node_registry: NodeRegistry::new(),
            should_build: !args.no_build,
            watch_mode: args.watch,
            cli_nodes_specified: !args.nodes.is_empty(),
            lifecycle,
            log_buffer,
        })
    }

    /// Setup infrastructure (Docker services, Redis). Starts Docker services if configured
    /// and sets up the Redis connection.
    pub async fn setup_infrastructure(&self, ctx: &mut CliContext) -> Result<()> {
        if self.config.docker.auto_start {
            ops::start_docker_services(ctx, &self.config, &self.project_name).await?;
        }

        ops::setup_redis_connection(ctx).await?;

        Ok(())
    }

    /// Build nodes if needed
    pub fn build_if_needed(&self, ctx: &mut CliContext) -> Result<()> {
        if self.should_build {
            ops::build_nodes(ctx)?;
        }
        Ok(())
    }

    /// Spawn node processes based on either CLI arguments or lifecycle mode configuration.
    /// If specific nodes were passed on CLI, those nodes are spawned regardless of mode.
    pub async fn spawn_nodes(&mut self, ctx: &mut CliContext) -> Result<()> {
        let lifecycle = self.lifecycle.as_mut().ok_or_else(|| {
            anyhow::anyhow!(
                "Lifecycle configuration is required. Please add a 'lifecycle' section to your mecha10.json"
            )
        })?;

        let nodes_to_spawn: Vec<String> = if self.cli_nodes_specified {
            tracing::info!(
                "CLI specified nodes: {:?} (overriding lifecycle mode '{}')",
                self.nodes.iter().map(|n| &n.name).collect::<Vec<_>>(),
                lifecycle.current_mode()
            );
            self.nodes.iter().map(|n| n.name.clone()).collect()
        } else {
            let mode_nodes = lifecycle.nodes_for_current_mode();
            tracing::info!(
                "Spawning nodes for mode '{}': {:?}",
                lifecycle.current_mode(),
                mode_nodes
            );
            mode_nodes
        };

        self.log_buffer
            .push(format!("MODE: Starting in {} mode", lifecycle.current_mode()));

        let outcome = ops::spawn_nodes_by_name(
            ctx,
            &nodes_to_spawn,
            &self.project_name,
            &self.config,
            Some(&self.log_buffer),
        )?;
        self.node_registry.record_spawn_outcome(&outcome);

        let actually_running: Vec<String> = self.node_registry.running_node_names();
        lifecycle.mark_nodes_running(&actually_running);

        if !outcome.failures.is_empty() {
            println!("⚠️  {} node(s) failed to start:", outcome.failures.len());
            for (node, err) in &outcome.failures {
                println!("   ✗ {}: {}", node, err);
                self.log_buffer
                    .push(format!("ERROR: {} failed to start on session init: {}", node, err));
            }
            println!();
        }

        Ok(())
    }

    /// Run the dev session, entering either watch mode or interactive mode based on config.
    pub async fn run(&mut self, ctx: &mut CliContext) -> Result<()> {
        if self.watch_mode {
            ops::run_watch_mode(ctx, self.node_registry.pids().clone()).await?;
        } else {
            if let Some(ref lifecycle) = self.lifecycle {
                let running: Vec<String> = self.node_registry.running_node_names();
                dev_banners::print_mode_status(lifecycle.current_mode(), &running, &self.nodes);
            }

            let config = ops::InteractiveModeConfig {
                project_config: &self.config,
                nodes_to_run: &self.nodes,
                project_name: &self.project_name,
            };

            let mut state = ops::InteractiveModeState {
                node_registry: std::mem::take(&mut self.node_registry),
                lifecycle: self.lifecycle.take(),
                log_buffer: self.log_buffer.clone(),
            };

            ops::run_interactive_mode(ctx, config, &mut state).await?;

            self.node_registry = state.node_registry;
            self.lifecycle = state.lifecycle;
            self.log_buffer = state.log_buffer;
        }

        Ok(())
    }

    /// Complete lifecycle: setup, build, spawn, and run.
    pub async fn execute(mut self, ctx: &mut CliContext) -> Result<()> {
        self.setup_infrastructure(ctx).await?;
        self.build_if_needed(ctx)?;

        let project_root = std::env::current_dir()?;
        let needs_preset_schema = self
            .config
            .simulation
            .as_ref()
            .and_then(|sim| sim.preset.as_ref())
            .is_some();
        let assets_dir = if needs_preset_schema {
            Some(
                ctx.assets_download()
                    .fetch_and_extract()
                    .await
                    .context("Failed to download mecha10-assets for preset schema validation")?,
            )
        } else {
            None
        };
        let schemas_dir = assets_dir
            .as_ref()
            .map(|dir| dir.path().join("configs/simulation/schemas"));
        if let Err(message) =
            ops::run_dependency_preflight(&self.nodes, &project_root, &self.config, schemas_dir.as_deref()).await
        {
            eprintln!("{}", message);
            println!();
            return Err(anyhow::anyhow!(message));
        }

        let shared_process = ctx.shared_process();

        let (shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel::<()>(1);
        let listener_config = CommandListenerConfig {
            redis_url: ctx.redis_url().to_string(),
            project_name: self.project_name.clone(),
            robot_id: self.config.robot.id.clone(),
            control_plane_url: self.config.environments.control_plane_url(),
            relay_url: self.config.environments.relay_url(),
            log_buffer: Some(self.log_buffer.clone()),
        };
        let command_listener = build_command_listener(listener_config, shared_process.clone());

        let listener_handle = tokio::spawn(async move {
            if let Err(e) = command_listener.run(shutdown_rx).await {
                tracing::error!("Command listener error: {}", e);
            }
        });

        tracing::info!("Command listener started for remote node control");

        self.spawn_nodes(ctx).await?;
        self.run(ctx).await?;

        let _ = shutdown_tx.send(());
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        listener_handle.abort();

        println!();
        Ok(())
    }
}

/// Enforce `mecha10 dev`'s hard requirement that a lifecycle-configured project defines a mode
/// literally named "dev". A no-op when there's no lifecycle config at all.
fn ensure_dev_mode_configured(lifecycle: Option<&CliLifecycleManager>) -> Result<()> {
    match lifecycle {
        Some(lifecycle_mgr) if !lifecycle_mgr.has_mode("dev") => Err(anyhow::anyhow!(
            "No \"dev\" target found in targets. `mecha10 dev` requires a target literally \
             named \"dev\" — add one to mecha10.json's targets."
        )),
        _ => Ok(()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::project::{NodesConfig, RobotConfig, TargetEntry, TargetsConfig};

    fn project_config_with_modes(mode_names: &[&str]) -> ProjectConfig {
        let entry = || Some(TargetEntry::default());
        let has = |name: &str| mode_names.contains(&name);

        ProjectConfig {
            name: "test".to_string(),
            version: "0.1.0".to_string(),
            template_version: None,
            robot: RobotConfig {
                id: "test".to_string(),
                name: "test".to_string(),
                platform: None,
                description: None,
            },
            simulation: None,
            nodes: NodesConfig::new(),
            targets: TargetsConfig {
                dev: if has("dev") { entry() } else { None },
                simulation: if has("simulation") { entry() } else { None },
                robot: if has("robot") { entry() } else { None },
                remote: if has("remote") { entry() } else { None },
                rl: if has("rl") { entry() } else { None },
            },
            behaviors: None,
            docker: Default::default(),
            environments: Default::default(),
            actuator: Default::default(),
        }
    }

    #[test]
    fn ensure_dev_mode_configured_is_ok_without_any_lifecycle_config() {
        assert!(ensure_dev_mode_configured(None).is_ok());
    }

    #[test]
    fn ensure_dev_mode_configured_is_ok_when_dev_mode_present() {
        let config = project_config_with_modes(&["dev", "simulation"]);
        let lifecycle_mgr = CliLifecycleManager::from_project_config(&config).unwrap();

        assert!(ensure_dev_mode_configured(Some(&lifecycle_mgr)).is_ok());
    }

    #[test]
    fn ensure_dev_mode_configured_errors_when_dev_mode_missing() {
        let config = project_config_with_modes(&["robot", "simulation"]);
        let lifecycle_mgr = CliLifecycleManager::from_project_config(&config).unwrap();

        let err = ensure_dev_mode_configured(Some(&lifecycle_mgr)).unwrap_err();
        assert!(err.to_string().contains("\"dev\""));
        assert!(err.to_string().contains("targets"));
    }
}