From bcb9e837f7226892fdcd5a1247876daafc8c643d Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 1 Aug 2026 00:36:09 +0200 Subject: [PATCH 1/3] fix(#2860): make hyperhive.forge.url nullable instead of guessing a URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The option had a `http://localhost:3000` default, which is only ever correct when the forge shares the caller's network namespace — inside an agent's netns `localhost` is the agent, and the forge may well be on another host. Making it *required* instead was worse: the flake's own container configs are what hive-c0re extends per agent, so the value they needed in order to evaluate became a second definition on every agent and collided with the real one. `null` resolves both. It is not a URL, so nothing can quietly talk to the wrong machine, and it needs no placeholder anywhere: the bases evaluate as they are, so nothing deployment-shaped sits on the config agents inherit from. The units that would consume the URL — tea-login and forge-avatar-sync — are simply not generated without one, making an absent forge an absent integration rather than a misdirected one. hive-forge-notify is unaffected: it reads HIVE_FORGE_URL from the forwarded global environment, not from this option. Verified: agent-base/ruth evaluate with forge.url = null, zero failing assertions bare base: tea_login_present = false, avatar_present = false, notify_present = true extended with a rendered URL: FORGE_URL=http://forge.real.test Refs #2860 --- flake.nix | 34 +++------ nix/agent-modules/forge.nix | 135 ++++++++++++++++++++---------------- 2 files changed, 85 insertions(+), 84 deletions(-) diff --git a/flake.nix b/flake.nix index 8eef657c..63818b00 100644 --- a/flake.nix +++ b/flake.nix @@ -149,34 +149,20 @@ nixosConfigurations = let - # Values the agent modules require but that only a real - # deployment can know. Real containers are built from the - # generated meta flake, where hive-c0re renders these per - # agent from the host's `HIVE_FORGE_URL` (see meta.rs's - # `SERVICE_URL_OPTIONS`) — they never evaluate through - # `self.nixosConfigurations`, so nothing here can reach a - # running agent. These two configs exist only to typecheck - # the modules and to pre-build the container closure - # (`system.extraDependencies`, see hive-c0re/default.nix). - # - # Deliberately a `.invalid` host (RFC 2606: guaranteed not to - # resolve) rather than something plausible like a loopback - # port. If this value ever *did* escape into a runtime path, - # it must fail loudly at DNS instead of quietly connecting to - # whatever happens to be listening — which is the entire - # point of removing the `http://localhost:3000` default this - # replaces. - evalOnlyPlaceholders = { - hyperhive.forge.url = "http://forge.invalid"; - }; + # These two configs are what hive-c0re extends per agent + # (meta.rs's `mkAgent` does `.extendModules { … }`, so + # the built base and every container stay on one version). + # Nothing deployment-specific may be set here: it would be + # inherited by every container on the hive and collide with + # the per-agent values hive-c0re renders. The agent modules + # are written so a bare evaluation needs no such values — + # service URLs default to `null`, meaning "not configured", + # and the units that would use them simply aren't generated. mkContainer = module: nixpkgs.lib.nixosSystem { system = "x86_64-linux"; - modules = [ - module - evalOnlyPlaceholders - ]; + modules = [ module ]; }; in { diff --git a/nix/agent-modules/forge.nix b/nix/agent-modules/forge.nix index 0e4eb8e9..a26cf295 100644 --- a/nix/agent-modules/forge.nix +++ b/nix/agent-modules/forge.nix @@ -20,7 +20,8 @@ let in { options.hyperhive.forge.url = lib.mkOption { - type = lib.types.str; + type = lib.types.nullOr lib.types.str; + default = null; example = "http://forge.internal:3000"; description = '' Base URL of the hyperhive-managed Forgejo. Used at container @@ -31,28 +32,38 @@ in forge-token file is missing (i.e. hive-forge isn't running on the host). - **Required, deliberately undefaulted.** hive-c0re renders it into - every agent's config from the host's `HIVE_FORGE_URL`, which - `hive-c0re.nix` sets unconditionally --- the forge is mandatory. - A loopback default would be a guess: the forge may run on a - different host from the agents, and inside an agent's network - namespace `localhost` reaches the agent, not the forge. An - unevaluatable config is better than one that builds and then - talks to the wrong machine. + **`null` means "no forge", not "guess one".** There is deliberately + no loopback default: the forge may run on a different host from + the agents, and inside an agent's network namespace `localhost` + reaches the agent rather than the forge, so a default would be a + value that builds fine and then talks to the wrong machine. + When this is `null` the tea-login and avatar-sync units are not + generated at all --- an absent integration, never a misdirected + one. + + On a real hive it is always set: hive-c0re renders it into every + agent's config from the host's `HIVE_FORGE_URL` (which + `hive-c0re.nix` sets unconditionally) and refuses to render a meta + flake without it, so `null` only survives where the modules are + evaluated outside a hive --- exactly the case that has no forge. ''; }; config = { assertions = [ - # The empty string is the one value the type permits that cannot - # be a URL, and it is what a caller supplies when they have - # nothing --- exactly the case the removed loopback default used - # to paper over. Reject it here so the failure names the option. + # Only a *set* value is constrained. `null` is the legitimate + # "no forge here" state (see the option doc) and is handled by + # not generating the units below, so it must not trip this. + # The empty string, by contrast, is the one non-null value the + # type permits that cannot be a URL --- it is what a caller + # supplies when they have nothing, which is precisely what `null` + # is for, so reject it and name the option. { assertion = - lib.hasPrefix "http://" config.hyperhive.forge.url + config.hyperhive.forge.url == null + || lib.hasPrefix "http://" config.hyperhive.forge.url || lib.hasPrefix "https://" config.hyperhive.forge.url; - message = "hyperhive.forge.url must be an http:// or https:// URL (got: \"${config.hyperhive.forge.url}\")"; + message = "hyperhive.forge.url must be an http:// or https:// URL, or null for no forge (got: \"${toString config.hyperhive.forge.url}\")"; } ]; @@ -105,7 +116,9 @@ in # One-shot: tea config.yml from the seeded forge token. Shape # contract (always exit 0, no set -e, skip-silently, re-runnable): # docs/conventions.md::Best-effort oneshot services. - systemd.services.tea-login = { + # Not generated at all when no forge is configured: an absent + # integration rather than one pointed at a guessed address. + systemd.services.tea-login = lib.mkIf (config.hyperhive.forge.url != null) { description = "configure tea CLI from hive-forge token (best-effort)"; wantedBy = [ "multi-user.target" ]; after = [ "local-fs.target" ]; @@ -188,49 +201,51 @@ in # avatar sync), so the unit only exists when an icon is configured # and needs no librsvg at runtime — Forgejo's Go image library # can't decode SVG, hence PNG. - systemd.services.forge-avatar-sync = lib.mkIf (config.hyperhive.icon != null) { - description = "sync agent icon to Forgejo user avatar (best-effort)"; - wantedBy = [ "multi-user.target" ]; - after = [ "tea-login.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = false; - # Pin the journal identity (else it's the `script` store-path wrapper). - SyslogIdentifier = "forge-avatar-sync"; - }; - path = [ - pkgs.curl - pkgs.coreutils - pkgs.jq - ]; - script = '' - FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} - # $HYPERHIVE_STATE_DIR is set system-wide by the meta flake - # (systemd.globalEnvironment) to `/agents//state`. - TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" - if [ ! -f "$TOKEN_FILE" ]; then - echo "forge-avatar-sync: no forge-token found; skipping" - exit 0 - fi - TOKEN=$(cat "$TOKEN_FILE") - IMAGE=$(base64 -w 0 < ${iconPng}) - # Forgejo POST /user/avatar expects {"image":""} — just the - # raw base64 string, NOT a data URI (data:image/png;base64,...). - # Use jq to build the payload so the large base64 value is safely quoted. - PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}') - RESP=$(curl -sf --max-time 10 \ - -X POST "$FORGE_URL/api/v1/user/avatar" \ - -H "Authorization: token $TOKEN" \ - -H "Content-Type: application/json" \ - -d "$PAYLOAD" \ - -w "\n%{http_code}" 2>/dev/null || true) - CODE=$(printf '%s' "$RESP" | tail -1) - if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then - echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)" - else - echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)" - fi - ''; - }; + systemd.services.forge-avatar-sync = + lib.mkIf (config.hyperhive.icon != null && config.hyperhive.forge.url != null) + { + description = "sync agent icon to Forgejo user avatar (best-effort)"; + wantedBy = [ "multi-user.target" ]; + after = [ "tea-login.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = false; + # Pin the journal identity (else it's the `script` store-path wrapper). + SyslogIdentifier = "forge-avatar-sync"; + }; + path = [ + pkgs.curl + pkgs.coreutils + pkgs.jq + ]; + script = '' + FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} + # $HYPERHIVE_STATE_DIR is set system-wide by the meta flake + # (systemd.globalEnvironment) to `/agents//state`. + TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" + if [ ! -f "$TOKEN_FILE" ]; then + echo "forge-avatar-sync: no forge-token found; skipping" + exit 0 + fi + TOKEN=$(cat "$TOKEN_FILE") + IMAGE=$(base64 -w 0 < ${iconPng}) + # Forgejo POST /user/avatar expects {"image":""} — just the + # raw base64 string, NOT a data URI (data:image/png;base64,...). + # Use jq to build the payload so the large base64 value is safely quoted. + PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}') + RESP=$(curl -sf --max-time 10 \ + -X POST "$FORGE_URL/api/v1/user/avatar" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + -w "\n%{http_code}" 2>/dev/null || true) + CODE=$(printf '%s' "$RESP" | tail -1) + if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then + echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)" + else + echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)" + fi + ''; + }; }; } From dbff9f09875c2f613eaba3a802a30a6c251e66c6 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 1 Aug 2026 00:36:09 +0200 Subject: [PATCH 2/3] fix(#2860): refuse to write a meta flake with no forge URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the agent option nullable, a missing `HIVE_FORGE_URL` would no longer fail anything — it would deploy a whole fleet of agents that silently never log into the forge. The forge is not optional on a running hive, so the hive asserts that itself rather than leaning on a module that legitimately allows "no forge" when evaluated standalone. `sync_agents` checks it before writing anything. That is the moment the hive commits to a flake, and it keeps `render_flake` a pure string operation: the renderer is exercised directly by a dozen tests, so making *it* env-dependent would force each of them to either set a process-wide var — the parallel-test race this module already avoids — or fail for reasons unrelated to what they assert. `require_service_urls` is pure over the already-collected pairs, so its two tests need no process env at all. Refs #2860 --- hive-c0re/src/meta.rs | 101 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 10 deletions(-) diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 080982b9..37ba30da 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -85,6 +85,11 @@ async fn stage_generated_meta_files(dir: &std::path::Path) -> Result<()> { /// no-op. pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { let _guard = META_LOCK.lock().await; + // Before anything is written: a hive without a forge URL would deploy a + // whole fleet of agents that silently never log in, because the agent + // option treats "unset" as "no forge configured" rather than erroring. + // This is the layer that knows a forge is mandatory, so it says so here. + require_service_urls(&forwarded_env_vars())?; let dir = crate::paths::meta_root(); std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; @@ -715,8 +720,12 @@ const SERVICE_URL_OPTIONS: &[(&str, &str)] = &[ /// other under the default parallel test runner. A pure function over the /// already-collected pairs has no such hazard. /// -/// A var that isn't present emits nothing rather than a guess — see the call -/// site for why that silence is the point. +/// A var that isn't present emits nothing rather than a guess. For an optional +/// service that is the whole point — the agent option defaults to `null`, +/// meaning "not configured", and the units that would use it aren't generated. +/// For a service the hive cannot run without, silence would instead produce a +/// fleet of agents quietly missing an integration, so those are checked by +/// [`require_service_urls`] before this is called. fn push_service_url_options(out: &mut String, vars: &[(&'static str, String)]) { use std::fmt::Write as _; for (var, val) in vars { @@ -728,6 +737,48 @@ fn push_service_url_options(out: &mut String, vars: &[(&'static str, String)]) { } } +/// Service URLs a running hive must always supply, checked before rendering. +/// +/// The forge is not optional on a real hive: `hive-c0re.nix` sets +/// `HIVE_FORGE_URL` unconditionally, so its absence means this daemon was +/// started outside the NixOS module. The agent option is nullable — `null` +/// legitimately means "no forge" when the modules are evaluated on their own — +/// which is exactly why the hive has to assert its own requirement here rather +/// than leaning on the module to reject the empty case. +const REQUIRED_SERVICE_URL_VARS: &[&str] = &["HIVE_FORGE_URL"]; + +/// Fails unless every [`REQUIRED_SERVICE_URL_VARS`] entry is present in +/// `vars`. +/// +/// Pure over the already-collected pairs so it can be tested without touching +/// process env (the same reason [`push_service_url_options`] is split out). +/// +/// Checked by `sync_agents` — the point at which the hive commits a rendered +/// flake to disk — rather than inside the renderer. Rendering is a pure string +/// operation that many tests exercise directly; making *it* env-dependent +/// would mean every one of those tests either sets a process-wide var (the +/// parallel-test race this module already avoids) or fails for reasons that +/// have nothing to do with what it asserts. +/// +/// # Errors +/// +/// When a required var is missing. `hive-c0re.nix` sets it unconditionally, so +/// this means the daemon is running outside the NixOS module; refusing to +/// write the flake beats writing one whose agents would silently all lack a +/// forge. +fn require_service_urls(vars: &[(&'static str, String)]) -> Result<()> { + for required in REQUIRED_SERVICE_URL_VARS { + if !vars.iter().any(|(name, _)| name == required) { + anyhow::bail!( + "{required} is unset — hive-c0re.nix sets it unconditionally, so this process \ + was started outside the NixOS module. Refusing to write a meta flake whose \ + agents would every one of them have no forge configured." + ); + } + } + Ok(()) +} + fn forwarded_env_vars() -> Vec<(&'static str, String)> { FORWARDED_VARS .iter() @@ -1177,10 +1228,16 @@ where // ever correct when the callee shares the caller's netns, and the forge // and homeserver are moving to swarm level, possibly on other hosts. // - // Absent vars emit nothing rather than a guess. Once the defaults are - // gone that surfaces as an eval failure, which is the point: better a - // build that stops than an agent quietly talking to a port on the wrong - // machine. + // Absent vars emit nothing rather than a guess, and the agent option + // treats "unset" as "this service is not configured" rather than + // substituting a loopback address — an absent integration instead of a + // misdirected one. + // + // That is the right default for an optional service and the wrong one for + // the forge, which a running hive always has — so `sync_agents` rejects a + // missing `HIVE_FORGE_URL` before it writes anything (`require_service_urls`). + // The check lives there rather than here because rendering is a pure string + // operation the tests exercise directly. push_service_url_options(&mut out, &forwarded_env_vars()); // GitHub integration is on by default in every agent // (`hyperhive.github.enable`); the host turns it off hive-wide via @@ -1900,12 +1957,36 @@ mod tests { ); } + #[test] + fn require_service_urls_accepts_a_rendered_forge_url() { + require_service_urls(&[ + ("HIVE_FORGE_URL", "http://forge.example.test".to_string()), + ("HYPERHIVE_HIVE_NAME", "pr1ma".to_string()), + ]) + .expect("a forwarded forge URL satisfies the requirement"); + } + + #[test] + fn require_service_urls_refuses_to_write_without_a_forge() { + // The agent option is nullable, so nothing downstream would complain: + // every agent would simply come up with no forge login and no way to + // tell that was unintended. The hive asserts its own requirement + // because it is the only layer that knows a forge is mandatory. + let err = require_service_urls(&[("HYPERHIVE_HIVE_NAME", "pr1ma".to_string())]) + .expect_err("a missing forge URL must stop the write"); + assert!( + err.to_string().contains("HIVE_FORGE_URL is unset"), + "the error must name the missing variable: {err}" + ); + } + #[test] fn service_url_options_emit_nothing_when_absent() { - // No guess when the host doesn't say. Once the nix-side defaults are - // removed this is what turns a missing value into a build failure - // rather than an agent quietly talking to a port on the wrong machine, - // so the absence has to be as deliberate as the presence. + // No guess when the host doesn't say — the agent option stays `null` + // ("not configured") and the units that would use it aren't generated, + // so absence is an absent integration rather than a misdirected one. + // Required services don't reach here: `require_service_urls` rejects + // them first. let mut out = String::new(); push_service_url_options(&mut out, &[("HYPERHIVE_HIVE_NAME", "pr1ma".to_string())]); assert!( From d10eebd45598da395ce5f3d522db63af3ec74b15 Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 1 Aug 2026 00:01:07 +0200 Subject: [PATCH 3/3] hive-c0re: annotate the 3 SSE/stream dashboard routes with utoipa --- hive-c0re/src/dashboard/build_logs.rs | 12 +++++++ hive-c0re/src/dashboard/mod.rs | 38 ++++++++--------------- hive-c0re/src/dashboard/state_snapshot.rs | 26 +++++++++++++++- 3 files changed, 50 insertions(+), 26 deletions(-) diff --git a/hive-c0re/src/dashboard/build_logs.rs b/hive-c0re/src/dashboard/build_logs.rs index 990af3ef..7c741a52 100644 --- a/hive-c0re/src/dashboard/build_logs.rs +++ b/hive-c0re/src/dashboard/build_logs.rs @@ -207,6 +207,18 @@ struct BuildLogFrame { /// always carries the full accumulated log so far (cursors start at 0); /// subsequent frames carry only new bytes. `done: true` on the final /// frame signals the browser to close the `EventSource`. +#[utoipa::path( + get, + path = "/api/build-logs/id/{id}/stream", + params(("id" = i64, Path, description = "build log row id")), + responses( + (status = 200, description = "server-sent event stream; each event's \ + `data` is a JSON-serialised `BuildLogFrame` \ + (stdout_append/stderr_append/status/done)", + body = String, content_type = "text/event-stream"), + ), + tag = "build_logs" +)] pub(super) async fn get_build_log_stream( State(state): State, AxumPath(id): AxumPath, diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index 5491e8cd..9e2a12e6 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -7,10 +7,8 @@ use std::sync::Arc; use anyhow::{Context, Result}; use axum::{ - Router, http::StatusCode, response::{IntoResponse, Response}, - routing::get, }; use utoipa::OpenApi; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -120,27 +118,12 @@ pub async fn serve( ) -> Result<()> { // API-only: the gateway static-serves the dashboard dist and proxies // non-static requests here (see hive-gateway.nix). Unmatched paths 404. - // Every `#[utoipa::path]`-annotated route is registered further down - // via `OpenApiRouter` instead of a plain `.route(...)` call here — see - // the `router`/`api` split below. What's left in this plain chain is - // exactly the SSE/streaming endpoints utoipa can't model (see the - // `ApiDoc` doc comment) plus anything not yet annotated. - let app = Router::new() - .route( - "/api/build-logs/id/{id}/stream", - get(build_logs::get_build_log_stream), - ) - .route( - "/api/dashboard/stream", - get(state_snapshot::dashboard_stream), - ) - .route( - "/api/dashboard/history", - get(state_snapshot::dashboard_history), - ) - // No static fallback — the gateway owns the dist; unmatched paths 404. - ; - + // No static fallback needed here either. Every route (SSE streams + // included — `utoipa::path` can document an SSE body as an opaque + // `text/event-stream` string, it just can't model individual frame + // shapes) is registered below via `OpenApiRouter`; see the `router`/ + // `api` split. + // // `routes!()` folds every handler it's given into ONE shared // `MethodRouter` for the whole macro invocation — it does NOT group // by path internally, so passing it handlers for more than one @@ -219,7 +202,9 @@ pub async fn serve( .routes(routes!(questions::post_cancel_question)) .routes(routes!(tombstones::post_purge_tombstone)) .routes(routes!(meta_inputs::post_meta_update)) - .merge(app.into()) + .routes(routes!(build_logs::get_build_log_stream)) + .routes(routes!(state_snapshot::dashboard_stream)) + .routes(routes!(state_snapshot::dashboard_history)) .split_for_parts(); let app = router .merge(SwaggerUi::new("/api/docs").url("/api/openapi.json", api)) @@ -453,6 +438,9 @@ mod router_build_probe { .routes(routes!(questions::post_answer_question)) .routes(routes!(questions::post_cancel_question)) .routes(routes!(tombstones::post_purge_tombstone)) - .routes(routes!(meta_inputs::post_meta_update)); + .routes(routes!(meta_inputs::post_meta_update)) + .routes(routes!(build_logs::get_build_log_stream)) + .routes(routes!(state_snapshot::dashboard_stream)) + .routes(routes!(state_snapshot::dashboard_history)); } } diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 37006cc1..f86000cb 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -18,6 +18,7 @@ use hive_sh4re::Approval; use serde::{Deserialize, Serialize}; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt}; +use utoipa::IntoParams; use crate::container_view::ContainerView; @@ -655,6 +656,17 @@ fn build_approval_views(approvals: Vec) -> Vec { out } +#[utoipa::path( + get, + path = "/api/dashboard/history", + responses( + (status = 200, description = "`{ seq, events }` — up to the last 200 \ + broker messages as `DashboardEvent::Sent`/`Delivered` JSON, plus \ + `seq`: the dashboard channel's high-water mark at fetch time \ + (used by clients to dedupe against buffered live SSE frames)"), + ), + tag = "state_snapshot" +)] pub(super) async fn dashboard_history(State(state): State) -> Response { // Backfill source for the dashboard terminal. Returns up to ~200 // historical broker messages (no other event kinds are persisted) @@ -731,7 +743,7 @@ pub(super) async fn dashboard_history(State(state): State) -> Response /// Useful for narrow pages (e.g. `flow.js` only cares about `sent` /// / `delivered` / `container_state_changed` / `container_removed`) /// that want to drop the dispatch overhead on every unrelated mutation. -#[derive(Deserialize, Default)] +#[derive(Deserialize, Default, IntoParams)] pub(super) struct DashboardStreamQuery { /// Comma-separated event kinds to forward. Each token is /// trimmed; unknown kinds are silently ignored on lookup @@ -739,6 +751,18 @@ pub(super) struct DashboardStreamQuery { kinds: Option, } +#[utoipa::path( + get, + path = "/api/dashboard/stream", + params(DashboardStreamQuery), + responses( + (status = 200, description = "server-sent event stream; each event's \ + `data` is a JSON-serialised `DashboardEvent` (seq-tagged; pair \ + with `/api/dashboard/history` to backfill + dedupe on connect)", + body = String, content_type = "text/event-stream"), + ), + tag = "state_snapshot" +)] pub(super) async fn dashboard_stream( State(state): State, axum::extract::Query(q): axum::extract::Query,