Skip to main content

mecha10_cli/dev/
lifecycle_adapter.rs

1//! Lifecycle adapter for CLI process management
2//!
3//! This module adapts the runtime lifecycle system for the CLI's process-based
4//! node management. While the runtime manages nodes in-process, the CLI spawns
5//! nodes as separate processes via node-runner.
6//!
7//! This adapter:
8//! - Reads mode/node config from mecha10.json's `targets`
9//! - Determines which nodes to spawn based on current mode
10//! - Provides mode change logic without runtime dependency
11
12use crate::types::project::{NodeRef, ProjectConfig, TargetsConfig};
13use anyhow::Result;
14use mecha10_runtime::lifecycle::diff_nodes;
15use std::collections::{HashMap, HashSet};
16
17pub struct CliLifecycleManager {
18    current_mode: String,
19
20    running_nodes: HashSet<String>,
21
22    mode_config: HashMap<String, Vec<String>>,
23}
24
25#[derive(Debug)]
26#[allow(dead_code)]
27pub struct ModeTransitionDiff {
28    pub start: Vec<String>,
29
30    pub stop: Vec<String>,
31}
32
33impl CliLifecycleManager {
34    /// Create a new CLI lifecycle manager from project config
35    ///
36    /// Returns None if project doesn't have any `targets` configured
37    ///
38    /// The initial mode is always `"dev"` — callers are responsible for verifying a `"dev"`
39    /// target exists in `targets` before constructing this (see `DevSession::initialize`,
40    /// which is `mecha10 dev`'s only production call site for this constructor).
41    pub fn from_project_config(config: &ProjectConfig) -> Option<Self> {
42        // `NodeRef::wire_identifier()` is the single conversion point from the structured
43        // `targets.<mode>.nodes` entries (plain string, or `{"name", "instance_id"}` for a
44        // keyed multi-instance node - LAB-4131) down to the plain identifier strings this
45        // adapter, the CLI's spawn pipeline, and node-runner all still deal in. An
46        // instance-keyed entry's identifier carries its instance id encoded as
47        // `"{name}::{instance_id}"`, unpacked back out only at the two places that actually
48        // need it split: `ProcessService::spawn_node_runner`/`spawn_node_direct`.
49        let mode_config: HashMap<String, Vec<String>> = config
50            .targets
51            .present_entries()
52            .into_iter()
53            .map(|(name, nodes)| (name.to_string(), nodes.iter().map(NodeRef::wire_identifier).collect()))
54            .collect();
55
56        if mode_config.is_empty() {
57            return None;
58        }
59
60        Some(Self {
61            current_mode: "dev".to_string(),
62            running_nodes: HashSet::new(),
63            mode_config,
64        })
65    }
66
67    /// Get the current mode
68    pub fn current_mode(&self) -> &str {
69        &self.current_mode
70    }
71
72    /// Check if a mode exists in the configuration
73    pub fn has_mode(&self, mode: &str) -> bool {
74        self.mode_config.contains_key(mode)
75    }
76
77    /// Get list of nodes that should run in the current mode
78    pub fn nodes_for_current_mode(&self) -> Vec<String> {
79        self.mode_config.get(&self.current_mode).cloned().unwrap_or_default()
80    }
81
82    #[allow(dead_code)]
83    pub fn calculate_mode_diff(&self, target_mode: &str) -> Result<ModeTransitionDiff> {
84        let target_nodes = self
85            .mode_config
86            .get(target_mode)
87            .ok_or_else(|| anyhow::anyhow!("Mode '{}' not found", target_mode))?;
88
89        let (start, stop) = diff_nodes(target_nodes, &self.running_nodes);
90
91        Ok(ModeTransitionDiff { start, stop })
92    }
93
94    /// Mark nodes as running (after spawning them)
95    pub fn mark_nodes_running(&mut self, nodes: &[String]) {
96        for node in nodes {
97            self.running_nodes.insert(node.clone());
98        }
99    }
100
101    /// Mark nodes as stopped (after killing them)
102    #[allow(dead_code)]
103    pub fn mark_nodes_stopped(&mut self, nodes: &[String]) {
104        for node in nodes {
105            self.running_nodes.remove(node);
106        }
107    }
108
109    /// Change to a new mode (updates internal state only)
110    ///
111    /// Returns the diff of what needs to change. Caller is responsible
112    /// for actually spawning/killing processes.
113    #[allow(dead_code)]
114    pub fn change_mode(&mut self, target_mode: &str) -> Result<ModeTransitionDiff> {
115        let diff = self.calculate_mode_diff(target_mode)?;
116        self.current_mode = target_mode.to_string();
117        Ok(diff)
118    }
119
120    /// Get available modes
121    #[allow(dead_code)]
122    pub fn available_modes(&self) -> Vec<&str> {
123        self.mode_config.keys().map(|s| s.as_str()).collect()
124    }
125
126    /// Validate target configuration
127    ///
128    /// Checks that:
129    /// - All node references exist in project config
130    #[allow(dead_code)]
131    pub fn validate(targets: &TargetsConfig, available_nodes: &[String]) -> Result<()> {
132        let node_set: HashSet<_> = available_nodes.iter().map(|s| s.as_str()).collect();
133
134        for (mode_name, nodes) in targets.present_entries() {
135            for node in nodes {
136                if !node_set.contains(node.name()) {
137                    return Err(anyhow::anyhow!(
138                        "Mode '{}' references unknown node '{}'",
139                        mode_name,
140                        node.name()
141                    ));
142                }
143            }
144        }
145
146        Ok(())
147    }
148}