//! `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, } /// 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()); 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, }); } 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 }