The dashboard pill was declared once per DAG at submit time, so a rebuild reported `rebuilding` for its entire life — through the prebuild, the stop, the swap, the tail and the reconcile. It named the intent of the request, not what was happening. It is now read off the nodes actually running. A node lights a pill when it is `Running` and declares the agent's own resource. Declaring is the test, not targeting: `Prebuild` and `MetaSync` name an agent but are lease-exempt on purpose (the container keeps serving), so they must not light one. It is also not the lease *owner* — `resource_state()` answers "who holds the slot", which is a different question from "what is running", and a descendant that borrows an ancestor's grant never appears in that map. `TransientKind` is gone entirely rather than being re-derived. The label is the node's own wire tag (`NodeKind::as_str`) — the same vocabulary `NodeView.kind` already ships, so a pill and a DAG node name an operation identically and there is no second taxonomy to keep in step. Work with no node behind it (destroy, migration) supplies its own literal. `DagSpec::transient`, `Claim::transient`, `DagMeta::transient` and `NodeKind::Dag`'s `transient` field all go with it. ## the safety half, which is deliberately not the display half `crash_watch::is_deliberate_stop` used to match a `TransientKind` to decide whether a vanished container was intentional or a crash. That made a pill's display vocabulary decide an alerting question, so renaming or adding a label would silently move the alerting boundary. `TransientState` now carries two independent fields: `label` (rendered, nothing branches on it) and `deliberate_stop` (read only by the crash watcher). The producer sets the second, because the producer is the only thing that knows — it is not recoverable from the first. For queue work that value is `NodeKind::takes_container_down()`, and it is emphatically not "holds a lease": `Create` and `Start` hold the agent's lease exactly like `Stop` does, and a container dying *while starting* is a real crash that must keep reporting as one. The default is `false` on purpose — a wrong `false` costs a spurious crash event, a wrong `true` swallows a real crash silently. ## known cost, accepted on the issue A restart no longer reads `restarting`. No `NodeKind` is unique to a restart — `restart_chain` reuses `Signal` / `StopForUpdate` / `Drain` / `Reconcile` — because "restart" is a property of the DAG's shape, not of any node. A restart now reads `signal` / `stop_for_update`, then the agent returns. `Start` / `Stop` / `PostSwap` run inside a lease-holding ancestor and re-declare nothing, so they light no pill and the agent reads idle for those windows. Closing that is the resources-where-constructed work (#2818), not this change. Checked with clippy (`--all-targets -D warnings`), `cargo test -p hive-c0re -p hive-jobq` (321 + 40 passed) and `nix fmt`.
488 lines
21 KiB
Rust
488 lines
21 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 crate::job_queue::DagView;
|
||
use chrono::{DateTime, Utc};
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||
pub enum DashboardEvent {
|
||
/// A new agent-initiated privileged action was recorded in the audit
|
||
/// log. The audit view (`/audit.html`) prepends `entry` live off
|
||
/// `/dashboard/stream` instead of polling. The `AuditEntry` fields
|
||
/// are flattened alongside the `kind` tag + `seq`, so the wire shape
|
||
/// matches one row of the `/api/audit-log` `entries` array exactly
|
||
/// (`{kind, seq, id, ts_unix, agent, action, target, outcome, detail}`).
|
||
AuditEntryAdded {
|
||
seq: u64,
|
||
#[serde(flatten)]
|
||
entry: crate::audit_log::AuditEntry,
|
||
},
|
||
/// 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 new approval landed in the pending queue. Payload carries
|
||
/// enough to render the dashboard row without a `/api/state`
|
||
/// refetch.
|
||
///
|
||
/// The approval's own kind (`"merge_config_pr"` / `"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>,
|
||
description: Option<String>,
|
||
/// Forge PR number, for `merge_config_pr` approvals only — lets
|
||
/// the live `applyApprovalAdded` path build the "review PR on
|
||
/// forge" link without waiting for a cold `/api/state` refresh
|
||
/// (mirrors `ApprovalView::pr_number`). `None` for every other
|
||
/// kind.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pr_number: Option<u64>,
|
||
},
|
||
/// 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: DateTime<Utc>,
|
||
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: DateTime<Utc>,
|
||
deadline_at: Option<DateTime<Utc>>,
|
||
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: DateTime<Utc>,
|
||
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,
|
||
/// 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.
|
||
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. 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 renders each DAG's multi-agent shape from its `nodes`
|
||
/// (grouped by `NodeView::agent`) — no cross-DAG grouping needed.
|
||
RebuildQueueChanged { seq: u64, queue: Vec<DagView> },
|
||
/// Full snapshot of all scheduled prompts. Emitted after every
|
||
/// operator mutation (new / edit / cancel / fire-now) and after the
|
||
/// worker fires or rearms a row. Same snapshot-shape rationale as
|
||
/// `RebuildQueueChanged` — the list is small and the client's
|
||
/// per-target `last_result` / `last_fired_at_unix` fields are most
|
||
/// naturally re-derived from the full list.
|
||
SchedulesChanged {
|
||
seq: u64,
|
||
schedules: Vec<hive_sh4re::WireSchedule>,
|
||
},
|
||
/// Full snapshot of capability grants (per-agent `Vec<cap_name>`).
|
||
/// Emitted from the rebuild-queue worker after a `PermChange`
|
||
/// `Capabilities` entry commits the JSON file. Lets the P3RM1SS10NS
|
||
/// tab update live when the worker applies a queued change.
|
||
CapabilitiesChanged {
|
||
seq: u64,
|
||
/// Ordered list of all known capability names (column headers).
|
||
caps: Vec<&'static str>,
|
||
/// Short description for each capability name (tooltip).
|
||
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
||
/// Per-agent *explicit* capability grant map; absent agents have
|
||
/// no extra caps (the UI badges those "(default)").
|
||
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||
/// Sorted roster the operator can manage (live ∪ explicit keys).
|
||
agents: Vec<String>,
|
||
/// Per-agent *effective* caps (explicit-or-default; default empty).
|
||
effective: std::collections::BTreeMap<String, Vec<String>>,
|
||
},
|
||
/// Full snapshot of tool-group assignments (per-agent `Vec<group_name>`).
|
||
/// Emitted from the rebuild-queue worker after a `PermChange`
|
||
/// `ToolGroups` entry commits the JSON file. Lets the P3RM1SS10NS
|
||
/// tab update live when the worker applies a queued change.
|
||
ToolGroupsChanged {
|
||
seq: u64,
|
||
/// Ordered list of all known tool-group names (column headers).
|
||
groups: Vec<&'static str>,
|
||
/// Short description for each group name (tooltip).
|
||
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
||
/// Per-agent *explicit* assignment map; absent agents use the role
|
||
/// default (the UI badges those "(default)").
|
||
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||
/// Sorted roster the operator can manage (live ∪ explicit keys).
|
||
agents: Vec<String>,
|
||
/// Per-agent *effective* groups (explicit-or-`AGENT_DEFAULT`).
|
||
effective: std::collections::BTreeMap<String, Vec<String>>,
|
||
},
|
||
}
|
||
|
||
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",
|
||
DashboardEvent::SchedulesChanged { .. } => "schedules_changed",
|
||
DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed",
|
||
DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed",
|
||
DashboardEvent::AuditEntryAdded { .. } => "audit_entry_added",
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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::ApprovalAdded {
|
||
seq: 1,
|
||
id: 1,
|
||
agent: "x".into(),
|
||
approval_kind: "merge_config_pr",
|
||
sha_short: None,
|
||
description: None,
|
||
pr_number: None,
|
||
},
|
||
DashboardEvent::ApprovalResolved {
|
||
seq: 1,
|
||
id: 1,
|
||
agent: "x".into(),
|
||
approval_kind: "merge_config_pr",
|
||
sha_short: None,
|
||
status: "approved",
|
||
resolved_at: hive_sh4re::wire_time::from_secs(0),
|
||
note: None,
|
||
description: None,
|
||
},
|
||
DashboardEvent::QuestionAdded {
|
||
seq: 1,
|
||
id: 1,
|
||
asker: "a".into(),
|
||
question: String::new(),
|
||
options: Vec::new(),
|
||
multi: false,
|
||
asked_at: hive_sh4re::wire_time::from_secs(0),
|
||
deadline_at: None,
|
||
target: None,
|
||
question_refs: Vec::new(),
|
||
},
|
||
DashboardEvent::QuestionResolved {
|
||
seq: 1,
|
||
id: 1,
|
||
answer: String::new(),
|
||
answerer: "a".into(),
|
||
answered_at: hive_sh4re::wire_time::from_secs(0),
|
||
cancelled: false,
|
||
target: None,
|
||
answer_refs: Vec::new(),
|
||
},
|
||
DashboardEvent::TransientSet {
|
||
seq: 1,
|
||
name: "x".into(),
|
||
transient_kind: "rebuilding".into(),
|
||
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(),
|
||
},
|
||
DashboardEvent::SchedulesChanged {
|
||
seq: 1,
|
||
schedules: Vec::new(),
|
||
},
|
||
DashboardEvent::CapabilitiesChanged {
|
||
seq: 1,
|
||
caps: Vec::new(),
|
||
descriptions: std::collections::BTreeMap::new(),
|
||
assignments: std::collections::BTreeMap::new(),
|
||
agents: Vec::new(),
|
||
effective: std::collections::BTreeMap::new(),
|
||
},
|
||
DashboardEvent::ToolGroupsChanged {
|
||
seq: 1,
|
||
groups: Vec::new(),
|
||
descriptions: std::collections::BTreeMap::new(),
|
||
assignments: std::collections::BTreeMap::new(),
|
||
agents: Vec::new(),
|
||
effective: std::collections::BTreeMap::new(),
|
||
},
|
||
DashboardEvent::AuditEntryAdded {
|
||
seq: 1,
|
||
entry: crate::audit_log::AuditEntry {
|
||
id: 1,
|
||
ts_unix: hive_sh4re::wire_time::from_secs(0),
|
||
agent: "atlas".into(),
|
||
action: "restart_infra".into(),
|
||
target: "hive-ci".into(),
|
||
outcome: "ok".into(),
|
||
detail: None,
|
||
},
|
||
},
|
||
];
|
||
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:?}");
|
||
}
|
||
}
|
||
|
||
/// The flattened `AuditEntry` fields must sit alongside `kind`/`seq`
|
||
/// at the top level (not nested under `entry`) so the wire shape
|
||
/// matches one `/api/audit-log` row — the audit view prepends it
|
||
/// directly.
|
||
#[test]
|
||
fn audit_entry_added_flattens_to_top_level() {
|
||
let ev = DashboardEvent::AuditEntryAdded {
|
||
seq: 7,
|
||
entry: crate::audit_log::AuditEntry {
|
||
id: 42,
|
||
ts_unix: hive_sh4re::wire_time::from_secs(1_700_000_000),
|
||
agent: "atlas".into(),
|
||
action: "restart_infra".into(),
|
||
target: "hive-gateway".into(),
|
||
outcome: "err".into(),
|
||
detail: Some("denied: missing infra_admin capability".into()),
|
||
},
|
||
};
|
||
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise");
|
||
assert_eq!(v["kind"], "audit_entry_added");
|
||
assert_eq!(v["seq"], 7);
|
||
assert_eq!(v["id"], 42);
|
||
assert_eq!(v["agent"], "atlas");
|
||
assert_eq!(v["target"], "hive-gateway");
|
||
assert_eq!(v["outcome"], "err");
|
||
assert_eq!(v["detail"], "denied: missing infra_admin capability");
|
||
// Not nested — there must be no `entry` sub-object.
|
||
assert!(v.get("entry").is_none());
|
||
}
|
||
}
|