//! Unified dashboard event channel. //! //! Anything the browser wants to react to in near-real-time flows through //! `Coordinator.dashboard_events`. Each event is stamped with a monotonic //! per-process `seq` so the client can dedupe its buffered live traffic //! against snapshot/history responses (drop frames with //! `seq <= snapshot.seq`). //! //! Why one channel instead of one-per-domain: browsers cap concurrent //! SSE connections per origin (~6 in chrome) and dispatch-by-kind on the //! client is a one-liner. Splits get reserved for high-volume sub-streams //! that most consumers don't care about (none yet). //! //! Message-broker traffic (`Sent` / `Delivered`) lives on this channel //! too. A background forwarder task in `main.rs` subscribes to the broker //! and re-emits each `MessageEvent` as a `DashboardEvent::Sent` / //! `DashboardEvent::Delivered` with a freshly-stamped seq. Keeping the //! broker's intra-process channel separate avoids coupling the broker //! (used by `recv_blocking_batch` inside the harness loop) to dashboard //! presentation concerns. //! //! New mutation kinds (approval added/resolved, question added/answered, //! transient changed, etc.) land here as additional variants. The client //! dispatches by `kind` and updates the relevant section. 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 (issue #259). 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, }, }