hyperhive/hive-c0re/src/container_view.rs
iris c580d721fb refactor(dash): fold active_model into hyperhive-harness.json, not a separate file
hive-c0re was reading harness/hyperhive-model directly to surface the
model badge on the dashboard. hyperhive-model is a runtime-override
file (not the resolved priority) and adds to the marker-file count.

Instead: mirror the fully-resolved model into hyperhive-harness.json
(the consolidated state file that already replaced hyperhive-rate-limited
/ hyperhive-needs-login). Written by hive-ag3nt on:
- Bus::new() startup (captures nix config > override > default)
- set_model() runtime change (MCP set-model call)
- emit_status() (keeps model current across rate-limit / auth flips)

hive-c0re reads active_model from hyperhive-harness.json, same dir +
same read path as rate_limited / needs_login. No new files.
2026-06-27 22:59:40 +02:00

280 lines
12 KiB
Rust

//! `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<String>,
/// 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<String>,
/// The Claude model the agent's harness is currently using, read from
/// `harness/hyperhive-model`. `None` when the agent has never started
/// a turn or the file 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<String>,
}
/// 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<ContainerView> {
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 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::<serde_json::Value>(&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<String>, Option<i64>) {
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<String>, Option<i64>, 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<String> {
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<String>, Option<String>) {
let read = |var: &str| -> Option<String> { std::env::var(var).ok().filter(|s| !s.is_empty()) };
(read("HYPERHIVE_HIVE_NAME"), read("HYPERHIVE_SWARM_NAME"))
}
/// Map of `agent-<n>` → locked sha from meta's flake.lock. Used to
/// render the `deployed:<sha12>` chip per container row.
fn read_meta_locked_revs() -> HashMap<String, String> {
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::<serde_json::Value>(&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
}