Two sites spelled it as a brace group, services.hyperhive.{hiveName,
swarmName}, which no anchored rewrite can handle correctly now that only
one of the two moves; both are written out separately. One of them is an
MCP tool description, so it is rendered into every agent's system prompt.
327 lines
14 KiB
Rust
327 lines
14 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};
|
|
|
|
// Independent per-agent flags, each its own badge on the dashboard card
|
|
// and each diffed separately by `rescan_containers_and_emit`. Grouping
|
|
// them into nested structs would need `serde(flatten)` to keep the JSON
|
|
// the frontend already consumes, for no readability gain — same
|
|
// rationale as `LifecycleScope` in hive-host-sock.
|
|
#[allow(
|
|
clippy::struct_excessive_bools,
|
|
reason = "flat wire projection of independent per-agent flags"
|
|
)]
|
|
#[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>,
|
|
/// 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
|
|
/// `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<String>,
|
|
/// The agent's turn loop is parked: the container is up and still
|
|
/// serving its web UI / MCP daemons, but it drives no turns and its
|
|
/// inbox messages queue unacked until it resumes. Sourced from the
|
|
/// pause marker in the harness dir (`Coordinator::is_paused`), so it
|
|
/// stays true across a container restart — and, unlike `needs_login`,
|
|
/// is reported for stopped containers too: pausing a stopped agent is
|
|
/// a legitimate way to keep it idle when it next boots.
|
|
#[serde(default)]
|
|
pub paused: bool,
|
|
/// Effective systemd `CPUQuota=` for this container (e.g. `"400%"`) —
|
|
/// the per-agent override from `meta/resource-limits.json` when set,
|
|
/// otherwise the hive-wide `agentCpuQuota`. Always populated: there
|
|
/// is no "unset" state to render, only "same as everyone else".
|
|
/// Reflects what the *drop-in says*, which is what the next start
|
|
/// will enforce — not a live cgroup reading.
|
|
pub cpu_quota: String,
|
|
/// Effective systemd `MemoryMax=` for this container (e.g. `"8G"`).
|
|
/// Same resolution + caveat as [`ContainerView::cpu_quota`].
|
|
pub memory_max: String,
|
|
}
|
|
|
|
/// Build the full container list. Wraps `lifecycle::list()` and
|
|
/// resolves every per-agent attribute the dashboard surfaces.
|
|
///
|
|
/// Takes `hive` because the effective resource limits are a per-field
|
|
/// fallback onto the hive-wide `agent_cpu_quota` / `agent_memory_max`,
|
|
/// and those live on [`HiveEnv`], not on disk. Both callers already
|
|
/// hold a `Coordinator`, so this is a parameter rather than a global.
|
|
pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> 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();
|
|
// Same once-per-scan treatment as the topology map: the override file
|
|
// is read here and resolved per agent below, rather than re-read for
|
|
// every container on every SSE scan.
|
|
let limits = crate::resource_limits::read();
|
|
let mut out = Vec::new();
|
|
for c in &raw {
|
|
let Some(logical) = c.strip_prefix(AGENT_PREFIX) else {
|
|
continue;
|
|
};
|
|
// Parse the nspawn machine suffix into an Ident once at this
|
|
// enumeration origin; a suffix that isn't a valid ident isn't one
|
|
// of our agents, so skip it.
|
|
let Ok(logical) = hive_types::Ident::parse(logical) 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.as_str(), deployed_full).await;
|
|
let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned());
|
|
let parent = topology.get(logical.as_str()).cloned().flatten();
|
|
let running = lifecycle::is_running(logical.as_str()).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
|
|
};
|
|
let paused = Coordinator::is_paused(&logical);
|
|
let (cpu_quota, memory_max) = crate::resource_limits::effective_from(
|
|
&limits,
|
|
logical.as_str(),
|
|
&hive.agent_cpu_quota,
|
|
&hive.agent_memory_max,
|
|
);
|
|
out.push(ContainerView {
|
|
port: lifecycle::agent_web_port(logical.as_str()),
|
|
running,
|
|
container: c.clone(),
|
|
name: logical.into_string(),
|
|
needs_update,
|
|
needs_login,
|
|
deployed_sha,
|
|
parent,
|
|
active_model,
|
|
paused,
|
|
cpu_quota,
|
|
memory_max,
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Host-side mirror of `hive_agent::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: &hive_types::Ident) -> (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: &hive_types::Ident) -> 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: &hive_types::Ident) -> (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: &hive_types::Ident,
|
|
) -> (Option<String>, Option<i64>, bool) {
|
|
if !lifecycle::is_running(name.as_str()).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: &hive_types::Ident) -> 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` + `services.hyperhive.swarm.name`
|
|
/// (the hive names itself; the swarm it joins is named one level out).
|
|
/// The agent-side
|
|
/// `hive-agent::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(crate::paths::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
|
|
}
|