Skip to main content

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}