mecha10-cli 0.1.47

Mecha10 CLI tool
Documentation
//! Dev session service
//!
//! Manages the complete lifecycle of a development mode session.
//! This service encapsulates all state and operations for a dev session,
//! providing a clean, high-level API for handlers.

use crate::commands::dev::DevArgs;
use crate::context::CliContext;
use crate::dev::lifecycle_adapter::CliLifecycleManager;
use crate::dev::node_selection::NodeToRun;
use crate::services::dev::SimulationPaths;
use crate::types::ProjectConfig;
use crate::ui::{dev_banners, LogBuffer};
use anyhow::Result;
use std::collections::HashMap;

use super::command_listener::{CommandListener, CommandListenerConfig};
use super::ops;

/// Development mode session
///
/// Encapsulates all state and operations for a development mode session.
/// This provides a stateful, high-level API that makes handlers extremely thin.
///
/// # Example
///
/// ```rust,ignore
/// let mut session = DevSession::initialize(ctx, args).await?;
/// session.setup_infrastructure(ctx).await?;
/// session.build_if_needed(ctx)?;
/// session.spawn_nodes(ctx)?;
/// session.run(ctx).await?;
/// ```
pub struct DevSession {
    /// Project name
    pub project_name: String,
    /// Project version
    #[allow(dead_code)]
    pub project_version: String,
    /// Project configuration
    pub config: ProjectConfig,
    /// Nodes to run
    pub nodes: Vec<NodeToRun>,
    /// Running process PIDs
    pub pids: HashMap<String, u32>,
    /// Godot executable path (if available)
    pub godot_path: Option<String>,
    /// Resolved simulation paths (model, environment, model_config, env_config)
    pub sim_paths: Option<SimulationPaths>,
    /// Whether to build before running
    pub should_build: bool,
    /// Whether to run in watch mode
    pub watch_mode: bool,
    /// Whether specific nodes were requested on CLI (vs using lifecycle mode)
    pub cli_nodes_specified: bool,
    /// Lifecycle manager (if project has lifecycle config)
    pub lifecycle: Option<CliLifecycleManager>,
    /// Log buffer for TUI logs
    pub log_buffer: LogBuffer,
}

impl DevSession {
    /// Initialize a new dev session
    ///
    /// This loads project configuration, validates the project,
    /// selects nodes to run, and resolves simulation paths.
    ///
    /// # Arguments
    ///
    /// * `ctx` - CLI context
    /// * `args` - Dev command arguments
    ///
    /// # Returns
    ///
    /// Initialized dev session ready for setup
    pub async fn initialize(ctx: &mut CliContext, args: &DevArgs) -> Result<Self> {
        // Display header
        dev_banners::print_header();

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

        // Load project info
        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);

