From 9067ba52a94e4eab0d1c6a6f1d92373b0521c63b Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 19 Aug 2026 21:51:55 +0200 Subject: [PATCH 1/3] swarm-controller: bulk-read endpoint for agent config-PR status --- swarm-controller/src/config_pr.rs | 27 +++++++++++++++++++++++++++ swarm-controller/src/main.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) 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(); From baac3ef023f7b388e327272e47807707fd0c313b Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 19 Aug 2026 22:23:45 +0200 Subject: [PATCH 2/3] swarm-controller: move bulk config-PR read off /api/agents to avoid a name clash --- swarm-controller/src/main.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 57ed689f..0e939810 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -810,19 +810,27 @@ async fn get_agent_config_pr( /// scale and isn't how the rest of swarm-ui's single-fetch pages (jobq, /// hives status) work. /// +/// Deliberately **not** `/api/agents/config-prs`: agent names are +/// user-specified (any string `hive_types::Ident` accepts), so a literal +/// path segment sitting where a `{name}` capture could plausibly also want +/// to live is a real, not theoretical, clash risk the moment someone names +/// an agent `config-prs` — per mara's review call, closed off by +/// construction rather than relying on axum's static-route-priority +/// tiebreak to paper over it. +/// /// 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", + path = "/api/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( +async fn get_config_prs( State(state): State, ) -> Result>, StatusUnavailable> { let Some(cache) = state.config_prs.as_ref() else { @@ -1034,7 +1042,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!(get_config_prs)) .routes(routes!(create_agent)) .routes(routes!(webhook::post_webhook_forge)) .split_for_parts(); From 775ab285b75291f7aa9a4c374b75295367513373 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 19 Aug 2026 22:27:12 +0200 Subject: [PATCH 3/3] swarm-controller: fix stale route reference in snapshot's doc comment --- swarm-controller/src/config_pr.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/swarm-controller/src/config_pr.rs b/swarm-controller/src/config_pr.rs index 8ae79cc8..913b3607 100644 --- a/swarm-controller/src/config_pr.rs +++ b/swarm-controller/src/config_pr.rs @@ -95,9 +95,8 @@ impl ConfigPrCache { /// 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. + /// `GET /api/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()