From 2173d211a5b27d7475a44afd51ae0436dbbbd50b Mon Sep 17 00:00:00 2001 From: lexis Date: Tue, 26 May 2026 19:45:20 +0200 Subject: [PATCH 1/4] docs: add hive-forge diff verb to gotchas cheat sheet (follow-up to #481) --- docs/gotchas.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/gotchas.md b/docs/gotchas.md index f41c9c90..32537c3d 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -133,6 +133,8 @@ hive-forge assign 42 damocles hive-forge close 42 hive-forge labels 42 add feature hive-forge pr 42 # PR metadata as JSON +hive-forge diff 42 # unified diff (lockfile hunks collapsed by default) +hive-forge diff 42 --full # include unfiltered lockfile hunks hive-forge branches deployed/ # filter branches by pattern hive-forge -r other-org/other-repo pr 7 # target a different repo ``` From f540584553ded3bcd609aebdd6d4ac6b24bdabb1 Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 26 May 2026 20:35:01 +0200 Subject: [PATCH 2/4] docs: document rebuild_queue dispatch for approval kinds (#124, #441 followup) --- docs/approvals.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/approvals.md b/docs/approvals.md index 7f5149aa..e587add0 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -187,6 +187,49 @@ approval id to retry. Because tags are first-class git objects, rejected and failed trees stay browsable forever — `git log --tags` in the applied repo is the audit trail. +### Dispatch via `rebuild_queue` (#441) + +Long-running approval work — `ApplyCommit`, `UpdateMetaInputs`, +`Spawn` — no longer runs inline inside `actions::approve`. Instead +the approval handler enqueues a `QueueEntry` into the global +`rebuild_queue`: + +| `ApprovalKind` | `QueueKind` queued | `QueueSource` | +|---|---|---| +| `ApplyCommit` | `Rebuild` | `Approval` | +| `UpdateMetaInputs` | `MetaUpdate` | `Approval` | +| `Spawn` | `Spawn` | `Approval` | +| `InitConfig` | — runs inline (sub-second git seed) | — | +| `SchedulePrompt` | — runs inline (single sqlite insert) | — | + +Each queue entry carries the originating `approval_id` so the +worker can re-fetch the approval row when it dispatches, run the +kind-specific pipeline (`run_approval_apply_commit` / +`run_approval_update_meta_inputs` / `run_approval_spawn`), and +fire the matching `HelperEvent::*` on completion via +`finish_approval`. + +Two visible consequences: + +- **Operator dashboard**: after clicking APPR0VE the work-in-progress + shows up on the *rebuild queue* card (`POST /api/state.rebuild_queue` + + live `rebuild_queue_changed` events), not on the approvals panel + (which already moved the row to "approved"). A long meta-update + cascade renders as a parent entry with one child per per-agent + rebuild — see `docs/web-ui.md` for the layout. +- **Cancellation**: the dashboard's *× cancel* button on a `Queued` + entry calls `POST /api/rebuild-queue/{id}/cancel`, which flips the + entry to `Cancelled` before the worker dispatches it. Returns + `{"cancelled": true}` on success, `{"cancelled": false}` if the + entry already left `Queued` (running / done / failed) — terminal + states can't be retroactively rewritten. + +`QueueSource::Approval` carries the `approval_id` so a tail-end +build failure surfaces back as a failed approval row, not just a +silent queue entry. `QueueSource::Manual` (dashboard ↻ R3BU1LD) +and `QueueSource::AutoUpdate` (boot-time sweep) use the same +queue but skip the approval row plumbing. + ### Forge mirror When the bundled `hive-forge` container is running — on by From d143abb63da720464a7f96562b73f266ff70f54b Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 26 May 2026 22:38:26 +0200 Subject: [PATCH 3/4] dashboard: filter /dashboard/stream by ?kinds= allow-list (#408) --- hive-c0re/src/dashboard.rs | 37 ++++++- hive-c0re/src/dashboard_events.rs | 157 ++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 956f9ae9..86ef4f6d 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -798,14 +798,49 @@ async fn dashboard_history(State(state): State) -> Response { } } +/// `/dashboard/stream` query string. Today's only field is `kinds` +/// (#408): a comma-separated allow-list of event-`kind` strings. +/// Empty / absent ⇒ no filter (current behaviour, all variants +/// forwarded). Set ⇒ only the named kinds reach the subscriber, +/// non-matches are skipped before the JSON serialise cost. +/// +/// Useful for narrow pages (e.g. `flow.js` only cares about `sent` +/// / `delivered` / `container_state_changed` / `container_removed`) +/// that want to drop the dispatch overhead on every unrelated mutation. +#[derive(Deserialize, Default)] +struct DashboardStreamQuery { + /// Comma-separated event kinds to forward. Each token is + /// trimmed; unknown kinds are silently ignored on lookup + /// (subscriber sees nothing instead of an error). + kinds: Option, +} + async fn dashboard_stream( State(state): State, + axum::extract::Query(q): axum::extract::Query, ) -> Sse>> { let rx = state.coord.dashboard_subscribe(); - let stream = BroadcastStream::new(rx).filter_map(|res| { + // Pre-parse the allow-list once at subscription time, so the + // per-event hot path is just a `HashSet::contains` on a + // `&'static str` — no string churn per frame. + let kind_filter: Option> = q.kinds.and_then(|raw| { + let set: std::collections::HashSet = raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + if set.is_empty() { None } else { Some(set) } + }); + let stream = BroadcastStream::new(rx).filter_map(move |res| { // Drop lagged frames. Browsers reconnect; the seq dedupe on // reconnect skips any frame already reflected in the snapshot. let event = res.ok()?; + if let Some(filter) = kind_filter.as_ref() + && !filter.contains(event.kind_tag()) + { + return None; + } let json = serde_json::to_string(&event).ok()?; Some(Ok(Event::default().data(json))) }); diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index fcea9c78..0ae794a8 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -218,3 +218,160 @@ pub enum DashboardEvent { queue: Vec, }, } + +impl DashboardEvent { + /// Snake-case identifier matching this variant's serde `tag` + /// (e.g. `Sent` → `"sent"`, `ContainerStateChanged` → + /// `"container_state_changed"`). Lets `/dashboard/stream`'s + /// `?kinds=` filter (#408) decide whether to forward a frame + /// without paying the JSON-serialise cost first. + /// + /// Keep in sync with `#[serde(rename_all = "snake_case", tag = + /// "kind")]` on `DashboardEvent` — if a new variant lands above, + /// add it here too. `cargo test` covers this via the + /// `kind_tag_matches_serde_kind_field` round-trip test. + #[must_use] + pub fn kind_tag(&self) -> &'static str { + match self { + DashboardEvent::Sent { .. } => "sent", + DashboardEvent::Delivered { .. } => "delivered", + DashboardEvent::ApprovalAdded { .. } => "approval_added", + DashboardEvent::ApprovalResolved { .. } => "approval_resolved", + DashboardEvent::QuestionAdded { .. } => "question_added", + DashboardEvent::QuestionResolved { .. } => "question_resolved", + DashboardEvent::TransientSet { .. } => "transient_set", + DashboardEvent::TransientCleared { .. } => "transient_cleared", + DashboardEvent::ContainerStateChanged { .. } => "container_state_changed", + DashboardEvent::ContainerRemoved { .. } => "container_removed", + DashboardEvent::TombstonesChanged { .. } => "tombstones_changed", + DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed", + DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running", + DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Round-trip representative variants through serde and confirm + /// the `kind` JSON field matches `kind_tag()`. The exhaustive + /// `match` in `kind_tag` already provides compile-time variant + /// coverage — this test is the value-side guard against + /// typos in the snake_case strings vs serde's `rename_all` + /// output. `ContainerStateChanged` is omitted from the sample + /// list only because `ContainerView` has no `Default` impl and + /// constructing one inline here is more boilerplate than the + /// test is worth; the variant is still covered by the + /// `kind_tag` match arm. + #[test] + fn kind_tag_matches_serde_kind_field() { + let samples: Vec = vec![ + DashboardEvent::Sent { + seq: 1, + id: 1, + from: "a".into(), + to: "b".into(), + body: String::new(), + at: 0, + in_reply_to: None, + file_refs: Vec::new(), + }, + DashboardEvent::Delivered { + seq: 1, + id: 1, + from: "a".into(), + to: "b".into(), + body: String::new(), + at: 0, + in_reply_to: None, + file_refs: Vec::new(), + }, + DashboardEvent::ApprovalAdded { + seq: 1, + id: 1, + agent: "x".into(), + approval_kind: "apply_commit", + sha_short: None, + diff: None, + description: None, + }, + DashboardEvent::ApprovalResolved { + seq: 1, + id: 1, + agent: "x".into(), + approval_kind: "apply_commit", + sha_short: None, + status: "approved", + resolved_at: 0, + note: None, + description: None, + }, + DashboardEvent::QuestionAdded { + seq: 1, + id: 1, + asker: "a".into(), + question: String::new(), + options: Vec::new(), + multi: false, + asked_at: 0, + deadline_at: None, + target: None, + question_refs: Vec::new(), + }, + DashboardEvent::QuestionResolved { + seq: 1, + id: 1, + answer: String::new(), + answerer: "a".into(), + answered_at: 0, + cancelled: false, + target: None, + answer_refs: Vec::new(), + }, + DashboardEvent::TransientSet { + seq: 1, + name: "x".into(), + transient_kind: "rebuilding", + since_unix: 0, + }, + DashboardEvent::TransientCleared { + seq: 1, + name: "x".into(), + }, + DashboardEvent::ContainerRemoved { + seq: 1, + name: "x".into(), + }, + DashboardEvent::TombstonesChanged { + seq: 1, + tombstones: Vec::new(), + }, + DashboardEvent::MetaInputsChanged { + seq: 1, + inputs: Vec::new(), + }, + DashboardEvent::MetaUpdateRunning { + seq: 1, + running: false, + }, + DashboardEvent::RebuildQueueChanged { + seq: 1, + queue: Vec::new(), + }, + ]; + for ev in samples { + let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise"); + let serde_kind = v + .get("kind") + .and_then(|k| k.as_str()) + .expect("kind field present"); + assert_eq!( + ev.kind_tag(), + serde_kind, + "kind_tag() drift on {ev:?}", + ); + } + } +} From ac4c8456e1f807c8286750029850f899c4ee8c53 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 26 May 2026 22:40:24 +0200 Subject: [PATCH 4/4] flow.js: subscribe to /dashboard/stream with ?kinds= filter (closes #408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend half of #408 — wires flow.js's terminal subscribe URL to the kinds-filter damocles shipped in #499. Only the four kinds flow.js actually handles get sent on the wire: - sent / delivered → broker terminal renderer - container_state_changed / container_removed → local autocomplete cache (flowContainers Map used by the @-mention composer) Everything else (approval_*, question_*, transient_*, tombstones_*, meta_*, rebuild_queue_*) is now dropped server-side before the JSON-serialise, instead of being deserialised + dispatched to flow.js's no-op `_default: () => {}` per frame. Tabs.js keeps the unfiltered `/dashboard/stream` subscribe since it routes every mutation kind into its derived stores. ## SharedWorker note `openStream` keys subscriptions by full URL (#448, #453). The filtered URL is therefore its own upstream connection to hive-c0re — when both /index.html and /flow.html are open at the same time the worker holds two upstreams (one filtered, one full) instead of today's single shared upstream. The trade is small per-frame wire-byte + serialise savings vs +1 backend connection in the both-open case. The flow-page-only case is a pure win. Acceptable per #408's intent ("split flow messages from main endpoint"). Round-trip note: if the backend's `kind_tag` strings ever drift from what's listed here, the affected kind silently never matches and flow loses that event class. damocles's #499 added a serde round-trip test against `kind_tag` to guard the backend side; the frontend mirrors the spelling exactly from the PR body. --- frontend/packages/dashboard/src/flow.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/packages/dashboard/src/flow.js b/frontend/packages/dashboard/src/flow.js index eefe8710..d4b1c9eb 100644 --- a/frontend/packages/dashboard/src/flow.js +++ b/frontend/packages/dashboard/src/flow.js @@ -187,10 +187,20 @@ import { logEl: flow, pillAnchor: flowMain, historyUrl: '/dashboard/history', - streamUrl: '/dashboard/stream', + // #408: server-side filter — only the kinds this page actually + // renders or routes (sent/delivered → broker terminal, + // container_state_changed/_removed → local autocomplete cache). + // Backend (#499) pre-parses the allow-list at subscribe time so + // the per-frame hot path is one HashSet::contains and the + // JSON-serialise is skipped entirely on irrelevant kinds. The + // dashboard tabs page (tabs.js) keeps the unfiltered subscribe + // since it routes every mutation kind into its derived stores. + streamUrl: '/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed', // #448: route through the SharedWorker so this page's SSE shares // a single backend connection with /index.html (and any other - // open hyperhive tab). + // open hyperhive tab). Worker keys on the full URL (incl. + // query string), so the filtered subscribe is its own upstream + // — won't accidentally share with tabs.js's wider subscribe. streamFactory: openStream, renderers: { sent: (ev, api) => renderMsg(ev, api, '→'),