        // Select nodes to run
        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);

        // Validate Godot
        let godot_path = ctx.simulation().validate_godot().ok().map(|info| info.path);

        // Resolve simulation paths
        let sim_paths = DevService::resolve_simulation_paths(ctx, &config)?;

        // Initialize lifecycle manager if project has lifecycle config
        let mut lifecycle = CliLifecycleManager::from_project_config(&config);

        // For `mecha10 dev`, always use "dev" mode if it exists
        // (default_mode might be "production" from a previous build)
        if let Some(ref mut lifecycle_mgr) = lifecycle {
            if lifecycle_mgr.current_mode() != "dev" && lifecycle_mgr.has_mode("dev") {
                tracing::info!(
                    "Switching from '{}' to 'dev' mode for development",
                    lifecycle_mgr.current_mode()
                );
                let _ = lifecycle_mgr.change_mode("dev");
            }
            tracing::info!(
                "Lifecycle management enabled. Mode: {}",
                lifecycle_mgr.current_mode()
            );
        }

        // Create log buffer for TUI
        let log_buffer = LogBuffer::new(1000);

        Ok(Self {
            project_name,
            project_version,
            config,
            nodes,
            pids: HashMap::new(),
            godot_path,
            sim_paths,
            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 Redis connection.
    ///
    /// # Arguments
    ///
    /// * `ctx` - CLI context
    pub async fn setup_infrastructure(&self, ctx: &mut CliContext) -> Result<()> {
        // Start Docker services if configured
        if self.config.docker.auto_start {
            ops::start_docker_services(ctx, &self.config, &self.project_name).await?;
        }

        // Setup Redis connection
        ops::setup_redis_connection(ctx).await?;

        Ok(())
    }

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

    /// Spawn node processes
    ///
    /// Spawns nodes based on either CLI arguments or lifecycle mode configuration.
    /// If specific nodes were passed on CLI (e.g., `mecha10 dev simulation-bridge`),
    /// those nodes are spawned regardless of lifecycle mode.
    ///
    /// If headless simulation is enabled and simulation-bridge is in the nodes list,
    /// this will automatically start the Docker simulation container first.
    ///
    /// # Arguments
    ///
    /// * `ctx` - CLI context
    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"
            )
        })?;

        // Determine which nodes to spawn:
        // - If CLI specified nodes, use those (overrides lifecycle mode)
        // - Otherwise, use lifecycle mode's nodes
        let nodes_to_spawn: Vec<String> = if self.cli_nodes_specified {
            // CLI-specified nodes take priority over lifecycle mode
            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 {
            // Use lifecycle mode's nodes
            let mode_nodes = lifecycle.nodes_for_current_mode();
            tracing::info!(
                "Spawning nodes for mode '{}': {:?}",
                lifecycle.current_mode(),
                mode_nodes
            );
            mode_nodes
        };

        // Check if we need to start headless simulation container
        // This is needed when simulation-bridge is in the nodes AND headless mode is enabled
        // Node identifiers can be full (@mecha10/simulation-bridge) or just names (simulation-bridge)
        let needs_headless_sim = nodes_to_spawn
            .iter()
            .any(|n| n == "simulation-bridge" || n.ends_with("/simulation-bridge"))
            && self.sim_paths.as_ref().is_some_and(|paths| paths.headless);

        if needs_headless_sim {
            if let Some(sim_paths) = &self.sim_paths {
                println!("🐳 Starting headless simulation container...");
                self.log_buffer
                    .push("Starting headless simulation container".to_string());

                // Start the headless simulation container
                ops::start_headless_simulation(ctx, sim_paths, &self.config).await?;
            }
        }

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

        self.pids = ops::spawn_nodes_by_name(
            ctx,
            &nodes_to_spawn,
            &self.project_name,
            &self.config,
            Some(&self.log_buffer),
        )?;

        // Mark only successfully spawned nodes as running in lifecycle manager
        // (pids map only contains nodes that actually started)
        let actually_running: Vec<String> = self.pids.keys().cloned().collect();
        lifecycle.mark_nodes_running(&actually_running);

        Ok(())
    }

    /// Run the dev session
    ///
    /// Enters either watch mode or interactive mode based on configuration.
    ///
    /// # Arguments
    ///
    /// * `ctx` - CLI context
    pub async fn run(&mut self, ctx: &mut CliContext) -> Result<()> {
        if self.watch_mode {
            ops::run_watch_mode(ctx, self.pids.clone()).await?;
        } else {
            // Display initial mode status before entering interactive mode
            if let Some(ref lifecycle) = self.lifecycle {
                let running: Vec<String> = self.pids.keys().cloned().collect();
                dev_banners::print_mode_status(lifecycle.current_mode(), &running, &self.nodes);
            }

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

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

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

            // Restore state back to session
            self.pids = state.pids;
            self.lifecycle = state.lifecycle;
            self.log_buffer = state.log_buffer;
        }

        Ok(())
    }

    /// Complete lifecycle: setup, build, spawn, and run
    ///
    /// This is a convenience method that runs the entire dev session lifecycle.
    /// Model validation and pulling is now handled automatically by node-runner.
    ///
    /// # Arguments
    ///
    /// * `ctx` - CLI context
    pub async fn execute(mut self, ctx: &mut CliContext) -> Result<()> {
        self.setup_infrastructure(ctx).await?;
        self.build_if_needed(ctx)?;

        // Start command listener for remote node control
        // Get shared process service BEFORE spawning nodes so they use the same instance
        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 = CommandListener::new(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");

        // Model pulling now handled by node-runner during spawn
        self.spawn_nodes(ctx).await?;
        self.run(ctx).await?;

        // Shutdown command listener
        let _ = shutdown_tx.send(());
        // Give it a moment to cleanup
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        listener_handle.abort();

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