swarm-controller: track agent config-PR status independently of hives

This commit is contained in:
damocles 2026-08-19 20:11:00 +02:00
commit 66c494697f
3 changed files with 217 additions and 2 deletions

View file

@ -29,13 +29,18 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use axum::{Json, extract::State, routing::get};
use axum::{
Json,
extract::{Path, State},
routing::get,
};
use hive_jobq_wire::GraphWire as _;
use serde::{Deserialize, Serialize};
use utoipa::{OpenApi, ToSchema};
use utoipa_axum::{router::OpenApiRouter, routes};
mod auth;
mod config_pr;
mod forge;
mod status;
mod webhook;
@ -352,6 +357,11 @@ struct AppState {
/// startup, and this way `as_deref()` yields the `&str` the verifier
/// takes without a second hop through `String`.
webhook_secret: Option<Arc<str>>,
/// Last successful `agent-configs/*` scan, kept current by
/// [`config_pr::spawn`]. `None` when this host has no forge configured
/// — same "absent means don't ask" shape as `status` and `forge` above,
/// not a startup failure.
config_prs: Option<Arc<config_pr::ConfigPrCache>>,
/// The swarm's human display name (`services.hyperhive.swarm.name`),
/// loaded once at startup (`load_swarm_name`). `None` when the
/// operator never set it — a swarm without a display name is a
@ -760,6 +770,38 @@ async fn get_jobq_rollup(State(state): State<AppState>) -> Json<Vec<hive_jobq_wi
Json(hive_jobq_wire::state_rollup(graph, roots))
}
/// `agent`'s open config PR, from the last successful swarm-level scan
/// ([`config_pr::spawn`]) — not a live forge read, so this answers even when
/// the forge itself is momentarily unreachable, at the cost of being up to
/// one [`config_pr::POLL_INTERVAL`] stale.
///
/// `200` with a `null` body means "no open PR" (or "no scan has completed
/// yet") — not distinguished, for the same reason [`ConfigPrCache::get`]
/// doesn't: both read as "nothing to show" to the swarm-ui config-PR panel
/// this feeds, and a cache is a best-effort read, not a source of truth
/// callers should expect to disambiguate against.
#[utoipa::path(
get,
path = "/api/agents/{name}/config-pr",
params(("name" = String, Path, description = "agent name")),
responses(
(status = 200, description = "the agent's open config PR, or null if none / not scanned yet", body = Option<forge::ConfigPrStatus>),
(status = 503, description = "no forge is configured on this host", body = String),
),
tag = "agents"
)]
async fn get_agent_config_pr(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<Option<forge::ConfigPrStatus>>, StatusUnavailable> {
let Some(cache) = state.config_prs.as_ref() else {
return Err(StatusUnavailable(
"no forge is configured on this host".to_owned(),
));
};
Ok(Json(cache.get(&name)))
}
/// The swarm's own public base URL, as the forge must address it.
///
/// Set by `swarm-controller.nix` **only when this host actually serves the
@ -938,6 +980,8 @@ async fn main() -> Result<()> {
}
};
let config_prs = forge_client.clone().map(config_pr::spawn);
register_swarm_webhooks(forge_client, webhook_secret.clone());
let state = AppState {
@ -946,6 +990,7 @@ async fn main() -> Result<()> {
status,
jobq,
webhook_secret,
config_prs,
swarm_name: load_swarm_name().map(Arc::from),
};
@ -957,6 +1002,7 @@ async fn main() -> Result<()> {
.routes(routes!(get_swarm_info))
.routes(routes!(get_jobq_graph))
.routes(routes!(get_jobq_rollup))
.routes(routes!(get_agent_config_pr))
.routes(routes!(create_agent))
.routes(routes!(webhook::post_webhook_forge))
.split_for_parts();
@ -1063,6 +1109,7 @@ mod tests {
status: None,
jobq: std::sync::Arc::clone(&sched),
webhook_secret: None,
config_prs: None,
swarm_name: None,
};
(state, sched)