Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller: split main.rs into jobq, routes and startup modules

no behaviour change: a pure move of items and tests into new modules, plus
visibility edits (pub(crate)) and path updates in sibling modules that named
crate-root items. same routes, same OpenAPI, 561 tests before and after.

why: #5470, new features stop landing in one 6.4k-line file by default.

files outside swarm-controller/src: docs/agent-lifecycle/agent-roster.md,
swarm-ui JobsPage.tsx and AgentsPage.tsx, nix/host-modules/swarm-controller.nix
and swarm-grafana.nix hold comments that named main.rs for items that moved.
This commit is contained in:
atlas 2026-10-18 04:53:59 +02:00
commit 738dd1c95b
42 changed files with 6854 additions and 6680 deletions

View file

@ -9,8 +9,8 @@ use problem_details::ProblemDetails;
use swarm_queue_client::agent_archetype::{self, ArchetypeRef};
use swarm_queue_client::archetypes::SourceRecord;
use crate::routes::{AppState, error_problem};
use crate::schedules::{queue, read_or_default, write_revision};
use crate::{AppState, error_problem};
/// How many times a losing writer re-reads and re-applies, as in
/// `wanted::MAX_ATTEMPTS`.

View file

@ -22,7 +22,7 @@ use serde::Serialize;
use swarm_queue_client::{AgentControl, AgentControlReply};
use utoipa::ToSchema;
use crate::{AppState, error_problem};
use crate::routes::{AppState, error_problem};
/// What became of one command.
#[derive(Debug, PartialEq, Eq)]

View file

@ -31,7 +31,7 @@ use serde::{Deserialize, Serialize};
use swarm_queue_client::AgentInput;
use utoipa::ToSchema;
use crate::{AppState, error_problem};
use crate::routes::{AppState, error_problem};
/// The header the gateway names the session's user in.
const SENDER_HEADER: &str = "remote-user";

View file

