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 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 ``` 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, '→'), 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:?}", + ); + } + } +}