//! 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::rebuild_queue::QueueEntry; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum DashboardEvent { /// 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: i64, #[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: i64, #[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 (`diff` is the raw unified diff text, same shape the /// snapshot ships). /// /// The approval's own kind (`"apply_commit"` / `"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, diff: Option, description: 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: i64, 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: i64, 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: i64, 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's grouping (`parent_id`) is most naturally re-derived /// from the full list. RebuildQueueChanged { seq: u64, 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 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] #[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: 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:?}",); } } }