//! `ContainerView` + the snapshot builder that turns //! `nixos-container list` (plus per-agent state on disk) into the row //! shape the dashboard renders. Extracted from `dashboard.rs` so the //! coordinator's rescan-and-emit helper can build the same view and //! diff against the last snapshot to fire //! `ContainerStateChanged` / `ContainerRemoved` events. use std::collections::HashMap; use std::path::Path; use serde::Serialize; use crate::coordinator::Coordinator; use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME}; #[derive(Serialize, Clone, PartialEq, Eq, Debug)] #[allow(clippy::struct_excessive_bools)] pub struct ContainerView { /// Logical agent name (no `h-` prefix). Used in action URLs. pub name: String, /// Container name as nixos-container sees it (`h-foo`, `hm1nd`). pub container: String, pub is_manager: bool, pub port: u16, pub running: bool, pub needs_update: bool, pub needs_login: bool, /// First 12 chars of the sha the meta flake currently has locked /// for this agent's input. #[serde(skip_serializing_if = "Option::is_none")] pub deployed_sha: Option, /// Count of this agent's pending reminders. Computed during /// `build_all` via `Broker::count_pending_reminders_for`; the /// dashboard renders a small chip when > 0. Updates with the /// 10s `crash_watch` rescan + every container mutation site; /// not real-time on remind/cancel-reminder but close enough. #[serde(default)] pub pending_reminders: u64, } /// Build the full container list. Wraps `lifecycle::list()` and /// resolves every per-agent attribute the dashboard surfaces. pub async fn build_all(coord: &Coordinator) -> Vec { let raw = lifecycle::list().await.unwrap_or_default(); let current_rev = crate::auto_update::current_flake_rev(&coord.hyperhive_flake); let locked = read_meta_locked_revs(); let mut out = Vec::new(); for c in &raw { let (logical, is_manager) = if c == MANAGER_NAME { (MANAGER_NAME.to_owned(), true) } else if let Some(n) = c.strip_prefix(AGENT_PREFIX) { (n.to_owned(), false) } else { continue; }; let needs_update = current_rev.as_deref().is_some_and(|rev| crate::auto_update::agent_needs_update(&logical, rev)); let needs_login = !is_manager && !claude_has_session(&Coordinator::agent_claude_dir(&logical)); let deployed_sha = locked .get(&format!("agent-{logical}")) .map(|s| s[..s.len().min(12)].to_owned()); // Recipient name the broker uses for this agent — sub-agents // are addressed by logical name, the manager by the // MANAGER_AGENT constant. Mirrors the rest of the broker // surface so the count matches what `mcp__hyperhive__remind` // queued. let reminder_recipient = if is_manager { hive_sh4re::MANAGER_AGENT } else { logical.as_str() }; let pending_reminders = coord .broker .count_pending_reminders_for(reminder_recipient) .unwrap_or(0); out.push(ContainerView { port: lifecycle::agent_web_port(&logical), running: lifecycle::is_running(&logical).await, container: c.clone(), name: logical, is_manager, needs_update, needs_login, deployed_sha, pending_reminders, }); } out } /// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true /// if the agent's bound `~/.claude/` dir on disk contains any regular /// file. Reads each `build_all()` so a login driven from the agent's /// own web UI reflects on the next snapshot. pub fn claude_has_session(dir: &Path) -> bool { let Ok(entries) = std::fs::read_dir(dir) else { return false; }; entries .flatten() .any(|e| e.file_type().is_ok_and(|t| t.is_file())) } /// Map of `agent-` → locked sha from meta's flake.lock. Used to /// render the `deployed:` chip per container row. fn read_meta_locked_revs() -> HashMap { let mut out = HashMap::new(); let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else { return out; }; let Ok(json) = serde_json::from_str::(&raw) else { return out; }; let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else { return out; }; let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else { return out; }; let Some(root_inputs) = nodes .get(root_name) .and_then(|n| n.get("inputs")) .and_then(|v| v.as_object()) else { return out; }; for alias in root_inputs.keys() { let target_name = match root_inputs.get(alias) { Some(serde_json::Value::String(s)) => s.clone(), _ => continue, }; if let Some(rev) = nodes .get(&target_name) .and_then(|n| n.get("locked")) .and_then(|v| v.get("rev")) .and_then(|v| v.as_str()) { out.insert(alias.clone(), rev.to_owned()); } } out }