Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-agent/src/web_ui/state.rs
atlas a6acf58b4f docs: stop writing repo-doc pointers as relative links rustdoc cannot resolve
Eleven doc comments pointed at `docs/` files as markdown links. Ten of
them render as broken hyperlinks in the docs rustdoc CI builds, and
nothing in the tree can tell.

Rustdoc renders a page at `target/doc/<crate>/<module…>/`, so a relative
link resolves against that directory and not against the source file it
was typed in. Every one of these except the single crate-root `//!` was
written for a reader resolving from the source tree, which is one `../`
short at module level and two short one directory deeper.

Two measurements on a throwaway crate, same build and same
`RUSTDOCFLAGS="-D rustdoc::all"`:

  * a bogus intra-doc link `[`no_such_item`]` is a hard error, so the
    `docs-rustdoc` check in nix/checks.nix works for its class;
  * a relative link to a nonexistent file in the same comment produces
    no diagnostic at all and lands in the html verbatim as
    href="../../../docs/does-not-exist.md".

So the class is invisible to the one gate whose stated purpose is to
stop a doc pointer dangling — and it is worse than the plain-text
failure that gate's comment describes, because a broken href still
looks clickable.

Fixing the depths was the other option and is rejected: the correct
depth is a function of how deeply the module is nested, so any module
move silently breaks it again, and no check we have would notice.

The link text was already the canonical pointer — `docs/x.md::Section`,
the same repo-root-relative form used everywhere else in the tree and
the form scripts/check-doc-refs.sh gates. Dropping the `[…](…)` wrapper
keeps every byte of information a reader uses and removes the only part
that was ever wrong.

Refs #3926.
2026-09-02 08:59:48 +02:00

440 lines
19 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();
// The alias the operator selected (e.g. "sonnet") vs. what the last
// turn actually ran on (e.g. "claude-sonnet-4-5-20260805") — mara:
// "i cannot see if sonnet became claude-sonnet-5 or something else".
// `None` until the first turn completes (no assistant event to
// resolve from yet); same source `serve_common.rs`'s turn-stats
// rollup already prefers over the requested alias.
let resolved_model = state.bus.last_resolved_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,
resolved_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(),
paused: crate::paths::paused_marker().exists(),
})
}
/// 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,
/// The concrete model id the most recent completed turn actually
/// ran on (e.g. `"claude-sonnet-4-5-20260805"`), resolved from that
/// turn's assistant events — `model` above is the alias the
/// operator picked, which claude itself maps to a specific dated
/// snapshot. `None` before any turn has completed.
resolved_model: Option<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>,
/// Whether this agent's turn loop is currently parked (the harness
/// keeps serving this page + its MCP daemons but drives no turns).
/// Same on-disk marker hive-c0re's `Coordinator::is_paused` checks
/// (`crate::paths::paused_marker`) — read directly here rather than
/// asking hive-c0re over the socket, since the harness already has
/// the file locally. hive-c0re owns the actual pause/resume *writes*
/// (via hive-priv, this process runs unprivileged) — the per-agent
/// page's own `⋯` menu POSTs to hive-c0re's existing
/// `/api/pause/<name>` / `/api/resume/<name>` (same endpoints
/// `<hive-agent-menu>` on the dashboard already uses), this field
/// only tells the frontend which of the two to show.
paused: bool,
}
#[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` for
/// the resolver + how `deployed:<sha>` ships alongside.
///
/// Icons are real emoji, not unicode arrow/geometric glyphs — mara:
/// operator-declared `dashboardLinks` extras are already free-choice
/// emoji, so the built-ins should read as the same family rather than
/// mixing in plain text glyphs (which also don't reliably render as a
/// hexagon/arrow across fonts to begin with). `config` went through two
/// rounds on this: gear (`⚙`, with and without a variation selector)
/// still rendered broken in mara's browser either way — same
/// font-unreliable codepoint `icons.tsx` already documents avoiding.
/// Landed on the wrench (`🔧`) instead: a supplementary-plane pictograph
/// with `Emoji_Presentation=Yes`, same category as every other icon
/// here (`📊`/`🖥`/`🔨`/`🧭`), so it doesn't need a variation selector to
/// render as color emoji in the first place.
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
}
}