mecha10_runtime/lifecycle.rs
1//! Lifecycle management for mode-based dynamic node control
2//!
3//! This module provides the lifecycle manager that orchestrates node
4//! start/stop based on operational mode changes.
5//!
6//! # Architecture
7//!
8//! ```text
9//! CLI → Redis (mode/request) → LifecycleManager → Supervisor → Nodes
10//! ```
11//!
12//! The lifecycle manager:
13//! - Subscribes to mode change requests
14//! - Calculates which nodes need to start/stop
15//! - Uses Supervisor to execute changes
16//! - Publishes mode changed events
17//!
18//! # Example
19//!
20//! ```rust,ignore
21//! use mecha10_runtime::lifecycle::LifecycleManager;
22//!
23//! let lifecycle = LifecycleManager::new(
24//! context,
25//! supervisor,
26//! mode_config,
27//! "startup".to_string(),
28//! );
29//!
30//! // Run in background
31//! tokio::spawn(async move {
32//! lifecycle.run().await
33//! });
34//! ```
35
36use anyhow::Result;
37use std::collections::{HashMap, HashSet};
38use std::sync::Arc;
39use tracing::{debug, error, info, warn};
40
41/// Lifecycle manager for mode-based node orchestration
42///
43/// Manages transitions between operational modes by starting and stopping
44/// nodes via the Supervisor.
45pub struct LifecycleManager {
46 /// Current operational mode
47 current_mode: String,
48
49 /// Set of currently running node names
50 pub(crate) running_nodes: HashSet<String>,
51
52 /// Mode configurations (mode_name -> ModeConfig)
53 mode_config: HashMap<String, ModeConfig>,
54
55 /// Supervisor for node management
56 supervisor: Arc<dyn SupervisorTrait>,
57}
58
59/// Configuration for a single mode
60///
61/// This is a simplified version that will be replaced with the actual
62/// type from mecha10-cli once we integrate.
63#[derive(Debug, Clone)]
64pub struct ModeConfig {
65 /// Nodes that should run in this mode
66 pub nodes: Vec<String>,
67
68 /// Description of this mode
69 pub description: Option<String>,
70}
71
72/// Trait for supervisor operations
73///
74/// This allows us to mock the supervisor for testing while
75/// using the real Supervisor in production.
76#[async_trait::async_trait]
77pub trait SupervisorTrait: Send + Sync {
78 /// Start a node by name
79 async fn start_node(&self, name: &str) -> Result<()>;
80
81 /// Stop a node by name
82 async fn stop_node(&self, name: &str) -> Result<()>;
83
84 /// Get list of running nodes
85 fn get_running_nodes(&self) -> Vec<String>;
86}
87
88impl LifecycleManager {
89 /// Create a new lifecycle manager
90 ///
91 /// # Arguments
92 ///
93 /// * `supervisor` - Supervisor instance for node management
94 /// * `mode_config` - Map of mode name to mode configuration
95 /// * `default_mode` - Initial mode to start in
96 pub fn new(
97 supervisor: Arc<dyn SupervisorTrait>,
98 mode_config: HashMap<String, ModeConfig>,
99 default_mode: String,
100 ) -> Self {
101 Self {
102 current_mode: default_mode,
103 running_nodes: HashSet::new(),
104 mode_config,
105 supervisor,
106 }
107 }
108
109 /// Get the current mode
110 pub fn current_mode(&self) -> &str {
111 &self.current_mode
112 }
113
114 /// Get running nodes
115 pub fn running_nodes(&self) -> &HashSet<String> {
116 &self.running_nodes
117 }
118
119 /// Request a mode change
120 ///
121 /// This is the main entry point for mode transitions.
122 /// It calculates the diff and executes the transition.
123 ///
124 /// # Arguments
125 ///
126 /// * `target_mode` - The mode to transition to
127 ///
128 /// # Returns
129 ///
130 /// Ok with nodes started/stopped, or Err if mode doesn't exist
131 pub async fn change_mode(&mut self, target_mode: &str) -> Result<ModeTransitionResult> {
132 info!("Mode change requested: {} -> {}", self.current_mode, target_mode);
133
134 if !self.mode_config.contains_key(target_mode) {
135 let available: Vec<_> = self.mode_config.keys().map(|k| k.as_str()).collect();
136 return Err(anyhow::anyhow!(
137 "Mode '{}' not found. Available modes: {}",
138 target_mode,
139 available.join(", ")
140 ));
141 }
142
143 let diff = self.calculate_mode_diff(target_mode)?;
144
145 info!("Mode transition plan: stop {:?}, start {:?}", diff.stop, diff.start);
146
147 self.execute_mode_transition(target_mode, diff).await
148 }
149
150 // duplicate: false positive — matches `CliLifecycleManager::calculate_mode_diff` in the
151 // CLI's dev lifecycle adapter because both are thin wrappers around the actual shared
152 // logic, `diff_nodes` (below), already extracted and imported by both call sites. What's
153 // left differs on purpose: this looks up a `ModeConfig` (target has extra fields beyond
154 // the node list) and returns `ModeDiff`, while the CLI adapter looks up a plain node list
155 // and returns `ModeTransitionDiff` for a different, dev-only config shape.
156 /// Calculate which nodes need to start/stop for a mode transition
157 pub fn calculate_mode_diff(&self, target_mode: &str) -> Result<ModeDiff> {
158 let target_config = self
159 .mode_config
160 .get(target_mode)
161 .ok_or_else(|| anyhow::anyhow!("Mode not found"))?;
162
163 let (start, stop) = diff_nodes(&target_config.nodes, &self.running_nodes);
164
165 Ok(ModeDiff { start, stop })
166 }
167
168 /// Execute a mode transition
169 async fn execute_mode_transition(&mut self, target_mode: &str, diff: ModeDiff) -> Result<ModeTransitionResult> {
170 debug!(
171 "Executing mode transition: {} -> {} (stop: {:?}, start: {:?})",
172 self.current_mode, target_mode, diff.stop, diff.start
173 );
174
175 let mut stopped = Vec::new();
176 let mut started = Vec::new();
177 let mut errors = Vec::new();
178
179 for node in &diff.stop {
180 info!("Stopping node: {}", node);
181 match self.supervisor.stop_node(node).await {
182 Ok(()) => {
183 self.running_nodes.remove(node);
184 stopped.push(node.clone());
185 debug!("✓ Stopped: {}", node);
186 }
187 Err(e) => {
188 warn!("Failed to stop node {}: {}", node, e);
189 errors.push(format!("stop {}: {}", node, e));
190 }
191 }
192 }
193
194 for node in &diff.start {
195 info!("Starting node: {}", node);
196 match self.supervisor.start_node(node).await {
197 Ok(()) => {
198 self.running_nodes.insert(node.clone());
199 started.push(node.clone());
200 debug!("✓ Started: {}", node);
201 }
202 Err(e) => {
203 error!("Failed to start node {}: {}", node, e);
204 errors.push(format!("start {}: {}", node, e));
205 }
206 }
207 }
208
209 let previous_mode = self.current_mode.clone();
210 self.current_mode = target_mode.to_string();
211
212 info!(
213 "Mode transition complete: {} -> {} (started: {}, stopped: {})",
214 previous_mode,
215 target_mode,
216 started.len(),
217 stopped.len()
218 );
219
220 Ok(ModeTransitionResult {
221 previous_mode,
222 current_mode: target_mode.to_string(),
223 nodes_started: started,
224 nodes_stopped: stopped,
225 errors,
226 })
227 }
228}
229
230/// Calculate which nodes need to start/stop to transition to a target set of nodes
231///
232/// Shared by [`LifecycleManager::calculate_mode_diff`] and the CLI's
233/// `CliLifecycleManager::calculate_mode_diff`, which perform the same set-diffing
234/// against their own independently-tracked mode configuration and running-node state.
235///
236/// # Arguments
237///
238/// * `target_nodes` - Nodes that should be running after the transition
239/// * `running_nodes` - Nodes that are currently running
240///
241/// # Returns
242///
243/// A `(start, stop)` tuple: nodes to start (in target but not running) and nodes to
244/// stop (running but not in target).
245pub fn diff_nodes(target_nodes: &[String], running_nodes: &HashSet<String>) -> (Vec<String>, Vec<String>) {
246 let target_set: HashSet<_> = target_nodes.iter().map(|s| s.as_str()).collect();
247
248 let start: Vec<_> = target_set
249 .iter()
250 .filter(|n| !running_nodes.contains(**n))
251 .map(|s| s.to_string())
252 .collect();
253
254 let stop: Vec<_> = running_nodes
255 .iter()
256 .filter(|n| !target_set.contains(n.as_str()))
257 .cloned()
258 .collect();
259
260 (start, stop)
261}
262
263/// Difference between current and target mode
264#[derive(Debug)]
265pub struct ModeDiff {
266 /// Nodes that need to be started
267 pub start: Vec<String>,
268
269 /// Nodes that need to be stopped
270 pub stop: Vec<String>,
271}
272
273/// Result of a mode transition
274#[derive(Debug, Clone)]
275pub struct ModeTransitionResult {
276 /// Previous mode
277 pub previous_mode: String,
278
279 /// New current mode
280 pub current_mode: String,
281
282 /// Nodes that were started
283 pub nodes_started: Vec<String>,
284
285 /// Nodes that were stopped
286 pub nodes_stopped: Vec<String>,
287
288 /// Errors encountered (non-fatal)
289 pub errors: Vec<String>,
290}