Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-c0re/src/dashboard_events.rs
atlas 4aa11b3a94 build agents only from internal/meta; delete the hive's local meta flake
The hive no longer renders, locks or commits a meta flake. A rebuild's
MetaSync node picks the internal/meta commit and records it with the
hyperhive and config pins it holds; Prebuild, Swap and Create build from it,
and a head that does not pin the agent's current config fails the build
naming the agent. hive-priv's meta_rev is required.

Removed: meta.rs sync/lock/render, the manual-rebuild relock, the boot
sweep's hyperhive bump (MetaLock), the update-meta-inputs form and its
panel, MetaWindow, the hive's hyperhive_rev setting.

Kept: agent_set::refresh holds what sync_agents also did (topology.json, the
gateway's agent-socket map and nginx include), called from spawn, destroy,
rebuild and the migration.

The boot reconcile and the stale-start upgrade compare the agent's marker
with the hyperhive pin of internal/meta; the dashboard's deployed chip reads
the config marker a successful build writes.
2026-10-25 15:32:16 +01:00

213 lines
8.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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::TombstoneView;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum DashboardEvent {
/// Broker `Sent` event mirrored onto the dashboard channel.
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>,
},
/// Broker `Delivered` event mirrored onto the dashboard channel.
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>,
},
/// 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,
/// What the pill renders. For queue-driven work this is the running
/// node's own wire tag (`"swap"`, `"create"`, `"stop_for_update"`, …) —
/// the same vocabulary the DAG view ships. Work with no node behind it
/// (destroy, migration) supplies its own (`"destroying"`,
/// `"rebuilding"`).
///
/// Owned rather than `&'static str`: a label now comes from the node
/// that happens to be running, not from a fixed set.
transient_kind: String,
since_unix: i64,
},
/// The matching lifecycle action resolved (success or failure).
/// Clients drop the spinner row.
///
/// `transient_kind` matches the `TransientSet` that opened this pill, and is
/// what makes the pair addressable: an agent can hold **several** pills at
/// once (the transient set tests status alone, so a lease-exempt `Prebuild`
/// and a lease-holding `StopForUpdate` are both live). Without it a client
/// holding two open pills for one agent cannot tell which one cleared, and
/// has to drop both or guess.
TransientCleared {
seq: u64,
name: String,
transient_kind: String,
},
/// One container row changed — a new container appeared, 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`, `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 by destroy (with
/// or without purge) and purge-tombstone. 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>,
},
/// The job queue changed — **a bare trigger, no payload.**
///
/// It used to carry a full typed snapshot of the queue. Nothing reads
/// that any more: the dashboard renders the queue from
/// `GET /api/jobq/graph`, so the event's whole job is telling a client
/// *when* to re-fetch. Shipping the graph twice — once here, once on
/// the endpoint — is two projections to keep in agreement for a client
/// that only ever used one.
///
/// Kept as an event rather than deleted in favour of polling because
/// push-on-change is what every other live surface here does
/// (transients, container state), and a poll loop would be
/// slower for the same information.
RebuildQueueChanged { seq: u64 },
}
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::TransientSet { .. } => "transient_set",
DashboardEvent::TransientCleared { .. } => "transient_cleared",
DashboardEvent::ContainerStateChanged { .. } => "container_state_changed",
DashboardEvent::ContainerRemoved { .. } => "container_removed",
DashboardEvent::TombstonesChanged { .. } => "tombstones_changed",
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<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,
},
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,
},
DashboardEvent::TransientSet {
seq: 1,
name: "x".into(),
transient_kind: "rebuilding".into(),
since_unix: 0,
},
DashboardEvent::TransientCleared {
seq: 1,
name: "x".into(),
transient_kind: "rebuilding".into(),
},
DashboardEvent::ContainerRemoved {
seq: 1,
name: "x".into(),
},
DashboardEvent::TombstonesChanged {
seq: 1,
tombstones: Vec::new(),
},
DashboardEvent::RebuildQueueChanged { seq: 1 },
];
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:?}");
}
}
}