diff --git a/swarm-controller/src/config_pr.rs b/swarm-controller/src/config_pr.rs index a94c7da7..8ae79cc8 100644 --- a/swarm-controller/src/config_pr.rs +++ b/swarm-controller/src/config_pr.rs @@ -93,6 +93,18 @@ impl ConfigPrCache { .cloned() } + /// Every agent with an open PR, per the last successful scan (plus any + /// webhook upserts since). The bulk counterpart to [`Self::get`] — for + /// `GET /api/agents/config-prs` (swarm-ui's config-PR table), which needs + /// every agent's status in one round trip rather than one request per + /// agent. + pub fn snapshot(&self) -> HashMap { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + /// ⚠️ Can race [`Self::apply_webhook_delivery`]: a scan started before a /// PR opened may finish *after* the webhook already upserted it, and /// this snapshot — taken before that PR existed — will overwrite the @@ -221,6 +233,21 @@ mod tests { assert_eq!(status.pr_number, 6); } + #[test] + fn snapshot_returns_every_agent_with_an_open_pr() { + let cache = ConfigPrCache::new(); + assert!( + cache.snapshot().is_empty(), + "an unpopulated cache snapshots empty, not missing" + ); + cache.apply_webhook_delivery(&payload("damocles", 5, "open")); + cache.apply_webhook_delivery(&payload("iris", 9, "open")); + let snapshot = cache.snapshot(); + assert_eq!(snapshot.len(), 2); + assert_eq!(snapshot["damocles"].pr_number, 5); + assert_eq!(snapshot["iris"].pr_number, 9); + } + #[test] fn a_malformed_payload_leaves_the_cache_unchanged() { let cache = ConfigPrCache::new(); diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index a9a578aa..57ed689f 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -804,6 +804,35 @@ async fn get_agent_config_pr( Ok(Json(cache.get(&name))) } +/// Every agent with an open config PR, in one response — the bulk +/// counterpart to [`get_agent_config_pr`]. swarm-ui's config-PR table needs +/// every agent's status to render, and fetching them one at a time doesn't +/// scale and isn't how the rest of swarm-ui's single-fetch pages (jobq, +/// hives status) work. +/// +/// Only agents with a currently-open PR are present — same "absence is the +/// answer" shape [`get_agent_config_pr`]'s `null` uses, just at map-entry +/// granularity instead of per-request. +#[utoipa::path( + get, + path = "/api/agents/config-prs", + responses( + (status = 200, description = "agent name -> open config PR, only agents with one present", body = std::collections::HashMap), + (status = 503, description = "no forge is configured on this host", body = String), + ), + tag = "agents" +)] +async fn get_agent_config_prs( + State(state): State, +) -> Result>, 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.snapshot())) +} + /// 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 @@ -1005,6 +1034,7 @@ async fn main() -> Result<()> { .routes(routes!(get_jobq_graph)) .routes(routes!(get_jobq_rollup)) .routes(routes!(get_agent_config_pr)) + .routes(routes!(get_agent_config_prs)) .routes(routes!(create_agent)) .routes(routes!(webhook::post_webhook_forge)) .split_for_parts();