220 lines
9.3 KiB
Rust
220 lines
9.3 KiB
Rust
//! 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<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: i64,
|
||
#[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 (`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<String>,
|
||
diff: Option<String>,
|
||
description: Option<String>,
|
||
},
|
||
/// 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: i64,
|
||
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: i64,
|
||
deadline_at: Option<i64>,
|
||
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: i64,
|
||
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 (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<QueueEntry>,
|
||
},
|
||
}
|