510 lines
20 KiB
Rust
510 lines
20 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 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<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,
|
|
/// 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<u64>,
|
|
/// 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<u64>,
|
|
/// 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<DashboardLink>,
|
|
/// 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<String>,
|
|
/// 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<i64>,
|
|
/// 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>,
|
|
}
|
|
|
|
/// 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 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<serde_json::Value> = 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<DashboardLink> {
|
|
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::<Vec<DashboardLink>>(&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::<serde_json::Value>(&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<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)
|
|
}
|
|
}
|
|
|
|
fn read_status(name: &str) -> (Option<String>, Option<i64>) {
|
|
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<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)
|
|
}
|
|
|
|
/// 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"))
|
|
}
|
|
|
|
/// 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<String, u64>) -> Option<u64> {
|
|
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-<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
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::resolve_ctx_window;
|
|
use std::collections::HashMap;
|
|
|
|
fn cfg() -> HashMap<String, u64> {
|
|
[
|
|
("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);
|
|
}
|
|
}
|