Compare commits

...
Author SHA1 Message Date
iris
ac4c8456e1 flow.js: subscribe to /dashboard/stream with ?kinds= filter (closes #408)
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.
2026-05-26 23:02:52 +02:00
damocles
d143abb63d dashboard: filter /dashboard/stream by ?kinds= allow-list (#408) 2026-05-26 23:01:46 +02:00
damocles
f540584553 docs: document rebuild_queue dispatch for approval kinds (#124, #441 followup) 2026-05-26 23:00:51 +02:00
lexis
2173d211a5 docs: add hive-forge diff verb to gotchas cheat sheet (follow-up to #481) 2026-05-26 20:32:27 +02:00
5 changed files with 250 additions and 3 deletions

View file

@ -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

View file

@ -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
```

View file

@ -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, '→'),

View file

@ -798,14 +798,49 @@ async fn dashboard_history(State(state): State<AppState>) -> 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<String>,
}
async fn dashboard_stream(
State(state): State<AppState>,
axum::extract::Query(q): axum::extract::Query<DashboardStreamQuery>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
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<std::collections::HashSet<String>> = q.kinds.and_then(|raw| {
let set: std::collections::HashSet<String> = 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)))
});

View file

@ -218,3 +218,160 @@ pub enum DashboardEvent {
queue: Vec<QueueEntry>,
},
}
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<DashboardEvent> = 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:?}",
);
}
}
}