//! `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 rusqlite::Connection; use serde::{Deserialize, Serialize}; use crate::coordinator::Coordinator; use crate::lifecycle::{self, AGENT_PREFIX}; /// An agent-declared extra navigation link surfaced on the dashboard card. /// Written by the `hive-dashboard-links` NixOS oneshot into /// `{state_dir}/hyperhive-dashboard-links.json` and read by `build_all`. #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Default)] pub struct DashboardLink { pub label: String, #[serde(default)] pub icon: String, pub url: String, } #[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`). 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, /// Context-window size (prompt tokens) from the agent's most recent /// completed turn, read directly from the turn-stats `SQLite`. /// `None` when the file is absent or the agent has no turns yet. /// Stale by up to one crash-watch cycle (~10s); good enough for /// the "which agent is close to the window?" dashboard glance. #[serde(default, skip_serializing_if = "Option::is_none")] pub ctx_tokens: Option, /// Context-window size (tokens) for the model this agent ran on its /// most recent turn — the model name from the last turn-stats row /// resolved against the host's per-model `contextWindowTokens` /// config. Lets the dashboard derive the ctx badge thresholds /// (75% / 50% of the window, matching the harness compaction /// watermarks) instead of hardcoding them. `None` when the agent /// has no turns yet or no config key matches the model. #[serde(default, skip_serializing_if = "Option::is_none")] pub context_window_tokens: Option, /// True while the harness is parked after an API rate-limit response. /// Detected via the sentinel file `{state_dir}/hyperhive-rate-limited` /// that the harness writes in `Bus::emit_status("rate_limited")` and /// removes when it resumes. Stale by up to one crash-watch cycle. #[serde(default)] pub rate_limited: bool, /// Extra navigation links declared by the agent via /// `hyperhive.dashboardLinks` in `agent.nix`. Written to /// `{state_dir}/hyperhive-dashboard-links.json` by the /// `hive-dashboard-links` oneshot at container boot. Empty when /// the file is absent or the agent declares no links. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub extra_links: Vec, /// Free-text status set by the agent via `mcp__hyperhive__set_status`. /// Persisted to `{state_dir}/hyperhive-status`. `None` when the file /// is absent or empty — the agent hasn't set one yet. #[serde(default, skip_serializing_if = "Option::is_none")] pub status_text: Option, /// Unix timestamp (seconds since epoch) when the status was last written. /// Derived from the `hyperhive-status` file's mtime. `None` when no /// status is set. #[serde(default, skip_serializing_if = "Option::is_none")] pub status_set_at: Option, /// 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, } /// 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); 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 extra_links = read_dashboard_links(&logical); let parent = topology.get(&logical).cloned().flatten(); let running = lifecycle::is_running(&logical).await; // Live-only fields — only meaningful while the harness is up. // When the container is stopped, sentinel files + turn-stats // rows + the on-disk status blob are all stale snapshots from // before the stop, so we clear them here rather than letting // the dashboard / `get_agent_meta` surface misleading values. // Static / declared fields (extra_links, deployed_sha, // pending_reminders, needs_update, parent) stay populated // regardless of run state. let ( needs_login, ctx_tokens, context_window_tokens, rate_limited, status_text, status_set_at, ) = if running { // 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. let needs_login = !claude_has_session(&Coordinator::agent_claude_dir(&logical)) || auth_failed_sentinel(&logical); let last_turn = read_last_turn(&logical); let ctx_tokens = last_turn.as_ref().map(|(toks, _)| *toks); let context_window_tokens = last_turn .as_ref() .and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens)); let rate_limited = is_rate_limited(&logical); let (status_text, status_set_at) = read_status(&logical); ( needs_login, ctx_tokens, context_window_tokens, rate_limited, status_text, status_set_at, ) } else { (false, None, None, false, None, None) }; out.push(ContainerView { port: lifecycle::agent_web_port(&logical), running, container: c.clone(), name: logical, needs_update, needs_login, deployed_sha, pending_reminders, ctx_tokens, context_window_tokens, rate_limited, extra_links, status_text, status_set_at, parent, }); } 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())) } /// Build the navigation link list for an agent's dashboard card. /// /// Mirrors the logic in `hive_ag3nt::web_ui::agent_links` but runs on /// the host via disk reads — no network call to the agent web UI needed. /// The GUI screen link is omitted here; `/etc/hyperhive/gui.json` lives /// inside the agent container and is not host-visible. /// /// Returns a `serde_json::Value` array matching the `AgentLink` JSON /// shape the harness returns from `GET /api/state`, so /// `dashboard::get_agent_links` can serve it directly. pub fn build_nav_links(name: &str) -> serde_json::Value { let state_dir = Coordinator::agent_notes_dir(name); let mut links: Vec = Vec::new(); // Stats page — always present. links.push(serde_json::json!({ "url": "stats.html", "icon": "📊", "label": "stats", "kind": "container", })); // Forge profile + config mirror — only when the agent has a forge account. if state_dir.join("forge-token").is_file() { links.push(serde_json::json!({ "url": format!("/{name}"), "icon": "⬡", "label": "forge", "kind": "forge", })); links.push(serde_json::json!({ "url": format!("/agent-configs/{name}"), "icon": "↳", "label": "config", "kind": "forge", })); } // Agent-declared extras (absolute URLs — passed through verbatim). for lnk in read_dashboard_links(name) { links.push(serde_json::json!({ "url": lnk.url, "icon": lnk.icon, "label": lnk.label, "kind": "external", })); } serde_json::Value::Array(links) } /// Read agent-declared extra dashboard links from /// `{state_dir}/hyperhive-dashboard-links.json`. Returns an empty vec when /// the file is absent, empty, or unparseable — best-effort, never panics. fn read_dashboard_links(name: &str) -> Vec { let path = Coordinator::agent_notes_dir(name).join("hyperhive-dashboard-links.json"); let text = match std::fs::read_to_string(&path) { Ok(t) if !t.trim().is_empty() => t, _ => return Vec::new(), }; serde_json::from_str::>(&text).unwrap_or_default() } /// Read `rate_limited` + `needs_login` 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")) { if let Ok(v) = serde_json::from_str::(&raw) { let rl = v .get("rate_limited") .and_then(|x| x.as_bool()) .unwrap_or(false); let nl = v .get("needs_login") .and_then(|x| x.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 is_rate_limited(name: &str) -> bool { read_harness_flags(name).0 } 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 `agent_server` and `manager_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) } } fn read_status(name: &str) -> (Option, Option) { read_agent_status(name) } /// 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) } /// 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")) } /// Read the agent's most recent completed turn from its turn-stats /// `SQLite`: the context-window size (prompt tokens) and the model name. /// Returns `None` when the file is absent or has no rows. Best-effort /// — any database error silently yields `None` so a missing or /// corrupt file never blocks `build_all`. /// /// Context tokens sum the prompt-side fields (`last_input_tokens`, /// `last_cache_read_input_tokens`, `last_cache_creation_input_tokens`), /// mirroring `hive_ag3nt::events::TokenUsage::context_tokens`. fn read_last_turn(name: &str) -> Option<(u64, String)> { let path = Coordinator::agent_notes_dir(name).join("hyperhive-turn-stats.sqlite"); let conn = Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?; conn.query_row( "SELECT last_input_tokens + last_cache_read_input_tokens + last_cache_creation_input_tokens, model \ FROM turn_stats ORDER BY started_at DESC LIMIT 1", [], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), ) .ok() .and_then(|(toks, model)| Some((u64::try_from(toks).ok()?, model))) } /// Resolve a model name to its context-window size using the host's /// per-model `contextWindowTokens` config. Mirrors the harness's /// `events::context_window_tokens` substring match: the first config /// key (lowercased, non-empty) that is a substring of the lowercased /// model name wins. `None` when nothing matches. fn resolve_ctx_window(model: &str, per_model: &HashMap) -> Option { let m = model.to_ascii_lowercase(); per_model .iter() .find(|(key, _)| { let k = key.to_ascii_lowercase(); !k.is_empty() && m.contains(&k) }) .map(|(_, &tokens)| tokens) } /// 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 } #[cfg(test)] mod tests { use super::resolve_ctx_window; use std::collections::HashMap; fn cfg() -> HashMap { [ ("haiku".to_owned(), 200_000), ("sonnet".to_owned(), 1_000_000), ("opus".to_owned(), 1_000_000), ] .into_iter() .collect() } #[test] fn resolves_family_substring() { assert_eq!( resolve_ctx_window("claude-3-5-haiku-20241022", &cfg()), Some(200_000) ); assert_eq!( resolve_ctx_window("claude-sonnet-4-5", &cfg()), Some(1_000_000) ); assert_eq!( resolve_ctx_window("claude-opus-4-1", &cfg()), Some(1_000_000) ); } #[test] fn resolution_is_case_insensitive() { assert_eq!( resolve_ctx_window("Claude-Sonnet-4", &cfg()), Some(1_000_000) ); } #[test] fn unknown_model_yields_none() { assert_eq!(resolve_ctx_window("some-other-llm", &cfg()), None); } #[test] fn empty_config_yields_none() { assert_eq!( resolve_ctx_window("claude-3-5-haiku", &HashMap::new()), None ); } #[test] fn empty_key_is_skipped() { let mut m = HashMap::new(); m.insert(String::new(), 999); assert_eq!(resolve_ctx_window("claude-3-5-haiku", &m), None); } }