Compare commits
5 changed files with 3 additions and 250 deletions
|
|
@ -187,49 +187,6 @@ approval id to retry. Because tags are first-class git objects,
|
||||||
rejected and failed trees stay browsable forever — `git log
|
rejected and failed trees stay browsable forever — `git log
|
||||||
--tags` in the applied repo is the audit trail.
|
--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
|
### Forge mirror
|
||||||
|
|
||||||
When the bundled `hive-forge` container is running — on by
|
When the bundled `hive-forge` container is running — on by
|
||||||
|
|
|
||||||
|
|
@ -133,8 +133,6 @@ hive-forge assign 42 damocles
|
||||||
hive-forge close 42
|
hive-forge close 42
|
||||||
hive-forge labels 42 add feature
|
hive-forge labels 42 add feature
|
||||||
hive-forge pr 42 # PR metadata as JSON
|
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 branches deployed/ # filter branches by pattern
|
||||||
hive-forge -r other-org/other-repo pr 7 # target a different repo
|
hive-forge -r other-org/other-repo pr 7 # target a different repo
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -187,20 +187,10 @@ import {
|
||||||
logEl: flow,
|
logEl: flow,
|
||||||
pillAnchor: flowMain,
|
pillAnchor: flowMain,
|
||||||
historyUrl: '/dashboard/history',
|
historyUrl: '/dashboard/history',
|
||||||
// #408: server-side filter — only the kinds this page actually
|
streamUrl: '/dashboard/stream',
|
||||||
// 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
|
// #448: route through the SharedWorker so this page's SSE shares
|
||||||
// a single backend connection with /index.html (and any other
|
// a single backend connection with /index.html (and any other
|
||||||
// open hyperhive tab). Worker keys on the full URL (incl.
|
// open hyperhive tab).
|
||||||
// query string), so the filtered subscribe is its own upstream
|
|
||||||
// — won't accidentally share with tabs.js's wider subscribe.
|
|
||||||
streamFactory: openStream,
|
streamFactory: openStream,
|
||||||
renderers: {
|
renderers: {
|
||||||
sent: (ev, api) => renderMsg(ev, api, '→'),
|
sent: (ev, api) => renderMsg(ev, api, '→'),
|
||||||
|
|
|
||||||
|
|
@ -798,49 +798,14 @@ 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(
|
async fn dashboard_stream(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
axum::extract::Query(q): axum::extract::Query<DashboardStreamQuery>,
|
|
||||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||||
let rx = state.coord.dashboard_subscribe();
|
let rx = state.coord.dashboard_subscribe();
|
||||||
// Pre-parse the allow-list once at subscription time, so the
|
let stream = BroadcastStream::new(rx).filter_map(|res| {
|
||||||
// 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
|
// Drop lagged frames. Browsers reconnect; the seq dedupe on
|
||||||
// reconnect skips any frame already reflected in the snapshot.
|
// reconnect skips any frame already reflected in the snapshot.
|
||||||
let event = res.ok()?;
|
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()?;
|
let json = serde_json::to_string(&event).ok()?;
|
||||||
Some(Ok(Event::default().data(json)))
|
Some(Ok(Event::default().data(json)))
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -218,160 +218,3 @@ pub enum DashboardEvent {
|
||||||
queue: Vec<QueueEntry>,
|
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:?}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue