diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index ff499f42..f363009d 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -724,12 +724,11 @@ window.marked = marked; head.append( el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name), ); - // 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 + // 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 // and must never reach the HTML parser. const navStrip = el('span', { class: 'nav-strip' }); head.append(navStrip); @@ -743,80 +742,36 @@ window.marked = marked; ? `/agent/${encodeURIComponent(c.name)}` : `http://${hostname}:${c.port}`; if (c.running) { - // Fetch the lean dashboard-state snapshot from the agent directly. - // Populates: nav strip links (including the screen link that - // c0re's disk-based build cannot detect), rate_limited badge, - // ctx-window badge, and self-reported status text. - // Fails gracefully when the agent is starting up or the gateway - // is not yet routing to it — badges simply don't appear. - fetch(`${containerBase}/api/dashboard-state`) - .then((r) => (r.ok ? r.json() : null)) - .then((ds) => { - if (!ds) return; - // ── nav strip ─────────────────────────────────────────── - if (Array.isArray(ds.links)) { - for (const lnk of ds.links) { - const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') - : lnk.kind === 'external' ? (lnk.url || '') - : /* container */ containerBase + '/' + (lnk.url || ''); - const a = el('a', { - class: 'nav-link', - 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), - )); + 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) { + const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') + : lnk.kind === 'external' ? (lnk.url || '') + : /* container */ containerBase + '/' + (lnk.url || ''); + const a = el('a', { + class: 'nav-link', + 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); } }) - .catch(() => { /* graceful: agent starting / gateway miss → no data */ }); + .catch(() => { /* graceful: agent down → no strip */ }); } // Status / runtime badges. Pending transients always win // (start / stop / restart / rebuild is in progress). Otherwise, - // when the container is stopped, surface a single `■ not running` - // badge. `needs_login` is still c0re-owned (reads auth sentinel - // files on the host). rate_limited / ctx / status_text are - // agent-owned and rendered by the async dashboard-state fetch above. + // when the container is stopped, surface a single `■ not + // running` badge; the backend has already cleared rate_limited / + // needs_login / ctx_tokens / status_text in that case (see + // 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) { head.append(el('span', { class: 'pending-state' }, el('span', { class: 'spinner' }, '◐'), ' ', pending + '…')); @@ -824,6 +779,10 @@ window.marked = marked; head.append(el('span', { class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' }, '■ 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) { head.append(el('a', { 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) { head.append(el('span', { @@ -865,8 +825,46 @@ window.marked = marked; }, `❓ ${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); + // ── 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 / // DESTR0Y / PURG3) moved to the selection bar — see // docs/web-ui.md::Selection bar. The contextual `needs update ↻` diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 799682a9..fdaecc20 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -109,7 +109,6 @@ 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)) @@ -565,82 +564,6 @@ async fn api_state(State(state): State) -> axum::Json { }) } -/// Lean snapshot of the agent-owned fields that the dashboard card -/// needs. Served at `GET /api/dashboard-state` (accessible through the -/// gateway at `/agent//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, - /// 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, - /// 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, - /// 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, -} - -/// 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, Option) { - 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) -> axum::Json { - 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` diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 2a507795..e0ca2679 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -8,11 +8,23 @@ use std::collections::HashMap; use std::path::Path; +use rusqlite::Connection; use serde::{Deserialize, Serialize}; use crate::coordinator::Coordinator; 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)] #[allow(clippy::struct_excessive_bools)] pub struct ContainerView { @@ -37,6 +49,45 @@ pub struct ContainerView { /// not real-time on remind/cancel-reminder but close enough. #[serde(default)] 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, + /// 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, + /// 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, + /// 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, + /// 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, /// Name of this agent's parent in the agent hierarchy. `None` /// marks the agent as root-level; the dashboard renders it without /// indentation. Sourced from `meta/topology.json` (single source of @@ -70,15 +121,48 @@ pub async fn build_all(coord: &Coordinator) -> Vec { .broker .count_pending_reminders_for(logical.as_str()) .unwrap_or(0); + let extra_links = read_dashboard_links(&logical); let parent = topology.get(&logical).cloned().flatten(); let running = lifecycle::is_running(&logical).await; - // 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. Cleared for stopped containers — - // stale sentinel state is not meaningful when the harness isn't up. - let needs_login = running - && (!claude_has_session(&Coordinator::agent_claude_dir(&logical)) - || auth_failed_sentinel(&logical)); + // Live-only fields — only meaningful while the harness is up. + // When the container is stopped, sentinel files + turn-stats + // rows + the on-disk status blob are all stale snapshots from + // before the stop, so we clear them here rather than letting + // the dashboard / `get_agent_meta` surface misleading values. + // Static / declared fields (extra_links, deployed_sha, + // 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 { port: lifecycle::agent_web_port(&logical), running, @@ -88,6 +172,12 @@ pub async fn build_all(coord: &Coordinator) -> Vec { needs_login, deployed_sha, pending_reminders, + ctx_tokens, + context_window_tokens, + rate_limited, + extra_links, + status_text, + status_set_at, 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())) } -/// Read `rate_limited` + `needs_login` (auth-failed sentinel) 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. +/// Build the navigation link list for an agent's dashboard card. +/// +/// Mirrors the logic in `hive_ag3nt::web_ui::agent_links` but runs on +/// 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 = 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 { + 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::>(&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) { let dir = Coordinator::agent_notes_dir(name); 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) } +fn is_rate_limited(name: &str) -> bool { + read_harness_flags(name).0 +} + fn auth_failed_sentinel(name: &str) -> bool { read_harness_flags(name).1 } @@ -176,6 +333,10 @@ pub fn read_agent_status(name: &str) -> (Option, Option) { } } +fn read_status(name: &str) -> (Option, Option) { + read_agent_status(name) +} + /// Wraps `read_agent_status` with the same "stopped containers have /// stale state" gate `build_all` uses. Returns `(None, None, false)` /// when the container isn't running so callers don't have to know @@ -209,6 +370,45 @@ pub fn hive_swarm_names() -> (Option, Option) { (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) -> Option { + 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-` → locked sha from meta's flake.lock. Used to /// render the `deployed:` chip per container row. fn read_meta_locked_revs() -> HashMap { @@ -248,3 +448,63 @@ fn read_meta_locked_revs() -> HashMap { } out } + +#[cfg(test)] +mod tests { + use super::resolve_ctx_window; + use std::collections::HashMap; + + fn cfg() -> HashMap { + [ + ("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); + } +} diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 24dd5392..97373c7a 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -72,6 +72,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .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}/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("/cancel-reminder/{id}", post(post_cancel_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) -> 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( State(state): State, AxumPath(id): AxumPath,