482 lines
21 KiB
Rust
482 lines
21 KiB
Rust
//! Unified dashboard event channel — all near-real-time browser events
|
||
//! flow through `Coordinator.dashboard_events`. Each event carries a
|
||
//! monotonic `seq` for client-side dedupe against `/api/state` snapshots.
|
||
//! Design rationale (single channel, broker forwarder isolation):
|
||
//! `docs/web-ui/shape.md::One unified channel`.
|
||
|
||
use serde::Serialize;
|
||
|
||
use crate::container_view::ContainerView;
|
||
use crate::dashboard::{MetaInputView, TombstoneView};
|
||
use crate::job_queue::DagView;
|
||
use chrono::{DateTime, Utc};
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||
pub enum DashboardEvent {
|
||
/// A new agent-initiated privileged action was recorded in the audit
|
||
/// log. The audit view (`/audit.html`) prepends `entry` live off
|
||
/// `/dashboard/stream` instead of polling. The `AuditEntry` fields
|
||
/// are flattened alongside the `kind` tag + `seq`, so the wire shape
|
||
/// matches one row of the `/api/audit-log` `entries` array exactly
|
||
/// (`{kind, seq, id, ts_unix, agent, action, target, outcome, detail}`).
|
||
AuditEntryAdded {
|
||
seq: u64,
|
||
#[serde(flatten)]
|
||
entry: crate::audit_log::AuditEntry,
|
||
},
|
||
/// Broker `Sent` event mirrored onto the dashboard channel.
|
||
/// `file_refs` carries every path-shaped token in `body` that
|
||
/// hive-c0re verified is a regular file under the allow-listed
|
||
/// roots (per-agent `state/` + `shared/`). The forwarder
|
||
/// pre-validates so the dashboard doesn't need a probe
|
||
/// endpoint — the client renders anchors only for tokens that
|
||
/// appear in this list, everything else stays plain text.
|
||
Sent {
|
||
seq: u64,
|
||
/// Broker row id. Allows the dashboard to track reply threads.
|
||
id: i64,
|
||
from: String,
|
||
to: String,
|
||
body: String,
|
||
at: DateTime<Utc>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
in_reply_to: Option<i64>,
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
file_refs: Vec<String>,
|
||
},
|
||
/// Broker `Delivered` event mirrored onto the dashboard channel.
|
||
/// `file_refs` is the same shape as `Sent`.
|
||
Delivered {
|
||
seq: u64,
|
||
/// Broker row id. Allows the dashboard to track reply threads.
|
||
id: i64,
|
||
from: String,
|
||
to: String,
|
||
body: String,
|
||
at: DateTime<Utc>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
in_reply_to: Option<i64>,
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
file_refs: Vec<String>,
|
||
},
|
||
/// A new approval landed in the pending queue. Payload carries
|
||
/// enough to render the dashboard row without a `/api/state`
|
||
/// refetch.
|
||
///
|
||
/// The approval's own kind (`"merge_config_pr"` / `"spawn"`) lives
|
||
/// on `approval_kind` rather than `kind` because the latter is taken
|
||
/// by the serde tag identifying which `DashboardEvent` variant
|
||
/// this is.
|
||
ApprovalAdded {
|
||
seq: u64,
|
||
id: i64,
|
||
agent: String,
|
||
approval_kind: &'static str,
|
||
sha_short: Option<String>,
|
||
description: Option<String>,
|
||
/// Forge PR number, for `merge_config_pr` approvals only — lets
|
||
/// the live `applyApprovalAdded` path build the "review PR on
|
||
/// forge" link without waiting for a cold `/api/state` refresh
|
||
/// (mirrors `ApprovalView::pr_number`). `None` for every other
|
||
/// kind.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pr_number: Option<u64>,
|
||
},
|
||
/// A pending approval transitioned to a terminal state
|
||
/// (approved / denied / failed). Clients move the row out of the
|
||
/// pending list and into history.
|
||
ApprovalResolved {
|
||
seq: u64,
|
||
id: i64,
|
||
agent: String,
|
||
approval_kind: &'static str,
|
||
sha_short: Option<String>,
|
||
/// `"approved"` / `"denied"` / `"failed"`.
|
||
status: &'static str,
|
||
resolved_at: DateTime<Utc>,
|
||
note: Option<String>,
|
||
description: Option<String>,
|
||
},
|
||
/// A question landed in the queue. `target = None` means
|
||
/// operator-targeted (`Ask { to: None | Some("operator") }`);
|
||
/// `target = Some(<agent>)` means a peer-to-peer question. Both
|
||
/// are surfaced on the dashboard so the operator can monitor /
|
||
/// override-answer stuck threads.
|
||
QuestionAdded {
|
||
seq: u64,
|
||
id: i64,
|
||
asker: String,
|
||
question: String,
|
||
options: Vec<String>,
|
||
multi: bool,
|
||
asked_at: DateTime<Utc>,
|
||
deadline_at: Option<DateTime<Utc>>,
|
||
target: Option<String>,
|
||
/// Verified file-path tokens that appear in `question`.
|
||
/// Same shape as broker `Sent`/`Delivered` events; the
|
||
/// client linkifies only what hive-c0re vouched for.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
question_refs: Vec<String>,
|
||
},
|
||
/// A question was answered (operator answer, peer answer,
|
||
/// operator override on a peer thread, or ttl watchdog
|
||
/// `[expired]`). Clients move the row from pending to history.
|
||
/// `cancelled = true` when the operator dismissed via the cancel
|
||
/// button.
|
||
QuestionResolved {
|
||
seq: u64,
|
||
id: i64,
|
||
answer: String,
|
||
answerer: String,
|
||
answered_at: DateTime<Utc>,
|
||
cancelled: bool,
|
||
target: Option<String>,
|
||
/// Verified file-path tokens that appear in `answer`.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
answer_refs: Vec<String>,
|
||
},
|
||
/// A lifecycle action started for an agent (spawn / start / stop
|
||
/// / restart / rebuild / destroy). Clients render a spinner next
|
||
/// to the row; the client computes "seconds in this state"
|
||
/// locally from `since_unix` so a slow rebuild's elapsed time
|
||
/// ticks without polling.
|
||
TransientSet {
|
||
seq: u64,
|
||
name: String,
|
||
/// Lifecycle kind: `"spawning"` / `"starting"` / `"stopping"` /
|
||
/// `"restarting"` / `"rebuilding"` / `"destroying"`.
|
||
transient_kind: &'static str,
|
||
since_unix: i64,
|
||
},
|
||
/// The matching lifecycle action resolved (success or failure).
|
||
/// Clients drop the spinner row.
|
||
TransientCleared { seq: u64, name: String },
|
||
/// One container row changed — new container appeared (post-spawn
|
||
/// finalise), an existing one flipped `running` / `needs_update` /
|
||
/// `sha`, etc. Clients upsert by `container.name`. Payload carries
|
||
/// the full row so cold-loaded clients and event-driven clients
|
||
/// converge on the same render.
|
||
///
|
||
/// Fired by `Coordinator::rescan_containers_and_emit`, which diffs
|
||
/// a fresh `nixos-container list`–derived snapshot against the
|
||
/// last one cached on the coordinator. Mutation sites (lifecycle
|
||
/// endpoints, `actions::destroy` / approve, `crash_watch`'s poll loop)
|
||
/// call the rescan after their work lands.
|
||
ContainerStateChanged { seq: u64, container: ContainerView },
|
||
/// A container that was in the previous snapshot is gone. Clients
|
||
/// drop the row by name. Fired alongside any
|
||
/// `nixos-container destroy` (operator-driven or otherwise) on the
|
||
/// next rescan.
|
||
ContainerRemoved { seq: u64, name: String },
|
||
/// Full snapshot of the tombstones list. Emitted on every
|
||
/// mutation that could add / remove a tombstone: destroy
|
||
/// (with or without purge), purge-tombstone, spawn approval
|
||
/// (which can consume a tombstone of the same name). Snapshot
|
||
/// shape (not diff) because the list is tiny (single-digit
|
||
/// typical) and recomputing avoids the add/remove races a
|
||
/// per-row event would have.
|
||
TombstonesChanged {
|
||
seq: u64,
|
||
tombstones: Vec<TombstoneView>,
|
||
},
|
||
/// Full snapshot of `meta/flake.lock`'s root inputs. Emitted
|
||
/// after every operation that bumps a lock: `meta-update`,
|
||
/// `rebuild_agent` (lock bumps via two-phase staging),
|
||
/// `update-all`. Same snapshot-shape rationale as
|
||
/// `TombstonesChanged` — the list is small (one row per agent
|
||
/// plus their fetched inputs).
|
||
MetaInputsChanged {
|
||
seq: u64,
|
||
inputs: Vec<MetaInputView>,
|
||
},
|
||
/// A dashboard-triggered `meta-update` started (`running: true`) or
|
||
/// finished (`running: false`). `post_meta_update` returns 200
|
||
/// immediately and runs the `nix flake update` + agent-rebuild
|
||
/// ripple in a background task — this event lets the META INPUTS
|
||
/// panel show a disabled "updating…" state for that whole window
|
||
/// instead of looking idle. Emitted by
|
||
/// `Coordinator::meta_update_guard` / `MetaUpdateGuard::drop` only
|
||
/// when the active-run count crosses 0, so concurrent updates flip
|
||
/// the flag exactly once.
|
||
MetaUpdateRunning { seq: u64, running: bool },
|
||
/// Full snapshot of the rebuild queue (`hive-c0re::rebuild_queue`)
|
||
/// — every entry, in enqueue order, including the few most-recent
|
||
/// terminal entries the queue retains for history. Same
|
||
/// snapshot-shape rationale as `TombstonesChanged` /
|
||
/// `MetaInputsChanged`: the list is small, snapshot semantics avoid
|
||
/// the add/remove races a per-row event would have, and the
|
||
/// dashboard renders each DAG's multi-agent shape from its `nodes`
|
||
/// (grouped by `NodeView::agent`) — no cross-DAG grouping needed.
|
||
RebuildQueueChanged { seq: u64, queue: Vec<DagView> },
|
||
/// Full snapshot of all scheduled prompts. Emitted after every
|
||
/// operator mutation (new / edit / cancel / fire-now) and after the
|
||
/// worker fires or rearms a row. Same snapshot-shape rationale as
|
||
/// `RebuildQueueChanged` — the list is small and the client's
|
||
/// per-target `last_result` / `last_fired_at_unix` fields are most
|
||
/// naturally re-derived from the full list.
|
||
SchedulesChanged {
|
||
seq: u64,
|
||
schedules: Vec<hive_sh4re::WireSchedule>,
|
||
},
|
||
/// Full snapshot of capability grants (per-agent `Vec<cap_name>`).
|
||
/// Emitted from the rebuild-queue worker after a `PermChange`
|
||
/// `Capabilities` entry commits the JSON file. Lets the P3RM1SS10NS
|
||
/// tab update live when the worker applies a queued change.
|
||
CapabilitiesChanged {
|
||
seq: u64,
|
||
/// Ordered list of all known capability names (column headers).
|
||
caps: Vec<&'static str>,
|
||
/// Short description for each capability name (tooltip).
|
||
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
||
/// Per-agent *explicit* capability grant map; absent agents have
|
||
/// no extra caps (the UI badges those "(default)").
|
||
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||
/// Sorted roster the operator can manage (live ∪ explicit keys).
|
||
agents: Vec<String>,
|
||
/// Per-agent *effective* caps (explicit-or-default; default empty).
|
||
effective: std::collections::BTreeMap<String, Vec<String>>,
|
||
},
|
||
/// Full snapshot of tool-group assignments (per-agent `Vec<group_name>`).
|
||
/// Emitted from the rebuild-queue worker after a `PermChange`
|
||
/// `ToolGroups` entry commits the JSON file. Lets the P3RM1SS10NS
|
||
/// tab update live when the worker applies a queued change.
|
||
ToolGroupsChanged {
|
||
seq: u64,
|
||
/// Ordered list of all known tool-group names (column headers).
|
||
groups: Vec<&'static str>,
|
||
/// Short description for each group name (tooltip).
|
||
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
||
/// Per-agent *explicit* assignment map; absent agents use the role
|
||
/// default (the UI badges those "(default)").
|
||
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||
/// Sorted roster the operator can manage (live ∪ explicit keys).
|
||
agents: Vec<String>,
|
||
/// Per-agent *effective* groups (explicit-or-`AGENT_DEFAULT`).
|
||
effective: std::collections::BTreeMap<String, Vec<String>>,
|
||
},
|
||
}
|
||
|
||
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 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",
|
||
DashboardEvent::SchedulesChanged { .. } => "schedules_changed",
|
||
DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed",
|
||
DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed",
|
||
DashboardEvent::AuditEntryAdded { .. } => "audit_entry_added",
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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]
|
||
#[allow(
|
||
clippy::too_many_lines,
|
||
reason = "exhaustive coverage of every DashboardEvent variant — the \
|
||
length is the point"
|
||
)]
|
||
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: hive_sh4re::wire_time::from_secs(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: hive_sh4re::wire_time::from_secs(0),
|
||
in_reply_to: None,
|
||
file_refs: Vec::new(),
|
||
},
|
||
DashboardEvent::ApprovalAdded {
|
||
seq: 1,
|
||
id: 1,
|
||
agent: "x".into(),
|
||
approval_kind: "merge_config_pr",
|
||
sha_short: None,
|
||
description: None,
|
||
pr_number: None,
|
||
},
|
||
DashboardEvent::ApprovalResolved {
|
||
seq: 1,
|
||
id: 1,
|
||
agent: "x".into(),
|
||
approval_kind: "merge_config_pr",
|
||
sha_short: None,
|
||
status: "approved",
|
||
resolved_at: hive_sh4re::wire_time::from_secs(0),
|
||
note: None,
|
||
description: None,
|
||
},
|
||
DashboardEvent::QuestionAdded {
|
||
seq: 1,
|
||
id: 1,
|
||
asker: "a".into(),
|
||
question: String::new(),
|
||
options: Vec::new(),
|
||
multi: false,
|
||
asked_at: hive_sh4re::wire_time::from_secs(0),
|
||
deadline_at: None,
|
||
target: None,
|
||
question_refs: Vec::new(),
|
||
},
|
||
DashboardEvent::QuestionResolved {
|
||
seq: 1,
|
||
id: 1,
|
||
answer: String::new(),
|
||
answerer: "a".into(),
|
||
answered_at: hive_sh4re::wire_time::from_secs(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(),
|
||
},
|
||
DashboardEvent::SchedulesChanged {
|
||
seq: 1,
|
||
schedules: Vec::new(),
|
||
},
|
||
DashboardEvent::CapabilitiesChanged {
|
||
seq: 1,
|
||
caps: Vec::new(),
|
||
descriptions: std::collections::BTreeMap::new(),
|
||
assignments: std::collections::BTreeMap::new(),
|
||
agents: Vec::new(),
|
||
effective: std::collections::BTreeMap::new(),
|
||
},
|
||
DashboardEvent::ToolGroupsChanged {
|
||
seq: 1,
|
||
groups: Vec::new(),
|
||
descriptions: std::collections::BTreeMap::new(),
|
||
assignments: std::collections::BTreeMap::new(),
|
||
agents: Vec::new(),
|
||
effective: std::collections::BTreeMap::new(),
|
||
},
|
||
DashboardEvent::AuditEntryAdded {
|
||
seq: 1,
|
||
entry: crate::audit_log::AuditEntry {
|
||
id: 1,
|
||
ts_unix: hive_sh4re::wire_time::from_secs(0),
|
||
agent: "atlas".into(),
|
||
action: "restart_infra".into(),
|
||
target: "hive-ci".into(),
|
||
outcome: "ok".into(),
|
||
detail: None,
|
||
},
|
||
},
|
||
];
|
||
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:?}");
|
||
}
|
||
}
|
||
|
||
/// The flattened `AuditEntry` fields must sit alongside `kind`/`seq`
|
||
/// at the top level (not nested under `entry`) so the wire shape
|
||
/// matches one `/api/audit-log` row — the audit view prepends it
|
||
/// directly.
|
||
#[test]
|
||
fn audit_entry_added_flattens_to_top_level() {
|
||
let ev = DashboardEvent::AuditEntryAdded {
|
||
seq: 7,
|
||
entry: crate::audit_log::AuditEntry {
|
||
id: 42,
|
||
ts_unix: hive_sh4re::wire_time::from_secs(1_700_000_000),
|
||
agent: "atlas".into(),
|
||
action: "restart_infra".into(),
|
||
target: "hive-gateway".into(),
|
||
outcome: "err".into(),
|
||
detail: Some("denied: missing infra_admin capability".into()),
|
||
},
|
||
};
|
||
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise");
|
||
assert_eq!(v["kind"], "audit_entry_added");
|
||
assert_eq!(v["seq"], 7);
|
||
assert_eq!(v["id"], 42);
|
||
assert_eq!(v["agent"], "atlas");
|
||
assert_eq!(v["target"], "hive-gateway");
|
||
assert_eq!(v["outcome"], "err");
|
||
assert_eq!(v["detail"], "denied: missing infra_admin capability");
|
||
// Not nested — there must be no `entry` sub-object.
|
||
assert!(v.get("entry").is_none());
|
||
}
|
||
}
|