feat(#1292): add GET /api/dashboard-state to hive-ag3nt

hive-ag3nt now exposes a lean DashboardState endpoint that returns the
agent-owned fields the dashboard card needs:

  - status_text / status_set_at  (from hyperhive-status on disk)
  - ctx_tokens / context_window_tokens  (from Bus)
  - rate_limited  (from Bus)
  - links  (from agent_links — includes screen link for GUI agents,
            which c0re's disk-based fallback cannot determine)

The dashboard fetches `${containerBase}/api/dashboard-state` instead of
the previous c0re proxy `/api/agent/{name}/links`. For gateway deployments
this is a same-origin call to the agent via the gateway's unix-socket
upstream; for direct TCP it hits the agent port directly. Both paths fail
gracefully (empty strip) when the agent is starting up.

The main behaviour fix: GUI agents now show the screen link in the dashboard
nav strip. c0re's build_nav_links reads /etc/hyperhive/gui.json from outside
the container (not possible), so it always omitted the screen link.
This commit is contained in:
iris 2026-06-04 19:16:40 +02:00
commit 40845c21cd
2 changed files with 92 additions and 11 deletions

View file

@ -109,6 +109,7 @@ pub async fn serve(
};
let app = Router::new()
.route("/api/state", get(api_state))
.route("/api/dashboard-state", get(api_dashboard_state))
.route("/events/stream", get(events_stream))
.route("/events/history", get(events_history))
.route("/send", post(post_send))
@ -564,6 +565,82 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
})
}
/// 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.
#[derive(serde::Serialize)]
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>,
/// Input token count from the most recent completed turn (`ctx_usage.input_tokens`).
/// `None` until the first turn finishes.
#[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) }
}
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
.api_context_window()
.unwrap_or_else(|| crate::events::context_window_tokens(&model));
let ctx_tokens = state.bus.last_ctx_usage().map(|u| u.input_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()),
})
}
/// 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`