mecha10-cli 0.8.0

Mecha10 CLI tool
Documentation
//! Consolidated node runtime state for `mecha10 dev`.
//!
//! Tracks running node PIDs and last-known failures in one place, kept
//! mutually consistent by construction.

use super::spawn::SpawnOutcome;
use std::collections::HashMap;

#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NodeState {
    Running(u32),
    Failed(String),
    Unknown,
}

#[derive(Debug, Default, Clone)]
pub struct NodeRegistry {
    pids: HashMap<String, u32>,
    last_known_failures: HashMap<String, String>,
}

impl NodeRegistry {
    /// A fresh registry with no known nodes.
    pub fn new() -> Self {
        Self::default()
    }

    /// Record that `node` successfully started with `pid`. Clears any previously stored failure
    /// for it - a fresh successful (re)spawn shouldn't keep showing a stale error.
    pub fn mark_started(&mut self, node: &str, pid: u32) {
        self.pids.insert(node.to_string(), pid);
        self.last_known_failures.remove(node);
    }

    /// Record that `node` is no longer running (e.g. as part of a lifecycle mode change stopping
    /// it). Returns its previous PID, if it was tracked as running.
    pub fn mark_exited(&mut self, node: &str) -> Option<u32> {
        self.pids.remove(node)
    }

    /// Merge a spawn attempt's failures into the persistent last-known-failure map, keyed by node
    /// name. A fresher failure always supersedes an older one for the same node, and any node
    /// currently tracked as running has its stored failure cleared.
    pub fn record_failures(&mut self, failures: &[(String, String)]) {
        for (node, err) in failures {
            self.last_known_failures.insert(node.clone(), err.clone());
        }
        self.last_known_failures.retain(|node, _| !self.pids.contains_key(node));
    }

    /// Merge one `spawn_nodes_by_name` attempt's outcome into the registry in one step: every
    /// newly-spawned node becomes running (see `mark_started`), then every failure is folded in
    /// (see `record_failures`).
    pub fn record_spawn_outcome(&mut self, outcome: &SpawnOutcome) {
        for (node, pid) in &outcome.pids {
            self.mark_started(node, *pid);
        }
        self.record_failures(&outcome.failures);
    }

    #[allow(dead_code)]
    pub fn is_running(&self, node: &str) -> bool {
        self.pids.contains_key(node)
    }

    /// The PID `node` is currently running under, if any. See `is_running`'s docs on why this is
    /// kept despite no current production call site.
    #[allow(dead_code)]
    pub fn pid(&self, node: &str) -> Option<u32> {
        self.pids.get(node).copied()
    }

    /// Read-only access to the full running-node -> PID map, for callers (UI status displays,
    /// `run_watch_mode`, `enter_teleop_mode`) that need the whole set at once rather than a
    /// single node's state.
    pub fn pids(&self) -> &HashMap<String, u32> {
        &self.pids
    }

    /// Names of all currently running nodes.
    pub fn running_node_names(&self) -> Vec<String> {
        self.pids.keys().cloned().collect()
    }

    /// Read-only access to the full node -> last-known-failure map, for callers that need to
    /// search it (e.g. matching a scope-stripped node name).
    pub fn last_known_failures(&self) -> &HashMap<String, String> {
        &self.last_known_failures
    }

    /// The current state of `node`, reconciling both halves of the registry in one call - this is
    /// the answer to "what do we currently know about node X?" that motivated this type. See
    /// `NodeState`'s docs on why this is kept despite no current production call site.
    #[allow(dead_code)]
    pub fn state(&self, node: &str) -> NodeState {
        if let Some(pid) = self.pids.get(node) {
            NodeState::Running(*pid)
        } else if let Some(err) = self.last_known_failures.get(node) {
            NodeState::Failed(err.clone())
        } else {
            NodeState::Unknown
        }
    }

