Watch
0
0
Fork
You've already forked hyperhive
0

drop hive and agent response fields the frontend never reads

This commit is contained in:
atlas 2026-11-13 03:25:41 +01:00 • committed by mara
commit a437f9ccd5
10 changed files with 347 additions and 598 deletions

View file

@ -7,7 +7,6 @@
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::coordinator::Coordinator;
@ -43,24 +42,6 @@ pub struct ContainerView {
#[serde(default)]
pub failed: bool,
pub needs_login: bool,
/// The Claude model the agent's harness is currently using, read from
/// `state/hyperhive-harness.json["active_model"]`. `None` when the
/// agent has never started a turn or the field is absent. Only
/// meaningful when `running` is true; the dashboard skips the badge
/// for stopped containers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_model: Option<String>,
/// The agent's current free-text status (`set_status`). `None` when
/// unset, or when the container isn't running — see
/// [`read_agent_status_live`] for why a stopped agent's on-disk
/// status is treated as stale rather than surfaced.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_text: Option<String>,
/// When `status_text` was last set, RFC 3339 UTC on the wire (see
/// `hive_sh4re::wire_time`). `None` exactly when `status_text` is
/// `None`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_set_at: Option<DateTime<Utc>>,
/// The agent's turn loop is parked: the container is up and still
/// serving its web UI / MCP daemons, but it drives no turns and its
/// inbox messages queue unacked until it resumes. Sourced from the
@ -95,9 +76,6 @@ impl From<ContainerView> for hive_sh4re::container::AgentStatusRow {
needs_login: v.needs_login,
pending_reminders: 0,
paused: v.paused,
active_model: v.active_model,
status_text: v.status_text,
status_set_at: v.status_set_at,
}
}
}
@ -173,25 +151,6 @@ pub async fn build_all() -> anyhow::Result<Vec<ContainerView>> {
let needs_login = running
&& ((!is_api_key && !claude_has_session(&Coordinator::agent_claude_dir(&logical)))
|| needs_login_sentinel);
// Read the active model from the harness state file. Only surfaced
// when the container is running — stale model info from a stopped
// agent is misleading (the model may change on next boot).
let active_model = if running {
read_active_model(&logical)
} else {
None
};
// Same running-gate as `active_model` just above, and the same
// rule `read_agent_status_live` applies for its other callers —
// duplicated inline rather than calling that helper because it
// would re-derive `running` with its own `lifecycle::is_running`
// shell-out when this loop already has it for free.
let (status_text, status_set_at) = if running {
let (text, set_at) = read_agent_status(&logical);
(text, set_at.map(hive_sh4re::wire_time::from_secs))
} else {
(None, None)
};
let paused = Coordinator::is_paused(&logical);
out.push(ContainerView {
port: lifecycle::agent_web_port(logical.as_str()),
@ -200,9 +159,6 @@ pub async fn build_all() -> anyhow::Result<Vec<ContainerView>> {
container: c.clone(),
name: logical.into_string(),
needs_login,
active_model,
status_text,
status_set_at,
paused,
});
}
@ -329,21 +285,6 @@ fn status_if_running(
(text, set_at, true)
}
/// Read the active Claude model from `hyperhive-harness.json` (the
/// consolidated harness state file in the agent's state dir). Written by
/// the harness on startup and on every `apply_model` / `emit_status` call,
/// so it always reflects the model the agent runs. Returns `None` when the
/// field is absent or the harness has not yet started a turn.
fn read_active_model(name: &hive_types::Ident) -> Option<String> {
let path = Coordinator::agent_notes_dir(name).join("hyperhive-harness.json");
let raw = std::fs::read_to_string(path).ok()?;
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
v.get("active_model")
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
/// Host-side hive + swarm display names, read from the c0re service's
/// own process env. The hyperhive NixOS module sets these from
/// `services.hyperhive.deploy.hive-controller.hiveName` + `services.hyperhive.swarm.name`
@ -366,6 +307,34 @@ pub fn hive_swarm_names() -> (Option<String>, Option<String>) {
#[cfg(test)]
mod tests {
/// The serialised row has exactly the keys the dashboard reads; `container`
/// is internal and skipped.
#[test]
fn the_container_view_serialises_exactly_its_read_fields() {
use super::ContainerView;
let view = ContainerView {
name: "alice".to_owned(),
container: "h-alice".to_owned(),
port: 7000,
running: true,
failed: false,
needs_login: false,
paused: false,
};
let value = serde_json::to_value(&view).unwrap();
let mut keys: Vec<&str> = value
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
["failed", "name", "needs_login", "paused", "port", "running"]
);
}
/// The row says nothing of an update being pending or of a deployed commit:
/// the hive applies a changed declaration on its own, so there is no state
/// for an operator to act on.
@ -379,9 +348,6 @@ mod tests {
running: true,
failed: false,
needs_login: false,
active_model: None,
status_text: None,
status_set_at: None,
paused: false,
};
let value = serde_json::to_value(&view).unwrap();
@ -403,7 +369,6 @@ mod tests {
#[test]
fn agent_status_row_carries_every_field_the_row_has_and_drops_the_rest() {
use super::ContainerView;
use chrono::{TimeZone, Utc};
use hive_sh4re::container::AgentStatusRow;
let view = ContainerView {
@ -413,9 +378,6 @@ mod tests {
running: true,
failed: false,
needs_login: false,
active_model: Some("claude-opus".to_owned()),
status_text: Some("shipping".to_owned()),
status_set_at: Some(Utc.timestamp_opt(1_700_000_000, 0).unwrap()),
paused: true,
};
@ -425,12 +387,6 @@ mod tests {
assert!(!row.failed);
assert!(!row.needs_login);
assert!(row.paused);
assert_eq!(row.active_model.as_deref(), Some("claude-opus"));
assert_eq!(row.status_text.as_deref(), Some("shipping"));
assert_eq!(
row.status_set_at,
Some(Utc.timestamp_opt(1_700_000_000, 0).unwrap())
);
// No source for reminders on this side (see the `From` impl's doc
// comment) — always the stub, never left uninitialised.
assert_eq!(row.pending_reminders, 0);

View file

@ -4,7 +4,6 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
@ -17,8 +16,8 @@ use crate::job_queue::RunningTransient;
use crate::socket_server::{self, AgentSocket};
/// Capacity of the dashboard event channel. Slow browser subscribers
/// (idle tab, throttled connection) drop frames past this — that's
/// fine, the seq dedupe makes a reconnect resync safe.
/// (idle tab, throttled connection) drop frames past this; the
/// dashboard refetches `/api/state` on reconnect.
const DASHBOARD_CHANNEL: usize = 256;
pub struct Coordinator {
@ -114,11 +113,7 @@ pub struct Coordinator {
/// stream. Carries broker messages (mirrored from `broker.subscribe`
/// by the forwarder task in `main.rs`) and dashboard-only mutation
/// events (container state, rebuild queue, etc.).
/// Snapshot endpoints capture `event_seq` before reading state so
/// the client can dedupe its buffered live traffic against the
/// snapshot.
dashboard_events: broadcast::Sender<DashboardEvent>,
event_seq: AtomicU64,
/// Last container snapshot seen by `rescan_containers_and_emit`,
/// keyed by `ContainerView.name`. The rescan diffs a fresh
@ -377,7 +372,6 @@ impl Coordinator {
updating: Mutex::new(HashSet::new()),
updating_changed: tokio::sync::Notify::new(),
dashboard_events,
event_seq: AtomicU64::new(0),
last_containers: tokio::sync::Mutex::new(HashMap::new()),
job_queue: Arc::new(crate::job_queue::JobQueue::new(build_slots)),
@ -432,9 +426,7 @@ impl Coordinator {
/// keeps `snapshot` because *that* is still what a client ends up with;
/// what changed is who serves it.
pub fn emit_rebuild_queue_snapshot(self: &Arc<Self>) {
self.emit_dashboard_event(DashboardEvent::RebuildQueueChanged {
seq: self.next_seq(),
});
self.emit_dashboard_event(DashboardEvent::RebuildQueueChanged);
}
/// Subscribe to the shutdown watch channel. Background tasks call
@ -461,24 +453,7 @@ impl Coordinator {
self.dashboard_events.subscribe()
}
/// Stamp the next sequence number. Each emission of a
/// `DashboardEvent` should fill its `seq` with `next_seq()` so the
/// frame the wire carries is the one the client uses to dedupe.
pub fn next_seq(&self) -> u64 {
self.event_seq.fetch_add(1, Ordering::SeqCst) + 1
}
/// Current high-water seq. Snapshot endpoints read this *before*
/// gathering state so the (snapshot.seq, snapshot) pair satisfies:
/// any frame with `seq > snapshot.seq` is post-snapshot. The seq
/// captured here may grow during snapshot construction — clients
/// may double-apply such events, which renderers must tolerate.
pub fn current_seq(&self) -> u64 {
self.event_seq.load(Ordering::SeqCst)
}
/// Broadcast a freshly-built `DashboardEvent` (caller fills `seq`
/// via `next_seq()`). Returns silently when there are no
/// Broadcast a freshly-built `DashboardEvent`. Returns silently when there are no
/// subscribers — the dashboard channel is best-effort presentation
/// plumbing, not a delivery guarantee.
pub fn emit_dashboard_event(&self, event: DashboardEvent) {
@ -533,16 +508,10 @@ impl Coordinator {
}
drop(last);
for c in changed_or_new {
self.emit_dashboard_event(DashboardEvent::ContainerStateChanged {
seq: self.next_seq(),
container: c,
});
self.emit_dashboard_event(DashboardEvent::ContainerStateChanged { container: c });
}
for name in removed {
self.emit_dashboard_event(DashboardEvent::ContainerRemoved {
seq: self.next_seq(),
name,
});
self.emit_dashboard_event(DashboardEvent::ContainerRemoved { name });
}
}
@ -594,7 +563,6 @@ impl Coordinator {
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
self.emit_dashboard_event(DashboardEvent::TransientSet {
seq: self.next_seq(),
name: name.to_owned(),
transient_kind: label,
since_unix,
@ -623,7 +591,6 @@ impl Coordinator {
(deliberate_stop, std::time::Instant::now()),
);
self.emit_dashboard_event(DashboardEvent::TransientCleared {
seq: self.next_seq(),
name: name.to_owned(),
transient_kind: label.to_owned(),
});

View file

@ -2,16 +2,11 @@
use axum::{
extract::{Path as AxumPath, State},
response::{IntoResponse, Response},
http::StatusCode,
};
use super::AppState;
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(super) struct CancelResultBody {
cancelled: bool,
}
/// Drop still-queued work from the job
/// queue.
///
@ -23,27 +18,25 @@ pub(super) struct CancelResultBody {
/// Refuses `Running` / terminal nodes: an
/// in-flight node owns the agent's nix store + nixos-container update
/// lock and can't be safely interrupted from the queue side. Always
/// returns 200; the body is `{"cancelled": true}` on a successful
/// flip to Cancelled, `{"cancelled": false}` when the DAG was
/// Running / terminal / gone. On success a fresh `RebuildQueueChanged`
/// snapshot fires so the state flip surfaces live.
/// returns 200 with an empty body, whether or not anything was cancelled
/// (Running / terminal / gone nodes are left alone). On a successful flip to
/// Cancelled a fresh `RebuildQueueChanged` snapshot fires so the state flip
/// surfaces live.
#[utoipa::path(
post,
path = "/api/rebuild-queue/{id}/cancel",
params(("id" = u64, Path, description = "job-queue node id (a DAG's root cancels the group)")),
responses((status = 200, description = "whether the DAG was cancelled", body = CancelResultBody)),
responses((status = 200, description = "request handled; an empty body, whether or not the node was cancelled")),
tag = "rebuild_queue"
)]
pub(super) async fn post_rebuild_queue_cancel(
State(state): State<AppState>,
AxumPath(id): AxumPath<u64>,
) -> Response {
) -> StatusCode {
if state.coord.job_queue.cancel(id) {
// Any terminal side effect is the DAG's own spared tail node, which the
// scheduler picks up on its next pass — nothing to fire from here.
state.coord.emit_rebuild_queue_snapshot();
axum::Json(CancelResultBody { cancelled: true }).into_response()
} else {
axum::Json(CancelResultBody { cancelled: false }).into_response()
}
StatusCode::OK
}

View file

@ -24,14 +24,6 @@ use super::tombstones::{TombstoneView, build_tombstone_views};
#[allow(clippy::struct_excessive_bools)]
#[derive(Serialize)]
pub(super) struct StateSnapshot {
/// Broker seq at the moment this snapshot was assembled. Clients
/// dedupe their buffered SSE traffic against this value: any
/// `MessageEvent` with `seq <= snapshot.seq` is already reflected in
/// the snapshot (or pre-dates it); anything with `seq > snapshot.seq`
/// is post-snapshot and should be applied. Set to 0 in the
/// pre-emit case (no events ever fired) — clients treat that as
/// "apply everything you've buffered".
seq: u64,
hostname: String,
/// Current rev of the pinned `hyperhive` flake input, resolved the
/// same way `get_agent_meta`'s per-agent `hyperhive_rev` is (see
@ -46,15 +38,6 @@ pub(super) struct StateSnapshot {
/// survive after a destroy-without-purge. The operator can re-spawn
/// with the same name to resume, or PURG3 to wipe them.
tombstones: Vec<TombstoneView>,
/// Agents whose FNV-1a hashed web UI port collides with at least
/// one other. Operator resolves by renaming. The dashboard renders
/// a banner at the top listing each cluster.
port_conflicts: Vec<PortConflict>,
/// Whether the hive-forge container is up. When true the dashboard
/// links each container's config into the forge's `agent-configs`
/// repos.
forge_present: bool,
/// Whether `hive-gateway` is in front of this dashboard. Sourced
/// from the `HIVE_GATEWAY_ENABLED` env var, which the c0re NixOS
/// module now always sets (the gateway runs unconditionally
@ -89,13 +72,6 @@ pub(super) struct StateSnapshot {
swarm_name: Option<String>,
}
#[derive(Serialize)]
struct PortConflict {
port: u16,
/// All agent names sharing this port (sorted, ≥2 entries).
agents: Vec<String>,
}
#[derive(Serialize)]
struct TransientView {
name: String,
@ -107,8 +83,7 @@ struct TransientView {
/// Cold-load snapshot of the whole dashboard.
///
/// Includes the roster, tombstones, job queue, and more. Live
/// clients then follow `/api/dashboard/stream` (SSE) for incremental updates
/// keyed off `seq`.
/// clients then follow `/api/dashboard/stream` (SSE) for incremental updates.
// `StateSnapshot` is a large tree of nested view types (`ContainerView`,
// `TransientView`, ...) with no `ToSchema` anywhere in that
// graph; wiring it up is a schema-modelling project of its own, well past
@ -130,14 +105,6 @@ pub(super) async fn api_state(
.unwrap_or("localhost");
let hostname = host.split(':').next().unwrap_or(host).to_owned();
// Capture the unified dashboard-channel seq *before* any read so the
// dedupe contract is "events with seq > snapshot.seq are
// post-snapshot, never missed." An event landing during snapshot
// construction may be doubly applied (snapshot caught the write +
// client also applies the SSE frame) — that's a renderer's problem
// to make idempotent, not ours to avoid here.
let seq = state.coord.current_seq();
// Refresh the coordinator's cached container snapshot before
// reading. Cold-load clients then see whatever the latest rescan
// produced; live clients converge via the matching
@ -169,18 +136,14 @@ pub(super) async fn api_state(
let transient_snapshot = state.coord.transient_snapshot();
let transients = build_transient_views(&containers, &transient_snapshot);
let tombstones = build_tombstone_views(&containers);
let port_conflicts = build_port_conflicts(&containers);
axum::Json(StateSnapshot {
seq,
hostname,
hyperhive_rev: crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake),
containers,
transients,
tombstones,
port_conflicts,
forge_present: crate::forge::is_present().await,
gateway_enabled: std::env::var_os("HIVE_GATEWAY_ENABLED").is_some_and(|v| {
// Accept any truthy string ("1", "true", "yes"): the c0re
// NixOS module sets the literal "1" (the gateway always runs
@ -203,29 +166,6 @@ pub(super) async fn api_state(
})
}
/// Group containers by their assigned web UI port; clusters with more
/// than one member are port-hash collisions the operator resolves by
/// renaming an agent.
///
/// Every container's port comes from [`crate::lifecycle::agent_web_port`],
/// which hashes the name into one range for all of them — so the manager
/// is a candidate for a collision exactly like any sub-agent.
fn build_port_conflicts(containers: &[ContainerView]) -> Vec<PortConflict> {
let mut by_port: std::collections::BTreeMap<u16, Vec<String>> =
std::collections::BTreeMap::new();
for c in containers {
by_port.entry(c.port).or_default().push(c.name.clone());
}
by_port
.into_iter()
.filter(|(_, agents)| agents.len() > 1)
.map(|(port, mut agents)| {
agents.sort();
PortConflict { port, agents }
})
.collect()
}
/// Transient state for agents whose container does NOT yet exist
/// (`Spawning`). Lifecycle ops on existing containers surface as
/// `ContainerView.pending` inline; this list only catches pre-creation.
@ -253,8 +193,7 @@ fn build_transient_views(
.collect();
// The caller rebuilds this map on every poll, so its iteration order
// differs between renders and the rows would shuffle under an operator
// watching an operation run. Sorted for the same reason
// `build_port_conflicts` sorts its clusters.
// watching an operation run. Sorted so the order is stable.
out.sort_by(|a, b| {
a.name
.cmp(&b.name)
@ -355,7 +294,7 @@ pub(super) struct DashboardStreamQuery {
params(DashboardStreamQuery),
responses(
(status = 200, description = "server-sent event stream; each event's \
`data` is a JSON-serialised `DashboardEvent` (seq-tagged)",
`data` is a JSON-serialised `DashboardEvent`",
body = String, content_type = "text/event-stream"),
),
tag = "state_snapshot"
@ -378,8 +317,7 @@ pub(super) async fn dashboard_stream(
if set.is_empty() { None } else { Some(set) }
});
let stream = BroadcastStream::new(rx).filter_map(move |res| {
// Drop lagged frames. Browsers reconnect; the seq dedupe on
// reconnect skips any frame already reflected in the snapshot.
// Drop lagged frames. Browsers reconnect and refetch the snapshot.
let event = res.ok()?;
if let Some(filter) = kind_filter.as_ref()
&& !filter.contains(event.kind_tag())
@ -398,7 +336,7 @@ mod tests {
use chrono::{Duration, Utc};
use super::{ContainerView, build_port_conflicts, build_transient_views};
use super::{ContainerView, build_transient_views};
use crate::job_queue::RunningTransient;
fn rt(agent: &str, label: &str, age_secs: i64) -> RunningTransient {
@ -418,6 +356,18 @@ mod tests {
m
}
fn cv(name: &str, port: u16, running: bool) -> ContainerView {
ContainerView {
name: name.to_owned(),
container: format!("h-{name}"),
port,
running,
failed: false,
needs_login: false,
paused: false,
}
}
#[test]
fn an_agent_whose_container_exists_is_not_transient() {
let snap = snapshot(vec![rt("alice", "spawn", 5)]);
@ -471,92 +421,44 @@ mod tests {
assert!(build_transient_views(&[], &snapshot(vec![])).is_empty());
}
fn cv(name: &str, port: u16, running: bool) -> ContainerView {
ContainerView {
name: name.to_owned(),
container: format!("h-{name}"),
port,
running,
failed: false,
needs_login: false,
active_model: None,
status_text: None,
status_set_at: None,
paused: false,
}
}
/// The JSON object a cold-loading dashboard receives carries exactly these
/// keys; a field added back must be added here, with a reader.
#[test]
fn distinct_ports_produce_no_conflicts() {
let cs = [cv("alice", 8101, true), cv("bob", 8102, true)];
assert!(build_port_conflicts(&cs).is_empty());
}
#[test]
fn a_lone_container_does_not_conflict_with_itself() {
assert!(build_port_conflicts(&[cv("alice", 8101, true)]).is_empty());
}
#[test]
fn two_agents_on_one_port_are_reported_together() {
let cs = [cv("alice", 8101, true), cv("bob", 8101, true)];
let got = build_port_conflicts(&cs);
assert_eq!(got.len(), 1);
assert_eq!(got[0].port, 8101);
assert_eq!(got[0].agents, ["alice", "bob"]);
}
/// The manager takes its port from the same hash as everyone else, so
/// it collides like any other name. A doc comment here used to claim it
/// sat at a fixed 8000 and could not — no such special case exists.
#[test]
fn the_manager_can_collide_with_a_sub_agent() {
let cs = [cv("manager", 8101, true), cv("alice", 8101, true)];
let got = build_port_conflicts(&cs);
assert_eq!(got.len(), 1);
assert_eq!(got[0].agents, ["alice", "manager"]);
}
/// A stopped agent keeps its hashed port, so the clash is real the
/// moment it starts. Reporting it while it is down is the point.
#[test]
fn a_stopped_container_still_claims_its_port() {
let cs = [cv("alice", 8101, true), cv("bob", 8101, false)];
assert_eq!(build_port_conflicts(&cs).len(), 1);
}
#[test]
fn names_are_sorted_within_a_conflict() {
let cs = [cv("zoe", 8101, true), cv("adam", 8101, true)];
assert_eq!(build_port_conflicts(&cs)[0].agents, ["adam", "zoe"]);
}
#[test]
fn more_than_two_agents_land_in_one_cluster() {
let cs = [
cv("c", 8101, true),
cv("a", 8101, true),
cv("b", 8101, true),
];
let got = build_port_conflicts(&cs);
assert_eq!(got.len(), 1, "one cluster, not one row per pair");
assert_eq!(got[0].agents, ["a", "b", "c"]);
}
#[test]
fn separate_clusters_are_ordered_by_port() {
let cs = [
cv("d", 8300, true),
cv("c", 8300, true),
cv("b", 8200, true),
cv("a", 8200, true),
];
let ports: Vec<u16> = build_port_conflicts(&cs).iter().map(|p| p.port).collect();
assert_eq!(ports, [8200, 8300]);
}
#[test]
fn no_containers_is_not_a_conflict() {
assert!(build_port_conflicts(&[]).is_empty());
fn the_state_snapshot_serialises_exactly_its_read_fields() {
let snap = super::StateSnapshot {
hostname: "h".to_owned(),
hyperhive_rev: None,
containers: Vec::new(),
transients: Vec::new(),
tombstones: Vec::new(),
gateway_enabled: true,
swarm_public_url: None,
authelia_public_url: None,
hive_name: None,
swarm_name: None,
};
let value = serde_json::to_value(&snap).unwrap();
let mut keys: Vec<&str> = value
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"authelia_public_url",
"containers",
"gateway_enabled",
"hive_name",
"hostname",
"hyperhive_rev",
"swarm_name",
"swarm_public_url",
"tombstones",
"transients",
]
);
}
}

View file

@ -106,7 +106,6 @@ pub(crate) async fn emit_tombstones_snapshot(coord: &Arc<Coordinator>) {
let containers = coord.containers_snapshot().await;
let tombstones = build_tombstone_views(&containers);
coord.emit_dashboard_event(crate::dashboard_events::DashboardEvent::TombstonesChanged {
seq: coord.next_seq(),
tombstones,
});
}

View file

@ -1,7 +1,6 @@
//! 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):
//! flow through `Coordinator.dashboard_events`. Design rationale
//! (single channel, broker forwarder isolation):
//! `docs/4-implementation/web-ui/shape.md::One unified channel`.
use serde::Serialize;
@ -15,7 +14,6 @@ use chrono::{DateTime, Utc};
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,
@ -27,7 +25,6 @@ pub enum DashboardEvent {
},
/// 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,
@ -43,7 +40,6 @@ pub enum DashboardEvent {
/// 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"`, …) —
@ -66,7 +62,6 @@ pub enum DashboardEvent {
/// 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,
},
@ -81,21 +76,18 @@ pub enum DashboardEvent {
/// 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 },
ContainerStateChanged { 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 },
ContainerRemoved { 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>,
},
TombstonesChanged { tombstones: Vec<TombstoneView> },
/// The job queue changed — **a bare trigger, no payload.**
///
@ -110,7 +102,7 @@ pub enum DashboardEvent {
/// 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 },
RebuildQueueChanged,
}
impl DashboardEvent {
@ -135,7 +127,7 @@ impl DashboardEvent {
DashboardEvent::ContainerRemoved { .. } => "container_removed",
DashboardEvent::TombstonesChanged { .. } => "tombstones_changed",
DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed",
DashboardEvent::RebuildQueueChanged => "rebuild_queue_changed",
}
}
}
@ -163,7 +155,6 @@ mod tests {
fn kind_tag_matches_serde_kind_field() {
let samples: Vec<DashboardEvent> = vec![
DashboardEvent::Sent {
seq: 1,
id: 1,
from: "a".into(),
to: "b".into(),
@ -172,7 +163,6 @@ mod tests {
in_reply_to: None,
},
DashboardEvent::Delivered {
seq: 1,
id: 1,
from: "a".into(),
to: "b".into(),
@ -181,25 +171,19 @@ mod tests {
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::ContainerRemoved { name: "x".into() },
DashboardEvent::TombstonesChanged {
seq: 1,
tombstones: Vec::new(),
},
DashboardEvent::RebuildQueueChanged { seq: 1 },
DashboardEvent::RebuildQueueChanged,
];
for ev in samples {
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise");
@ -210,4 +194,40 @@ mod tests {
assert_eq!(ev.kind_tag(), serde_kind, "kind_tag() drift on {ev:?}");
}
}
/// Every frame is its `kind` tag plus the variant's own fields, nothing else.
#[test]
fn frames_carry_no_sequence_number() {
let removed = serde_json::to_value(DashboardEvent::ContainerRemoved { name: "x".into() })
.expect("serialise");
let mut keys: Vec<&str> = removed
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(keys, ["kind", "name"]);
let transient = serde_json::to_value(DashboardEvent::TransientSet {
name: "x".into(),
transient_kind: "rebuilding".into(),
since_unix: 0,
})
.expect("serialise");
let mut keys: Vec<&str> = transient
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(keys, ["kind", "name", "since_unix", "transient_kind"]);
let trigger = serde_json::to_value(DashboardEvent::RebuildQueueChanged).expect("serialise");
assert_eq!(
trigger,
serde_json::json!({ "kind": "rebuild_queue_changed" })
);
}
}

View file

@ -13,8 +13,6 @@ use std::sync::OnceLock;
use anyhow::{Context, Result};
use url::Url;
const FORGE_CONTAINER: &str = "hive-forge";
/// Base HTTP URL for the local Forgejo instance, from `HIVE_FORGE_URL`
/// (set unconditionally by the hyperhive NixOS module on the `hive-c0re`
/// unit, to `http://<forge.domain>`).
@ -83,17 +81,6 @@ fn basic_auth_header(login: &str, token: &str) -> String {
format!("Authorization: Basic {basic}")
}
/// Probe whether `hive-forge` exists as a nixos-container. Cheap —
/// `nixos-container list` is just a directory scan in /etc. Routed
/// through hive-priv: `nixos-container` needs root, and hive-c0re runs
/// unprivileged (privsep).
pub async fn is_present() -> bool {
let Ok(stdout) = crate::priv_client::list_containers().await else {
return false;
};
stdout.lines().any(|l| l.trim() == FORGE_CONTAINER)
}
#[cfg(test)]
mod tests {
use super::git_url_with_base;

View file

@ -374,9 +374,8 @@ async fn cmd_serve(
});
}
// Forward every broker event onto the unified dashboard
// channel with a freshly-stamped seq, so the dashboard SSE
// sees broker messages + future mutation events on one
// stream with one monotonic seq. The broker's intra-process
// channel, so the dashboard SSE sees broker messages and
// mutation events on one stream. The broker's intra-process
// channel (used by `recv_blocking_batch`) stays untouched.
spawn_broker_to_dashboard_forwarder(coord.clone());
let dash_coord = coord.clone();
@ -409,7 +408,7 @@ async fn cmd_serve(
}
/// Re-emit every broker `MessageEvent` onto the dashboard channel as
/// a `DashboardEvent::Sent` / `Delivered` with a freshly-stamped seq.
/// a `DashboardEvent::Sent` / `Delivered`.
/// Background task; runs for the life of the process. On a lagged
/// broker subscription we just keep going — the dashboard channel is
/// best-effort presentation plumbing, the broker keeps its own sqlite
@ -430,7 +429,6 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
in_reply_to,
}) => {
coord.emit_dashboard_event(DashboardEvent::Sent {
seq: coord.next_seq(),
id,
from,
to,
@ -448,7 +446,6 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
in_reply_to,
}) => {
coord.emit_dashboard_event(DashboardEvent::Delivered {
seq: coord.next_seq(),
id,
from,
to,

View file

@ -343,9 +343,6 @@ mod tests {
running: true,
failed: false,
needs_login,
active_model: None,
status_text: None,
status_set_at: None,
paused: false,
}
}

View file

@ -54,9 +54,7 @@ pub struct Delivery {
/// Intra-process broker event. `recv_blocking_batch` listens on the
/// same channel as the dashboard forwarder; the forwarder re-emits
/// each event as a `DashboardEvent` with a freshly-stamped seq from
/// the Coordinator. The broker itself doesn't stamp seqs — that's a
/// wire concern, not a storage concern.
/// each event as a `DashboardEvent`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum MessageEvent {