hyperhive/hive-c0re/src/container_view.rs

225 lines
8.8 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, MANAGER_NAME};
/// 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`, `hm1nd`).
pub container: String,
pub is_manager: bool,
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>,
/// 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>,
}
/// 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();
let mut out = Vec::new();
for c in &raw {
let (logical, is_manager) = if c == MANAGER_NAME {
(MANAGER_NAME.to_owned(), true)
} else if let Some(n) = c.strip_prefix(AGENT_PREFIX) {
(n.to_owned(), false)
} else {
continue;
};
let deployed_full = locked.get(&format!("agent-{logical}")).map(|s| s.as_str());
let needs_update = crate::auto_update::agent_config_pending(&logical, deployed_full);
let needs_login =
!is_manager && !claude_has_session(&Coordinator::agent_claude_dir(&logical));
let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned());
// Recipient name the broker uses for this agent — sub-agents
// are addressed by logical name, the manager by the
// MANAGER_AGENT constant. Mirrors the rest of the broker
// surface so the count matches what `mcp__hyperhive__remind`
// queued.
let reminder_recipient = if is_manager {
hive_sh4re::MANAGER_AGENT
} else {
logical.as_str()
};
let pending_reminders = coord
.broker
.count_pending_reminders_for(reminder_recipient)
.unwrap_or(0);
let ctx_tokens = read_last_ctx_tokens(&logical);
let rate_limited = is_rate_limited(&logical);
let extra_links = read_dashboard_links(&logical);
out.push(ContainerView {
port: lifecycle::agent_web_port(&logical),
running: lifecycle::is_running(&logical).await,
container: c.clone(),
name: logical,
is_manager,
needs_update,
needs_login,
deployed_sha,
pending_reminders,
ctx_tokens,
rate_limited,
extra_links,
});
}
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 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()
}
/// Returns true if the agent's harness is currently parked after an API
/// rate-limit response. Detected via the sentinel file written by
/// `hive_ag3nt::events::Bus::emit_status("rate_limited")`.
fn is_rate_limited(name: &str) -> bool {
Coordinator::agent_notes_dir(name)
.join("hyperhive-rate-limited")
.exists()
}
/// Read the most recent completed turn's context-window size (prompt
/// tokens) from the agent's turn-stats SQLite. Returns `None` when
/// the file is absent or has no rows. Best-effort — any DB error
/// silently yields `None` so a missing/corrupt file never blocks
/// `build_all`.
///
/// Context tokens = `last_input_tokens + last_cache_read_input_tokens
/// + last_cache_creation_input_tokens`, mirroring
/// `hive_ag3nt::events::TokenUsage::context_tokens`.
fn read_last_ctx_tokens(name: &str) -> Option<u64> {
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 \
FROM turn_stats ORDER BY started_at DESC LIMIT 1",
[],
|row| row.get::<_, i64>(0),
)
.ok()
.and_then(|v| u64::try_from(v).ok())
}
/// 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
}