Compare commits
4 changed files with 367 additions and 163 deletions
|
|
@ -724,12 +724,11 @@ window.marked = marked;
|
||||||
head.append(
|
head.append(
|
||||||
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
|
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
|
||||||
);
|
);
|
||||||
// Icon-only nav strip — populated async from the agent's own
|
// Icon-only nav strip — populated async from `/api/agent/{name}/links`,
|
||||||
// `GET /api/dashboard-state` (via gateway when enabled, direct
|
// a same-origin proxy that forwards the agent backend's own link list
|
||||||
// TCP otherwise). The agent is the single source of truth for its
|
// (stats / screen-if-gui / forge profile / agent-configs / extras).
|
||||||
// link list: stats / screen (GUI agents only — c0re's disk-based
|
// The agent backend is the single source of truth; no hardcoded link
|
||||||
// fallback cannot detect this) / forge profile / agent-configs /
|
// list here. DOM-built — link strings come from the agent's process
|
||||||
// extras. DOM-built — link strings come from the agent's process
|
|
||||||
// and must never reach the HTML parser.
|
// and must never reach the HTML parser.
|
||||||
const navStrip = el('span', { class: 'nav-strip' });
|
const navStrip = el('span', { class: 'nav-strip' });
|
||||||
head.append(navStrip);
|
head.append(navStrip);
|
||||||
|
|
@ -743,80 +742,36 @@ window.marked = marked;
|
||||||
? `/agent/${encodeURIComponent(c.name)}`
|
? `/agent/${encodeURIComponent(c.name)}`
|
||||||
: `http://${hostname}:${c.port}`;
|
: `http://${hostname}:${c.port}`;
|
||||||
if (c.running) {
|
if (c.running) {
|
||||||
// Fetch the lean dashboard-state snapshot from the agent directly.
|
fetch(`/api/agent/${encodeURIComponent(c.name)}/links`)
|
||||||
// Populates: nav strip links (including the screen link that
|
.then((r) => (r.ok ? r.json() : []))
|
||||||
// c0re's disk-based build cannot detect), rate_limited badge,
|
.then((links) => {
|
||||||
// ctx-window badge, and self-reported status text.
|
if (!Array.isArray(links)) return;
|
||||||
// Fails gracefully when the agent is starting up or the gateway
|
for (const lnk of links) {
|
||||||
// is not yet routing to it — badges simply don't appear.
|
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
|
||||||
fetch(`${containerBase}/api/dashboard-state`)
|
: lnk.kind === 'external' ? (lnk.url || '')
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
: /* container */ containerBase + '/' + (lnk.url || '');
|
||||||
.then((ds) => {
|
const a = el('a', {
|
||||||
if (!ds) return;
|
class: 'nav-link',
|
||||||
// ── nav strip ───────────────────────────────────────────
|
href,
|
||||||
if (Array.isArray(ds.links)) {
|
target: '_blank',
|
||||||
for (const lnk of ds.links) {
|
rel: 'noopener',
|
||||||
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
|
title: lnk.label || '',
|
||||||
: lnk.kind === 'external' ? (lnk.url || '')
|
});
|
||||||
: /* container */ containerBase + '/' + (lnk.url || '');
|
// Plain text — agent-controlled strings stay out of innerHTML.
|
||||||
const a = el('a', {
|
a.textContent = lnk.icon || lnk.label || '';
|
||||||
class: 'nav-link',
|
navStrip.append(a);
|
||||||
href,
|
|
||||||
target: '_blank',
|
|
||||||
rel: 'noopener',
|
|
||||||
title: lnk.label || '',
|
|
||||||
});
|
|
||||||
// Plain text — agent-controlled strings stay out of innerHTML.
|
|
||||||
a.textContent = lnk.icon || lnk.label || '';
|
|
||||||
navStrip.append(a);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ── agent-owned status badges ────────────────────────────
|
|
||||||
// rate_limited: only show when no other critical badge is
|
|
||||||
// already shown (pending / not-running already handled sync).
|
|
||||||
if (ds.rate_limited) {
|
|
||||||
head.append(el('span',
|
|
||||||
{ class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' },
|
|
||||||
'⊘ rate limited'));
|
|
||||||
}
|
|
||||||
// ctx-window badge
|
|
||||||
if (ds.ctx_tokens != null) {
|
|
||||||
const k = Math.round(ds.ctx_tokens / 1000);
|
|
||||||
const win = ds.context_window_tokens;
|
|
||||||
const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS;
|
|
||||||
const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS;
|
|
||||||
const ctxClass = ds.ctx_tokens >= warn ? 'badge-ctx-warn'
|
|
||||||
: ds.ctx_tokens >= caution ? 'badge-ctx-caution'
|
|
||||||
: 'badge-ctx-ok';
|
|
||||||
const title = win != null
|
|
||||||
? `last turn context: ${ds.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} `
|
|
||||||
+ `tokens (${Math.round((ds.ctx_tokens / win) * 100)}% of the window)`
|
|
||||||
: `last turn context size: ${ds.ctx_tokens.toLocaleString()} tokens`;
|
|
||||||
head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`));
|
|
||||||
}
|
|
||||||
// ── agent status text (self-reported via set_status) ─────
|
|
||||||
if (ds.status_text) {
|
|
||||||
const nowUnix = Math.floor(Date.now() / 1000);
|
|
||||||
const ageStr = ds.status_set_at != null
|
|
||||||
? ` (set ${fmtAgeSecs(nowUnix - ds.status_set_at)} ago)` : '';
|
|
||||||
body.append(el('div', {
|
|
||||||
class: 'agent-status',
|
|
||||||
title: `agent self-reported status${ageStr}`,
|
|
||||||
},
|
|
||||||
el('span', { class: 'status-icon' }, '◈ '),
|
|
||||||
ds.status_text,
|
|
||||||
el('span', { class: 'status-age' }, ageStr),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { /* graceful: agent starting / gateway miss → no data */ });
|
.catch(() => { /* graceful: agent down → no strip */ });
|
||||||
}
|
}
|
||||||
// Status / runtime badges. Pending transients always win
|
// Status / runtime badges. Pending transients always win
|
||||||
// (start / stop / restart / rebuild is in progress). Otherwise,
|
// (start / stop / restart / rebuild is in progress). Otherwise,
|
||||||
// when the container is stopped, surface a single `■ not running`
|
// when the container is stopped, surface a single `■ not
|
||||||
// badge. `needs_login` is still c0re-owned (reads auth sentinel
|
// running` badge; the backend has already cleared rate_limited /
|
||||||
// files on the host). rate_limited / ctx / status_text are
|
// needs_login / ctx_tokens / status_text in that case (see
|
||||||
// agent-owned and rendered by the async dashboard-state fetch above.
|
// docs/web-ui.md::Container row) so the rest of the chain is a
|
||||||
|
// no-op for stopped containers — but we still want SOME badge
|
||||||
|
// there so the row doesn't look empty.
|
||||||
if (pending) {
|
if (pending) {
|
||||||
head.append(el('span', { class: 'pending-state' },
|
head.append(el('span', { class: 'pending-state' },
|
||||||
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
|
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
|
||||||
|
|
@ -824,6 +779,10 @@ window.marked = marked;
|
||||||
head.append(el('span',
|
head.append(el('span',
|
||||||
{ class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' },
|
{ class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' },
|
||||||
'■ not running'));
|
'■ not running'));
|
||||||
|
} else if (c.rate_limited) {
|
||||||
|
head.append(el('span',
|
||||||
|
{ class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' },
|
||||||
|
'⊘ rate limited'));
|
||||||
} else if (c.needs_login) {
|
} else if (c.needs_login) {
|
||||||
head.append(el('a',
|
head.append(el('a',
|
||||||
{ class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
|
{ class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
|
||||||
|
|
@ -837,6 +796,7 @@ window.marked = marked;
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (c.pending_reminders && c.pending_reminders > 0) {
|
if (c.pending_reminders && c.pending_reminders > 0) {
|
||||||
head.append(el('span',
|
head.append(el('span',
|
||||||
{
|
{
|
||||||
|
|
@ -865,8 +825,46 @@ window.marked = marked;
|
||||||
},
|
},
|
||||||
`❓ ${agentQCount}`));
|
`❓ ${agentQCount}`));
|
||||||
}
|
}
|
||||||
|
if (c.ctx_tokens != null) {
|
||||||
|
const k = Math.round(c.ctx_tokens / 1000);
|
||||||
|
// Thresholds track the model's real context window when the
|
||||||
|
// backend supplies it; otherwise fall back to fixed constants.
|
||||||
|
const win = c.context_window_tokens;
|
||||||
|
const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS;
|
||||||
|
const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS;
|
||||||
|
const ctxClass = c.ctx_tokens >= warn ? 'badge-ctx-warn'
|
||||||
|
: c.ctx_tokens >= caution ? 'badge-ctx-caution'
|
||||||
|
: 'badge-ctx-ok';
|
||||||
|
const title = win != null
|
||||||
|
? `last turn context: ${c.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} `
|
||||||
|
+ `tokens (${Math.round((c.ctx_tokens / win) * 100)}% of the window)`
|
||||||
|
: `last turn context size: ${c.ctx_tokens.toLocaleString()} tokens`;
|
||||||
|
head.append(el('span',
|
||||||
|
{ class: `badge ${ctxClass}`, title },
|
||||||
|
`ctx·${k}k`));
|
||||||
|
}
|
||||||
body.append(head);
|
body.append(head);
|
||||||
|
|
||||||
|
// ── agent status text ─────────────────────────────────────────
|
||||||
|
// Self-reported status (via set_status MCP tool) — only fresh
|
||||||
|
// while the harness is up. The backend already clears
|
||||||
|
// `status_text` on stopped containers (docs/web-ui.md::Container
|
||||||
|
// row) so we can render unconditionally here: a stopped
|
||||||
|
// container simply has no `status_text` and skips naturally.
|
||||||
|
if (c.status_text) {
|
||||||
|
const nowUnix = Math.floor(Date.now() / 1000);
|
||||||
|
const ageStr = c.status_set_at != null
|
||||||
|
? ` (set ${fmtAgeSecs(nowUnix - c.status_set_at)} ago)` : '';
|
||||||
|
body.append(el('div', {
|
||||||
|
class: 'agent-status',
|
||||||
|
title: `agent self-reported status${ageStr}`,
|
||||||
|
},
|
||||||
|
el('span', { class: 'status-icon' }, '◈ '),
|
||||||
|
c.status_text,
|
||||||
|
el('span', { class: 'status-age' }, ageStr),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
|
// Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
|
||||||
// DESTR0Y / PURG3) moved to the selection bar — see
|
// DESTR0Y / PURG3) moved to the selection bar — see
|
||||||
// docs/web-ui.md::Selection bar. The contextual `needs update ↻`
|
// docs/web-ui.md::Selection bar. The contextual `needs update ↻`
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,6 @@ pub async fn serve(
|
||||||
};
|
};
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/api/state", get(api_state))
|
.route("/api/state", get(api_state))
|
||||||
.route("/api/dashboard-state", get(api_dashboard_state))
|
|
||||||
.route("/events/stream", get(events_stream))
|
.route("/events/stream", get(events_stream))
|
||||||
.route("/events/history", get(events_history))
|
.route("/events/history", get(events_history))
|
||||||
.route("/send", post(post_send))
|
.route("/send", post(post_send))
|
||||||
|
|
@ -565,82 +564,6 @@ 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
|
/// Build the navigation link list for the agent page header. URLs
|
||||||
/// are paths (relative) for `Container`/`Forge` targets and absolute
|
/// are paths (relative) for `Container`/`Forge` targets and absolute
|
||||||
/// for `External`; the frontend resolves each against its `kind`
|
/// for `External`; the frontend resolves each against its `kind`
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,23 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::coordinator::Coordinator;
|
use crate::coordinator::Coordinator;
|
||||||
use crate::lifecycle::{self, AGENT_PREFIX};
|
use crate::lifecycle::{self, AGENT_PREFIX};
|
||||||
|
|
||||||
|
/// 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)]
|
#[derive(Serialize, Clone, PartialEq, Eq, Debug)]
|
||||||
#[allow(clippy::struct_excessive_bools)]
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
pub struct ContainerView {
|
pub struct ContainerView {
|
||||||
|
|
@ -37,6 +49,45 @@ pub struct ContainerView {
|
||||||
/// not real-time on remind/cancel-reminder but close enough.
|
/// not real-time on remind/cancel-reminder but close enough.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pending_reminders: u64,
|
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>,
|
||||||
|
/// Context-window size (tokens) for the model this agent ran on its
|
||||||
|
/// most recent turn — the model name from the last turn-stats row
|
||||||
|
/// resolved against the host's per-model `contextWindowTokens`
|
||||||
|
/// config. Lets the dashboard derive the ctx badge thresholds
|
||||||
|
/// (75% / 50% of the window, matching the harness compaction
|
||||||
|
/// watermarks) instead of hardcoding them. `None` when the agent
|
||||||
|
/// has no turns yet or no config key matches the model.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context_window_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>,
|
||||||
|
/// Free-text status set by the agent via `mcp__hyperhive__set_status`.
|
||||||
|
/// Persisted to `{state_dir}/hyperhive-status`. `None` when the file
|
||||||
|
/// is absent or empty — the agent hasn't set one yet.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status_text: Option<String>,
|
||||||
|
/// Unix timestamp (seconds since epoch) when the status was last written.
|
||||||
|
/// Derived from the `hyperhive-status` file's mtime. `None` when no
|
||||||
|
/// status is set.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status_set_at: Option<i64>,
|
||||||
/// Name of this agent's parent in the agent hierarchy. `None`
|
/// Name of this agent's parent in the agent hierarchy. `None`
|
||||||
/// marks the agent as root-level; the dashboard renders it without
|
/// marks the agent as root-level; the dashboard renders it without
|
||||||
/// indentation. Sourced from `meta/topology.json` (single source of
|
/// indentation. Sourced from `meta/topology.json` (single source of
|
||||||
|
|
@ -70,15 +121,48 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
|
||||||
.broker
|
.broker
|
||||||
.count_pending_reminders_for(logical.as_str())
|
.count_pending_reminders_for(logical.as_str())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
let extra_links = read_dashboard_links(&logical);
|
||||||
let parent = topology.get(&logical).cloned().flatten();
|
let parent = topology.get(&logical).cloned().flatten();
|
||||||
let running = lifecycle::is_running(&logical).await;
|
let running = lifecycle::is_running(&logical).await;
|
||||||
// needs_login fires when EITHER the claude session dir is missing
|
// Live-only fields — only meaningful while the harness is up.
|
||||||
// (boot-time / fresh container) OR the harness wrote the auth-failed
|
// When the container is stopped, sentinel files + turn-stats
|
||||||
// sentinel because a turn hit 401. Cleared for stopped containers —
|
// rows + the on-disk status blob are all stale snapshots from
|
||||||
// stale sentinel state is not meaningful when the harness isn't up.
|
// before the stop, so we clear them here rather than letting
|
||||||
let needs_login = running
|
// the dashboard / `get_agent_meta` surface misleading values.
|
||||||
&& (!claude_has_session(&Coordinator::agent_claude_dir(&logical))
|
// Static / declared fields (extra_links, deployed_sha,
|
||||||
|| auth_failed_sentinel(&logical));
|
// pending_reminders, needs_update, parent) stay populated
|
||||||
|
// regardless of run state.
|
||||||
|
let (
|
||||||
|
needs_login,
|
||||||
|
ctx_tokens,
|
||||||
|
context_window_tokens,
|
||||||
|
rate_limited,
|
||||||
|
status_text,
|
||||||
|
status_set_at,
|
||||||
|
) = if running {
|
||||||
|
// needs_login fires when EITHER the claude session dir is
|
||||||
|
// missing (boot-time / fresh container) OR the harness wrote
|
||||||
|
// the auth-failed sentinel because a turn hit 401.
|
||||||
|
let needs_login = !claude_has_session(&Coordinator::agent_claude_dir(&logical))
|
||||||
|
|| auth_failed_sentinel(&logical);
|
||||||
|
let last_turn = read_last_turn(&logical);
|
||||||
|
let ctx_tokens = last_turn.as_ref().map(|(toks, _)| *toks);
|
||||||
|
let context_window_tokens = last_turn
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens));
|
||||||
|
let rate_limited = is_rate_limited(&logical);
|
||||||
|
let (status_text, status_set_at) = read_status(&logical);
|
||||||
|
(
|
||||||
|
needs_login,
|
||||||
|
ctx_tokens,
|
||||||
|
context_window_tokens,
|
||||||
|
rate_limited,
|
||||||
|
status_text,
|
||||||
|
status_set_at,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(false, None, None, false, None, None)
|
||||||
|
};
|
||||||
out.push(ContainerView {
|
out.push(ContainerView {
|
||||||
port: lifecycle::agent_web_port(&logical),
|
port: lifecycle::agent_web_port(&logical),
|
||||||
running,
|
running,
|
||||||
|
|
@ -88,6 +172,12 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
|
||||||
needs_login,
|
needs_login,
|
||||||
deployed_sha,
|
deployed_sha,
|
||||||
pending_reminders,
|
pending_reminders,
|
||||||
|
ctx_tokens,
|
||||||
|
context_window_tokens,
|
||||||
|
rate_limited,
|
||||||
|
extra_links,
|
||||||
|
status_text,
|
||||||
|
status_set_at,
|
||||||
parent,
|
parent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -107,10 +197,73 @@ pub fn claude_has_session(dir: &Path) -> bool {
|
||||||
.any(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
.any(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read `rate_limited` + `needs_login` (auth-failed sentinel) from
|
/// Build the navigation link list for an agent's dashboard card.
|
||||||
/// the consolidated `hyperhive-harness.json`. Falls back to the legacy
|
///
|
||||||
/// individual sentinel files written by older harness builds so in-place
|
/// Mirrors the logic in `hive_ag3nt::web_ui::agent_links` but runs on
|
||||||
/// upgrades don't lose state during the transition window.
|
/// the host via disk reads — no network call to the agent web UI needed.
|
||||||
|
/// The GUI screen link is omitted here; `/etc/hyperhive/gui.json` lives
|
||||||
|
/// inside the agent container and is not host-visible.
|
||||||
|
///
|
||||||
|
/// Returns a `serde_json::Value` array matching the `AgentLink` JSON
|
||||||
|
/// shape the harness returns from `GET /api/state`, so
|
||||||
|
/// `dashboard::get_agent_links` can serve it directly.
|
||||||
|
pub fn build_nav_links(name: &str) -> serde_json::Value {
|
||||||
|
let state_dir = Coordinator::agent_notes_dir(name);
|
||||||
|
let mut links: Vec<serde_json::Value> = Vec::new();
|
||||||
|
|
||||||
|
// Stats page — always present.
|
||||||
|
links.push(serde_json::json!({
|
||||||
|
"url": "stats.html",
|
||||||
|
"icon": "📊",
|
||||||
|
"label": "stats",
|
||||||
|
"kind": "container",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Forge profile + config mirror — only when the agent has a forge account.
|
||||||
|
if state_dir.join("forge-token").is_file() {
|
||||||
|
links.push(serde_json::json!({
|
||||||
|
"url": format!("/{name}"),
|
||||||
|
"icon": "⬡",
|
||||||
|
"label": "forge",
|
||||||
|
"kind": "forge",
|
||||||
|
}));
|
||||||
|
links.push(serde_json::json!({
|
||||||
|
"url": format!("/agent-configs/{name}"),
|
||||||
|
"icon": "↳",
|
||||||
|
"label": "config",
|
||||||
|
"kind": "forge",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent-declared extras (absolute URLs — passed through verbatim).
|
||||||
|
for lnk in read_dashboard_links(name) {
|
||||||
|
links.push(serde_json::json!({
|
||||||
|
"url": lnk.url,
|
||||||
|
"icon": lnk.icon,
|
||||||
|
"label": lnk.label,
|
||||||
|
"kind": "external",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::Value::Array(links)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `rate_limited` + `needs_login` from the consolidated
|
||||||
|
/// `hyperhive-harness.json`. Falls back to the legacy individual
|
||||||
|
/// sentinel files written by older harness builds so in-place upgrades
|
||||||
|
/// don't lose state during the transition window.
|
||||||
fn read_harness_flags(name: &str) -> (bool, bool) {
|
fn read_harness_flags(name: &str) -> (bool, bool) {
|
||||||
let dir = Coordinator::agent_notes_dir(name);
|
let dir = Coordinator::agent_notes_dir(name);
|
||||||
if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json")) {
|
if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json")) {
|
||||||
|
|
@ -132,6 +285,10 @@ fn read_harness_flags(name: &str) -> (bool, bool) {
|
||||||
(rate_limited, needs_login)
|
(rate_limited, needs_login)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_rate_limited(name: &str) -> bool {
|
||||||
|
read_harness_flags(name).0
|
||||||
|
}
|
||||||
|
|
||||||
fn auth_failed_sentinel(name: &str) -> bool {
|
fn auth_failed_sentinel(name: &str) -> bool {
|
||||||
read_harness_flags(name).1
|
read_harness_flags(name).1
|
||||||
}
|
}
|
||||||
|
|
@ -176,6 +333,10 @@ pub fn read_agent_status(name: &str) -> (Option<String>, Option<i64>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_status(name: &str) -> (Option<String>, Option<i64>) {
|
||||||
|
read_agent_status(name)
|
||||||
|
}
|
||||||
|
|
||||||
/// Wraps `read_agent_status` with the same "stopped containers have
|
/// Wraps `read_agent_status` with the same "stopped containers have
|
||||||
/// stale state" gate `build_all` uses. Returns `(None, None, false)`
|
/// stale state" gate `build_all` uses. Returns `(None, None, false)`
|
||||||
/// when the container isn't running so callers don't have to know
|
/// when the container isn't running so callers don't have to know
|
||||||
|
|
@ -209,6 +370,45 @@ pub fn hive_swarm_names() -> (Option<String>, Option<String>) {
|
||||||
(read("HYPERHIVE_HIVE_NAME"), read("HYPERHIVE_SWARM_NAME"))
|
(read("HYPERHIVE_HIVE_NAME"), read("HYPERHIVE_SWARM_NAME"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the agent's most recent completed turn from its turn-stats
|
||||||
|
/// `SQLite`: the context-window size (prompt tokens) and the model name.
|
||||||
|
/// Returns `None` when the file is absent or has no rows. Best-effort
|
||||||
|
/// — any database error silently yields `None` so a missing or
|
||||||
|
/// corrupt file never blocks `build_all`.
|
||||||
|
///
|
||||||
|
/// Context tokens sum the prompt-side fields (`last_input_tokens`,
|
||||||
|
/// `last_cache_read_input_tokens`, `last_cache_creation_input_tokens`),
|
||||||
|
/// mirroring `hive_ag3nt::events::TokenUsage::context_tokens`.
|
||||||
|
fn read_last_turn(name: &str) -> Option<(u64, String)> {
|
||||||
|
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, model \
|
||||||
|
FROM turn_stats ORDER BY started_at DESC LIMIT 1",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
.and_then(|(toks, model)| Some((u64::try_from(toks).ok()?, model)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a model name to its context-window size using the host's
|
||||||
|
/// per-model `contextWindowTokens` config. Mirrors the harness's
|
||||||
|
/// `events::context_window_tokens` substring match: the first config
|
||||||
|
/// key (lowercased, non-empty) that is a substring of the lowercased
|
||||||
|
/// model name wins. `None` when nothing matches.
|
||||||
|
fn resolve_ctx_window(model: &str, per_model: &HashMap<String, u64>) -> Option<u64> {
|
||||||
|
let m = model.to_ascii_lowercase();
|
||||||
|
per_model
|
||||||
|
.iter()
|
||||||
|
.find(|(key, _)| {
|
||||||
|
let k = key.to_ascii_lowercase();
|
||||||
|
!k.is_empty() && m.contains(&k)
|
||||||
|
})
|
||||||
|
.map(|(_, &tokens)| tokens)
|
||||||
|
}
|
||||||
|
|
||||||
/// Map of `agent-<n>` → locked sha from meta's flake.lock. Used to
|
/// Map of `agent-<n>` → locked sha from meta's flake.lock. Used to
|
||||||
/// render the `deployed:<sha12>` chip per container row.
|
/// render the `deployed:<sha12>` chip per container row.
|
||||||
fn read_meta_locked_revs() -> HashMap<String, String> {
|
fn read_meta_locked_revs() -> HashMap<String, String> {
|
||||||
|
|
@ -248,3 +448,63 @@ fn read_meta_locked_revs() -> HashMap<String, String> {
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::resolve_ctx_window;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn cfg() -> HashMap<String, u64> {
|
||||||
|
[
|
||||||
|
("haiku".to_owned(), 200_000),
|
||||||
|
("sonnet".to_owned(), 1_000_000),
|
||||||
|
("opus".to_owned(), 1_000_000),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_family_substring() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_ctx_window("claude-3-5-haiku-20241022", &cfg()),
|
||||||
|
Some(200_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_ctx_window("claude-sonnet-4-5", &cfg()),
|
||||||
|
Some(1_000_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_ctx_window("claude-opus-4-1", &cfg()),
|
||||||
|
Some(1_000_000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolution_is_case_insensitive() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_ctx_window("Claude-Sonnet-4", &cfg()),
|
||||||
|
Some(1_000_000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_model_yields_none() {
|
||||||
|
assert_eq!(resolve_ctx_window("some-other-llm", &cfg()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_config_yields_none() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_ctx_window("claude-3-5-haiku", &HashMap::new()),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_key_is_skipped() {
|
||||||
|
let mut m = HashMap::new();
|
||||||
|
m.insert(String::new(), 999);
|
||||||
|
assert_eq!(resolve_ctx_window("claude-3-5-haiku", &m), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/api/build-logs/id/{id}", get(get_build_log_full))
|
.route("/api/build-logs/id/{id}", get(get_build_log_full))
|
||||||
.route("/api/build-logs/id/{id}/stream", get(get_build_log_stream))
|
.route("/api/build-logs/id/{id}/stream", get(get_build_log_stream))
|
||||||
.route("/api/build-logs/id/{id}/raw", get(get_build_log_raw))
|
.route("/api/build-logs/id/{id}/raw", get(get_build_log_raw))
|
||||||
|
.route("/api/agent/{name}/links", get(get_agent_links))
|
||||||
.route("/api/agent/{name}/mark-all-read", post(post_mark_all_read))
|
.route("/api/agent/{name}/mark-all-read", post(post_mark_all_read))
|
||||||
.route("/cancel-reminder/{id}", post(post_cancel_reminder))
|
.route("/cancel-reminder/{id}", post(post_cancel_reminder))
|
||||||
.route("/retry-reminder/{id}", post(post_retry_reminder))
|
.route("/retry-reminder/{id}", post(post_retry_reminder))
|
||||||
|
|
@ -2122,6 +2123,28 @@ async fn post_schedule_cancel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same-origin proxy that fetches the named agent's
|
||||||
|
/// `GET /api/state` and forwards only the `links` field to the
|
||||||
|
/// dashboard JS. Lets the agent backend stay the
|
||||||
|
/// single source of truth for its own nav links: the dashboard
|
||||||
|
/// card's icon-only strip and the per-agent page's labelled row
|
||||||
|
/// render the same list, no shared Rust wire type required, no
|
||||||
|
/// CORS surface on the agent side.
|
||||||
|
///
|
||||||
|
/// Failure modes (agent down, slow response, malformed JSON) all
|
||||||
|
/// degrade to an empty list so the dashboard still renders.
|
||||||
|
async fn get_agent_links(AxumPath(name): AxumPath<String>) -> Response {
|
||||||
|
// Format-only guard. Unknown/malformed names get an empty list.
|
||||||
|
if validate_agent_name(&name).is_some() {
|
||||||
|
return axum::Json(serde_json::json!([])).into_response();
|
||||||
|
}
|
||||||
|
// Links are built from disk — no TCP call to the agent web UI.
|
||||||
|
// The old TCP proxy broke when all agents switched to unix-socket
|
||||||
|
// binding (HIVE_WEB_SOCKET). See container_view::build_nav_links
|
||||||
|
// for the full rationale.
|
||||||
|
axum::Json(container_view::build_nav_links(&name)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn post_cancel_reminder(
|
async fn post_cancel_reminder(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath(id): AxumPath<i64>,
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue