use super::registry::NodeRegistry;
use super::spawn::spawn_nodes_by_name;
use crate::context::CliContext;
use crate::dev::node_selection::NodeToRun;
use crate::paths;
use crate::types::ProjectConfig;
use crate::ui::LogBuffer;
use anyhow::Result;
use mecha10_cli_core::utils::strip_node_scope;
use std::collections::HashMap;
pub async fn run_watch_mode(ctx: &mut CliContext, _pids: HashMap<String, u32>) -> Result<()> {
use std::sync::atomic::Ordering;
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 = crate::dev::termination::install_quit_signal()?;
while running.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_secs(1)).await;
let status = ctx.process().get_status();
for (node, node_status) in &status {
if !node_status.contains("running") {
println!("⚠️ {} stopped unexpectedly", node);
}
}
}
println!();
println!("Stopping all nodes...");
cleanup(ctx)?;
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)]
pub(super) async fn handle_mode_change(
ctx: &mut CliContext,
lifecycle: &mut crate::dev::lifecycle_adapter::CliLifecycleManager,
target_mode: &str,
registry: &mut NodeRegistry,
project_name: &str,
project_config: &ProjectConfig,
nodes_to_run: &[NodeToRun],
log_buffer: Option<&LogBuffer>,
) -> Result<Vec<(String, String)>> {
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 registry.mark_exited(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);
}
let mut start_failures: Vec<(String, String)> = Vec::new();
if !diff.start.is_empty() {
println!("✅ Starting: {}", diff.start.join(", "));
let outcome = spawn_nodes_by_name(ctx, &diff.start, project_name, project_config, log_buffer)?;
let actually_started: Vec<String> = outcome.pids.keys().cloned().collect();
for (node, pid) in &outcome.pids {
registry.mark_started(node, *pid);
}
lifecycle.mark_nodes_running(&actually_started);
start_failures = outcome.failures;
start_failures.extend(recheck_liveness(ctx, &actually_started).await);
}
if start_failures.is_empty() {
println!("✓ Mode changed to: {}", target_mode);
} else {
println!("⚠️ Mode changed to: {} — but some nodes failed to start:", target_mode);
for (node, err) in &start_failures {
println!(" ✗ {}: {}", node, err);
}
}
println!();
if let Some(buffer) = log_buffer {
if start_failures.is_empty() {
buffer.push(format!("MODE: Successfully changed to {} mode", target_mode));
} else {
let failed_names: Vec<&str> = start_failures.iter().map(|(name, _)| name.as_str()).collect();
buffer.push(format!(
"MODE: Changed to {} mode, but failed to start: {}",
target_mode,
failed_names.join(", ")
));
for (node, err) in &start_failures {
buffer.push(format!("ERROR: {} failed to start during mode change: {}", node, err));
}
}
}
let running: Vec<String> = registry.running_node_names();
crate::ui::dev_banners::print_mode_status(target_mode, &running, nodes_to_run);
Ok(start_failures)
}
async fn recheck_liveness(ctx: &mut CliContext, started: &[String]) -> Vec<(String, String)> {
if started.is_empty() {
return Vec::new();
}
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
let statuses = {
let shared_process = ctx.shared_process();
let mut process_service = shared_process.lock().unwrap();
process_service.get_status()
};
let mut died_early = Vec::new();
for node_name in started {
let Some(status) = statuses.get(node_name) else {
continue;
};
if status == "running" {
continue;
}
let detail = tail_node_log(node_name);
let reason = match detail {
Some(detail) => format!("exited shortly after starting ({status}): {detail}"),
None => format!(
"exited shortly after starting ({status}) - see logs/{}.log for details \
(enable `environments.<env>.logging.disk_logging_enabled` in mecha10.json if that file is empty)",
strip_node_scope(node_name)
),
};
died_early.push((node_name.clone(), reason));
}
died_early
}
fn tail_node_log(node_name: &str) -> Option<String> {
let log_path = std::path::Path::new(paths::project::LOGS_DIR).join(format!("{}.log", strip_node_scope(node_name)));
let content = std::fs::read_to_string(log_path).ok()?;
last_nonempty_line(&content)
}
fn last_nonempty_line(content: &str) -> Option<String> {
content
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string())
}
#[cfg(test)]
mod liveness_tests {
use super::*;
#[test]
fn last_nonempty_line_skips_trailing_blank_lines() {
let content = "line one\nline two\n\n \n";
assert_eq!(last_nonempty_line(content), Some("line two".to_string()));
}
#[test]
fn last_nonempty_line_trims_whitespace() {
let content = "first\n last line with padding \n";
assert_eq!(last_nonempty_line(content), Some("last line with padding".to_string()));
}
#[test]
fn last_nonempty_line_none_for_empty_content() {
assert_eq!(last_nonempty_line(""), None);
assert_eq!(last_nonempty_line("\n\n \n"), None);
}
#[test]
fn tail_node_log_returns_none_when_file_missing() {
assert_eq!(tail_node_log("@mecha10/definitely-not-a-real-node-xyz"), None);
}
}