//! 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, #[serde(default, skip_serializing_if = "Option::is_none")] in_reply_to: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] file_refs: Vec, }, /// 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, #[serde(default, skip_serializing_if = "Option::is_none")] in_reply_to: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] file_refs: Vec, }, /// 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, description: Option, /// 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, }, /// 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, /// `"approved"` / `"denied"` / `"failed"`. status: &'static str, resolved_at: DateTime, note: Option, description: Option, }, /// A question landed in the queue. `target = None` means /// operator-targeted (`Ask { to: None | Some("operator") }`); /// `target = Some()` 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, multi: bool, asked_at: DateTime, deadline_at: Option>, target: Option, /// 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, }, /// 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, cancelled: bool, target: Option, /// Verified file-path tokens that appear in `answer`. #[serde(default, skip_serializing_if = "Vec::is_empty")] answer_refs: Vec, }, /// 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, }, /// 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, }, /// 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 }, /// 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, }, /// Full snapshot of capability grants (per-agent `Vec`). /// 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>, /// Sorted roster the operator can manage (live ∪ explicit keys). agents: Vec, /// Per-agent *effective* caps (explicit-or-default; default empty). effective: std::collections::BTreeMap>, }, /// Full snapshot of tool-group assignments (per-agent `Vec`). /// 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>, /// Sorted roster the operator can manage (live ∪ explicit keys). agents: Vec, /// Per-agent *effective* groups (explicit-or-`AGENT_DEFAULT`). effective: std::collections::BTreeMap>, }, } 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 = 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()); } }