trusty-mpm 1.3.4

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
Documentation
//! The `health` verb: a daemon health probe (`GET /health` + fleet summary).
//!
//! Why: operators (and the action-capable coordinator chat, #1496) routinely ask
//! "run a health check" / "is the daemon up?". The chat-core nucleus had no
//! health verb, so the coordinator deflected. This module is the single
//! dispatchable health probe every adapter shares — it reuses the existing
//! `GET /health` route (liveness + catalog-freshness flags) and the managed-list
//! call (fleet summary) rather than inventing a new daemon endpoint. Splitting it
//! into its own executor submodule keeps every file under the 500-SLOC cap.
//! What: the [`CommandExecutor::health`] method — it probes `GET /health` and the
//! managed list, composing a [`CommandResult::Health`]. A dead daemon yields a
//! `Health { reachable: false, status: "unreachable", .. }` result (NEVER a
//! panic), so an unreachable daemon stays renderable.
//! Test: `execute_health_against_test_daemon` and `execute_health_dead_daemon_renders`,
//! plus `execute_health_excludes_deleted_tombstones_from_managed_total` (#3034
//! fix-round), all in `tests.rs`.

use super::CommandExecutor;
use crate::client::result::{CommandResult, HealthReport};

/// The liveness word reported when the daemon's `/health` probe fails.
const STATUS_UNREACHABLE: &str = "unreachable";

impl CommandExecutor {
    /// `health` — report daemon reachability, catalog freshness, and a fleet
    /// summary.
    ///
    /// Why: the one health probe shared by every adapter and the action loop. It
    /// must be genuinely useful (liveness + catalog drift + how many sessions, how
    /// many blocked) yet degrade gracefully — a daemon that is entirely down is a
    /// renderable `reachable: false` result, not an error or a panic.
    /// What: GETs `GET /health` for the liveness word and the `catalog_stale` /
    /// `catalog_unknown` flags; on success, additionally fetches the managed list
    /// to count total sessions and those blocked on a pending decision. If the
    /// `/health` probe fails (daemon down), returns a `Health` with
    /// `reachable: false` and `status: "unreachable"`, leaving the remaining
    /// fields at their defaults; a managed-list failure after a healthy probe
    /// leaves the fleet counts at zero (the daemon is up; only the secondary call
    /// failed) rather than masking the up status.
    /// Test: `execute_health_against_test_daemon`, `execute_health_dead_daemon_renders`,
    /// `execute_health_excludes_deleted_tombstones_from_managed_total`.
    pub(super) async fn health(&self) -> CommandResult {
        let url = self.client().base_url().to_string();
        let snapshot = match self.client().health_snapshot().await {
            Ok(snapshot) => snapshot,
            // The daemon is unreachable: convey that as a renderable Health result
            // (reachable=false) rather than a transport error or a panic.
            Err(_) => {
                return CommandResult::Health(HealthReport {
                    reachable: false,
                    url,
                    status: STATUS_UNREACHABLE.to_string(),
                    ..HealthReport::default()
                });
            }
        };

        // The daemon answered: summarise the managed fleet too. A failure of this
        // secondary call must not flip the report to "down" — the daemon IS up —
        // so the counts default to zero and the up status is preserved.
        //
        // #3034 fix-round HIGH-2: `list_managed_sessions` now includes stable-slot
        // `deleted: true` tombstone rows (blank fields, no real session behind
        // them) alongside live sessions — they are intentionally shown in `tm ls`
        // per Bob's directive, but a tombstone is not a session an operator can
        // act on, so it must not inflate `managed_total`/`managed_pending_decisions`
        // here. `pending_decision` is always `None` on a tombstone anyway, so the
        // `deleted` filter only changes `managed_total`'s count, not `pending`'s —
        // filtering both keeps this loop's intent explicit rather than relying on
        // that incidental blank-field fact.
        let (managed_total, managed_pending_decisions) =
            match self.client().list_managed_sessions().await {
                Ok(sessions) => {
                    let live = sessions.iter().filter(|s| !s.deleted);
                    let total = live.clone().count();
                    let pending = live.filter(|s| s.pending_decision.is_some()).count();
                    (total, pending)
                }
                Err(_) => (0, 0),
            };

        CommandResult::Health(HealthReport {
            reachable: true,
            url,
            status: snapshot.status,
            catalog_stale: snapshot.catalog_stale,
            catalog_unknown: snapshot.catalog_unknown,
            managed_total,
            managed_pending_decisions,
        })
    }
}