mara on #5170: "dont split caps and perms. its all perms. / dont include the now empty tool groups / nothing set -> default / also add perms to agent creation screen". - hive-sh4re: one `Permission` enum (messaging, meta, inbox, execution, web_tools, manage_root_agent) replaces `ToolGroup` and `Capability`; `lifecycle`/`forge` are gone. `Permission::default_for` is the default for an agent the swarm sets nothing for. - The record is `{ "permissions": [...] }`. Missing key, unset field or no successful read yet = default; `[]` = none; a failed read after a good one keeps the last value. - hive-agent reads its own key every 10s and builds each turn's --tools, --allowedTools and MCP config (rewritten before every turn) from it; the subagent daemon reads it on each start/continue. Agents get a read-only grant on their own key. - hive-c0re restarts an agent only when its manage_root_agent changes. The import now merges meta/capabilities.json and meta/tool-groups.json into one list per agent and deletes both; the "import pending, don't read" decision is `ImportOutcome::read_allowed`, table-tested. - swarm-controller: `GET /api/permissions` (names + default) and an optional `permissions` on `POST /api/agents`, stored before the job is queued. swarm-ui's create-agent form gets a permissions field. - Deleted: HIVE_TOOL_GROUPS and its forward to the subagent daemon, tool_groups.rs, ruth's tool-group seed, the permissions rebuild job (PermPayload/WritePermFile/perm_change), the dashboard permissions tab and its stale-entries panel. Part of #5170.
254 lines
11 KiB
Rust
254 lines
11 KiB
Rust
//! 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 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.
|
||
/// `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<Utc>,
|
||
#[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: DateTime<Utc>,
|
||
#[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 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>,
|
||
},
|
||
/// 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. 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 },
|
||
/// 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::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<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,
|
||
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::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::MetaInputsChanged {
|
||
seq: 1,
|
||
inputs: Vec::new(),
|
||
},
|
||
DashboardEvent::MetaUpdateRunning {
|
||
seq: 1,
|
||
running: false,
|
||
},
|
||
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:?}");
|
||
}
|
||
}
|
||
}
|