Closes the #3110 split — lib.rs is now just the crate doc comment and the pub mod list. journal.rs's new doc comment fixes a pre-existing bug: the old JournalPriority doc text in lib.rs was actually half Capability's doc (a leftover from an earlier reorder that moved the code but not the comment above it).
400 lines
17 KiB
Rust
400 lines
17 KiB
Rust
//! `/api/state` + `/api/dashboard-state` snapshot builders.
|
|
|
|
use axum::extract::State;
|
|
use serde::Serialize;
|
|
|
|
use crate::login::LoginState;
|
|
use crate::login_session::drop_if_finished;
|
|
|
|
use super::AppState;
|
|
|
|
pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
|
// Capture seq *before* any reads so the dedupe contract is
|
|
// "events with seq > snapshot.seq are post-snapshot, never missed."
|
|
let seq = state.bus.current_seq();
|
|
drop_if_finished(&state.session);
|
|
let login = *state.login.lock().unwrap();
|
|
let session_snapshot = state.session.lock().unwrap().clone();
|
|
let (status, session_view) = match (login, session_snapshot) {
|
|
(LoginState::Online, _) if state.bus.is_rate_limited() => ("rate_limited", None),
|
|
(LoginState::Online, _) => ("online", None),
|
|
(LoginState::NeedsLogin, None) => ("needs_login_idle", None),
|
|
(LoginState::NeedsLogin, Some(s)) => (
|
|
"needs_login_in_progress",
|
|
Some(SessionView {
|
|
url: s.url(),
|
|
output: s.output(),
|
|
finished: s.finished(),
|
|
exit_note: s.exit_note(),
|
|
}),
|
|
),
|
|
};
|
|
let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT")
|
|
.ok()
|
|
.and_then(|s| s.parse::<u16>().ok())
|
|
.unwrap_or(7000);
|
|
let inbox = recent_inbox(&state.socket).await;
|
|
let (turn_state, turn_state_since) = state.bus.state_snapshot();
|
|
let model = state.bus.model();
|
|
let context_window_tokens = state.bus.effective_context_window(&model);
|
|
let ctx_usage = state.bus.last_ctx_usage();
|
|
let cost_usage = state.bus.last_cost_usage();
|
|
let effort = state.bus.effort();
|
|
axum::Json(StateSnapshot {
|
|
seq,
|
|
label: state.label.clone(),
|
|
qualified_label: crate::identity::qualify(&state.label),
|
|
dashboard_port,
|
|
status,
|
|
session: session_view,
|
|
inbox,
|
|
turn_state,
|
|
turn_state_since,
|
|
model,
|
|
context_window_tokens,
|
|
ctx_usage,
|
|
cost_usage,
|
|
links: agent_links(&state.label, state.gui_vnc_port.is_some()),
|
|
forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL")
|
|
.ok()
|
|
.filter(|s| !s.is_empty()),
|
|
hive_name: crate::identity::hive_name(),
|
|
swarm_name: crate::identity::swarm_name(),
|
|
available_models: available_models(),
|
|
effort,
|
|
available_efforts: crate::harness_state::EFFORT_LEVELS
|
|
.iter()
|
|
.map(ToString::to_string)
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
/// Lean snapshot of the agent-owned fields that the dashboard card
|
|
/// needs. Served at `GET /api/dashboard-state` (accessible through the
|
|
/// gateway at `/agent/<name>/api/dashboard-state`). The dashboard
|
|
/// fetches this once per running agent to get fresh, agent-authoritative
|
|
/// values instead of relying on hive-c0re's periodic file-reads.
|
|
///
|
|
/// Structural fields (running, `needs_update`, `deployed_sha`, parent, …)
|
|
/// continue to come from hive-c0re's `/api/state`; this endpoint covers
|
|
/// only the fields the agent itself is the source of truth for.
|
|
pub(super) async fn api_dashboard_state(
|
|
State(state): State<AppState>,
|
|
) -> axum::Json<DashboardState> {
|
|
let (status_text, status_set_at) = read_own_status();
|
|
let rate_limited = state.bus.is_rate_limited();
|
|
let model = state.bus.model();
|
|
let context_window_tokens = state.bus.effective_context_window(&model);
|
|
// Full context-window size = input + cache-read + cache-creation. Using
|
|
// raw `input_tokens` here reported only the *uncached* sliver, which is
|
|
// ~0 once prompt caching kicks in — so every card showed `ctx·0k`. Match
|
|
// the agent page (which ships the whole `ctx_usage` and sums it) and the
|
|
// cache-TTL logic in turn.rs, both of which use `context_tokens()`.
|
|
let ctx_tokens = state.bus.last_ctx_usage().map(|u| u.context_tokens());
|
|
axum::Json(DashboardState {
|
|
status_text,
|
|
status_set_at,
|
|
ctx_tokens,
|
|
context_window_tokens,
|
|
rate_limited,
|
|
links: agent_links(&state.label, state.gui_vnc_port.is_some()),
|
|
})
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub(super) struct StateSnapshot {
|
|
/// Bus seq at the moment this snapshot was assembled. Clients dedupe
|
|
/// their buffered SSE traffic against this value: events with
|
|
/// `seq <= snapshot.seq` are already reflected (or pre-date the
|
|
/// snapshot); `seq > snapshot.seq` is post-snapshot. Reset to 0 on
|
|
/// harness restart — clients treat reconnect as a fresh world.
|
|
seq: u64,
|
|
label: String,
|
|
/// Hive-qualified long name (`${label}@${hyperhive.domain}`) when
|
|
/// the host has been configured for a multi-hive swarm; falls back
|
|
/// to the short label when the hive domain env var is unset.
|
|
/// The frontend uses this for the page title / agent self-introduction;
|
|
/// when it equals `label`, the page renders the short form unchanged.
|
|
qualified_label: String,
|
|
dashboard_port: u16,
|
|
/// `"online"` | `"rate_limited"` | `"needs_login_idle"` | `"needs_login_in_progress"`.
|
|
status: &'static str,
|
|
/// Present when `status == "needs_login_in_progress"`.
|
|
session: Option<SessionView>,
|
|
/// Last N messages addressed to this agent, newest-first. Pulled
|
|
/// from the broker via the per-agent socket on each render.
|
|
/// Empty on transport failure.
|
|
inbox: Vec<hive_sh4re::inbox::InboxRow>,
|
|
/// Authoritative turn-loop state from the harness and the unix
|
|
/// timestamp the state was entered. The JS computes the age
|
|
/// client-side off this rather than tracking it from SSE events.
|
|
turn_state: crate::events::TurnState,
|
|
turn_state_since: i64,
|
|
/// Currently-active claude model name. Reflected on the page so
|
|
/// the operator can see what they just switched to (and what's
|
|
/// in flight). Mutable at runtime via `POST /api/model`.
|
|
model: String,
|
|
/// Effective context-window token budget for the current model.
|
|
/// Primary source: API-reported `modelUsage.*.contextWindow` from
|
|
/// the last result event (authoritative per-inference active window).
|
|
/// Falls back to `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars, then 200 000.
|
|
/// Consumers (e.g. dashboard badge) use this to render ctx-usage %.
|
|
context_window_tokens: u64,
|
|
/// Last-inference token usage from the most recent completed
|
|
/// turn — represents the current context-window size at turn-end.
|
|
/// `null` until the first turn finishes.
|
|
ctx_usage: Option<hive_claude::TokenUsage>,
|
|
/// Cumulative token usage across the most recent turn's inferences
|
|
/// (cost signal). `null` until the first turn finishes.
|
|
cost_usage: Option<hive_claude::TokenUsage>,
|
|
/// Navigation links for this agent page. Also served via
|
|
/// `DashboardState.links` (`GET /api/dashboard-state`) for the
|
|
/// dashboard card's icon strip. Both are produced by `agent_links()`
|
|
/// — single source of truth. See [`docs/web-ui/dashboard.md::Container row`]
|
|
/// for the frontend resolver + which links appear in which conditions.
|
|
links: Vec<AgentLink>,
|
|
/// Public URL of the forge (e.g.
|
|
/// `"https://forge.pr1ma.darkest.space"`). Sourced from
|
|
/// `HIVE_FORGE_PUBLIC_URL` (set from `services.hyperhive.forge.
|
|
/// publicUrl`); `None` when unset. The frontend uses this to build
|
|
/// forge nav-strip links, and **hides** the forge link entirely
|
|
/// when absent rather than guessing `<hostname>:3000`.
|
|
forge_public_url: Option<String>,
|
|
/// Human name of this hive instance (e.g. `"pr1ma"`). Sourced
|
|
/// from `HYPERHIVE_HIVE_NAME`; `None` when unset. The frontend
|
|
/// uses this for the page `<title>` and header label so browser
|
|
/// tabs disambiguate when multiple hives are open in parallel.
|
|
hive_name: Option<String>,
|
|
/// Human name of the swarm (e.g. `"constellat1on"`). Sourced from
|
|
/// `HYPERHIVE_SWARM_NAME`; `None` when unset.
|
|
swarm_name: Option<String>,
|
|
/// Ordered list of model short-names the operator has declared as
|
|
/// available on this hive. Sourced from `HIVE_AVAILABLE_MODELS`
|
|
/// (comma-separated, set by `services.hyperhive.availableModels`).
|
|
/// Falls back to `["haiku", "sonnet", "opus"]` when the env var is
|
|
/// absent or empty. The frontend model quick-picker renders one button
|
|
/// per entry in this list, so operators can add new models or drop
|
|
/// ones they don't want without touching the frontend code.
|
|
available_models: Vec<String>,
|
|
/// Currently-active claude effort level. Reflected on the page so the
|
|
/// operator's effort picker shows the live selection. Mutable at
|
|
/// runtime via `POST /api/effort`; applies on the next session.
|
|
effort: String,
|
|
/// Selectable effort levels for the picker, ascending. Fixed set
|
|
/// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from
|
|
/// [`crate::harness_state::EFFORT_LEVELS`], not operator-configurable like
|
|
/// `available_models`. The frontend renders one button per entry.
|
|
available_efforts: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SessionView {
|
|
/// First `https://…` claude emitted on stdout, if any.
|
|
url: Option<String>,
|
|
/// Accumulated stdout + stderr.
|
|
output: String,
|
|
finished: bool,
|
|
exit_note: Option<String>,
|
|
}
|
|
|
|
/// One navigation link in the agent page header row. The same JSON
|
|
/// shape appears in both `StateSnapshot.links` (`GET /api/state`,
|
|
/// per-agent page) and `DashboardState.links` (`GET /api/dashboard-state`,
|
|
/// dashboard card icon strip). `agent_links()` is the single source
|
|
/// of truth for what links an agent exposes.
|
|
#[derive(Serialize)]
|
|
struct AgentLink {
|
|
/// `kind = Container | Forge` → path; `kind = External` → full URL.
|
|
/// The frontend prepends the right base before rendering.
|
|
url: String,
|
|
icon: String,
|
|
label: String,
|
|
kind: AgentLinkKind,
|
|
}
|
|
|
|
/// Resolution hint for `AgentLink.url`. The agent backend can't know
|
|
/// which hostname the browser sees (especially when the dashboard
|
|
/// proxies the call from a different origin), so it labels each link
|
|
/// and lets the frontend prepend the right base.
|
|
#[derive(Serialize, Clone, Copy)]
|
|
#[serde(rename_all = "snake_case")]
|
|
enum AgentLinkKind {
|
|
/// `url` is a path on the agent's container web UI (`/stats`,
|
|
/// `/screen`). Agent page: same-origin path. Dashboard:
|
|
/// `http://<host>:<container.port><url>`.
|
|
Container,
|
|
/// `url` is a path on the local Forgejo (`/<label>`,
|
|
/// `/agent-configs/<label>`). Both surfaces:
|
|
/// `http://<host>:3000<url>`.
|
|
Forge,
|
|
/// `url` is already a fully-qualified absolute URL — use as-is.
|
|
/// Agent-declared `hyperhive.dashboardLinks` extras arrive here.
|
|
External,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
pub(super) struct DashboardState {
|
|
/// Free-text status set by `set_status`, read directly from the
|
|
/// `hyperhive-status` file the harness writes. `None` when unset.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
status_text: Option<String>,
|
|
/// Unix timestamp (seconds) when the status file was last written.
|
|
/// `None` when no status is set.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
status_set_at: Option<i64>,
|
|
/// Full context-window size from the most recent completed turn
|
|
/// (`ctx_usage.context_tokens()` = input + cache-read + cache-creation).
|
|
/// `None` until the first turn finishes. Drives the `ctx·Nk` card badge.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
ctx_tokens: Option<u64>,
|
|
/// Effective context-window budget for the current model. Same
|
|
/// derivation as `StateSnapshot::context_window_tokens`.
|
|
context_window_tokens: u64,
|
|
/// True while the harness is parked after a rate-limit response.
|
|
rate_limited: bool,
|
|
/// Navigation links for the dashboard card's icon strip. This is
|
|
/// the authoritative source — includes the screen link (GUI agents)
|
|
/// which hive-c0re's disk-based fallback cannot determine.
|
|
links: Vec<AgentLink>,
|
|
}
|
|
|
|
/// Read the agent's own free-text status and the timestamp when it was
|
|
/// set, directly from the `hyperhive-status` file in the state dir.
|
|
/// Mirrors `hive_c0re::container_view::read_agent_status` but runs
|
|
/// inside the agent container using its own state dir.
|
|
fn read_own_status() -> (Option<String>, Option<i64>) {
|
|
let path = crate::paths::state_dir().join("hyperhive-status");
|
|
let meta = std::fs::metadata(&path).ok();
|
|
let text = std::fs::read_to_string(&path)
|
|
.ok()
|
|
.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)
|
|
}
|
|
}
|
|
|
|
/// Build the navigation link list for the agent page header. URLs
|
|
/// are paths (relative) for `Container`/`Forge` targets and absolute
|
|
/// for `External`; the frontend resolves each against its `kind`
|
|
/// against the right base so the backend never has to guess the
|
|
/// operator's browser host. See
|
|
/// [`docs/web-ui/dashboard.md::Container row`](../../../docs/web-ui/dashboard.md) for
|
|
/// the resolver + how `deployed:<sha>` ships alongside.
|
|
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
|
let mut links = Vec::new();
|
|
|
|
links.push(AgentLink {
|
|
url: "stats.html".to_owned(),
|
|
icon: "📊".to_owned(),
|
|
label: "stats".to_owned(),
|
|
kind: AgentLinkKind::Container,
|
|
});
|
|
|
|
if gui_enabled {
|
|
links.push(AgentLink {
|
|
url: "screen.html".to_owned(),
|
|
icon: "🖥".to_owned(),
|
|
label: "screen".to_owned(),
|
|
kind: AgentLinkKind::Container,
|
|
});
|
|
}
|
|
|
|
if crate::paths::state_dir().join("forge-token").is_file() {
|
|
links.push(AgentLink {
|
|
url: format!("/{label}"),
|
|
icon: "⬡".to_owned(),
|
|
label: "forge".to_owned(),
|
|
kind: AgentLinkKind::Forge,
|
|
});
|
|
links.push(AgentLink {
|
|
url: format!("/agent-configs/{label}"),
|
|
icon: "↳".to_owned(),
|
|
label: "config".to_owned(),
|
|
kind: AgentLinkKind::Forge,
|
|
});
|
|
}
|
|
|
|
// Agent-declared extras (`hyperhive.dashboardLinks` → the
|
|
// `hive-dashboard-links` NixOS oneshot writes them to
|
|
// `{state_dir}/hyperhive-dashboard-links.json`). Shape on disk
|
|
// is `{label, icon, url}` with absolute URLs — those become
|
|
// `kind = External` links, passed through verbatim.
|
|
let extras_path = crate::paths::state_dir().join("hyperhive-dashboard-links.json");
|
|
if let Ok(text) = std::fs::read_to_string(&extras_path)
|
|
&& !text.trim().is_empty()
|
|
&& let Ok(extras) = serde_json::from_str::<Vec<ExtraLink>>(&text)
|
|
{
|
|
for e in extras {
|
|
links.push(AgentLink {
|
|
url: e.url,
|
|
icon: e.icon,
|
|
label: e.label,
|
|
kind: AgentLinkKind::External,
|
|
});
|
|
}
|
|
}
|
|
|
|
links
|
|
}
|
|
|
|
/// On-disk shape of `hyperhive-dashboard-links.json` (the
|
|
/// `hive-dashboard-links` NixOS oneshot's output). Mapped to
|
|
/// `AgentLink { kind: External }` inside `agent_links`.
|
|
#[derive(serde::Deserialize)]
|
|
struct ExtraLink {
|
|
label: String,
|
|
#[serde(default)]
|
|
icon: String,
|
|
url: String,
|
|
}
|
|
|
|
/// Best-effort: pull the last 30 messages addressed to us via the
|
|
/// per-agent / manager socket. Empty list on any transport / decode
|
|
/// failure — the inbox section is decorative, not authoritative.
|
|
async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::inbox::InboxRow> {
|
|
const LIMIT: u64 = 30;
|
|
// Deadline-bounded (via `broker_request`): `/api/state` must render even
|
|
// when hive-c0re is busy — an empty inbox section beats a hung snapshot.
|
|
match super::broker_request(
|
|
socket,
|
|
&hive_core_agent_sock::Request::Recent { limit: LIMIT },
|
|
)
|
|
.await
|
|
{
|
|
Ok(hive_core_agent_sock::Response::Recent { rows }) => rows,
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Read `HIVE_AVAILABLE_MODELS` (comma-separated short names injected by
|
|
/// `services.hyperhive.availableModels`) and return the parsed list.
|
|
/// Falls back to `["haiku", "sonnet", "opus"]` when the env var is absent
|
|
/// or resolves to an empty list after trimming.
|
|
fn available_models() -> Vec<String> {
|
|
const DEFAULT: &[&str] = &["haiku", "sonnet", "opus"];
|
|
// Absent / empty / all-whitespace env all funnel to the single
|
|
// emptiness check below — no separate up-front guard needed.
|
|
let models: Vec<String> = std::env::var("HIVE_AVAILABLE_MODELS")
|
|
.unwrap_or_default()
|
|
.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
if models.is_empty() {
|
|
DEFAULT.iter().map(ToString::to_string).collect()
|
|
} else {
|
|
models
|
|
}
|
|
}
|