//! `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}; #[derive(Serialize, Clone, PartialEq, Eq, Debug)] 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`). Internal only; /// not serialized to API responses since the dashboard no longer displays it. #[serde(skip)] pub container: String, 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, /// Name of this agent's parent in the agent hierarchy. `None` /// marks the agent as root-level; the dashboard renders it without /// indentation. Sourced from `meta/topology.json` (single source of /// truth, hive-c0re-owned) — NOT from per-agent agent.nix, because /// an agent shouldn't be able to unilaterally declare its own place /// in the tree. See `docs/agent-hierarchy.md::Current state`. #[serde(default, skip_serializing_if = "Option::is_none")] pub parent: Option, /// The Claude model the agent's harness is currently using, read from /// `state/hyperhive-harness.json["active_model"]`. `None` when the /// agent has never started a turn or the field is absent. Only /// meaningful when `running` is true; the dashboard skips the badge /// for stopped containers. #[serde(default, skip_serializing_if = "Option::is_none")] pub active_model: 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 locked = read_meta_locked_revs(); // Pull the topology map once and look up each agent's parent below. // Empty / absent topology.json → every agent root-level (safe // degradation for fresh installs that haven't run sync_agents yet). let topology = crate::topology::read(); let mut out = Vec::new(); for c in &raw { let Some(logical) = c.strip_prefix(AGENT_PREFIX).map(str::to_owned) else { continue; }; let deployed_full = locked .get(&format!("agent-{logical}")) .map(std::string::String::as_str); let needs_update = crate::auto_update::agent_config_pending(&logical, deployed_full).await; let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned()); let pending_reminders = coord .broker .count_pending_reminders_for(logical.as_str()) .unwrap_or(0); let parent = topology.get(&logical).cloned().flatten(); let running = lifecycle::is_running(&logical).await; // needs_login fires when EITHER the claude session dir is missing // (boot-time / fresh container) OR the harness wrote the auth-failed // sentinel because a turn hit 401. Cleared for stopped containers — // stale sentinel state is not meaningful when the harness isn't up. let needs_login = running && (!claude_has_session(&Coordinator::agent_claude_dir(&logical)) || auth_failed_sentinel(&logical)); // Read the active model from the harness state file. Only surfaced // when the container is running — stale model info from a stopped // agent is misleading (the model may change on next boot). let active_model = if running { read_active_model(&logical) } else { None }; out.push(ContainerView { port: lifecycle::agent_web_port(&logical), running, container: c.clone(), name: logical, needs_update, needs_login, deployed_sha, pending_reminders, parent, active_model, }); } 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())) } /// Read `rate_limited` + `needs_login` (auth-failed sentinel) from /// the consolidated `hyperhive-harness.json`. Falls back to the legacy /// individual sentinel files written by older harness builds so in-place /// upgrades don't lose state during the transition window. fn read_harness_flags(name: &str) -> (bool, bool) { let dir = Coordinator::agent_notes_dir(name); if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json")) && let Ok(v) = serde_json::from_str::(&raw) { let rl = v .get("rate_limited") .and_then(serde_json::Value::as_bool) .unwrap_or(false); let nl = v .get("needs_login") .and_then(serde_json::Value::as_bool) .unwrap_or(false); return (rl, nl); } // Legacy fallback: presence of individual sentinel files. let rate_limited = dir.join("hyperhive-rate-limited").exists(); let needs_login = dir.join("hyperhive-needs-login").exists(); (rate_limited, needs_login) } fn auth_failed_sentinel(name: &str) -> bool { read_harness_flags(name).1 } /// Read the agent's free-text status and the Unix timestamp when it was last set /// (derived from the file's mtime). Returns `(None, None)` when the file is absent /// or empty. `pub` so `socket_server` and `socket_server` can populate `AgentMeta`. /// /// NB: callers building `AgentMeta` for a *stopped* container should /// clear the result — the on-disk status is a stale snapshot from /// before the stop. Use `read_agent_status_live` for that. pub fn read_agent_status(name: &str) -> (Option, Option) { let path = Coordinator::agent_notes_dir(name).join("hyperhive-status"); let meta = std::fs::metadata(&path).ok(); // Read at most STATUS_MAX_CHARS * 4 + 2 bytes: 4 is the max UTF-8 byte // width per char, +2 for the trailing newline. Guards against a // pathologically large file written outside the harness validation path. let s = { use std::io::Read as _; std::fs::File::open(&path).ok().and_then(|f| { let cap = (crate::limits::STATUS_MAX_CHARS * 4 + 2) as u64; let mut buf = String::new(); f.take(cap).read_to_string(&mut buf).ok().map(|_| buf) }) }; let text = s .as_deref() .map(str::trim) .filter(|t| !t.is_empty()) .map(str::to_owned); let mtime = meta.and_then(|m| { m.modified().ok().and_then(|t| { t.duration_since(std::time::UNIX_EPOCH) .ok() .and_then(|d| i64::try_from(d.as_secs()).ok()) }) }); if text.is_none() { (None, None) } else { (text, mtime) } } /// Wraps `read_agent_status` with the same "stopped containers have /// stale state" gate `build_all` uses. Returns `(None, None, false)` /// when the container isn't running so callers don't have to know /// about the sentinel rules — they just hand back what we give them. /// /// Returned tuple is `(status_text, status_set_at, running)`. /// `name` is the logical agent name (same as the broker recipient). pub async fn read_agent_status_live(name: &str) -> (Option, Option, bool) { if !lifecycle::is_running(name).await { return (None, None, false); } let (text, set_at) = read_agent_status(name); (text, set_at, true) } /// Read the active Claude model from `hyperhive-harness.json` (the /// consolidated harness state file in the agent's state dir). Written by /// the harness on startup and on every `set_model` / `emit_status` call, /// so it always reflects the resolved priority (nix config > runtime /// override > default). Returns `None` when the field is absent or the /// harness has not yet started a turn. fn read_active_model(name: &str) -> Option { let path = Coordinator::agent_notes_dir(name).join("hyperhive-harness.json"); let raw = std::fs::read_to_string(path).ok()?; let v: serde_json::Value = serde_json::from_str(&raw).ok()?; v.get("active_model") .and_then(serde_json::Value::as_str) .filter(|s| !s.is_empty()) .map(str::to_owned) } /// Host-side hive + swarm display names, read from the c0re service's /// own process env. The `hive-c0re.nix` module sets these from /// `services.hyperhive.{hiveName, swarmName}`. The agent-side /// `hive-ag3nt::identity::{hive_name, swarm_name}` accessors read the /// same env vars after they're forwarded into each sub-agent's /// harness service environment by `meta::render_flake`; surfacing /// them here from c0re's own env keeps the manager + agent /// `GetAgentMeta` paths consistent without a round-trip to the /// target container. /// /// Returns `(hive_name, swarm_name)`. Each is `None` when the /// corresponding env var is unset or empty. #[must_use] pub fn hive_swarm_names() -> (Option, Option) { let read = |var: &str| -> Option { std::env::var(var).ok().filter(|s| !s.is_empty()) }; (read("HYPERHIVE_HIVE_NAME"), read("HYPERHIVE_SWARM_NAME")) } /// 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 }