    /// A full point-in-time view of every node the registry currently has an opinion about -
    /// running or failed - keyed by node name. Nodes never mentioned to this registry don't
    /// appear at all.
    #[allow(dead_code)]
    pub fn snapshot(&self) -> HashMap<String, NodeState> {
        let mut snapshot: HashMap<String, NodeState> = self
            .pids
            .iter()
            .map(|(node, pid)| (node.clone(), NodeState::Running(*pid)))
            .collect();
        for (node, err) in &self.last_known_failures {
            snapshot
                .entry(node.clone())
                .or_insert_with(|| NodeState::Failed(err.clone()));
        }
        snapshot
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn mark_started_tracks_pid_and_clears_stored_failure() {
        let mut registry = NodeRegistry::new();
        registry.record_failures(&[("@mecha10/teleop".to_string(), "binary download failed".to_string())]);

        registry.mark_started("@mecha10/teleop", 1234);

        assert!(registry.is_running("@mecha10/teleop"));
        assert_eq!(registry.pid("@mecha10/teleop"), Some(1234));
        assert_eq!(registry.last_known_failures().get("@mecha10/teleop"), None);
    }

    #[test]
    fn mark_exited_removes_pid_and_returns_previous_value() {
        let mut registry = NodeRegistry::new();
        registry.mark_started("@mecha10/teleop", 1234);

        let previous = registry.mark_exited("@mecha10/teleop");

        assert_eq!(previous, Some(1234));
        assert!(!registry.is_running("@mecha10/teleop"));
    }

    #[test]
    fn mark_exited_returns_none_for_a_node_that_was_not_running() {
        let mut registry = NodeRegistry::new();

        assert_eq!(registry.mark_exited("@mecha10/teleop"), None);
    }

    #[test]
    fn record_failures_stores_new_failures() {
        let mut registry = NodeRegistry::new();

        registry.record_failures(&[(
            "@mecha10/teleop".to_string(),
            "binary download failed: HTTP 404".to_string(),
        )]);

        assert_eq!(
            registry.last_known_failures().get("@mecha10/teleop"),
            Some(&"binary download failed: HTTP 404".to_string())
        );
    }

    #[test]
    fn record_failures_keeps_older_failure_when_node_not_retried() {
        let mut registry = NodeRegistry::new();
        registry.record_failures(&[(
            "@mecha10/teleop".to_string(),
            "binary download failed: HTTP 404".to_string(),
        )]);

        registry.record_failures(&[]);

        assert_eq!(
            registry.last_known_failures().get("@mecha10/teleop"),
            Some(&"binary download failed: HTTP 404".to_string()),
            "the original failure reason should survive a call that didn't touch teleop at all"
        );
    }

    #[test]
    fn record_failures_overwrites_with_fresher_failure() {
        let mut registry = NodeRegistry::new();
        registry.record_failures(&[(
            "@mecha10/teleop".to_string(),
            "binary download failed: HTTP 404".to_string(),
        )]);
        registry.record_failures(&[("@mecha10/teleop".to_string(), "connection timed out".to_string())]);

        assert_eq!(
            registry.last_known_failures().get("@mecha10/teleop"),
            Some(&"connection timed out".to_string())
        );
    }

    #[test]
    fn record_failures_clears_failure_once_node_is_running() {
        let mut registry = NodeRegistry::new();
        registry.record_failures(&[(
            "@mecha10/teleop".to_string(),
            "binary download failed: HTTP 404".to_string(),
        )]);
        registry.mark_started("@mecha10/teleop", 1234);

        registry.record_failures(&[]);

        assert!(
            registry.last_known_failures().get("@mecha10/teleop").is_none(),
            "a running node shouldn't keep a stale stored failure"
        );
    }

    #[test]
    fn record_spawn_outcome_marks_pids_running_and_records_failures() {
        let mut registry = NodeRegistry::new();
        let outcome = SpawnOutcome {
            pids: HashMap::from([("@mecha10/camera".to_string(), 42)]),
            failures: vec![(
                "@mecha10/teleop".to_string(),
                "binary download failed: HTTP 404".to_string(),
            )],
        };

        registry.record_spawn_outcome(&outcome);

        assert_eq!(registry.pid("@mecha10/camera"), Some(42));
        assert_eq!(
            registry.last_known_failures().get("@mecha10/teleop"),
            Some(&"binary download failed: HTTP 404".to_string())
        );
    }

    #[test]
    fn state_reports_running_for_a_tracked_pid() {
        let mut registry = NodeRegistry::new();
        registry.mark_started("@mecha10/camera", 42);

        assert_eq!(registry.state("@mecha10/camera"), NodeState::Running(42));
    }

    #[test]
    fn state_reports_failed_for_a_node_with_a_stored_failure() {
        let mut registry = NodeRegistry::new();
        registry.record_failures(&[("@mecha10/teleop".to_string(), "connection timed out".to_string())]);

        assert_eq!(
            registry.state("@mecha10/teleop"),
            NodeState::Failed("connection timed out".to_string())
        );
    }

    #[test]
    fn state_reports_unknown_for_a_node_never_mentioned() {
        let registry = NodeRegistry::new();

        assert_eq!(registry.state("@mecha10/unknown"), NodeState::Unknown);
    }

    #[test]
    fn snapshot_combines_running_and_failed_nodes() {
        let mut registry = NodeRegistry::new();
        registry.mark_started("@mecha10/camera", 42);
        registry.record_failures(&[("@mecha10/teleop".to_string(), "connection timed out".to_string())]);

        let snapshot = registry.snapshot();

        assert_eq!(snapshot.get("@mecha10/camera"), Some(&NodeState::Running(42)));
        assert_eq!(
            snapshot.get("@mecha10/teleop"),
            Some(&NodeState::Failed("connection timed out".to_string()))
        );
        assert_eq!(snapshot.len(), 2);
    }
}