Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/forge_account.rs
atlas 2fc0166427 swarm-controller: record the review classifier's runs and list them
Every run is recorded when it ends: a try-out when its reply settles it,
a requested review once its review is posted or the post fails, and a
request turned away by a full queue once its comment is posted. A
duplicate request that starts nothing is not recorded. The newest 100
are one JSON array under `recent` in a new controller-owned KV bucket,
`review-classifier-runs`, written with compare-and-set as
`schedule-definitions` is (five attempts). A failed write is logged; the
review is already posted by then.

Each recorded run is also one info event, "review classifier run
finished", with the record's fields as tracing fields. The controller
logs natively to the journal, so they reach VictoriaLogs as F_<NAME>.

GET /api/review-classifier/runs returns the list, newest first, gated
by the gateway like the try-out GET. post_classifier_review now returns
the posted review's html_url, which the record keeps as review_url.

Closes #5153, part of #5096.
2026-10-12 21:48:51 +02:00

242 lines
9.5 KiB
Rust

//! An agent's accounts on external forges: an operator hands us a base URL and
//! a token, we put them in the swarm's secret store.
//!
//! The agent end is `nix/agent-modules/forge-accounts.nix`, which lists the
//! agent's accounts and reads each under the agent's own certificate, and writes
//! the files `hive-forge -f <label>` reads. No hive is in the path.
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use swarm_secret_client::forge;
use utoipa::ToSchema;
use super::linked_accounts::link;
use super::{AppState, error_problem, swarm_hive};
/// The account to store for one agent's external forge.
///
/// No `Debug` derive: this carries a token.
#[derive(Deserialize, ToSchema)]
pub struct PutForgeAccountRequest {
/// The forge's base URL, `http://` or `https://`. A trailing slash is
/// dropped.
#[schema(example = "https://forge.example.org")]
url: String,
/// The access token. Never logged, and never returned by this route.
token: String,
}
/// `put_forge_account`'s success body.
#[derive(Debug, Serialize, ToSchema)]
pub struct PutForgeAccountResponse {
/// The base URL as stored.
url: String,
}
/// Store an agent's external forge account, unless one is stored under the
/// label already.
#[utoipa::path(
put,
path = "/api/hives/{hive}/agents/{agent}/forge-accounts/{label}",
params(
("hive" = String, Path, description = "hive the agent runs on"),
("agent" = String, Path, description = "agent the account belongs to"),
("label" = String, Path, description = "the name the agent passes to `hive-forge -f`"),
),
request_body = PutForgeAccountRequest,
responses(
(status = 200, description = "stored", body = PutForgeAccountResponse),
(status = 400, description = "the agent or label is not an identifier, the URL is not http(s), the token is empty, or the hive is not in this swarm (problem+json)", body = String),
(status = 409, description = "an account is stored under the label already; nothing was written (problem+json)", body = String),
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
),
tag = "agents"
)]
pub async fn put_forge_account(
State(state): State<AppState>,
axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>,
Json(req): Json<PutForgeAccountRequest>,
) -> Result<Json<PutForgeAccountResponse>, problem_details::ProblemDetails> {
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
let agent = hive_types::Ident::parse(&agent)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
// `hive-forge` accepts only `[a-z0-9-]` after `-f`, narrower than the
// store's charset, so a label it would refuse is refused here.
let label = hive_types::Ident::parse(&label)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
let secret_path = forge::account_path(&agent, &label)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
let account = account(req).map_err(|e| error_problem(StatusCode::BAD_REQUEST, e))?;
let store = state.accounts.open().await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
link(&store, &secret_path, &account).await.map_err(|e| {
// The path names the agent and the label; the value is not in it.
tracing::warn!(path = %secret_path, error = ?e, "linking the forge account failed");
e.problem(&existing(&agent, &label))
})?;
tracing::info!(%hive, %agent, %label, url = %account.url, "forge account stored");
Ok(Json(PutForgeAccountResponse { url: account.url }))
}
/// The account a refused link names.
fn existing(agent: &str, label: &str) -> String {
format!("agent {agent} already has forge account {label:?}")
}
/// The request as it is stored, or why it cannot be.
fn account(req: PutForgeAccountRequest) -> Result<forge::Account, &'static str> {
let url = req.url.trim().trim_end_matches('/');
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err("url must start with http:// or https://");
}
if req.token.trim().is_empty() {
return Err("token is required");
}
Ok(forge::Account {
value: req.token,
url: url.to_owned(),
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::super::linked_accounts::{Accounts, tests::FakeStore};
use super::{PutForgeAccountRequest, account};
fn request(url: &str, token: &str) -> PutForgeAccountRequest {
PutForgeAccountRequest {
url: url.to_owned(),
token: token.to_owned(),
}
}
#[test]
fn the_url_loses_its_trailing_slash_and_the_token_is_kept() {
let a = account(request("https://forge.example.org/ ", "t0k3n")).expect("valid");
assert_eq!(a.url, "https://forge.example.org");
assert_eq!(a.value, "t0k3n");
}
#[test]
fn a_url_that_is_not_http_is_refused() {
assert!(account(request("forge.example.org", "t")).is_err());
assert!(account(request("file:///etc/passwd", "t")).is_err());
// The control: plain http is accepted.
assert!(account(request("http://forge.lan", "t")).is_ok());
}
#[test]
fn an_empty_token_is_refused() {
assert!(account(request("https://forge.example.org", " ")).is_err());
}
/// 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 {
name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(),
}]),
links: std::sync::Arc::new(Vec::new()),
status: None,
wanted: None,
agent_status: None,
agent_icons: None,
reported_inference: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
))),
webhook_secret: None,
config_prs: std::sync::Arc::default(),
swarm_name: None,
auth: None,
forge: std::sync::Arc::default(),
create_gate: std::sync::Arc::default(),
accounts: Accounts::Fake(Arc::clone(store)),
try_outs: super::super::review_classifier::TryOuts::default(),
reviews: std::sync::Arc::default(),
runs: crate::review_classifier::runs::Runs::Live(None),
}
}
async fn put(
store: &Arc<FakeStore>,
label: &str,
) -> Result<axum::Json<super::PutForgeAccountResponse>, problem_details::ProblemDetails> {
super::put_forge_account(
axum::extract::State(state(store)),
axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), label.to_owned())),
axum::Json(request("https://forge.example.org", "t0k3n")),
)
.await
}
/// A label `hive-forge -f` could not name is refused before the store.
#[tokio::test]
async fn a_label_hive_forge_cannot_name_is_refused_before_the_store() {
let store = Arc::default();
for bad in ["MyForge", "my_forge", "../x"] {
let problem = put(&store, bad).await.expect_err("refused");
assert_eq!(
problem.status,
Some(axum::http::StatusCode::BAD_REQUEST),
"{bad}: {problem:?}"
);
}
assert!(store.written().is_empty());
}
/// The control: a plain label is written.
#[tokio::test]
async fn a_plain_label_reaches_the_store() {
let store = Arc::default();
let stored = put(&store, "myforge").await.expect("nothing is stored");
assert_eq!(stored.url, "https://forge.example.org");
assert_eq!(store.written(), ["swarm/agents/atlas/forge/myforge"]);
}
#[tokio::test]
async fn linking_a_label_twice_is_a_409_and_the_first_account_stays() {
use super::super::linked_accounts::{AccountStore, link};
use swarm_secret_client::forge;
let store = FakeStore::default();
let path = forge::account_path("atlas", "myforge").expect("a valid path");
let first = account(request("https://forge.example.org", "t0k3n-first")).expect("valid");
let second = account(request("https://forge.lan", "t0k3n-second")).expect("valid");
link(&store, &path, &first)
.await
.expect("nothing is stored");
let problem = link(&store, &path, &second)
.await
.expect_err("an account is stored")
.problem(&super::existing("atlas", "myforge"));
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
let detail = problem.detail.expect("a detail");
assert!(
detail.contains(r#"agent atlas already has forge account "myforge""#),
"{detail}"
);
assert_eq!(store.written(), std::slice::from_ref(&path));
let kept: forge::Account = store
.read_optional(&path)
.await
.expect("store answers")
.expect("still stored");
assert_eq!(kept.url, "https://forge.example.org");
assert_eq!(kept.value, "t0k3n-first");
}
}