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:
parent
750f64b7f0
commit
40845c21cd
2 changed files with 92 additions and 11 deletions
|
|
@ -724,11 +724,12 @@ window.marked = marked;
|
|||
head.append(
|
||||
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
|
||||
);
|
||||
// Icon-only nav strip — populated async from `/api/agent/{name}/links`,
|
||||
// a same-origin proxy that forwards the agent backend's own link list
|
||||
// (stats / screen-if-gui / forge profile / agent-configs / extras).
|
||||
// The agent backend is the single source of truth; no hardcoded link
|
||||
// list here. DOM-built — link strings come from the agent's process
|
||||
// Icon-only nav strip — populated async from the agent's own
|
||||
// `GET /api/dashboard-state` (via gateway when enabled, direct
|
||||
// TCP otherwise). The agent is the single source of truth for its
|
||||
// link list: stats / screen (GUI agents only — c0re's disk-based
|
||||
// fallback cannot detect this) / forge profile / agent-configs /
|
||||
// extras. DOM-built — link strings come from the agent's process
|
||||
// and must never reach the HTML parser.
|
||||
const navStrip = el('span', { class: 'nav-strip' });
|
||||
head.append(navStrip);
|
||||
|
|
@ -742,11 +743,14 @@ window.marked = marked;
|
|||
? `/agent/${encodeURIComponent(c.name)}`
|
||||
: `http://${hostname}:${c.port}`;
|
||||
if (c.running) {
|
||||
fetch(`/api/agent/${encodeURIComponent(c.name)}/links`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((links) => {
|
||||
if (!Array.isArray(links)) return;
|
||||
for (const lnk of links) {
|
||||
// Fetch the lean dashboard-state snapshot from the agent directly.
|
||||
// Fails gracefully (empty strip) when the agent is starting up
|
||||
// or the gateway is not yet routing to it.
|
||||
fetch(`${containerBase}/api/dashboard-state`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((ds) => {
|
||||
if (!ds || !Array.isArray(ds.links)) return;
|
||||
for (const lnk of ds.links) {
|
||||
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
|
||||
: lnk.kind === 'external' ? (lnk.url || '')
|
||||
: /* container */ containerBase + '/' + (lnk.url || '');
|
||||
|
|
@ -762,7 +766,7 @@ window.marked = marked;
|
|||
navStrip.append(a);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* graceful: agent down → no strip */ });
|
||||
.catch(() => { /* graceful: agent starting / gateway miss → no strip */ });
|
||||
}
|
||||
// Status / runtime badges. Pending transients always win
|
||||
// (start / stop / restart / rebuild is in progress). Otherwise,
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Reference in a new issue