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 {
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,
})
}
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(())
}
pub fn build_if_needed(&self, ctx: &mut CliContext) -> Result<()> {
if self.should_build {
ops::build_nodes(ctx)?;
}
Ok(())
}
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(())
}
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(())
}
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()?;
if let Err(message) = ops::run_dependency_preflight(&self.nodes, &project_root, &self.config).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(())
}
}
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"));
}
}