@ -19,8 +19,8 @@ use serde::{Deserialize, Serialize};
use swarm_queue_client::agent_limits::{self, AgentCaps};
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem, wanted_error_status};
use crate::schedules::{live, queue, read_or_default, refused, write_revision};
use crate::{AppState, error_problem, wanted_error_status};
/// How many times a losing writer re-reads and re-applies, as in
/// `wanted::MAX_ATTEMPTS`.
@ -478,9 +478,9 @@ pub(crate) mod tests {
}
/// One hive, `pr1ma`, on `client`.
pub(crate) fn state(client: &async_nats::Client) -> crate::AppState {
crate::AppState {
hives: Arc::new(vec![crate::HiveEntry {
pub(crate) fn state(client: &async_nats::Client) -> crate::routes::AppState {
crate::routes::AppState {
hives: Arc::new(vec![crate::routes::hives::HiveEntry {
name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),
@ -516,7 +516,7 @@ pub(crate) mod tests {
}
/// A state on a real server whose `pr1ma` declares `atlas`.
pub(crate) async fn declared(client: &async_nats::Client) -> crate::AppState {
pub(crate) async fn declared(client: &async_nats::Client) -> crate::routes::AppState {
WantedWriter::new(client.clone())
.set("pr1ma", "atlas", AgentState::Up)
.await
@ -532,7 +532,7 @@ pub(crate) mod tests {
}
async fn put(
state: &crate::AppState,
state: &crate::routes::AppState,
agent: &str,
value: Caps,
) -> Result<axum::Json<Caps>, problem_details::ProblemDetails> {
@ -544,7 +544,7 @@ pub(crate) mod tests {
.await
}
async fn get(state: &crate::AppState, agent: &str) -> Caps {
async fn get(state: &crate::routes::AppState, agent: &str) -> Caps {
get_agent_limits(State(state.clone()), Path(agent.to_owned()))
.await
.expect("caps are read")
@ -662,7 +662,7 @@ pub(crate) mod tests {
let _ = put(&state, "atlas", caps(Some("400%"), Some("8G")))
.await
.expect("cap atlas");
crate::build_app(state)
crate::startup::build_app(state)
}
async fn put_raw(app: &axum::Router, body: &'static str) -> StatusCode {

View file

@ -23,9 +23,9 @@ use serde::{Deserialize, Serialize};
use swarm_queue_client::agent_permissions::{self, AgentPermissions, Import, ImportReply};
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem, wanted_error_status};
use crate::schedules::{live, queue, read_or_default, refused, write, write_revision};
use crate::wanted::WantedWriter;
use crate::{AppState, error_problem, wanted_error_status};
/// How many times a losing writer re-reads and re-applies, as in
/// `wanted::MAX_ATTEMPTS`.
@ -549,7 +549,7 @@ mod tests {
}
async fn put(
state: &crate::AppState,
state: &crate::routes::AppState,
agent: &str,
value: Permissions,
) -> Result<axum::Json<Permissions>, problem_details::ProblemDetails> {
@ -561,7 +561,7 @@ mod tests {
.await
}
async fn get(state: &crate::AppState, agent: &str) -> Permissions {
async fn get(state: &crate::routes::AppState, agent: &str) -> Permissions {
get_agent_permissions(State(state.clone()), Path(agent.to_owned()))
.await
.expect("read")
@ -641,7 +641,7 @@ mod tests {
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
let _ = put(&state, "atlas", granted()).await.expect("set");
let app = crate::build_app(state);
let app = crate::startup::build_app(state);
for (body, status) in [
(
@ -803,9 +803,9 @@ mod tests {
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
let create = |name: &str, permissions| {
crate::create_agent(
crate::routes::agents::create_agent(
State(state.clone()),
axum::Json(crate::CreateAgentRequest {
axum::Json(crate::routes::agents::CreateAgentRequest {
name: name.to_owned(),
hive: "pr1ma".to_owned(),
limits: None,
@ -855,18 +855,18 @@ mod tests {
let state = crate::agent_limits::tests::declared(&client).await;
let _ = put(&state, "atlas", granted()).await.expect("set");
let _ = crate::set_agent_state(
let _ = crate::routes::agents::set_agent_state(
State(state.clone()),
Path(("pr1ma".to_owned(), "atlas".to_owned())),
axum::Json(crate::SetAgentStateRequest {
axum::Json(crate::routes::agents::SetAgentStateRequest {
state: swarm_queue_client::wanted::AgentState::Destroyed,
}),
)
.await
.expect("destroyed");
let _ = crate::create_agent(
let _ = crate::routes::agents::create_agent(
State(state.clone()),
axum::Json(crate::CreateAgentRequest {
axum::Json(crate::routes::agents::CreateAgentRequest {
name: "atlas".to_owned(),
hive: "pr1ma".to_owned(),
limits: None,

View file

@ -29,7 +29,7 @@ use axum::extract::{Path, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::{Stream, StreamExt as _};
use crate::AppState;
use crate::routes::AppState;
#[utoipa::path(
get,
@ -60,19 +60,21 @@ pub(crate) async fn stream_agent_state(
Path(agent): Path<String>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, problem_details::ProblemDetails> {
let Some(status) = state.status.as_ref() else {
return Err(crate::error_problem(
return Err(crate::routes::error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"no swarm queue is configured on this host",
));
};
let Some(agent_status) = state.agent_status.as_ref() else {
return Err(crate::error_problem(
return Err(crate::routes::error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"no agent-status reader is configured on this host",
));
};
let agent = hive_types::Ident::parse(&agent)
.map_err(|reason| crate::error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.map_err(|reason| {
crate::routes::error_problem(axum::http::StatusCode::BAD_REQUEST, reason)
})?
.into_string();
// One-agent "roster": reuses `AgentStatusReader::view`'s already-tested
@ -84,12 +86,12 @@ pub(crate) async fn stream_agent_state(
.map_err(|e| {
let detail = format!("{e:#}");
tracing::warn!(%agent, error = %detail, "state stream: reading agent status failed");
crate::error_problem(axum::http::StatusCode::INTERNAL_SERVER_ERROR, &detail)
crate::routes::error_problem(axum::http::StatusCode::INTERNAL_SERVER_ERROR, &detail)
})?
.into_iter()
.find_map(|row| row.hive)
.ok_or_else(|| {
crate::error_problem(
crate::routes::error_problem(
axum::http::StatusCode::NOT_FOUND,
&format!("{agent:?} has never reported to the swarm"),
)
@ -101,7 +103,7 @@ pub(crate) async fn stream_agent_state(
// against a queue that is not there — the honest answer is 503, the
// same shape every other queue-backed route here already uses.
swarm_queue_client::ensure_connected(&client).map_err(|e| {
crate::error_problem(
crate::routes::error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
&swarm_queue_client::chain(&e),
)

View file

@ -20,7 +20,7 @@ use swarm_queue_client::AgentControl;
use swarm_queue_client::agents_md::{Apply, ApplyReply, Mirror};
use crate::agent_control::{ControlSink, Outcome};
use crate::{AppState, error_problem, wanted_error_status};
use crate::routes::{AppState, error_problem, wanted_error_status};
/// Reads the `agents-md` bucket, the way [`crate::agent_icon::AgentIconReader`]
/// reads the icon bucket, and creates it: the agents writing it cannot.

View file

@ -23,7 +23,7 @@ use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::{AppState, error_problem};
use crate::routes::{AppState, error_problem};
/// The env var `swarm-controller.nix` sets.
const URL_ENV: &str = "SWARM_CONTROLLER_GRAFANA_URL";
@ -282,12 +282,12 @@ mod tests {
}
async fn get_alerts_with(grafana: Option<Grafana>) -> (StatusCode, serde_json::Value) {
let (mut state, _) = crate::tests::state_with_roster();
let (mut state, _) = crate::routes::tests::state_with_roster();
state.alerts = grafana.map(Arc::new);
let req = Request::get("/api/alerts")
.body(Body::empty())
.expect("a request");
let res = crate::build_app(state)
let res = crate::startup::build_app(state)
.oneshot(req)
.await
.expect("infallible");

View file

@ -21,8 +21,9 @@ use swarm_wire::builder::{
};
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem};
use crate::schedules::{queue, read_or_default, refused, write};
use crate::{AppState, agent_limits, agent_permissions, error_problem, inference};
use crate::{agent_limits, agent_permissions, inference};
/// How often the builder is asked. Its source only changes when its host is
/// redeployed, so this bounds how long a redeploy takes to reach the create

View file

@ -18,8 +18,8 @@ use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem};
use crate::schedules::{read_with, write};
use crate::{AppState, error_problem};
/// The bucket holding the builds. Agents hold no grant on it.
pub const BUCKET: &str = "builder-runs";
@ -449,9 +449,9 @@ pub(crate) mod tests {
push(&*doc, &record("b")).await.expect("stored");
let get_at = |builds: Builds, path: &'static str| async move {
let (mut state, _) = crate::tests::state_with_roster();
let (mut state, _) = crate::routes::tests::state_with_roster();
state.builds = builds;
let res = crate::build_app(state)
let res = crate::startup::build_app(state)
.oneshot(Request::get(path).body(Body::empty()).expect("a request"))
.await
.expect("infallible");

View file

@ -24,7 +24,7 @@ use serde::{Deserialize, Serialize};
use swarm_wire::claude_login::{REPLY_DEADLINE, Reply, Request, SUBJECT};
use utoipa::ToSchema;
use crate::{AppState, error_problem};
use crate::routes::{AppState, error_problem};
/// The header the gateway names the session's user in.
const OPERATOR_HEADER: &str = "remote-user";

View file

@ -13,7 +13,8 @@ use swarm_secret_client::forge;
use utoipa::ToSchema;
use super::linked_accounts::link;
use super::{AppState, error_problem, swarm_hive};
use crate::routes::agents::swarm_hive;
use crate::routes::{AppState, error_problem};
/// The account to store for one agent's external forge.
///
@ -141,9 +142,9 @@ mod tests {
}
/// Bare-minimum `AppState`, as `matrix_account`'s tests build it.
fn state(store: &Arc<FakeStore>) -> super::super::AppState {
super::super::AppState {
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
fn state(store: &Arc<FakeStore>) -> crate::routes::AppState {
crate::routes::AppState {
hives: std::sync::Arc::new(vec![crate::routes::hives::HiveEntry {
name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),

View file

@ -13,7 +13,8 @@ use swarm_secret_client::github;
use utoipa::ToSchema;
use super::linked_accounts::link;
use super::{AppState, error_problem, swarm_hive};
use crate::routes::agents::swarm_hive;
use crate::routes::{AppState, error_problem};
/// The token to store for one agent.
///
@ -111,9 +112,9 @@ mod tests {
}
/// Bare-minimum `AppState`, as `forge_account`'s tests build it.
fn state(store: &Arc<FakeStore>) -> super::super::AppState {
super::super::AppState {
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
fn state(store: &Arc<FakeStore>) -> crate::routes::AppState {
crate::routes::AppState {
hives: std::sync::Arc::new(vec![crate::routes::hives::HiveEntry {
name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),

View file

@ -26,8 +26,8 @@ use serde::{Deserialize, Serialize};
use swarm_queue_client::agent_inference::{self, AgentInference, Effective, Effort, Source};
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem, wanted_error_status};
use crate::schedules::{live, queue, read_or_default, refused, write_revision};
use crate::{AppState, error_problem, wanted_error_status};
/// How many times a losing writer re-reads and re-applies, as in
/// `wanted::MAX_ATTEMPTS`.

View file

@ -12,8 +12,8 @@ use axum::{
extract::{Path, State},
};
use super::{AppState, StatusUnavailable, error_problem};
use crate::forge;
use crate::routes::{AppState, StatusUnavailable, error_problem};
/// The 503 detail when the forge did not answer within
/// [`forge::FORGE_DEADLINE`].
@ -21,7 +21,7 @@ const DEADLINE_DETAIL: &str = "the forge did not answer within the deadline";
/// Repos with at least one open issue, as `owner/name` full names — the
/// data source for swarm-ui's repo-filter dropdown. Not scoped to
/// `forge::CONFIG_ORG` the way `main.rs`'s `get_config_prs` is: this
/// `forge::CONFIG_ORG` the way `routes/config_prs.rs`'s `get_config_prs` is: this
/// report deliberately covers the whole forge instance, not one org, since
/// the report itself is a general-purpose browsing tool rather than
/// something agent-config-specific.
@ -156,7 +156,7 @@ pub async fn get_issue_report(
///
/// `Client::issue_report` propagates through `anyhow`, so the concrete
/// [`forgejo_api::ForgejoError`] survives underneath but is not the type in
/// hand — downcasting back to it, same pattern `main.rs`'s
/// hand — downcasting back to it, same pattern `routes`'s
/// `wanted_error_status` uses for `swarm_queue_client::Error`. A 404 there
/// (`forge::is_repo_not_found`) means the repo itself doesn't exist;
/// anything else — including a 404 on some *other* call this function
@ -257,7 +257,7 @@ mod tests {
"/api/issue-report",
"/api/repos/o/r/issue-report",
] {
let (state, _) = crate::tests::state_with_roster();
let (state, _) = crate::routes::tests::state_with_roster();
assert!(
state
.forge
@ -268,7 +268,7 @@ mod tests {
);
let start = tokio::time::Instant::now();
let req = Request::get(uri).body(Body::empty()).expect("a request");
let res = crate::build_app(state)
let res = crate::startup::build_app(state)
.oneshot(req)
.await
.expect("infallible");

2398
swarm-controller/src/jobq.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -29,7 +29,8 @@ use serde::{Deserialize, Serialize};
use swarm_secret_client::{Error, SecretStore, forge, github, matrix};
use utoipa::{IntoParams, ToSchema};
use super::{AppState, error_problem, swarm_hive};
use crate::routes::agents::swarm_hive;
use crate::routes::{AppState, error_problem};
/// The host shown for a GitHub token. The store keeps none: a token is only
/// ever used against github.com.
@ -950,9 +951,9 @@ pub(crate) mod tests {
}
/// Bare-minimum `AppState`, as `matrix_account`'s tests build it.
fn state(store: &Arc<FakeStore>) -> super::super::AppState {
super::super::AppState {
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
fn state(store: &Arc<FakeStore>) -> crate::routes::AppState {
crate::routes::AppState {
hives: std::sync::Arc::new(vec![crate::routes::hives::HiveEntry {
name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),

File diff suppressed because it is too large Load diff

View file

@ -34,7 +34,8 @@ use swarm_secret_client::matrix;
use utoipa::ToSchema;
use super::linked_accounts::{AccountStore, Linking, link, refuse_linked};
use super::{AppState, error_problem, swarm_hive};
use crate::routes::agents::swarm_hive;
use crate::routes::{AppState, error_problem};
pub mod agent_token;
pub mod hive_sender;
@ -445,9 +446,9 @@ mod tests {
/// Bare-minimum `AppState` for a handler test: one hive, nothing
/// queue-backed, an empty in-memory job graph the endpoint under test
/// never touches, and `store` as the account store.
fn state(store: &Arc<FakeStore>) -> super::super::AppState {
super::super::AppState {
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
fn state(store: &Arc<FakeStore>) -> crate::routes::AppState {
crate::routes::AppState {
hives: std::sync::Arc::new(vec![crate::routes::hives::HiveEntry {
name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),

View file

@ -15,7 +15,7 @@ use utoipa::IntoParams;
use crate::forge::{self, OpenPull};
use crate::issue_report::issue_report_error_status;
use crate::review_classifier::split_repo;
use crate::{AppState, error_problem};
use crate::routes::{AppState, error_problem};
/// Whose open pull requests to list.
#[derive(Debug, Deserialize, IntoParams)]
@ -117,9 +117,9 @@ mod tests {
use tower::ServiceExt as _;
async fn get(uri: &str) -> (StatusCode, serde_json::Value) {
let (state, _) = crate::tests::state_with_roster();
let (state, _) = crate::routes::tests::state_with_roster();
let req = Request::get(uri).body(Body::empty()).expect("a request");
let res = crate::build_app(state)
let res = crate::startup::build_app(state)
.oneshot(req)
.await
.expect("infallible");

View file

@ -38,7 +38,7 @@ use utoipa::{IntoParams, ToSchema};
use self::settings::{Settings, SettingsSource};
use crate::forge::{self, DefaultBranchFiles, PullForReview};
use crate::{AppState, error_problem};
use crate::routes::{AppState, error_problem};
pub(crate) mod open_pr;
pub(crate) mod review;
@ -715,7 +715,7 @@ async fn start(
fn settings_problem(e: &anyhow::Error) -> ProblemDetails {
let detail = format!("{e:#}");
tracing::warn!(error = %detail, "reading the review classifier's settings for a try-out failed");
error_problem(crate::wanted_error_status(e), &detail)
error_problem(crate::routes::wanted_error_status(e), &detail)
}
/// 404 when the forge knows no such repo or PR, 503 for anything else.
@ -915,9 +915,9 @@ pub(crate) mod tests {
}
fn app_with_files(reply: Reply, files: Vec<(&'static str, String)>) -> axum::Router {
let (mut state, _) = crate::tests::state_with_roster();
let (mut state, _) = crate::routes::tests::state_with_roster();
state.try_outs.fake = Some(Arc::new(Fake::new(reply, files)));
crate::build_app(state)
crate::startup::build_app(state)
}
fn body(repo: &str, pr: u64) -> String {
@ -1001,7 +1001,7 @@ pub(crate) mod tests {
cache_write: None,
},
};
let (mut state, _) = crate::tests::state_with_roster();
let (mut state, _) = crate::routes::tests::state_with_roster();
let fake = Arc::new(Fake {
settings: Settings {
model: "opus".to_owned(),
@ -1013,7 +1013,7 @@ pub(crate) mod tests {
..Fake::new(done(), Vec::new())
});
state.try_outs.fake = Some(Arc::clone(&fake));
let app = crate::build_app(state);
let app = crate::startup::build_app(state);
let (status, accepted) = post(&app, body("o/r", 7)).await;
assert_eq!(status, StatusCode::ACCEPTED, "{accepted}");
settled(&app, accepted["id"].as_str().expect("an id")).await;
@ -1051,10 +1051,10 @@ pub(crate) mod tests {
(failed, runs::RunOutcome::Failed, Some("no API key")),
] {
let doc = Arc::new(runs::tests::FakeDoc::default());
let (mut state, _) = crate::tests::state_with_roster();
let (mut state, _) = crate::routes::tests::state_with_roster();
state.try_outs.fake = Some(Arc::new(Fake::new(reply.clone(), Vec::new())));
state.runs = runs::Runs::Fake(Arc::clone(&doc));
let app = crate::build_app(state);
let app = crate::startup::build_app(state);
let (_, accepted) = post(&app, body("o/r", 7)).await;
let id = accepted["id"].as_str().expect("an id");
settled(&app, id).await;
@ -1481,8 +1481,8 @@ classifications:
#[tokio::test]
async fn rules_without_forge_access_on_this_host_are_503() {
let (state, _) = crate::tests::state_with_roster();
let (status, problem) = get_rules(&crate::build_app(state), "?repo=o/r").await;
let (state, _) = crate::routes::tests::state_with_roster();
let (status, problem) = get_rules(&crate::startup::build_app(state), "?repo=o/r").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{problem}");
assert_eq!(problem["detail"], "no forge access on this host yet");
}

View file

@ -32,7 +32,7 @@ use utoipa::ToSchema;
use super::{FileSource, RULES_PATHS, check_classifications, operator, parse_rules, split_repo};
use crate::forge::classifier_account::{BranchCommit, CommitAuthor, LOGIN};
use crate::forge::{self, BranchFile};
use crate::{AppState, error_problem};
use crate::routes::{AppState, error_problem};
/// Where a branch's name starts, so the forge's branch list groups them.
const BRANCH_PREFIX: &str = "review-classifier/";
@ -676,9 +676,9 @@ classifications:
}
fn app(fake: Fake) -> axum::Router {
let (mut state, _) = crate::tests::state_with_roster();
let (mut state, _) = crate::routes::tests::state_with_roster();
state.try_outs.fake = Some(Arc::new(fake));
crate::build_app(state)
crate::startup::build_app(state)
}
fn fake() -> Fake {

View file

@ -33,10 +33,10 @@ use tokio::task::JoinHandle;
use super::runs::{self, RunOutcome, RunRecord, Runs, Trigger};
use super::settings::{Settings, SettingsSource};
use super::{FileSource, PullSource, RunSink, split_repo};
use crate::AppState;
use crate::agent_renewal::unix_now;
use crate::forge::pull_diff::CONTEXT_LINES;
use crate::forge::{self, classifier_account::LOGIN, classifier_account::Review};
use crate::routes::AppState;
/// The most review runs waiting for a slot.
const MAX_WAITING_REVIEWS: usize = 8;

View file

@ -23,8 +23,8 @@ use serde::{Deserialize, Serialize};
use swarm_queue_client::review_classifier::{Overall, Reply, Row};
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem};
use crate::schedules::{read_with, write};
use crate::{AppState, error_problem};
/// The bucket holding the runs. Agents hold no grant on it.
pub const BUCKET: &str = "review-classifier-runs";
@ -593,9 +593,9 @@ pub(crate) mod tests {
push(&*doc, &reviewed).await.expect("stored");
let get = |runs: Runs| async move {
let (mut state, _) = crate::tests::state_with_roster();
let (mut state, _) = crate::routes::tests::state_with_roster();
state.runs = runs;
let res = crate::build_app(state)
let res = crate::startup::build_app(state)
.oneshot(
Request::get("/api/review-classifier/runs")
.body(Body::empty())

View file

@ -24,8 +24,8 @@ use swarm_queue_client::agent_inference::Effort;
use swarm_queue_client::review_classifier::{Acp, Runtime};
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem, wanted_error_status};
use crate::schedules::{queue, read_with, refused, write};
use crate::{AppState, error_problem, wanted_error_status};
/// The bucket holding the settings. No other service holds a grant on it.
pub const BUCKET: &str = "review-classifier-settings";
@ -598,12 +598,12 @@ mod tests {
/// An app whose queue is `nats`.
async fn app(nats: &TestNats) -> (axum::Router, AppState) {
let (mut state, _) = crate::tests::state_with_roster();
let (mut state, _) = crate::routes::tests::state_with_roster();
state.status = Some(std::sync::Arc::new(crate::status::StatusReader::new(
nats.client().await,
std::time::Duration::from_mins(1),
)));
(crate::build_app(state.clone()), state)
(crate::startup::build_app(state.clone()), state)
}
async fn send(
@ -777,21 +777,21 @@ mod tests {
#[tokio::test]
async fn a_put_without_an_operator_is_401() {
let (state, _) = crate::tests::state_with_roster();
let (state, _) = crate::routes::tests::state_with_roster();
let req = Request::put("/api/review-classifier/settings")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&Settings::default()).unwrap(),
))
.expect("a request");
let (status, _, _) = send(&crate::build_app(state), req).await;
let (status, _, _) = send(&crate::startup::build_app(state), req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn without_a_queue_both_verbs_are_503() {
let (state, _) = crate::tests::state_with_roster();
let app = crate::build_app(state);
let (state, _) = crate::routes::tests::state_with_roster();
let app = crate::startup::build_app(state);
let (status, _, _) = send(&app, get()).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
let body = serde_json::to_string(&Settings::default()).unwrap();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,81 @@
//! Agents' open config PRs.
use anyhow::Result;
use axum::{
Json,
extract::{Path, State},
};
use crate::forge;
use crate::routes::{AppState, StatusUnavailable};
/// `agent`'s open config PR, from [`ConfigPrCache`](crate::config_pr::ConfigPrCache) — kept
/// current by a webhook nudge (near-real-time) backstopped by a periodic
/// scan (worst case: up to one `config_pr::POLL_INTERVAL` stale if a
/// delivery was ever missed). Not a live forge read either way, so this
/// answers even when the forge itself is momentarily unreachable.
///
/// `200` with a `null` body means "no open PR" (or "no scan has completed
/// yet") — not distinguished, for the same reason
/// [`ConfigPrCache::get`](crate::config_pr::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"
)]
pub(crate) 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.get() else {
return Err(StatusUnavailable(
"no forge is configured on this host".to_owned(),
));
};
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.
///
/// 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/config-prs",
responses(
(status = 200, description = "agent name -> open config PR, only agents with one present", body = std::collections::HashMap<String, forge::ConfigPrStatus>),
(status = 503, description = "no forge is configured on this host", body = String),
),
tag = "agents"
)]
pub(crate) async fn get_config_prs(
State(state): State<AppState>,
) -> Result<Json<std::collections::HashMap<String, forge::ConfigPrStatus>>, StatusUnavailable> {
let Some(cache) = state.config_prs.get() else {
return Err(StatusUnavailable(
"no forge is configured on this host".to_owned(),
));
};
Ok(Json(cache.snapshot()))
}

View file

@ -0,0 +1,236 @@
//! Forge site admins and an agent's forge token, on request.
use anyhow::Result;
use axum::{
Json,
extract::{Path, State},
};
use swarm_controller_api::agents::MintAgentForgeTokenResponse;
use swarm_controller_api::forge::{ForgeAdminChange, MakeForgeAdminResponse};
use crate::forge;
use crate::jobq::queue_forge_token_mints;
use crate::routes::agents::in_roster;
use crate::routes::{AppState, error_problem};
/// Check an agent's forge token now, and mint one if it is missing or stale.
///
/// The periodic pass (`forge::agent_token::spawn`) does the same every five
/// minutes for every agent with a store identity; this is for an operator who
/// does not want to wait, or for an agent the pass skipped. Queues the same
/// node agent creation does. Idempotent: a current token is left alone.
#[utoipa::path(
post,
path = "/api/agents/{name}/forge-token",
params(("name" = String, Path, description = "agent name")),
responses(
(status = 200, description = "mint queued", body = MintAgentForgeTokenResponse),
(status = 400, description = "`name` is not a valid identifier (problem+json)", body = String),
(status = 500, description = "the job could not be queued (problem+json)", body = String),
),
tag = "agents"
)]
pub(crate) async fn mint_agent_forge_token(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<MintAgentForgeTokenResponse>, problem_details::ProblemDetails> {
let agent = hive_types::Ident::parse(&name)
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string();
let ids = queue_forge_token_mints(&state.jobq, vec![agent]).map_err(|e| {
error_problem(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
&e.to_string(),
)
})?;
let [id] = ids[..] else {
unreachable!("exactly one handle was asked for");
};
Ok(Json(MintAgentForgeTokenResponse { node_id: id.get() }))
}
/// Make an existing human forge account a site admin. Idempotent.
///
/// Never creates the account: a human's is made by their first SSO login to
/// the forge, and until then this answers 404. Refuses an agent's name: a
/// site admin can create repos whatever its `max_repo_creation`, and an
/// agent's account is locked to 0 so its token cannot create a repo and
/// self-merge in it.
#[utoipa::path(
post,
path = "/api/forge/users/{name}/admin",
params(("name" = String, Path, description = "forge username")),
responses(
(status = 200, description = "the account is a site admin", body = MakeForgeAdminResponse),
(status = 400, description = "`name` is not a valid identifier (problem+json)", body = String),
(status = 404, description = "no such account: the user has not logged in via SSO yet (problem+json)", body = String),
(status = 409, description = "`name` is an agent (problem+json)", body = String),
(status = 500, description = "the forge refused the read or the edit (problem+json)", body = String),
(status = 503, description = "no forge configured, or the agent roster could not be read (problem+json)", body = String),
),
tag = "forge"
)]
pub(crate) async fn make_forge_admin(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<MakeForgeAdminResponse>, problem_details::ProblemDetails> {
let name = hive_types::Ident::parse(&name)
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string();
let Some(forge) = state.forge.get().cloned() else {
return Err(error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"no forge is configured on this host",
));
};
let is_agent = in_roster(state.auth.as_deref(), &name).await;
admin_unless_agent(&name, is_agent, || async {
forge.make_site_admin(&name).await
})
.await
}
/// Run `promote` only if `name` is known not to be an agent, and answer with
/// what it found.
///
/// An unreadable roster refuses too: a site admin is the one thing an agent's
/// account must never become, so "could not tell" is not "no".
pub(crate) async fn admin_unless_agent<F, Fut>(
name: &str,
is_agent: Result<bool, String>,
promote: F,
) -> Result<Json<MakeForgeAdminResponse>, problem_details::ProblemDetails>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<forge::site_admin::Plan>>,
{
match is_agent {
Ok(false) => {}
Ok(true) => {
return Err(error_problem(
axum::http::StatusCode::CONFLICT,
&format!("{name:?} is an agent; an agent's forge account is never a site admin"),
));
}
Err(e) => {
return Err(error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
&format!("cannot tell whether {name:?} is an agent, so refusing: {e}"),
));
}
}
let plan = promote().await.map_err(|e| {
error_problem(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
&format!("{e:#}"),
)
})?;
let change = match plan {
forge::site_admin::Plan::NoSuchUser => {
return Err(error_problem(
axum::http::StatusCode::NOT_FOUND,
&format!(
"the forge has no user {name:?}: the user has not logged in via SSO yet. \
The forge creates the account on their first authelia login; run this \
again after it"
),
));
}
forge::site_admin::Plan::AlreadyAdmin => ForgeAdminChange::AlreadyAdmin,
forge::site_admin::Plan::Promote => ForgeAdminChange::Promoted,
};
Ok(Json(MakeForgeAdminResponse { change }))
}
#[cfg(test)]
mod tests {
use super::*;
/// `admin_unless_agent` with a `promote` that records whether it ran and
/// answers `plan`.
async fn forge_admin_route(
is_agent: Result<bool, String>,
plan: crate::forge::site_admin::Plan,
) -> (
Result<axum::Json<super::MakeForgeAdminResponse>, problem_details::ProblemDetails>,
bool,
) {
let ran = std::sync::atomic::AtomicBool::new(false);
let result = super::admin_unless_agent("atlas", is_agent, || async {
ran.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(plan)
})
.await;
(result, ran.into_inner())
}
/// An agent's name never reaches the forge.
#[tokio::test]
async fn the_forge_admin_route_refuses_an_agent() {
let (result, ran) =
forge_admin_route(Ok(true), crate::forge::site_admin::Plan::Promote).await;
let Err(problem) = result else {
panic!("an agent's name must be refused");
};
assert!(!ran, "the account must not be touched");
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
}
/// A roster that can't be read can't vouch for the name, so the route
/// refuses rather than guessing "not an agent".
#[tokio::test]
async fn the_forge_admin_route_refuses_on_an_unreadable_roster() {
let (result, ran) = forge_admin_route(
Err("bridge down".to_owned()),
crate::forge::site_admin::Plan::Promote,
)
.await;
let Err(problem) = result else {
panic!("an unreadable roster must refuse");
};
assert!(!ran, "the account must not be touched");
assert_eq!(
problem.status,
Some(axum::http::StatusCode::SERVICE_UNAVAILABLE)
);
}
/// A missing account is a 404 that tells the operator what to wait for.
#[tokio::test]
async fn the_forge_admin_route_names_the_missing_sso_login() {
let (result, ran) =
forge_admin_route(Ok(false), crate::forge::site_admin::Plan::NoSuchUser).await;
let Err(problem) = result else {
panic!("a missing account must not report success");
};
assert!(ran);
assert_eq!(problem.status, Some(axum::http::StatusCode::NOT_FOUND));
let rendered = format!("{problem:?}");
assert!(
rendered.contains("has not logged in via SSO yet"),
"{rendered}"
);
}
#[tokio::test]
async fn the_forge_admin_route_promotes_a_non_agent() {
let (result, ran) =
forge_admin_route(Ok(false), crate::forge::site_admin::Plan::Promote).await;
let Ok(axum::Json(resp)) = result else {
panic!("a non-agent must be promoted");
};
assert!(ran);
assert_eq!(resp.change, super::ForgeAdminChange::Promoted);
}
/// Idempotent: a second run succeeds and says nothing changed.
#[tokio::test]
async fn the_forge_admin_route_succeeds_for_an_admin() {
let (result, _) =
forge_admin_route(Ok(false), crate::forge::site_admin::Plan::AlreadyAdmin).await;
let Ok(axum::Json(resp)) = result else {
panic!("an existing admin must succeed");
};
assert_eq!(resp.change, super::ForgeAdminChange::AlreadyAdmin);
}
}

View file

@ -0,0 +1,126 @@
//! The swarm's hive directory, service links and display name.
use anyhow::Result;
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::routes::{AppState, StatusUnavailable};
use crate::status;
/// One hive in the swarm's directory — the same `name`/`domain` pair
/// `services.hyperhive.swarm.hives` (nix/host-modules/swarm.nix) declares,
/// plus that hive's `swarm.dashboardDomains` entry, carried across
/// unchanged rather than reshaped, so this stays a direct mirror of the
/// nix source of truth instead of a second vocabulary for the same fields.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
pub(crate) struct HiveEntry {
pub(crate) name: String,
pub(crate) domain: String,
/// The host the hive's dashboard answers on.
pub(crate) dashboard: String,
}
/// The swarm's hive directory — every hive, including whichever one this
/// controller instance happens to run on (there is no "self" to exclude
/// at the swarm level, unlike `hive-c0re`'s peer list).
#[utoipa::path(
get,
path = "/api/hives",
responses((status = 200, description = "every hive in the swarm", body = Vec<HiveEntry>)),
tag = "hives"
)]
pub(crate) async fn get_hives(State(state): State<AppState>) -> Json<Vec<HiveEntry>> {
Json((*state.hives).clone())
}
/// One quick link to a swarm-wide service (authelia, matrix, forge, this
/// daemon's own swagger UI, …). Deliberately generic rather than named
/// fields per service: each service's own nix module contributes its own
/// entry to `services.hyperhive.swarm.controller.links` (same list-merge
/// idiom `services.hyperhive.gateway.localNames` already uses), so adding
/// a new one is a nix-only change — no new field here, no swarm-ui change.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
pub(crate) struct ServiceLink {
pub(crate) label: String,
/// Emoji or short glyph. Empty string, not `Option`, when a
/// contributing module has none — one fewer null-vs-absent case for
/// the frontend to handle, and every real contributor sets one today.
pub(crate) icon: String,
pub(crate) url: String,
}
/// Quick links to swarm-wide services (authelia, matrix, forge, this
/// daemon's own swagger UI, …), as contributed by each service's own nix
/// module. Empty when nothing was configured to contribute — a caller
/// renders 0 links the same way it renders any other count, no special
/// "not configured" case.
#[utoipa::path(
get,
path = "/api/links",
responses((status = 200, description = "swarm service quick links", body = Vec<ServiceLink>)),
tag = "links"
)]
pub(crate) async fn get_links(State(state): State<AppState>) -> Json<Vec<ServiceLink>> {
Json((*state.links).clone())
}
/// Body of `GET /api/swarm`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
pub(crate) struct SwarmInfo {
/// `None` when the operator never set `services.hyperhive.swarm.name`
/// — a caller (swarm-ui's chrome) falls back to a generic label rather
/// than treating this as an error.
name: Option<String>,
}
/// The swarm's own display name, for UI chrome (page title, nav) that
/// wants to say which swarm it's showing — distinct from [`get_hives`],
/// which lists the *hives inside* the swarm, not the swarm itself.
#[utoipa::path(
get,
path = "/api/swarm",
responses((status = 200, description = "the swarm's display name, if the operator set one", body = SwarmInfo)),
tag = "hives"
)]
pub(crate) async fn get_swarm_info(State(state): State<AppState>) -> Json<SwarmInfo> {
Json(SwarmInfo {
name: state.swarm_name.as_deref().map(str::to_owned),
})
}
/// What each hive last said about itself, read from the swarm queue at
/// request time.
///
/// Every hive in the roster gets a row whether or not it has ever
/// reported — see the `status` module for why absence, not presence, is
/// the case this is built around.
#[utoipa::path(
get,
path = "/api/hives/status",
responses(
(status = 200, description = "a row per hive, freshness derived now", body = Vec<status::HiveStatus>),
(status = 503, description = "no swarm queue is configured here, or its store could not be read", body = String),
),
tag = "hives"
)]
pub(crate) async fn get_hives_status(
State(state): State<AppState>,
) -> Result<Json<Vec<status::HiveStatus>>, StatusUnavailable> {
let Some(reader) = state.status.as_ref() else {
return Err(StatusUnavailable(
"no swarm queue is configured on this host".to_owned(),
));
};
match reader
.view(&state.hives, std::time::SystemTime::now())
.await
{
Ok(rows) => Ok(Json(rows)),
Err(e) => {
let detail = format!("{e:#}");
tracing::warn!(error = %detail, "reading the swarm status bucket failed");
Err(StatusUnavailable(detail))
}
}
}

View file

@ -0,0 +1,65 @@
//! Read-only views of the swarm-level job graph.
use axum::{Json, extract::State};
use hive_jobq_wire::GraphWire as _;
use serde::Deserialize;
use crate::routes::AppState;
/// Query params for `GET /api/jobq/graph` — `?states=` narrows to root
/// groups in the named states, same shape `hive_jobq_wire::parse_states`
/// parses.
#[derive(Deserialize, utoipa::IntoParams)]
pub(crate) struct JobqGraphQuery {
states: Option<String>,
}
/// Every node of every root group in the swarm-level job graph. Nothing
/// filters "done and old" here — with zero nodes ever submitted there is
/// nothing to bound yet, so `graph.roots()` (everything) is the whole
/// roster passed to [`hive_jobq_wire::GraphWire::wire_snapshot`].
#[utoipa::path(
get,
path = "/api/jobq/graph",
params(JobqGraphQuery),
responses((status = 200, description = "every node of every root group, as generic \
`hive_jobq` graph nodes. `?states=` narrows to root groups in the named states.",
body = Vec<hive_jobq_wire::GraphNode>)),
tag = "jobq"
)]
pub(crate) async fn get_jobq_graph(
State(state): State<AppState>,
axum::extract::Query(q): axum::extract::Query<JobqGraphQuery>,
) -> Json<Vec<hive_jobq_wire::GraphNode>> {
let states = hive_jobq_wire::parse_states(q.states.as_deref());
let sched = state
.jobq
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let graph = sched.graph();
let roots: Vec<hive_jobq::NodeId> = graph.roots().map(|n| n.id).collect();
let nodes = graph.wire_snapshot(roots);
Json(hive_jobq_wire::filter_nodes_by_state(
nodes,
states.as_deref(),
))
}
/// Counts by lifecycle state over the same groups `/api/jobq/graph` serves.
#[utoipa::path(
get,
path = "/api/jobq/rollup",
responses((status = 200, description = "counts by lifecycle state", body = Vec<hive_jobq_wire::StateCount>)),
tag = "jobq"
)]
pub(crate) async fn get_jobq_rollup(
State(state): State<AppState>,
) -> Json<Vec<hive_jobq_wire::StateCount>> {
let sched = state
.jobq
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let graph = sched.graph();
let roots: Vec<hive_jobq::NodeId> = graph.roots().map(|n| n.id).collect();
Json(hive_jobq_wire::state_rollup(graph, roots))
}

View file

@ -0,0 +1,365 @@
//! The HTTP state every handler shares, the error shapes they answer with,
//! and the handlers that live outside a resource module of their own.
pub(crate) mod agents;
pub(crate) mod config_prs;
pub(crate) mod forge_admin;
pub(crate) mod hives;
pub(crate) mod jobq_graph;
use std::sync::{Arc, Mutex};
use crate::jobq::{Late, SwarmNodeKind, SwarmResourceKind};
use crate::routes::hives::{HiveEntry, ServiceLink};
use crate::{
agent_icon, agent_login, agent_status, agents_md, alerts, auth, builder, config_pr, forge,
inference, linked_accounts, review_classifier, status, wanted,
};
/// Liveness probe. Returns the build's version so an operator can tell
/// *which* controller answered without shelling onto the host.
#[utoipa::path(
get,
path = "/health",
responses((status = 200, description = "process is up, body is \"swarm-controller <version>\"", body = String)),
tag = "health"
)]
pub(crate) async fn health() -> &'static str {
concat!("swarm-controller ", env!("CARGO_PKG_VERSION"), "\n")
}
#[derive(Clone)]
pub(crate) struct AppState {
/// Loaded once at startup (`load_hives`); never mutated, so an
/// `Arc` clone per request is the whole synchronization story.
pub(crate) hives: Arc<Vec<HiveEntry>>,
/// Loaded once at startup (`load_links`); same synchronization story
/// as `hives`.
pub(crate) links: Arc<Vec<ServiceLink>>,
/// `None` when this deployment wired up no swarm queue — the only
/// state in which `/api/hives/status` cannot answer at all. A queue
/// that is merely *unreachable* still yields a reader, because
/// `async-nats` reconnects underneath it.
pub(crate) status: Option<Arc<status::StatusReader>>,
/// Publishes the agent set this swarm declares for each hive.
///
/// `None` in exactly the state `status` is: no swarm queue was wired
/// up, so there is nowhere to publish a declaration to. Shares that
/// reader's connection rather than opening a second one.
pub(crate) wanted: Option<Arc<wanted::WantedWriter>>,
/// Per-agent status, sharing `status`'s connection — same "one queue
/// connection, several consumers" rationale as `wanted` above. `None`
/// in exactly the state `status` is.
pub(crate) agent_status: Option<Arc<agent_status::AgentStatusReader>>,
/// Per-agent icons, sharing `status`'s connection — same "one queue
/// connection, several consumers" rationale as `agent_status` above,
/// and `None` in exactly the state `status` is.
pub(crate) agent_icons: Option<Arc<agent_icon::AgentIconReader>>,
/// Each agent's published `AGENTS.md`, on `status`'s connection like
/// `agent_icons`, and `None` in exactly the state `status` is.
pub(crate) agents_md: Option<Arc<agents_md::AgentsMdReader>>,
/// What each agent last reported running on, from its turn-state
/// headers on `status`'s connection. `None` in exactly the state
/// `status` is.
pub(crate) reported_inference: Option<Arc<inference::Reported>>,
/// Whether each agent last said it needs a login, from its turn-state
/// headers on `status`'s connection. `None` in exactly the state
/// `status` is.
pub(crate) agent_login: Option<Arc<agent_login::Reported>>,
/// The swarm-level job graph, wrapped in its
/// [`hive_jobq::scheduler::Scheduler`] now that something drives it
/// (`spawn_jobq_worker`) — the graph alone was enough for the
/// read-only endpoints, the scheduler is what a `claim_next` loop
/// needs. `std::sync::Mutex`, not `tokio`'s — every lock scope below
/// is synchronous (no `.await` while held). Always present, never
/// gated on the swarm queue: this is process state, not something
/// read over the network.
pub(crate) jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
/// The identity store's only reader, for `GET /api/agents`.
///
/// `None` when no bridge is wired up, which is the one state in which
/// the roster cannot be answered at all — the same shape as `status`
/// above, and for the same reason: an empty roster and an unreadable
/// one are different facts.
///
/// ⚠️ The two `/api/agents` verbs treat this differently on purpose.
/// **POST does not need it**: creation only queues a job, and
/// whether a bridge exists is `run_swarm_node`'s concern (it holds its
/// own clone from `spawn_jobq_worker`), so a request still queues
/// cleanly on a bridge-less host and fails loud once claimed. POST reads
/// it only for a name that breaks a naming rule, to tell a new agent
/// from an existing one, and warns when it cannot (`name_verdict`). **GET
/// has nowhere to defer to** — there is no job, only an answer it
/// either has or does not. `POST /api/forge/users/{name}/admin` reads
/// it for every name and refuses when it cannot (`admin_unless_agent`).
pub(crate) auth: Option<Arc<auth::AuthBridge>>,
/// HMAC secret for swarm-wide forge webhooks, loaded once at startup.
/// `None` when it could not be read or created — the webhook endpoint
/// then refuses every delivery with 503 rather than admitting one it
/// cannot verify. Deliberately not fatal to startup: nothing is
/// registered against that endpoint yet, and the rest of this daemon's
/// surface is unaffected. See [`load_or_generate_secret`](crate::webhook::load_or_generate_secret).
///
/// `Arc<str>`, not `Arc<String>`: the value is never mutated after
/// startup, and this way `as_deref()` yields the `&str` the verifier
/// takes without a second hop through `String`.
pub(crate) webhook_secret: Option<Arc<str>>,
/// Last successful `agent-configs/*` scan, kept current by
/// [`config_pr::spawn`]. Empty while this daemon has no forge access —
/// same "absent means don't ask" shape as `status` and `forge`, not a
/// startup failure — and filled when the forge client is.
pub(crate) config_prs: Late<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
/// supported, if less friendly, state, not a startup failure. Same
/// `Arc<str>` rationale as `webhook_secret`: never mutated, so a
/// clone per request is just a refcount bump.
pub(crate) swarm_name: Option<Arc<str>>,
/// The forge client itself, for the read-only repo/issue-report
/// routes (`GET /api/repos`, `GET /api/repos/{org}/{repo}/issue-report`)
/// and `POST /api/forge/users/{name}/admin` — distinct from `config_prs`, which holds a *cache* built off this
/// same client rather than the client. Empty while this daemon has no
/// forge access, and filled the moment `forge::enable` builds the client.
pub(crate) forge: Late<Arc<forge::Client>>,
/// Held by `create_agent` from reading where a name is placed until its
/// graph is queued, and by `set_agent_state` from that read until its
/// declaration is written, so two placements of one name cannot both
/// find it unplaced. `tokio`'s mutex, unlike `jobq`'s: the read awaits
/// the queue.
pub(crate) create_gate: Arc<tokio::sync::Mutex<()>>,
/// The store the linked-account routes read and write.
pub(crate) accounts: linked_accounts::Accounts,
/// The review classifier try-outs started here, until their results
/// expire.
pub(crate) try_outs: review_classifier::TryOuts,
/// The requested reviews running or waiting.
pub(crate) reviews: Arc<review_classifier::review::Reviews>,
/// The review classifier's finished runs.
pub(crate) runs: review_classifier::runs::Runs,
/// The swarm builder's finished builds.
pub(crate) builds: builder::runs::Builds,
/// Grafana's alerts API, for `GET /api/alerts`. `None` when no Grafana URL
/// is configured.
pub(crate) alerts: Option<Arc<alerts::Grafana>>,
}
/// Why the status route answers 503 rather than an empty list.
///
/// "I cannot reach the store" and "every hive is silent" are different
/// answers, and rendering the second when the first is true is exactly
/// the smoothing this endpoint exists to avoid — a caller would draw a
/// swarm-wide outage out of a local one. The cause is carried in the
/// body because a bare 503 on an operator-facing diagnostic is how a
/// misconfiguration costs an afternoon; it is a queue/JetStream error
/// string, and this surface is already behind the swarm's SSO.
///
/// It travels in `detail` of an RFC 9457 `application/problem+json` body
/// rather than as a bare string, so the cause is an addressable field
/// instead of the whole payload — see `error_problem` below.
pub(crate) struct StatusUnavailable(pub(crate) String);
impl axum::response::IntoResponse for StatusUnavailable {
fn into_response(self) -> axum::response::Response {
error_problem(axum::http::StatusCode::SERVICE_UNAVAILABLE, &self.0).into_response()
}
}
/// Every error this daemon returns, in one shape.
///
/// RFC 9457 `application/problem+json` is the hive-wide contract for HTTP
/// error bodies (`docs/process/conventions.md`), and the operator UIs read `detail`
/// for display. A bare string forces the reader to treat the entire body as
/// the message, which is the difference between a UI that can offer "copy the
/// cause" and one that can only dump a response.
pub(crate) fn error_problem(
status: axum::http::StatusCode,
detail: &str,
) -> problem_details::ProblemDetails {
problem_details::ProblemDetails::from_status_code(status).with_detail(detail)
}
/// The status a [`wanted::WantedWriter`], [`agent_icon::AgentIconReader`] or
/// `schedules` queue failure should answer with.
///
/// `WantedWriter::view`/`set` call `ensure_connected` internally and
/// propagate through `anyhow`, so the concrete
/// [`swarm_queue_client::Error`] survives underneath but is not the type in
/// hand — `swarm_queue_client`'s own doc says a caller "gets a type it can
/// match on", which means downcasting back to it rather than string-matching
/// the rendered message. `NotConnected` is a retryable condition (the queue
/// is reconnecting, or hasn't yet) and every other queue-backed route here
/// already answers 503 for it; anything else is a genuine failure to publish
/// or read, which stays 500.
pub(crate) fn wanted_error_status(e: &anyhow::Error) -> axum::http::StatusCode {
match e.downcast_ref::<swarm_queue_client::Error>() {
Some(swarm_queue_client::Error::NotConnected(_)) => {
axum::http::StatusCode::SERVICE_UNAVAILABLE
}
_ => axum::http::StatusCode::INTERNAL_SERVER_ERROR,
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// The 503 this route returns is what the operator UIs' shared error
/// component renders, so assert the RENDERED response rather than the
/// `problem_details` crate: the contract a UI depends on is the content
/// type plus a `detail` it can address, and a handler that built the
/// value and returned it as a bare string would satisfy any test
/// written against the type alone.
#[tokio::test]
async fn status_unavailable_renders_problem_json_with_the_cause_in_detail() {
use axum::response::IntoResponse as _;
let cause = "listing status bucket keys: timed out";
let resp = StatusUnavailable(cause.to_owned()).into_response();
assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
let ct = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned();
assert!(
ct.starts_with("application/problem+json"),
"RFC 9457 media type, got {ct:?}"
);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.expect("body reads");
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("problem+json parses");
assert_eq!(v["status"], 503);
// The cause is an addressable field, not the entire payload — that
// distinction is the point of the change, so it is what is asserted.
assert_eq!(v["detail"], cause);
}
/// Drives `SwarmNodeKind::CreateRepo` through the real
/// `hive_jobq::scheduler::Scheduler` claim → run → complete path,
/// rather than only through `create_agent`'s endpoint test (there
/// isn't one — the endpoint itself is thin, insert-and-return; the
/// interesting behavior is in `run_swarm_node`'s executor arm, which
/// this exercises directly).
///
/// Deliberately offline: with the forge URL unset,
/// `forge::Client::from_env` returns `Ok(None)` (see that module's doc
/// comment), so this exercises the whole claim → run → complete path
/// through `hive_jobq::scheduler::Scheduler` without a real forge
/// server — at the cost of only ever observing the
/// graceful-absence-is-failure branch here. The happy path needs an
/// actual forge instance and isn't something a unit test in this crate
/// can reach.
///
/// SAFETY: single-threaded mutation of the `forge` env var this
/// test itself owns, restored before returning — no other test in this
/// crate reads them.
/// One roster, one agent name, two hives — the accepted one and a
/// typo. Built per test so neither can see the other's queue.
/// The scheduler handle these tests hold onto so they can assert on
/// what the endpoint queued. Aliased because the full type is three
/// nested generics deep and reads worse inline than named.
pub(crate) type SharedSched = std::sync::Arc<
std::sync::Mutex<
hive_jobq::scheduler::Scheduler<
crate::jobq::SwarmNodeKind,
crate::jobq::SwarmResourceKind,
>,
>,
>;
pub(crate) fn state_with_roster() -> (super::AppState, SharedSched) {
let sched =
std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
)));
let state = super::AppState {
hives: std::sync::Arc::new(vec![HiveEntry {
name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),
}]),
links: std::sync::Arc::new(Vec::new()),
status: None,
// No queue, for the same reason as `status`: these tests build
// the creation graph and read its shape, and never run the node
// that would write a declaration.
wanted: None,
agent_status: None,
agent_icons: None,
agents_md: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::clone(&sched),
webhook_secret: None,
config_prs: std::sync::Arc::default(),
swarm_name: None,
// No bridge: these tests drive agent *creation*, which queues a
// job and consults one only for a rule-breaking name. The roster
// read is the verb that needs it, and it has its own test below.
auth: None,
forge: std::sync::Arc::default(),
create_gate: std::sync::Arc::default(),
accounts: crate::linked_accounts::Accounts::Fake(std::sync::Arc::default()),
try_outs: crate::review_classifier::TryOuts::default(),
reviews: std::sync::Arc::default(),
runs: crate::review_classifier::runs::Runs::Live(None),
builds: crate::builder::runs::Builds::Live(None),
alerts: None,
};
(state, sched)
}
/// `wanted_error_status` as a pure function first: build the exact
/// `swarm_queue_client::Error::NotConnected` `ensure_connected` raises,
/// wrap it the same way `?` inside `WantedWriter::view`/`set` does, and
/// confirm it downcasts back to the status those routes are meant to
/// answer.
#[test]
fn not_connected_downcasts_to_503() {
let e: anyhow::Error =
swarm_queue_client::Error::NotConnected(async_nats::connection::State::Pending).into();
assert_eq!(
super::wanted_error_status(&e),
axum::http::StatusCode::SERVICE_UNAVAILABLE
);
}
/// Invert-proof for the test above: an `anyhow::Error` that does not
/// wrap a `swarm_queue_client::Error` at all (the shape a genuine write
/// or decode failure takes — `apply`'s "declaration is not decodable"
/// context, for instance) must NOT downcast to `NotConnected`, and must
/// stay 500. Without this, a `wanted_error_status` that answered 503
/// unconditionally would still pass the test above.
#[test]
fn an_unrelated_error_stays_500() {
let e = anyhow::anyhow!("the hive's current declaration is not decodable");
assert_eq!(
super::wanted_error_status(&e),
axum::http::StatusCode::INTERNAL_SERVER_ERROR
);
}
/// Second invert-proof, closer to the actual bug this closes: a
/// *different* `swarm_queue_client::Error` variant (a real connect
/// failure, not `NotConnected`) also stays 500 — proving the match
/// looks at the variant, not just at "was this crate's error type
/// involved at all".
#[test]
fn a_different_queue_client_error_variant_stays_500() {
let e: anyhow::Error = swarm_queue_client::Error::Connect {
url: "nats://queue.example:4222".to_owned(),
source: async_nats::ConnectErrorKind::TimedOut.into(),
}
.into();
assert_eq!(
super::wanted_error_status(&e),
axum::http::StatusCode::INTERNAL_SERVER_ERROR
);
}
}

View file

@ -33,8 +33,8 @@ use swarm_controller_api::schedules::{
use swarm_queue_client::agent_schedules::{self, AgentSchedules, Schedule};
use crate::agent_renewal::unix_now;
use crate::routes::{AppState, error_problem};
use crate::wanted::WantedWriter;
use crate::{AppState, error_problem};
/// The bucket holding every schedule definition. Agents hold no grant on it.
pub const DEFINITIONS_BUCKET: &str = "schedule-definitions";
@ -384,7 +384,7 @@ fn problem(failure: Failure) -> ProblemDetails {
}
Failure::Queue(e) => {
tracing::warn!(error = %format!("{e:#}"), "schedules: queue failure");
error_problem(crate::wanted_error_status(&e), &format!("{e:#}"))
error_problem(crate::routes::wanted_error_status(&e), &format!("{e:#}"))
}
}
}

View file

@ -0,0 +1,910 @@
//! Process startup: the config this daemon loads once from env vars, the
//! background passes it starts, and the router it serves.
//!
//! `OpenAPI` spec generation mirrors `hive-c0re/src/dashboard/mod.rs`
//! exactly: `#[utoipa::path(...)]` per handler, an `ApiDoc` root, and a
//! raw JSON route at `/api/openapi.json`. Swagger UI itself is
//! nginx-hosted from the nix store, same shape as the per-hive
//! dashboard's (`nix/host-modules/hive-c0re/vhosts.nix`'s
//! `swaggerUiLocations` — a swagger-ui-theme dist under `/api/docs/`, no
//! fallback to this daemon). Only annotated routes appear in the spec;
//! an unannotated one just doesn't show up, nothing breaks.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use anyhow::Result;
use axum::{Json, routing::get};
use backon::{ExponentialBuilder, Retryable as _};
use utoipa::OpenApi;
use utoipa_axum::{router::OpenApiRouter, routes};
use crate::jobq::{
Late, SwarmNodeKind, SwarmResourceKind, queue_agent_renewals, queue_builder_forge_token_mint,
queue_ci_metrics_forge_token_mint, queue_classifier_forge_token_mint, queue_docs_grants,
queue_forge_token_mints, queue_hive_forge_token_mints, queue_hive_sender_mints,
queue_matrix_account_mints,
};
use crate::routes::hives::{HiveEntry, ServiceLink};
use crate::routes::{self, AppState, agents, config_prs, forge_admin, hives, jobq_graph};
use crate::{
agent_control, agent_icon, agent_input, agent_limits, agent_login, agent_permissions,
agent_renewal, agent_state_stream, agent_status, agents_md, alerts, archetypes, auth, builder,
claude_login, config_pr, forge, forge_account, github_account, inference, issue_report,
linked_accounts, matrix_account, pulls, queue_identity, review_classifier, schedules, status,
subagent_term, term_stream, wanted, webhook,
};
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
///
/// A compiled-in default is legitimate here and is *not* the mistake that
/// a hardcoded remote address would be: this is a path this process
/// **creates**, not an address it hopes to find something at. systemd's
/// `RuntimeDirectory=swarm-controller` makes the parent exist before
/// `ExecStart`, so the default names a directory the unit just produced.
///
/// The directory is its own — deliberately not shared with hive-c0re's
/// `/run/hyperhive`. The socket is `0666`, so its directory is the only
/// access control it has; co-locating it with c0re's admin socket would
/// put both within reach of whatever can reach either. nginx runs on the
/// host, so nothing narrows its reach for you.
const DEFAULT_SOCKET: &str = "/run/swarm-controller/controller.sock";
pub(crate) fn socket_path() -> PathBuf {
std::env::var_os("SWARM_CONTROLLER_SOCKET")
.map_or_else(|| PathBuf::from(DEFAULT_SOCKET), PathBuf::from)
}
/// Root of the auto-generated `OpenAPI` spec, served raw at
/// `/api/openapi.json` — see the module doc comment above. Tag list
/// grows alongside the swarm-level surfaces this daemon picks up, same
/// as `hive-c0re::dashboard::ApiDoc`'s tag list did.
#[derive(OpenApi)]
#[openapi(
info(
title = "hyperhive swarm-controller API",
description = "swarm-controller's HTTP surface, served over its unix \
socket behind the gateway's swarm-UI vhost."
),
tags(
(name = "health", description = "liveness probe"),
(name = "hives", description = "the swarm's hive directory"),
(name = "links", description = "swarm service quick links"),
(name = "jobq", description = "the swarm-level job graph"),
(name = "agents", description = "creating agent identities at swarm level"),
(name = "webhook", description = "swarm-wide forge webhook receipt"),
(name = "repos", description = "forge repo browsing (swarm-ui's issue-report page)"),
(name = "forge", description = "forge site admins (`swarmctl forge make-admin`)"),
(name = "schedules", description = "scheduled prompts (`swarmctl schedule`)"),
(name = "review-classifier", description = "trying review classifications against a PR (swarm-ui's try-out page), and the classifier's swarm-wide settings"),
(name = "pulls", description = "open pull requests, for the try-out page's dropdowns"),
(name = "alerts", description = "the swarm Grafana's current alerts (swarm-ui's alerts page)"),
(name = "builder", description = "the swarm builder's finished builds of agent config PRs, which their commit statuses link to"),
)
)]
pub(crate) struct ApiDoc;
/// Env var the controller's NixOS module sets from
/// `services.hyperhive.swarm.hives`, JSON-encoded — the full directory
/// (this daemon has no "self" to exclude) rather than peers-minus-self.
const HIVES_ENV: &str = "SWARM_CONTROLLER_HIVES";
/// Parses [`HIVES_ENV`] into the swarm's hive directory. Unset or
/// unparseable both fall back to an empty list with a warning rather than
/// failing startup — a swarm-controller that can't yet see its own config
/// (dev run, a module not wired up yet) should still serve `/health`.
pub(crate) fn load_hives() -> Vec<HiveEntry> {
let Some(raw) = std::env::var_os(HIVES_ENV) else {
return Vec::new();
};
match serde_json::from_str(&raw.to_string_lossy()) {
Ok(hives) => hives,
Err(e) => {
tracing::warn!(error = %e, env = HIVES_ENV, "failed to parse hive directory, serving an empty list");
Vec::new()
}
}
}
/// Env var the controller's NixOS module sets from the merged
/// `services.hyperhive.swarm.controller.links` list, JSON-encoded — same
/// shape/rationale as [`HIVES_ENV`]. Consumed by `GET /api/links`.
const LINKS_ENV: &str = "SWARM_CONTROLLER_LINKS";
/// Parses [`LINKS_ENV`] into the swarm's service-link list. Same
/// fall-back-to-empty rationale as `load_hives`: a daemon that can't yet
/// see this config should still serve `/health` rather than fail startup.
pub(crate) fn load_links() -> Vec<ServiceLink> {
let Some(raw) = std::env::var_os(LINKS_ENV) else {
return Vec::new();
};
match serde_json::from_str(&raw.to_string_lossy()) {
Ok(links) => links,
Err(e) => {
tracing::warn!(error = %e, env = LINKS_ENV, "failed to parse service links, serving an empty list");
Vec::new()
}
}
}
/// Env var the controller's NixOS module sets from
/// `services.hyperhive.swarm.name` — unset (rather than an empty string)
/// when the operator never configured it. Consumed by `GET /api/swarm`.
const NAME_ENV: &str = "SWARM_CONTROLLER_NAME";
/// Reads [`NAME_ENV`]. `None` on absence — no fallback-and-warn shape like
/// `load_hives`/`load_links` because there is nothing to parse and fail:
/// an unset env var and an operator who never named the swarm are the same
/// state, not an error.
pub(crate) fn load_swarm_name() -> Option<String> {
std::env::var(NAME_ENV).ok()
}
/// The declaration writer, sharing the status reader's connection.
///
/// The controller holds exactly one queue connection by design — see
/// `StatusReader::queue_client`. A second connect would double the
/// auth-callout traffic and give the two paths independent reconnect state,
/// so one could be serving while the other was still down.
pub(crate) fn wanted_writer(
status: Option<&Arc<status::StatusReader>>,
) -> Option<Arc<wanted::WantedWriter>> {
status.map(|s| Arc::new(wanted::WantedWriter::new(s.queue_client())))
}
/// The per-agent status reader, sharing the status reader's connection and
/// staleness threshold — same rationale as [`wanted_writer`], and the same
/// threshold on purpose: both buckets are fed by the same publisher cadence
/// (`hive-c0re`'s `PUBLISH_INTERVAL`), so two separately-configured
/// thresholds would just be two ways to get out of sync with one cadence.
pub(crate) fn agent_status_reader(
status: Option<&Arc<status::StatusReader>>,
) -> Option<Arc<agent_status::AgentStatusReader>> {
status.map(|s| {
Arc::new(agent_status::AgentStatusReader::new(
s.queue_client(),
status::StatusReader::stale_after_from_env(),
))
})
}
/// The per-agent icon reader, sharing the status reader's connection —
/// same rationale as [`wanted_writer`]. No staleness threshold, unlike
/// [`agent_status_reader`]: an icon is a property of the agent rather than
/// a report about it, so there is no cadence it could be late against.
///
/// Also starts creating the bucket, which the agents writing it cannot do.
pub(crate) fn agent_icon_reader(
status: Option<&Arc<status::StatusReader>>,
) -> Option<Arc<agent_icon::AgentIconReader>> {
let reader = Arc::new(agent_icon::AgentIconReader::new(status?.queue_client()));
let creating = Arc::clone(&reader);
tokio::spawn(async move { creating.create_when_connected().await });
Some(reader)
}
/// The `AGENTS.md` reader, sharing the status reader's connection, and
/// creating the bucket, as [`agent_icon_reader`] does for icons.
pub(crate) fn agents_md_reader(
status: Option<&Arc<status::StatusReader>>,
) -> Option<Arc<agents_md::AgentsMdReader>> {
let reader = Arc::new(agents_md::AgentsMdReader::new(status?.queue_client()));
let creating = Arc::clone(&reader);
tokio::spawn(async move { creating.create_when_connected().await });
Some(reader)
}
/// What each agent last reported running on, read on the status reader's
/// connection — same rationale as [`wanted_writer`].
pub(crate) fn reported_inference(
status: Option<&Arc<status::StatusReader>>,
) -> Option<Arc<inference::Reported>> {
Some(inference::Reported::spawn(status?.queue_client()))
}
/// Whether each agent last said it needs a login, read on the status
/// reader's connection — same rationale as [`wanted_writer`].
pub(crate) fn agent_login(
status: Option<&Arc<status::StatusReader>>,
) -> Option<Arc<agent_login::Reported>> {
Some(agent_login::Reported::spawn(status?.queue_client()))
}
/// Same "log and carry on" shape as the queue/bridge/forge wiring in `main`.
/// A controller that cannot hold a webhook secret still serves every other
/// route; the webhook endpoint answers 503, which is the honest answer
/// rather than a silent accept. Lifted out of `main` for
/// `clippy::too_many_lines`.
pub(crate) fn load_webhook_secret() -> Option<Arc<str>> {
match webhook::load_or_generate_secret() {
Ok(secret) => Some(Arc::from(secret)),
Err(e) => {
tracing::warn!(
error = %format!("{e:#}"),
"webhook secret unavailable; swarm-wide forge webhooks are off"
);
None
}
}
}
/// The homeserver agents' own matrix accounts are minted on. Unset is this
/// controller creating agents with no matrix account, which the mint node
/// reports by name rather than `main` refusing to start.
pub(crate) fn configured_matrix_homeserver() -> Option<Arc<str>> {
matrix_account::configured_default_homeserver()
.filter(|v| !v.is_empty())
.map(Arc::from)
}
/// Start the recovery passphrase pass, which needs only the store; the
/// matrix-account backfill and the hive sender-token pass when a homeserver is
/// configured; and the operator admin pass when the bridge is too. Lifted out
/// of `main` for `clippy::too_many_lines`.
pub(crate) fn spawn_matrix_account_backfill(
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
homeserver: Option<Arc<str>>,
hives: &[HiveEntry],
auth: Option<Arc<auth::AuthBridge>>,
) {
matrix_account::recovery::spawn();
let Some(base) = homeserver else {
return;
};
if let Some(bridge) = auth {
matrix_account::operator_admin::spawn(bridge, Arc::clone(&base));
}
let sched = Arc::clone(jobq);
matrix_account::agent_token::spawn(Arc::clone(&base), move |agents| {
if let Err(e) = queue_matrix_account_mints(&sched, agents) {
tracing::warn!(error = %format!("{e:#}"), "agent matrix accounts: queueing failed");
}
});
let sched = Arc::clone(jobq);
let hive_names = hives.iter().map(|h| h.name.clone()).collect();
matrix_account::hive_sender::spawn(base, hive_names, move |hives| {
if let Err(e) = queue_hive_sender_mints(&sched, hives) {
tracing::warn!(error = %format!("{e:#}"), "hive sender tokens: queueing failed");
}
});
}
/// Start the forge's periodic passes — the swarm-wide objects, the runner
/// registration token, hives' and the builder's read tokens, agents'
/// `internal/docs` grants, and
/// agents' tokens (which also
/// sweeps legacy tokens; see `forge::legacy_tokens`) —
/// once there is a forge client, and the operators' profiles when the bridge
/// and the queue are wired up too. Lifted out of `main` for
/// `clippy::too_many_lines`.
fn spawn_forge_workers(
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
client: Arc<forge::Client>,
hives: &[HiveEntry],
auth: Option<Arc<auth::AuthBridge>>,
queue: Option<async_nats::Client>,
) {
forge::objects::spawn(Arc::clone(&client));
if let (Some(bridge), Some(queue)) = (auth, queue) {
forge::operator_profiles::spawn(Arc::clone(&client), bridge, queue);
}
forge::runner_token::spawn(Arc::clone(&client));
let sched = Arc::clone(jobq);
let hive_names = hives.iter().map(|h| h.name.clone()).collect();
forge::hive_token::spawn(Arc::clone(&client), hive_names, move |hives| {
if let Err(e) = queue_hive_forge_token_mints(&sched, hives) {
tracing::warn!(error = %format!("{e:#}"), "hive forge tokens: queueing failed");
}
});
let sched = Arc::clone(jobq);
forge::classifier_account::spawn(Arc::clone(&client), move || {
if let Err(e) = queue_classifier_forge_token_mint(&sched) {
tracing::warn!(error = %format!("{e:#}"), "review classifier forge token: queueing failed");
}
});
let sched = Arc::clone(jobq);
forge::builder_account::spawn(Arc::clone(&client), move || {
if let Err(e) = queue_builder_forge_token_mint(&sched) {
tracing::warn!(error = %format!("{e:#}"), "builder forge token: queueing failed");
}
});
let sched = Arc::clone(jobq);
forge::ci_metrics_account::spawn(Arc::clone(&client), move || {
if let Err(e) = queue_ci_metrics_forge_token_mint(&sched) {
tracing::warn!(error = %format!("{e:#}"), "ci metrics forge token: queueing failed");
}
});
let sched = Arc::clone(jobq);
forge::docs_grant::spawn(Arc::clone(&client), move |agents| {
if let Err(e) = queue_docs_grants(&sched, agents) {
tracing::warn!(error = %format!("{e:#}"), "docs grants: queueing failed");
}
});
let sched = Arc::clone(jobq);
forge::agent_token::spawn(client, move |agents| {
if let Err(e) = queue_forge_token_mints(&sched, agents) {
tracing::warn!(error = %format!("{e:#}"), "agent forge tokens: queueing failed");
}
});
}
/// Start the agent credential renewal pass when a swarm queue is wired up:
/// the pass reads the wanted-state declarations to tell live agents from
/// destroyed ones, and without a queue it has none. Lifted out of `main` for
/// `clippy::too_many_lines`.
pub(crate) fn spawn_agent_renewal(
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
wanted: Option<Arc<wanted::WantedWriter>>,
hives: &[HiveEntry],
) {
let Some(wanted) = wanted else {
return;
};
let hives = hives.iter().map(|h| h.name.clone()).collect();
let sched = Arc::clone(jobq);
agent_renewal::spawn(wanted, hives, move |renewals| {
if let Err(e) = queue_agent_renewals(&sched, renewals) {
tracing::warn!(error = %format!("{e:#}"), "agent credential renewal: queueing failed");
}
});
}
/// Start creating every live agent's subagent and terminal streams when a swarm
/// queue is wired up. Lifted out of `main` for `clippy::too_many_lines`.
fn spawn_subagent_streams(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else {
return;
};
let hives = hives.iter().map(|h| h.name.clone()).collect();
subagent_term::spawn(status.queue_client(), wanted, hives);
}
/// The per-agent background passes: subagent and terminal streams, and
/// schedule keys, and the permissions bucket and its import, and the
/// archetypes agents are created from.
/// Lifted out of `main` for `clippy::too_many_lines`.
pub(crate) fn spawn_per_agent_upkeep(
status: Option<&Arc<status::StatusReader>>,
hives: &[HiveEntry],
) {
spawn_subagent_streams(status, hives);
spawn_schedule_reconcile(status, hives);
spawn_agent_permissions(status, hives);
if let Some(status) = status {
archetypes::spawn(status.queue_client());
}
}
/// Create the agent-permissions bucket and answer hives' imports when a swarm
/// queue is wired up. Same shape as [`spawn_subagent_streams`].
fn spawn_agent_permissions(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else {
return;
};
let hives = hives.iter().map(|h| h.name.clone()).collect();
agent_permissions::spawn(status.queue_client(), wanted, hives);
}
/// Start rewriting every agent's schedule key from the definitions when a
/// swarm queue is wired up. Same shape as [`spawn_subagent_streams`].
fn spawn_schedule_reconcile(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else {
return;
};
let hives = hives.iter().map(|h| h.name.clone()).collect();
schedules::spawn_reconcile(status.queue_client(), wanted, hives);
}
/// 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
/// swarm vhost** that carries the `/webhook/forge/` location. Absent means
/// "the endpoint is not reachable from outside", and the right response to
/// that is to register nothing: a hook pointing at a URL nothing answers is
/// worse than no hook, because forgejo records failed deliveries against a
/// registration that looks configured.
pub(crate) const PUBLIC_URL_ENV: &str = "SWARM_CONTROLLER_PUBLIC_URL";
/// Register the swarm-wide forge hooks against this controller, in the
/// background.
///
/// Detached rather than awaited, and never fatal: the forge may be slow or
/// briefly down at boot, and none of the daemon's other routes depend on a
/// hook existing.
///
/// Retried on a backoff rather than left to the next process start. Losing
/// that race is the *common* case, not an edge one — the controller and the
/// forge come up together on a rebuild, and a deploy was measured where both
/// consecutive starts got `502 Bad Gateway` from the gateway because forgejo
/// was not serving yet. Nothing schedules another start, so "the next restart
/// retries" can leave a swarm with no hooks registered for as long as the
/// daemon keeps running — and the failure is silent at both ends, because the
/// forge has nothing to report about a call that never arrived.
///
/// Runs once there is a forge client, and silently does nothing when either
/// other precondition is missing — each is a legitimate deployment shape (no
/// state directory to hold a secret, no public vhost), and each is already
/// logged where it is discovered.
fn register_swarm_webhooks(forge: Arc<forge::Client>, secret: Option<Arc<str>>) {
let Some(secret) = secret else { return };
let Ok(public_url) = std::env::var(PUBLIC_URL_ENV) else {
tracing::info!(
"{PUBLIC_URL_ENV} unset; not registering swarm-wide forge webhooks (the \
endpoint is not published on this host)"
);
return;
};
tokio::spawn(async move {
// A bounded schedule, not a poll loop: 15s doubling to 240s, five
// retries. It exists to outlast a forge that is slow to start, not to
// re-register periodically. Registration is idempotent, so a repeat
// costs one API call and changes nothing.
const RETRIES: usize = 5;
let mut failed = 0;
let registered = (|| forge.ensure_swarm_webhooks(&public_url, &secret))
.retry(
ExponentialBuilder::default()
.with_min_delay(std::time::Duration::from_secs(15))
.with_max_delay(std::time::Duration::from_mins(4))
.with_max_times(RETRIES),
)
.notify(|e, wait| {
failed += 1;
tracing::info!(
error = %format!("{e:#}"),
attempt = failed,
retry_in = ?wait,
"registering swarm-wide forge webhooks failed; retrying"
);
})
.await;
if let Err(e) = registered {
tracing::warn!(
error = %format!("{e:#}"),
attempts = RETRIES + 1,
"registering swarm-wide forge webhooks failed; giving up until the next start"
);
}
});
}
/// Connect to the swarm queue when this deployment wired one up, extracted
/// out of `main` purely to keep that function under clippy's line-count
/// lint — no behavior split, every comment below is unchanged from where
/// it used to sit inline.
///
/// Deliberately NOT fatal on failure: the controller's HTTP surface is
/// useful without the queue, and a hive that cannot be read from renders
/// as `unknown` rather than as an outage of this daemon. What IS fatal is
/// an identity that could not be resolved, which `queue_identity::init`
/// reports in `main` before this runs: silently behaving like an
/// unconfigured host is how every hive ends up reading `never_reported`
/// with nothing to point at.
pub(crate) async fn connect_status_reader() -> Result<Option<Arc<status::StatusReader>>> {
let Some(cfg) = queue_identity::get().cloned() else {
tracing::info!("no swarm queue configured; status aggregation is off");
return Ok(None);
};
match swarm_queue_client::connect(cfg).await {
Ok(client) => {
// NOT "connected": `retry_on_initial_connect` returns a client
// before any connection has been established, so claiming a
// connection here would put "connected to the swarm queue" in
// the journal moments before every request 503s with "not
// connected" — and a reader would rightly distrust the second
// line rather than the first. The connection's real state is
// reported by the status endpoint, which checks it per request.
tracing::info!("swarm queue configured; connecting in the background");
Ok(Some(Arc::new(status::StatusReader::new(
client,
status::StatusReader::stale_after_from_env(),
))))
}
Err(e) => {
// `chain`, not `{:#}`: this is the queue client's own
// error type, and thiserror's Display ignores the
// alternate flag — the source would be dropped silently.
tracing::warn!(
error = swarm_queue_client::chain(&e),
"swarm queue unreachable"
);
Ok(None)
}
}
}
/// Fill the forge slots and start everything that runs off the forge client —
/// its periodic passes, the config-PR poll and the webhook registration — now
/// if the token is in the store, otherwise when `forge::enable`'s background
/// re-read finds it. Nothing to start when no forge URL is configured. Lifted
/// out of `main` for `clippy::too_many_lines`.
pub(crate) async fn start_forge(
forge_client: Late<Arc<forge::Client>>,
config_prs: Late<Arc<config_pr::ConfigPrCache>>,
jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
hives: Vec<HiveEntry>,
webhook_secret: Option<Arc<str>>,
auth: Option<Arc<auth::AuthBridge>>,
queue: Option<async_nats::Client>,
) {
let url = match forge::url_from_env() {
Ok(Some(url)) => url,
Ok(None) => return,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "forge misconfigured; repo provisioning is off");
return;
}
};
forge::enable(url, forge_client, move |client| {
spawn_forge_workers(&jobq, Arc::clone(&client), &hives, auth, queue);
config_prs.get_or_init(|| config_pr::spawn(Arc::clone(&client)));
register_swarm_webhooks(client, webhook_secret);
})
.await;
}
/// Every route this daemon serves, wired to its state.
///
/// Split out of `main` because the route list is the part that grows, and
/// `main` sat exactly on the `too_many_lines` limit — adding an endpoint
/// tripped a lint about the startup sequence, which is not where the change
/// was. A new route now costs one line here and none there.
pub(crate) fn build_app(state: AppState) -> axum::Router {
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
.routes(routes!(routes::health))
.routes(routes!(alerts::get_alerts))
.routes(routes!(hives::get_hives))
.routes(routes!(hives::get_hives_status))
.routes(routes!(hives::get_links))
.routes(routes!(hives::get_swarm_info))
.routes(routes!(jobq_graph::get_jobq_graph))
.routes(routes!(jobq_graph::get_jobq_rollup))
.routes(routes!(config_prs::get_agent_config_pr))
.routes(routes!(agents::get_agent_icon))
.routes(routes!(agents_md::get_agents_md, agents_md::put_agents_md))
.routes(routes!(
inference::get_agent_inference,
inference::put_agent_inference,
inference::delete_agent_inference
))
.routes(routes!(
agent_limits::get_agent_limits,
agent_limits::put_agent_limits,
agent_limits::delete_agent_limits
))
.routes(routes!(
agent_permissions::get_agent_permissions,
agent_permissions::put_agent_permissions,
agent_permissions::delete_agent_permissions
))
.routes(routes!(agent_permissions::get_permission_catalogue))
.routes(routes!(archetypes::get_archetypes))
.routes(routes!(config_prs::get_config_prs))
.routes(routes!(agents::create_agent))
.routes(routes!(forge_admin::mint_agent_forge_token))
.routes(routes!(forge_admin::make_forge_admin))
.routes(routes!(agents::get_agents))
.routes(routes!(agents::get_agents_status))
.routes(routes!(agents::set_agent_state))
.routes(routes!(
matrix_account::put_matrix_account,
linked_accounts::delete_matrix_account
))
.routes(routes!(
forge_account::put_forge_account,
linked_accounts::delete_forge_account
))
.routes(routes!(
github_account::put_github_account,
linked_accounts::delete_github_account
))
.routes(routes!(linked_accounts::get_linked_accounts))
.routes(routes!(agents::get_hive_wanted))
.routes(routes!(term_stream::stream_agent_term))
.routes(routes!(subagent_term::list_subagents))
.routes(routes!(subagent_term::stream_subagent_term))
.routes(routes!(agent_state_stream::stream_agent_state))
.routes(routes!(agent_input::post_agent_input))
.routes(routes!(agent_input::post_agents_broadcast))
.routes(routes!(agent_control::post_agent_cancel))
.routes(routes!(
claude_login::post_claude_login,
claude_login::delete_claude_login
))
.routes(routes!(claude_login::post_claude_login_code))
.routes(routes!(schedules::list_schedules, schedules::add_schedule))
.routes(routes!(
schedules::edit_schedule,
schedules::remove_schedule
))
.routes(routes!(issue_report::get_repos))
.routes(routes!(issue_report::get_issue_report_all))
.routes(routes!(issue_report::get_issue_report))
.routes(routes!(pulls::get_pull_repos))
.routes(routes!(pulls::get_pulls))
.routes(routes!(review_classifier::post_try_out))
.routes(routes!(review_classifier::get_try_out))
.routes(routes!(review_classifier::get_rules))
.routes(routes!(review_classifier::open_pr::post_open_pr))
.routes(routes!(review_classifier::runs::get_runs))
.routes(routes!(builder::runs::get_runs))
.routes(routes!(builder::runs::get_run))
.routes(routes!(
review_classifier::settings::get_settings,
review_classifier::settings::put_settings
))
.routes(routes!(webhook::post_webhook_forge))
.split_for_parts();
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
// the nix store (see the module doc comment above). `api` is
// `Clone`; each request gets its own owned copy for `Json` to
// serialize, same as `hive-c0re::dashboard::serve`.
router
.route(
"/api/openapi.json",
get(move || async move { Json(api.clone()) }),
)
.with_state(state)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::routes::tests::state_with_roster;
/// The socket must not share a directory with anything else, because
/// the socket is `0666` and the directory is therefore the only access
/// control it has. `/run/hyperhive` in particular holds hive-c0re's
/// **admin** socket, and nginx — a host service — is bounded by
/// nothing but the directory itself.
///
/// A test rather than a comment: the failure this guards against is a
/// one-word edit that looks tidier and reads fine in review.
#[test]
fn socket_lives_in_its_own_runtime_dir() {
let parent = Path::new(DEFAULT_SOCKET)
.parent()
.expect("socket path has a parent directory");
assert_eq!(
parent,
Path::new("/run/swarm-controller"),
"the socket's directory is its access control — moving it under a shared \
directory (notably /run/hyperhive, which holds the host admin socket) \
exposes everything else in that directory to the gateway's nginx"
);
}
/// One test, not three, deliberately: `HIVES_ENV` is a real process
/// env var, and `cargo test`'s default parallel runner would race
/// separate missing/malformed/valid tests against each other. Driving
/// all three states sequentially inside one test needs no mutex and
/// still proves each branch of `load_hives`.
///
/// SAFETY: single-threaded mutation of a process env var no other
/// test in this crate reads; restored (removed) before returning.
#[test]
fn load_hives_covers_missing_malformed_and_valid() {
unsafe {
std::env::remove_var(HIVES_ENV);
}
assert_eq!(
load_hives(),
Vec::<HiveEntry>::new(),
"unset env var is an empty directory, not a startup failure"
);
unsafe {
std::env::set_var(HIVES_ENV, "not json");
}
assert_eq!(
load_hives(),
Vec::<HiveEntry>::new(),
"unparseable env var falls back to empty rather than panicking — \
/health must still answer even if this daemon's own config is wrong"
);
unsafe {
std::env::set_var(
HIVES_ENV,
r#"[{"name":"pr1ma","domain":"pr1ma.example.com","dashboard":"pr1ma.hives.example"},{"name":"umbra","domain":"umbra.example.com","dashboard":"umbra.hives.example"}]"#,
);
}
assert_eq!(
load_hives(),
vec![
HiveEntry {
name: "pr1ma".to_string(),
domain: "pr1ma.example.com".to_string(),
dashboard: "pr1ma.hives.example".to_string(),
},
HiveEntry {
name: "umbra".to_string(),
domain: "umbra.example.com".to_string(),
dashboard: "umbra.hives.example".to_string(),
},
]
);
unsafe {
std::env::remove_var(HIVES_ENV);
}
}
/// Same three-state coverage as `load_hives_covers_missing_malformed_and_valid`,
/// same reason for one test rather than three (a shared process env var).
///
/// SAFETY: single-threaded mutation of a process env var no other test
/// in this crate reads; restored (removed) before returning.
#[test]
fn load_links_covers_missing_malformed_and_valid() {
unsafe {
std::env::remove_var(LINKS_ENV);
}
assert_eq!(
load_links(),
Vec::<ServiceLink>::new(),
"unset env var is an empty list, not a startup failure"
);
unsafe {
std::env::set_var(LINKS_ENV, "not json");
}
assert_eq!(
load_links(),
Vec::<ServiceLink>::new(),
"unparseable env var falls back to empty rather than panicking"
);
unsafe {
std::env::set_var(
LINKS_ENV,
r#"[{"label":"Authelia","icon":"🔑","url":"https://auth.example.com/"}]"#,
);
}
assert_eq!(
load_links(),
vec![ServiceLink {
label: "Authelia".to_string(),
icon: "🔑".to_string(),
url: "https://auth.example.com/".to_string(),
}]
);
unsafe {
std::env::remove_var(LINKS_ENV);
}
}
/// Two states, not three like `load_hives`/`load_links` above: there is
/// nothing to parse here, so no malformed-input branch exists to cover.
///
/// SAFETY: single-threaded mutation of a process env var no other test
/// in this crate reads; restored (removed) before returning.
#[test]
fn load_swarm_name_covers_missing_and_set() {
unsafe {
std::env::remove_var(NAME_ENV);
}
assert_eq!(
load_swarm_name(),
None,
"unset env var is an unnamed swarm, not a startup failure"
);
unsafe {
std::env::set_var(NAME_ENV, "constellat1on");
}
assert_eq!(load_swarm_name(), Some("constellat1on".to_string()));
unsafe {
std::env::remove_var(NAME_ENV);
}
}
// ── NotConnected maps to 503, everything else stays 500 ─────────────
/// Every inference verb refuses a name that is not an identifier with a
/// 400 and answers 503 without a queue; a PUT whose body is refused is a
/// 400 before the queue is asked.
#[tokio::test]
async fn the_inference_routes_check_the_name_and_body_before_the_queue() {
use crate::inference::{delete_agent_inference, get_agent_inference, put_agent_inference};
use axum::extract::{Path, State};
use axum::http::StatusCode;
fn status<T>(r: Result<T, problem_details::ProblemDetails>) -> Option<StatusCode> {
r.map(|_| ()).expect_err("must not succeed").status
}
let (state, _sched) = state_with_roster();
let body = |json: &str| axum::Json(serde_json::from_str(json).expect("decodes"));
for name in ["iris", "Not An Ident"] {
let want = Some(if name == "iris" {
StatusCode::SERVICE_UNAVAILABLE
} else {
StatusCode::BAD_REQUEST
});
let path = || Path(name.to_owned());
assert_eq!(
status(get_agent_inference(State(state.clone()), path()).await),
want,
"GET {name}"
);
assert_eq!(
status(
put_agent_inference(State(state.clone()), path(), body(r#"{"model":"opus"}"#))
.await
),
want,
"PUT {name}"
);
assert_eq!(
status(delete_agent_inference(State(state.clone()), path()).await),
want,
"DELETE {name}"
);
}
for bad in [r#"{"model":"opus 4"}"#, r#"{"model":"--print"}"#] {
assert_eq!(
status(
put_agent_inference(State(state.clone()), Path("iris".to_owned()), body(bad))
.await
),
Some(StatusCode::BAD_REQUEST),
"{bad}"
);
}
}
#[tokio::test]
async fn listing_subagents_needs_a_queue_and_an_identifier() {
let (state, _sched) = state_with_roster();
let no_queue = crate::subagent_term::list_subagents(
axum::extract::State(state.clone()),
axum::extract::Path("iris".to_owned()),
)
.await
.expect_err("no queue is configured");
assert_eq!(
no_queue.status,
Some(axum::http::StatusCode::SERVICE_UNAVAILABLE),
"{no_queue:?}"
);
let bad_name = crate::subagent_term::list_subagents(
axum::extract::State(state),
axum::extract::Path("iris.sub".to_owned()),
)
.await
.expect_err("a dot would widen the subject");
assert_eq!(
bad_name.status,
Some(axum::http::StatusCode::BAD_REQUEST),
"{bad_name:?}"
);
}
/// A subagent name is a subject token, so a wildcard or a dot in it is
/// refused before anything subscribes.
#[tokio::test]
async fn a_subagent_stream_refuses_a_name_that_is_not_one_token() {
let (state, _sched) = state_with_roster();
for subagent in ["*", ">", "a.b"] {
let Err(problem) = crate::subagent_term::stream_subagent_term(
axum::extract::State(state.clone()),
axum::extract::Path(("iris".to_owned(), subagent.to_owned())),
axum::http::HeaderMap::new(),
)
.await
else {
panic!("{subagent:?} must be refused");
};
assert_eq!(
problem.status,
Some(axum::http::StatusCode::BAD_REQUEST),
"{subagent:?}: {problem:?}"
);
}
}
}

View file

@ -36,7 +36,7 @@ use futures_util::TryStreamExt as _;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::HiveEntry;
use crate::routes::hives::HiveEntry;
/// Default age past which a snapshot is reported stale.
///
@ -355,7 +355,7 @@ pub(crate) fn row(
#[cfg(test)]
mod tests {
use super::{DEFAULT_STALE_AFTER, Freshness, Offered, STALE_AFTER_ENV, StatusReader, render};
use crate::HiveEntry;
use crate::routes::hives::HiveEntry;
use std::collections::BTreeMap;
use std::time::{Duration, SystemTime};

View file

@ -37,7 +37,7 @@ use serde::Serialize;
use swarm_queue_client::{agent_term, subagent_term};
use utoipa::ToSchema;
use crate::AppState;
use crate::routes::AppState;
use crate::wanted::WantedWriter;
/// How often [`spawn`] checks every live agent's stream. Also how long a newly
@ -264,11 +264,11 @@ fn ident(name: &str) -> Result<String, &'static str> {
}
fn bad_request(reason: &str) -> problem_details::ProblemDetails {
crate::error_problem(StatusCode::BAD_REQUEST, reason)
crate::routes::error_problem(StatusCode::BAD_REQUEST, reason)
}
fn unavailable(detail: &str) -> problem_details::ProblemDetails {
crate::error_problem(StatusCode::SERVICE_UNAVAILABLE, detail)
crate::routes::error_problem(StatusCode::SERVICE_UNAVAILABLE, detail)
}
fn is_stream_not_found(e: &async_nats::jetstream::context::GetStreamError) -> bool {
@ -282,7 +282,7 @@ fn is_stream_not_found(e: &async_nats::jetstream::context::GetStreamError) -> bo
fn read_failed(agent: &str, e: &dyn std::error::Error) -> problem_details::ProblemDetails {
let detail = swarm_queue_client::chain(e);
tracing::warn!(%agent, error = %detail, "listing subagents: reading the stream failed");
crate::error_problem(StatusCode::INTERNAL_SERVER_ERROR, &detail)
crate::routes::error_problem(StatusCode::INTERNAL_SERVER_ERROR, &detail)
}
#[cfg(test)]

View file

@ -37,7 +37,7 @@ use futures_util::stream::BoxStream;
use futures_util::{Stream, StreamExt as _};
use swarm_queue_client::agent_term;
use crate::AppState;
use crate::routes::AppState;
#[utoipa::path(
get,
@ -68,19 +68,21 @@ pub(crate) async fn stream_agent_term(
headers: HeaderMap,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, problem_details::ProblemDetails> {
let Some(status) = state.status.as_ref() else {
return Err(crate::error_problem(
return Err(crate::routes::error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"no swarm queue is configured on this host",
));
};
let Some(agent_status) = state.agent_status.as_ref() else {
return Err(crate::error_problem(
return Err(crate::routes::error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"no agent-status reader is configured on this host",
));
};
let agent = hive_types::Ident::parse(&agent)
.map_err(|reason| crate::error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.map_err(|reason| {
crate::routes::error_problem(axum::http::StatusCode::BAD_REQUEST, reason)
})?
.into_string();
// One-agent "roster": reuses `AgentStatusReader::view`'s already-tested
@ -92,12 +94,12 @@ pub(crate) async fn stream_agent_term(
.map_err(|e| {
let detail = format!("{e:#}");
tracing::warn!(%agent, error = %detail, "term stream: reading agent status failed");
crate::error_problem(axum::http::StatusCode::INTERNAL_SERVER_ERROR, &detail)
crate::routes::error_problem(axum::http::StatusCode::INTERNAL_SERVER_ERROR, &detail)
})?
.into_iter()
.find_map(|row| row.hive)
.ok_or_else(|| {
crate::error_problem(
crate::routes::error_problem(
axum::http::StatusCode::NOT_FOUND,
&format!("{agent:?} has never reported to the swarm"),
)
@ -110,7 +112,7 @@ pub(crate) async fn stream_agent_term(
// answer is 503, the same shape every other queue-backed route
// here already uses.
swarm_queue_client::ensure_connected(&client).map_err(|e| {
crate::error_problem(
crate::routes::error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
&swarm_queue_client::chain(&e),
)
@ -277,7 +279,7 @@ pub(crate) async fn subscribe(
) -> Result<async_nats::Subscriber, problem_details::ProblemDetails> {
let subscriber = client.subscribe(subject.clone()).await.map_err(|e| {
tracing::warn!(%subject, error = %e, "{what} stream: subscribe failed");
crate::error_problem(
crate::routes::error_problem(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
&format!("subscribing to {subject} failed: {e}"),
)

View file

@ -21,7 +21,7 @@ use axum::{
use hmac::{Hmac, KeyInit as _, Mac as _};
use sha2::Sha256;
use super::AppState;
use crate::routes::AppState;
/// Where the HMAC secret is kept, relative to the daemon's state directory.
///
@ -416,10 +416,10 @@ pub(super) async fn post_webhook_forge(
cache.apply_webhook_delivery(&body);
}
if let Some(merged) = crate::config_pr::merged(&body) {
super::queue_merged_config_deploy(&state, merged).await;
crate::jobq::queue_merged_config_deploy(&state, merged).await;
}
if let Some(changed) = crate::config_pr::changed_head(&body) {
super::queue_config_build(&state, changed);
crate::jobq::queue_config_build(&state, changed);
}
}
DeliveryKind::VcsActivity => {