diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 440ff87b..52497f16 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -480,20 +480,15 @@ re-renders the terminal row. Manager is addressed as `@root`. ## H0M3 page (`/`) The H0M3 hub is the primary landing page (served at `/` by default). A -responsive grid of link tiles — Dashboard, Flow, Logs, Matrix (when enabled), -Forge (when enabled) — each pointing to their respective surfaces. The page -is a pure portal with no tab-bar or SSE subscriptions. Typography + colours -inherit from the shared theme (Catppuccin Mocha via `common.css` + `theme.css`). -Optional tiles are hidden until `home.js` confirms their availability: -Matrix is hidden until `home.js` confirms `matrix_gui_enabled` (same gating as -the dashboard's M4TR1X tab); Forge is hidden until `home.js` confirms -`state.forge_present` and fills the href from `state.forge_public_url` (the -gateway-served public URL when `services.hyperhive.forge.behindGateway=true`) -or falls back to the direct `:3000` port. Operators without matrix or forge -enabled never see dead links. `home.js` also fills the swarm/hive identity -line at the top. Dashboard is now served at `/dashboard.html` (route swap -completed in #1464 step 2); the home page at `/` replaces the old dashboard -root. All dashboard sub-pages include a `← Home` back-link for navigation. +responsive grid of link tiles — Dashboard, Flow, Logs, Matrix (when enabled) +— each pointing to their respective surfaces. The page is a pure portal with +no tab-bar or SSE subscriptions. Typography + colours inherit from the shared +theme (Catppuccin Mocha via `common.css` + `theme.css`). The Matrix tile is +hidden until `home.js` confirms `matrix_gui_enabled` (same gating as the +dashboard's M4TR1X tab); `home.js` also fills the swarm/hive identity line +at the top. Dashboard is now served at `/dashboard.html` (route swap completed +in #1464 step 2); the home page at `/` replaces the old dashboard root. All +dashboard sub-pages include a `← Home` back-link for navigation. ## L0GS page (`/logs.html`) diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 3798351a..53feb7e5 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -756,16 +756,6 @@ window.marked = marked; const h = Math.floor(m / 60); return h + 'h ' + (m % 60) + 'm'; } - // Wall-clock HH:MM:SS (UTC, matching the inbox timestamps on this page) - // from a unix-seconds value. Used to label turn-start / turn-end rows - // when the event carries a `ts` (see the turn renderers below). - function fmtClock(sec) { - return new Date(sec * 1000).toISOString().slice(11, 19); - } - // Unix-seconds stamp of the most recent open turn-start, so the - // matching turn-end can show a duration. Turns are sequential, so a - // single slot is enough (history replays chronologically too). - let pendingTurnStartTs = null; const STATE_TOOLTIPS = { loading: 'harness not yet contacted', offline: 'harness unreachable or claude not logged in', @@ -1707,16 +1697,6 @@ window.marked = marked; if (api.fromHistory) openTurnsFromHistory += 1; else { setBannerActive(true); setState('thinking'); } const block = api.row('turn-start', '◆ TURN ← ' + ev.from); - // Turn start time. Guarded on a numeric `ts` (unix seconds) - // so the row degrades to its old text-only form until the - // backend surfaces per-event timestamps. - if (typeof ev.ts === 'number') { - pendingTurnStartTs = ev.ts; - const t = document.createElement('span'); - t.className = 'turn-time'; - t.textContent = '· ' + fmtClock(ev.ts); - block.appendChild(t); - } if (ev.unread > 0) { const badge = document.createElement('span'); badge.className = 'unread-badge'; @@ -1737,23 +1717,9 @@ window.marked = marked; refreshLooseEnds(); } const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail'; - const row = api.row(cls, + api.row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : '')); - // Turn end time + duration since the paired turn-start. - // Same `ts` guard as turn_start; duration only when we saw the - // matching start's stamp. - if (typeof ev.ts === 'number') { - const t = document.createElement('span'); - t.className = 'turn-time'; - let label = '· ' + fmtClock(ev.ts); - if (pendingTurnStartTs != null && ev.ts >= pendingTurnStartTs) { - label += ' · ' + fmtAge((ev.ts - pendingTurnStartTs) * 1000); - } - t.textContent = label; - row.appendChild(t); - } - pendingTurnStartTs = null; }, note(ev, api) { const t = String(ev.text || ''); diff --git a/frontend/packages/dashboard/src/home.js b/frontend/packages/dashboard/src/home.js index 2e216ce0..95112e4b 100644 --- a/frontend/packages/dashboard/src/home.js +++ b/frontend/packages/dashboard/src/home.js @@ -27,18 +27,6 @@ async function init() { if (tile) tile.hidden = false; } - // Forge tile: reveal + point at the live forge only when the - // hive-forge container is up. Prefer the gateway-served public URL - // (set when forge.behindGateway=true), fall back to the direct :3000 - // port — same precedence the dashboard uses for forge links. - if (state.forge_present) { - const tile = $('home-tile-forge'); - if (tile) { - tile.href = state.forge_public_url || `http://${location.hostname}:3000`; - tile.hidden = false; - } - } - const ident = $('hive-identity'); if (ident && (state.swarm_name || state.hive_name)) { const parts = [state.swarm_name, state.hive_name].filter(Boolean); diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index 64021b66..669b40a1 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -68,18 +68,6 @@ operator-local prefs · browser notifications - - - diff --git a/frontend/packages/shared/src/terminal.css b/frontend/packages/shared/src/terminal.css index 37413827..2cfe7dee 100644 --- a/frontend/packages/shared/src/terminal.css +++ b/frontend/packages/shared/src/terminal.css @@ -100,10 +100,6 @@ .live .row .md, .live .row > details { text-indent: 0; } .live .turn-end-ok { color: var(--green); border-left-color: var(--green); } .live .turn-end-fail { color: var(--red); border-left-color: var(--red); } -/* Wall-clock time (+ duration on turn-end) appended to the turn-start / - turn-end rows. Dim + smaller so the boundary glyph stays the focus and - the timestamp reads as metadata. */ -.live .turn-time { color: var(--muted); font-size: 0.85em; margin-left: 0.5em; } .live .text { color: var(--fg); } .live .thinking { color: var(--muted); font-style: italic; } .live .tool-use { color: var(--cyan); } diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index a4e15a07..0acc3bd5 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -169,23 +169,6 @@ CREATE INDEX IF NOT EXISTS idx_events_ts ON events (ts); #[derive(Debug, Clone, Serialize)] pub struct BusEvent { pub seq: u64, - /// Unix seconds at emit time. Serialized as a sibling of the `kind` - /// tag so the agent terminal can render turn start/end times (and - /// turn duration) on the live stream; history rows carry the same - /// `ts` field sourced from the persisted `events.ts` column, so the - /// renderer reads `ts` identically for live + scrollback. - pub ts: i64, - #[serde(flatten)] - pub event: LiveEvent, -} - -/// A persisted event paired with its stored unix-seconds timestamp. -/// Serializes with `ts` as a sibling of the `kind` tag — same wire shape -/// as a live [`BusEvent`] minus `seq` — so the agent terminal reads `ts` -/// identically whether an event arrives live or is replayed from history. -#[derive(Debug, Clone, Serialize)] -pub struct StoredEvent { - pub ts: i64, #[serde(flatten)] pub event: LiveEvent, } @@ -298,7 +281,7 @@ impl EventStore { Ok(()) } - fn recent(&self, limit: usize) -> rusqlite::Result> { + fn recent(&self, limit: usize) -> rusqlite::Result> { let (events, _, _) = self.page(None, limit)?; Ok(events) } @@ -310,52 +293,43 @@ impl EventStore { &self, before_id: Option, limit: usize, - ) -> rusqlite::Result<(Vec, Option, bool)> { + ) -> rusqlite::Result<(Vec, Option, bool)> { let limit_i = i64::try_from(limit).unwrap_or(i64::MAX); let conn = self.conn.lock().unwrap(); // Fetch one extra row so we can tell whether more exist. let fetch = limit_i.saturating_add(1); - // `ts` is the persisted emit-time unix-seconds stamp; carried out - // alongside each event so history replay shows the same turn - // start/end times the live stream did. - let rows: Vec<(i64, StoredEvent)> = if let Some(bid) = before_id { + let rows: Vec<(i64, LiveEvent)> = if let Some(bid) = before_id { let mut stmt = conn.prepare( - "SELECT id, ts, payload_json FROM events + "SELECT id, payload_json FROM events WHERE id < ?1 ORDER BY id DESC LIMIT ?2", )?; stmt.query_map(params![bid, fetch], |row| { let id: i64 = row.get(0)?; - let ts: i64 = row.get(1)?; - let s: String = row.get(2)?; - Ok(serde_json::from_str::(&s) - .ok() - .map(|event| (id, StoredEvent { ts, event }))) + let s: String = row.get(1)?; + Ok(serde_json::from_str::(&s).ok().map(|e| (id, e))) })? .flatten() .flatten() .collect() } else { let mut stmt = conn.prepare( - "SELECT id, ts, payload_json FROM events + "SELECT id, payload_json FROM events ORDER BY id DESC LIMIT ?1", )?; stmt.query_map(params![fetch], |row| { let id: i64 = row.get(0)?; - let ts: i64 = row.get(1)?; - let s: String = row.get(2)?; - Ok(serde_json::from_str::(&s) - .ok() - .map(|event| (id, StoredEvent { ts, event }))) + let s: String = row.get(1)?; + Ok(serde_json::from_str::(&s).ok().map(|e| (id, e))) })? .flatten() .flatten() .collect() }; let has_more = rows.len() > limit; - let mut rows: Vec<(i64, StoredEvent)> = rows.into_iter().take(limit).collect(); + let mut rows: Vec<(i64, LiveEvent)> = rows.into_iter().take(limit).collect(); rows.reverse(); // oldest first let min_id = rows.first().map(|(id, _)| *id); let events = rows.into_iter().map(|(_, e)| e).collect(); @@ -1029,7 +1003,6 @@ impl Bus { } let envelope = BusEvent { seq: self.next_seq(), - ts: now_unix(), event, }; // Lagged subscribers drop events — fine; the UI is a tail, not a log. @@ -1045,7 +1018,7 @@ impl Bus { /// Drives the terminal pre-fill when the operator opens the agent /// page; without a store (db open failed) this is empty. #[must_use] - pub fn history(&self) -> Vec { + pub fn history(&self) -> Vec { let Some(store) = &self.store else { return Vec::new(); }; @@ -1062,7 +1035,7 @@ impl Bus { &self, before_id: Option, limit: usize, - ) -> (Vec, Option, bool) { + ) -> (Vec, Option, bool) { let Some(store) = &self.store else { return (Vec::new(), None, false); }; @@ -1078,41 +1051,9 @@ impl Default for Bus { #[cfg(test)] mod tests { - use super::{ - BusEvent, DEFAULT_EFFORT, EFFORT_LEVELS, LiveEvent, StoredEvent, TokenUsage, - is_valid_effort, - }; + use super::{DEFAULT_EFFORT, EFFORT_LEVELS, TokenUsage, is_valid_effort}; use serde_json::json; - #[test] - fn stored_event_serializes_ts_beside_kind() { - // History-row wire shape: `ts` is a flattened sibling of `kind`, - // which is what the agent terminal reads to time turn boundaries. - let v = serde_json::to_value(StoredEvent { - ts: 1_700_000_000, - event: LiveEvent::Note { text: "hi".into() }, - }) - .unwrap(); - assert_eq!(v["ts"], 1_700_000_000_i64); - assert_eq!(v["kind"], "note"); - assert_eq!(v["text"], "hi"); - } - - #[test] - fn bus_event_serializes_ts_and_seq_beside_kind() { - // Live SSE frame: same `ts` sibling as history (plus `seq`), so the - // renderer is path-agnostic between live + scrollback. - let v = serde_json::to_value(BusEvent { - seq: 7, - ts: 1_700_000_000, - event: LiveEvent::Note { text: "yo".into() }, - }) - .unwrap(); - assert_eq!(v["seq"], 7); - assert_eq!(v["ts"], 1_700_000_000_i64); - assert_eq!(v["kind"], "note"); - } - #[test] fn effort_validation_accepts_only_known_levels() { for level in EFFORT_LEVELS { diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index c5e0f4b4..607b79a3 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -46,7 +46,7 @@ pub async fn run(socket: PathBuf) { let token_path = format!("{state_dir}/forge-token"); // Retry reading the token to handle races where hive-priv provisions the // token after the harness starts, or where a parent-container chown briefly - // makes the file unreadable. We wait up to + // makes the file unreadable (see #1304 / #1309). We wait up to // TOKEN_RETRY_MAX * TOKEN_RETRY_SECS before giving up. let token = { let mut attempts = 0u32; @@ -205,14 +205,14 @@ fn escape_md_headings(body: &str) -> String { /// Strict `CommonMark` ATX-heading detector: 1-6 leading `#`s followed /// by either a space, tab, or end-of-line. Anything tighter (`#tag`, -/// `#9`) is a non-heading line that the renderer will not promote. +/// `#123`) is a non-heading line that the renderer will not promote. fn is_atx_heading(line: &str) -> bool { let hashes = line.bytes().take_while(|&b| b == b'#').count(); if !(1..=6).contains(&hashes) { return false; } // Bare `#` / `##` / ... on its own line, or proper ATX with a - // space/tab after the run of `#`s; anything else (`#tag` / `#9`) + // space/tab after the run of `#`s; anything else (`#tag` / `#123`) // is not a heading. matches!(line.as_bytes().get(hashes), None | Some(b' ' | b'\t')) } @@ -843,11 +843,11 @@ mod tests { #[test] fn escape_md_headings_skips_non_atx_hash_lines() { - // ATX requires a space after the `#`s. Lines like `#tag`, a - // hash-then-digits run, or `#!/bin/bash` are NOT headings — - // escaping them would just add cosmetic noise where the - // renderer wouldn't promote the line in the first place. - let body = "#tag\n#123\n#!/bin/bash\n####### too many hashes\nbody"; // lint:allow: hash-digit heading test input, not a tracker tag + // ATX requires a space after the `#`s. Lines like `#tag`, + // `#123`, `#!/bin/bash` are NOT headings — escaping them + // would just add cosmetic noise where the renderer + // wouldn't promote the line in the first place. + let body = "#tag\n#123\n#!/bin/bash\n####### too many hashes\nbody"; let escaped = escape_md_headings(body); // All four leading `#` lines pass through untouched: too few // (still need space), seven `#`s (over the cap), shebang diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index c8842d06..13ae10a1 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -841,7 +841,7 @@ pub(crate) fn handle_send( let resolved = crate::topology::resolve_recipient(agent, to); // Validate that the resolved recipient is a known local agent or the // special "operator" recipient. Without this check a typo in `to` - // silently queues a message nobody will ever read. + // silently queues a message nobody will ever read (issue #1165). // // Cross-hive messaging (`name@hive` qualified names) is not routed // through the broker — use the Matrix MCP tools for that instead. diff --git a/hive-c0re/src/broker.rs b/hive-c0re/src/broker.rs index 52d67e2c..1ad74403 100644 --- a/hive-c0re/src/broker.rs +++ b/hive-c0re/src/broker.rs @@ -347,7 +347,7 @@ impl Broker { } /// Unacknowledged messages addressed to `recipient`, newest-first. - /// Backs the dashboard's operator inbox: the operator never + /// Backs the dashboard's operator inbox (#1469): the operator never /// `recv`s over an agent socket, so messages to `"operator"` sit in /// the broker with `acked_at IS NULL` until the operator hits "mark /// all read" (which calls [`Broker::mark_all_read`]). This read @@ -1352,7 +1352,7 @@ mod tests { assert_eq!(broker.ack_turn("b").unwrap(), 5); } - /// Transient-wake regression guard: a `ping` fired while no `recv` is parked + /// The #1462 fix: a transient `ping` fired while no `recv` is parked /// must NOT be lost — it's buffered and drained by the next collect. #[test] fn transient_ping_buffered_when_no_receiver_parked() { diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index ff79f063..69487a03 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -114,14 +114,6 @@ pub struct Coordinator { /// watcher consults both this and the active map before declaring /// a stop deliberate. recent_transient: Mutex>, - /// Timestamps of recent unexpected container crashes, keyed by agent. - /// Fed by `crash_watch` each time it classifies a stop as a crash (so - /// a crash-looping container — which `Restart=on-failure` flips back - /// to running between polls — accumulates one entry per down-transition, - /// not just whatever its point-in-time state happens to be). Read by - /// the dashboard's `agents_crashing` banner warning via - /// `recent_crash_counts`, which prunes entries older than its window. - recent_crashes: Mutex>>, /// Unified wire-facing event channel feeding the dashboard SSE /// stream. Carries broker messages (mirrored from `broker.subscribe` /// by the forwarder task in `main.rs`) and dashboard-only mutation @@ -438,7 +430,6 @@ impl Coordinator { agents: Mutex::new(HashMap::new()), transient: Mutex::new(HashMap::new()), recent_transient: Mutex::new(HashMap::new()), - recent_crashes: Mutex::new(HashMap::new()), dashboard_events, event_seq: AtomicU64::new(0), meta_updates_active: AtomicU64::new(0), @@ -1084,33 +1075,6 @@ impl Coordinator { .collect() } - /// Record an unexpected crash for `agent`. Called by the crash - /// watcher whenever it classifies a container stop as a crash (not an - /// operator action). Append-only here; pruning happens lazily on read - /// in `recent_crash_counts`. - pub fn record_crash(&self, agent: &str) { - self.recent_crashes - .lock() - .unwrap() - .entry(agent.to_owned()) - .or_default() - .push(std::time::Instant::now()); - } - - /// Per-agent count of crashes within the last `window`. Lazily reaps - /// older timestamps and drops agents with none left, so the map stays - /// bounded and only lists agents actively crashing. Powers the - /// dashboard's `agents_crashing` banner warning. - pub fn recent_crash_counts(&self, window: std::time::Duration) -> HashMap { - let now = std::time::Instant::now(); - let mut map = self.recent_crashes.lock().unwrap(); - map.retain(|_, times| { - times.retain(|ts| now.duration_since(*ts) <= window); - !times.is_empty() - }); - map.iter().map(|(k, v)| (k.clone(), v.len())).collect() - } - /// Set a transient state and return a guard that clears it on drop. /// Use this from any path where the surrounding future could be /// cancelled or panic between set and clear (HTTP handlers, spawned diff --git a/hive-c0re/src/crash_watch.rs b/hive-c0re/src/crash_watch.rs index c7cca394..6a7d89dc 100644 --- a/hive-c0re/src/crash_watch.rs +++ b/hive-c0re/src/crash_watch.rs @@ -95,7 +95,6 @@ fn emit_crash_transitions(coord: &Coordinator, prev: &HashSet, current: continue; } tracing::warn!(agent = %stopped, "container crash detected"); - coord.record_crash(stopped); coord.notify_manager(&hive_sh4re::HelperEvent::ContainerCrash { agent: stopped.clone(), note: Some("container stopped without an operator action".into()), diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 05e8b43f..d5e20fc3 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -463,13 +463,6 @@ where } } -/// Window over which container crashes count toward the `agents_crashing` -/// banner warning. Wide enough that a crash-looping container (restarted -/// by `Restart=on-failure` every few seconds) keeps the warning lit -/// between flaps, short enough that a single recovered crash clears within -/// minutes. -const CRASH_WARNING_WINDOW: std::time::Duration = std::time::Duration::from_mins(10); - async fn api_state(headers: HeaderMap, State(state): State) -> axum::Json { let host = headers .get("host") @@ -530,18 +523,6 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J .map(QuestionView::from_question) .collect(); - // Banner warnings: host probes (disk) + agent-state (pending logins, - // crashing agents). Built before the response struct because the - // agent-state producer borrows `containers`, which moves in below. - let server_warnings = { - let mut w = crate::host_stats::server_warnings(); - w.extend(crate::host_stats::agent_state_warnings( - &containers, - &state.coord.recent_crash_counts(CRASH_WARNING_WINDOW), - )); - w - }; - axum::Json(StateSnapshot { seq, hostname, @@ -583,7 +564,7 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J .ok() .filter(|s| !s.is_empty()), peer_hives: parse_peer_hives(), - server_warnings, + server_warnings: crate::host_stats::server_warnings(), }) } @@ -1240,8 +1221,8 @@ pub(crate) fn emit_meta_inputs_snapshot(coord: &Coordinator) { }); } -/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox. -/// Returns messages addressed to `"operator"` that haven't been +/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox +/// (#1469). Returns messages addressed to `"operator"` that haven't been /// acked yet (the operator clears them via the existing /// `POST /api/agent/operator/mark-all-read`). Newest-first; path-shaped /// tokens are validated so the client renders file links like the diff --git a/hive-c0re/src/host_stats.rs b/hive-c0re/src/host_stats.rs index 712e5568..61c95d9a 100644 --- a/hive-c0re/src/host_stats.rs +++ b/hive-c0re/src/host_stats.rs @@ -13,12 +13,8 @@ //! pressure, a failed unit, …) is a backend-only change — no frontend //! edit. Keep producers cheap; this runs on every `/api/state` assembly. -use std::collections::HashMap; - use serde::Serialize; -use crate::container_view::ContainerView; - /// One server-level warning for the dashboard's top-of-page banner. #[derive(Debug, Clone, Serialize)] pub struct ServerWarning { @@ -69,69 +65,6 @@ pub fn server_warnings() -> Vec { out } -/// Agent-state warnings derived from the live container snapshot the -/// dashboard already holds: agents that need a claude login, and agents -/// that are crashing. Kept separate from [`server_warnings`] (host -/// probes) because the caller owns the container list + crash counts; -/// the dashboard concatenates both into one banner list. -/// -/// `crash_counts` is `Coordinator::recent_crash_counts(window)` — agent → -/// number of crashes inside that window — so a crash-looping agent shows -/// its repeat count rather than a single point-in-time flap. -#[must_use] -pub fn agent_state_warnings( - containers: &[ContainerView], - crash_counts: &HashMap, -) -> Vec { - let mut out = Vec::new(); - - // `needs_login` is already running-gated in `container_view::build_all`, - // so a stopped container never lights this. - let mut pending: Vec<&str> = containers - .iter() - .filter(|c| c.needs_login) - .map(|c| c.name.as_str()) - .collect(); - if !pending.is_empty() { - pending.sort_unstable(); - out.push(ServerWarning { - kind: "pending_logins", - level: "warn", - message: format!( - "{n} agent{plural} {verb} claude login: {list} \ - — run `hivectl login ` to authenticate", - n = pending.len(), - plural = if pending.len() == 1 { "" } else { "s" }, - verb = if pending.len() == 1 { "needs" } else { "need" }, - list = pending.join(", "), - ), - }); - } - - if !crash_counts.is_empty() { - let mut crashing: Vec<(&String, usize)> = - crash_counts.iter().map(|(a, n)| (a, *n)).collect(); - crashing.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0))); - let list = crashing - .iter() - .map(|(a, n)| format!("{a} (×{n})")) - .collect::>() - .join(", "); - out.push(ServerWarning { - kind: "agents_crashing", - level: "crit", - message: format!( - "{n} agent{plural} crashing: {list} \ - — check the container journal (`hivectl logs `)", - n = crashing.len(), - plural = if crashing.len() == 1 { "" } else { "s" }, - ), - }); - } - - out -} - /// Disk usage for the filesystem backing the host nix store — internal to /// the disk-pressure producer above. struct DiskUsage { @@ -189,71 +122,3 @@ fn disk_usage(path: &str) -> Option { used_pct, }) } - -#[cfg(test)] -mod tests { - use super::*; - - fn cv(name: &str, needs_login: bool) -> ContainerView { - ContainerView { - name: name.to_owned(), - container: format!("h-{name}"), - port: 0, - running: true, - needs_update: false, - needs_login, - deployed_sha: None, - pending_reminders: 0, - parent: None, - } - } - - #[test] - fn no_agent_warnings_when_all_healthy() { - let containers = [cv("alice", false), cv("bob", false)]; - assert!(agent_state_warnings(&containers, &HashMap::new()).is_empty()); - } - - #[test] - fn pending_logins_lists_sorted_agents() { - let containers = [cv("zoe", true), cv("amy", false), cv("bob", true)]; - let w = agent_state_warnings(&containers, &HashMap::new()); - assert_eq!(w.len(), 1); - assert_eq!(w[0].kind, "pending_logins"); - assert_eq!(w[0].level, "warn"); - // sorted, login-needing only, count reflected - assert!( - w[0].message - .starts_with("2 agents need claude login: bob, zoe") - ); - } - - #[test] - fn singular_grammar_for_one_agent() { - let containers = [cv("solo", true)]; - let w = agent_state_warnings(&containers, &HashMap::new()); - assert!(w[0].message.starts_with("1 agent needs claude login: solo")); - } - - #[test] - fn crashing_warning_is_crit_and_count_ordered() { - let crashes = HashMap::from([("flap".to_owned(), 5), ("blip".to_owned(), 1)]); - let w = agent_state_warnings(&[], &crashes); - assert_eq!(w.len(), 1); - assert_eq!(w[0].kind, "agents_crashing"); - assert_eq!(w[0].level, "crit"); - // higher crash count first - assert!(w[0].message.contains("flap (×5), blip (×1)")); - } - - #[test] - fn both_warnings_coexist() { - let containers = [cv("a", true)]; - let crashes = HashMap::from([("b".to_owned(), 2)]); - let kinds: Vec<&str> = agent_state_warnings(&containers, &crashes) - .iter() - .map(|w| w.kind) - .collect(); - assert_eq!(kinds, ["pending_logins", "agents_crashing"]); - } -} diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 011e363b..51d7af3a 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -1145,7 +1145,7 @@ async fn set_nspawn_flags( // Make /shared writable by every agent. Containers share host uids (no // PrivateUsers), but each agent is a distinct unix user, so a root-owned // 0755 dir leaves them unable to write — the documented "read/write for - // all agents" contract was broken. A setgid group would need a + // all agents" contract was broken (#1374). A setgid group would need a // pinned GID declared in every container plus all agent users joined to // it (cross-container coordination + a rebuild cascade); instead we use // the /tmp model — sticky world-writable (1777). The sticky bit lets any diff --git a/hive-c0re/src/rebuild_queue.rs b/hive-c0re/src/rebuild_queue.rs index 394d515f..6d8d6735 100644 --- a/hive-c0re/src/rebuild_queue.rs +++ b/hive-c0re/src/rebuild_queue.rs @@ -1224,7 +1224,7 @@ mod tests { kind: QueueKind::Rebuild, agent: "agent-a".to_owned(), source: QueueSource::Approval, - reason: "approval 42 apply commit".to_owned(), + reason: "approval #42 apply commit".to_owned(), parent_id: None, inputs: Vec::new(), approval_id: Some(42), diff --git a/hive-forge/src/body.rs b/hive-forge/src/body.rs index 05e39c88..0025c572 100644 --- a/hive-forge/src/body.rs +++ b/hive-forge/src/body.rs @@ -1,5 +1,5 @@ //! Body-input resolution shared by every verb that posts a body. -//! Matches the bash `resolve_body` helper: exactly one source +//! Matches the bash `resolve_body` helper (#382): exactly one source //! between `--body`, `--body-file`, and piped stdin. Passing both //! `--body` and `--body-file` is a clear error. diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index ace4a3b3..3308fc9e 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -32,7 +32,7 @@ pub struct Client { pub default_repo: String, /// Global `--json` flag — verbs that have a human-readable /// default path branch on `client.json_mode()` to pick the - /// JSON output shape instead. + /// JSON output shape instead. Closes #421. json_mode: bool, } @@ -131,7 +131,7 @@ impl Client { /// params (`?limit=N&state=open&...`) are preserved. Pages drain /// while the response carries a `Link: rel="next"` header, up to /// `max_pages` (the runaway-loop safety cap). Returns the merged - /// array. Used by `lint` for repo-wide queries. + /// array. Used by `lint` for repo-wide queries (closes #505). pub fn get_json_all(&self, path: &str, max_pages: u32) -> Result> { let sep = if path.contains('?') { '&' } else { '?' }; let mut merged = Vec::new(); @@ -271,8 +271,8 @@ fn read_token() -> Result { } /// Surface non-2xx HTTP responses as anyhow errors with the response -/// body included (matches `curl --fail-with-body`) — turns -/// silent failures into errors with a clear message. +/// body included (matches `curl --fail-with-body`). Closes #353's +/// "silent failures with no clue what went wrong" case. fn check_status(resp: Response, op: &str) -> Result { let status = resp.status(); if status.is_success() { diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 4f99bfb1..d086a556 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -8,7 +8,7 @@ //! Single binary with verb subcommands. Replaces the prior bash //! script (`hive-forge-tools.nix`) so that agents and operators get //! the same error handling, exit codes, and JSON shapes regardless -//! of how the bash mood was that day. +//! of how the bash mood was that day (closes #280). #![warn(missing_docs)] // Clap-derived `Args` structs are intentionally consumed by their @@ -26,7 +26,7 @@ use clap::{Parser, Subcommand}; #[derive(Parser)] #[command( name = "hive-forge", - about = "Forgejo CLI wrapper for hyperhive", + about = "Forgejo CLI wrapper for hyperhive (closes #280)", disable_help_subcommand = true )] struct Cli { @@ -36,7 +36,7 @@ struct Cli { #[arg(short = 'r', long, global = true)] repo: Option, /// Emit JSON output instead of the verb's default human-readable - /// shape, for verbs that support both. Verbs whose + /// shape, for verbs that support both (closes #421). Verbs whose /// only output is already JSON (`issue`, `pr`, etc.) ignore this /// flag — they always print JSON regardless. #[arg(long, global = true)] @@ -100,7 +100,7 @@ enum Verb { Subscription(verbs::subscription::Args), /// List timeline events on an issue or PR (closes, label adds, /// assignments, commit refs, pushes, etc.) — the audit trail - /// `view` + `comments` don't surface. + /// `view` + `comments` don't surface (closes #783). Timeline(verbs::timeline::Args), /// Upload a file as an attachment to an issue. AttachIssue(verbs::attach::IssueArgs), diff --git a/hive-forge/src/verbs/assign.rs b/hive-forge/src/verbs/assign.rs index d257e239..5c9ed713 100644 --- a/hive-forge/src/verbs/assign.rs +++ b/hive-forge/src/verbs/assign.rs @@ -1,7 +1,7 @@ //! `assign [--remove]` — add or remove a user from an //! issue/PR's assignee list. Forgejo has no dedicated POST endpoint — -//! we read the current list, mutate, and PATCH the issue back (Forgejo -//! has no such endpoint; matches the bash helper's logic). +//! we read the current list, mutate, and PATCH the issue back (closes +//! #353's "no such endpoint" trap; matches the bash helper's logic). use anyhow::Result; use clap::Args as ClapArgs; diff --git a/hive-forge/src/verbs/comments.rs b/hive-forge/src/verbs/comments.rs index 6c5bcb6a..026bbc1a 100644 --- a/hive-forge/src/verbs/comments.rs +++ b/hive-forge/src/verbs/comments.rs @@ -1,6 +1,6 @@ //! `comments [--limit N | --tail N]` — list comments on an -//! issue or PR. Replaces the curl fallback; `--tail` -//! handles the paging-for-long-threads +//! issue or PR. Closes the curl-fallback gap (#418); `--tail` +//! closes the third of the four #694 gaps (paging-for-long-threads //! awkwardness). //! //! - `--limit N` (default 50, Forgejo's cap) returns the first N @@ -14,7 +14,7 @@ //! on this long thread?" without scrolling through the whole //! history. //! -//! Use the global `--json` flag for JSON output. +//! Use the global `--json` flag for JSON output (#421). use anyhow::Result; use clap::Args as ClapArgs; diff --git a/hive-forge/src/verbs/diff.rs b/hive-forge/src/verbs/diff.rs index 57f27bbf..a0eb6e82 100644 --- a/hive-forge/src/verbs/diff.rs +++ b/hive-forge/src/verbs/diff.rs @@ -5,7 +5,7 @@ //! `package-lock.json`, …) is collapsed to a single //! `[: contents changed (+N -M, --full for content)]` //! line so a `flake.lock` rev bump doesn't drown the human- -//! authored changes in 5 000 lines of lock churn. The +//! authored changes in 5 000 lines of lock churn (#222). The //! per-file git headers (`diff --git`, `index`, `---`, `+++`, //! and any rename / mode metadata) are suppressed alongside the //! hunks since the placeholder already carries the file path and diff --git a/hive-forge/src/verbs/lint.rs b/hive-forge/src/verbs/lint.rs index ff4dd516..af24ffac 100644 --- a/hive-forge/src/verbs/lint.rs +++ b/hive-forge/src/verbs/lint.rs @@ -1,5 +1,5 @@ //! `lint ` — issue/PR/branch lint queries for triage -//! workflows. Replaces ad-hoc curl + jq filtering with +//! workflows (closes #505). Replaces ad-hoc curl + jq filtering with //! typed commands that always emit JSON via the global `--json` //! (default is a compact one-line-per-item human shape). //! @@ -97,7 +97,7 @@ struct NoReviewerArgs { /// Reviewer login to look for (matches `@` in PR body or /// any comment). Required — defaulting to a specific name would /// bake one deployment's reviewer convention into the binary - /// (flagged in review). + /// (mara's nit on #507). #[arg(long)] reviewer: String, } @@ -177,7 +177,7 @@ fn run_no_reviewer(client: &Client, args: NoReviewerArgs) -> Result<()> { continue; } // Paginate so PRs with >50 comments don't yield false positives - // (flagged in review). Same 1000-comment ceiling as elsewhere. + // (argus nit on #507). Same 1000-comment ceiling as elsewhere. let comments = client.get_json_all( &format!("/repos/{repo}/issues/{number}/comments?limit={PAGE_LIMIT}"), MAX_PAGES, diff --git a/hive-forge/src/verbs/list.rs b/hive-forge/src/verbs/list.rs index c8764a10..49a78bc9 100644 --- a/hive-forge/src/verbs/list.rs +++ b/hive-forge/src/verbs/list.rs @@ -5,7 +5,7 @@ //! //! Mirrors Forgejo's `GET /repos/{owner}/{repo}/issues` query-string //! filters one-for-one so the mental model carries over. Closes the -//! read-side curl-fallback gap (no boundary concerns — +//! second of the four #694 gaps (read-side; no boundary concerns — //! every agent + the operator queries the issue tracker constantly). use std::fmt::Write as _; diff --git a/hive-forge/src/verbs/pr_create.rs b/hive-forge/src/verbs/pr_create.rs index c6cd21eb..97a2b23d 100644 --- a/hive-forge/src/verbs/pr_create.rs +++ b/hive-forge/src/verbs/pr_create.rs @@ -8,7 +8,7 @@ //! hint block is filtered out of git's stderr (we print the canonical //! URL ourselves once the API returns). Other git stderr passes //! through. Default behaviour is unchanged: no push unless asked. -//! Adds the auto-push path per operator decision (opt-in +//! Closes the auto-push half of #222 per operator decision (opt-in //! flag). //! //! With `--agit` the PR is opened via Forgejo's `AGit` flow instead of diff --git a/hive-forge/src/verbs/timeline.rs b/hive-forge/src/verbs/timeline.rs index bd662e72..8677cb8d 100644 --- a/hive-forge/src/verbs/timeline.rs +++ b/hive-forge/src/verbs/timeline.rs @@ -1,7 +1,7 @@ //! `timeline [--limit N]` — list timeline events on an -//! issue or PR. Fills the gap where agents kept +//! issue or PR. Closes #783 (last piece of the #694 epic: agents kept //! falling back to curl for "who closed this?" / "when was this -//! labelled?" archaeology. Composes naturally with `view ` / +//! labelled?" archaeology). Composes naturally with `view ` / //! `comments ` — separate verb keeps the existing shapes stable. //! //! Forgejo's `/issues/{n}/timeline` endpoint returns BOTH the actual @@ -12,7 +12,7 @@ //! //! `--tail N` is a follow-up (the timeline endpoint doesn't expose a //! total-count field so we can't use the count-then-page trick that -//! `comments --tail` uses; future shape probably mirrors +//! `comments --tail` lands in #770; future shape probably mirrors //! `comments --tail` once Forgejo grows a `count` query or we accept //! the trailing-slice cost). @@ -211,7 +211,7 @@ mod tests { //! Tests call `format_event` directly so any new event-type arm //! added in `print_event`'s dispatch is automatically covered by //! the rendering path (no parallel test-side dispatch to keep in - //! sync). A review flagged: "extract a `format_event(ev) -> + //! sync). Argus on PR #798 🟡: "extract a `format_event(ev) -> //! String` helper and test that function directly instead of //! duplicating the logic" — addressed. use super::*; diff --git a/hive-matrix-mcp/src/handlers.rs b/hive-matrix-mcp/src/handlers.rs index 23125db7..1be0d4a2 100644 --- a/hive-matrix-mcp/src/handlers.rs +++ b/hive-matrix-mcp/src/handlers.rs @@ -4,7 +4,7 @@ //! the payload to claude; `Error { message }` becomes the tool-call //! error message claude sees). //! -//! Tool surface mirrors damocles-daemon's v0 set per the operator's call: +//! Tool surface mirrors damocles-daemon's v0 set per mara on #548: //! `send_message`, `send_dm`, `send_reaction`, `send_reply`, `mark_read`, //! `list_rooms`, `list_room_members`, `read_room`. Plus a `ping` for the //! MCP bridge's liveness probe. diff --git a/hive-matrix-mcp/src/lib.rs b/hive-matrix-mcp/src/lib.rs index 25433b0d..8db22f9a 100644 --- a/hive-matrix-mcp/src/lib.rs +++ b/hive-matrix-mcp/src/lib.rs @@ -5,8 +5,8 @@ //! The wire protocol between the two binaries lives in [`protocol`]. //! Path helpers (token file, daemon socket) live in [`paths`]. //! -//! Architecture rationale and tool surface mirror the existing -//! `damocles-daemon` v0 set. +//! Phase 3 of #548. Architecture rationale + tool surface mirror the +//! existing `damocles-daemon` (see issue thread for details). pub mod client; pub mod paths; diff --git a/hive-matrix-mcp/src/paths.rs b/hive-matrix-mcp/src/paths.rs index 92918dd9..9838aff5 100644 --- a/hive-matrix-mcp/src/paths.rs +++ b/hive-matrix-mcp/src/paths.rs @@ -18,7 +18,7 @@ pub const DEFAULT_HOMESERVER: &str = "http://localhost:8008"; /// Lives under systemd's `RuntimeDirectory=hive-matrix` (a tmpfs path /// that disappears on container restart — fine, because the daemon /// recreates the socket on its own boot) so the agent unix user -/// can bind a socket inside it without root in `/run`. +/// (post-#658) can bind a socket inside it without root in `/run`. pub const DEFAULT_DAEMON_SOCKET: &str = "/run/hive-matrix/socket"; /// Resolve the matrix access-token file path. Override via diff --git a/hive-matrix-mcp/src/timeline.rs b/hive-matrix-mcp/src/timeline.rs index 4a81669f..fcb7e425 100644 --- a/hive-matrix-mcp/src/timeline.rs +++ b/hive-matrix-mcp/src/timeline.rs @@ -1,7 +1,7 @@ //! Matrix event handlers: incoming room messages fire a hyperhive //! wake signal so the agent's harness drives a new claude turn. //! -//! Per the operator's call: wake body is a SHORT TEASER, not the full message +//! Per mara on #548: wake body is a SHORT TEASER, not the full message //! (msg stays unread server-side; agent fetches via `read_room`). The //! `wake::format_wake_body` truncates to ~100 chars. //! diff --git a/hive-matrix-mcp/src/wake.rs b/hive-matrix-mcp/src/wake.rs index 48d4ed53..2fef492e 100644 --- a/hive-matrix-mcp/src/wake.rs +++ b/hive-matrix-mcp/src/wake.rs @@ -7,7 +7,7 @@ //! The agent harness's `agent_server` parses it and treats it as a //! `Wake` from the matrix subsystem. //! -//! Per the operator's call (phase 3): the body is a SHORT TEASER, not +//! Per mara's call on #548 phase 3: the body is a SHORT TEASER, not //! the full message — the agent then reads the unmarked event via //! the `read_room` MCP tool. Truncation to ~100 chars keeps the wake //! prompt focused (`forge_notify` embeds longer excerpts because the diff --git a/hive-sh4re/src/assets.rs b/hive-sh4re/src/assets.rs index ca6e239f..b5fcc0e1 100644 --- a/hive-sh4re/src/assets.rs +++ b/hive-sh4re/src/assets.rs @@ -77,7 +77,7 @@ pub fn core_avatar_png() -> PathBuf { /// `$HIVE_ASSETS_DIR/branding/agent-configs.png` — secondary org /// mark for the `agent-configs/` mirror org. Rendered from /// `agent-configs.svg` at asset-build time (was rendered in -/// `hive-c0re/build.rs`). +/// `hive-c0re/build.rs` before #555). #[must_use] pub fn config_org_avatar_png() -> PathBuf { dir().join("branding/agent-configs.png") diff --git a/scripts/check-issue-refs.sh b/scripts/check-issue-refs.sh index 8053bed2..5cec3d3e 100755 --- a/scripts/check-issue-refs.sh +++ b/scripts/check-issue-refs.sh @@ -19,24 +19,16 @@ # overruns) and digit-runs followed by a letter — e.g. hash-route # fragments like #24h. Residual: a pure-numeric short hex (e.g. three # identical digits) trips it — write the six-digit form to dodge. -# -# Escape hatch: a line containing the marker `lint:allow` is exempt. -# Reserve it for genuine `#` that aren't tracker tags — e.g. a -# `#123` markdown-heading example or hash-prefixed test-input data — -# and keep a short reason next to the marker. Don't use it to keep a -# real tracker tag; rewrite those to prose. set -eu pattern='#[0-9]{2,5}([^0-9a-zA-Z]|$)' # `/dev/null` forces grep to always print a filename prefix, even when # xargs hands it a single file. `-r`/`-0` keep it robust to odd paths -# and an empty file list. Lines carrying the `lint:allow` marker are -# dropped (legitimate non-tracker `#`; see the header). +# and an empty file list. hits="$( git ls-files -z '*.rs' '*.nix' '*.js' '*.ts' '*.css' '*.html' \ - | xargs -0 -r grep -nE "$pattern" /dev/null 2>/dev/null \ - | grep -v 'lint:allow' || true + | xargs -0 -r grep -nE "$pattern" /dev/null 2>/dev/null || true )" if [ -n "$hits" ]; then