Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-review-classifier/src/main.rs
atlas 741fe388f9 move startup_read from swarm-queue-client to swarm-secret-client
read_with_startup_retry retries the first read of a client secret through a store that is restarting. Nothing in swarm-queue-client calls it; its only callers are the four binaries that read the secret (swarm-builder, swarm-claude-login, swarm-controller, swarm-review-classifier), and they already depend on swarm-secret-client. It sits beside stored_secret, which moved there in #5538. swarm-queue-client drops its backon dependency and the tokio test-util dev-dependency that only these tests used.
2026-10-18 16:11:55 +02:00

176 lines
6.3 KiB
Rust

//! The review classifier's try-out service.
//!
//! Connects to the swarm queue as its own OIDC client, takes try-out requests
//! from swarm-controller on
//! [`swarm_queue_client::review_classifier::SUBJECT`], answers each one on its
//! reply subject, and logs one line per run. It holds no forge credential:
//! the controller reads the PR and sends it along.
//!
//! The store identity comes from the `BAO_*` environment. The queue client
//! secret is read at start and again when authelia refuses it, the run's key
//! once per run, both from the store under that identity.
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use futures_util::StreamExt as _;
use swarm_queue_client::review_classifier::{QUEUE_GROUP, Reply, SUBJECT, TryOut};
use swarm_queue_client::{ClientSecret, QueueConfig};
use swarm_secret_client::stored_secret::StoredSecret;
mod acp;
mod aggregate;
mod answer;
mod child_env;
mod cost;
mod prompt;
mod run;
/// The cert-auth role this service logs in to the store as. `swarm-bao.nix`
/// writes it, matching the leaf's subject.
const CERT_ROLE: &str = "swarm-review-classifier";
/// The environment prefix `swarm-review-classifier.nix` sets.
const PREFIX: &str = "SWARM_REVIEW_CLASSIFIER";
fn main() -> Result<()> {
// Before anything else: as the trampoline, this process becomes the
// model child.
child_env::exec_if_requested();
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("starting the async runtime")?
.block_on(serve())
}
async fn serve() -> Result<()> {
hive_log::init();
swarm_secret_client::install_crypto_provider();
let var = |name: &str| {
let full = format!("{PREFIX}_{name}");
std::env::var(&full).with_context(|| format!("{full} is unset"))
};
let client_id = var("OIDC_CLIENT_ID")?;
let trampoline =
std::env::current_exe().context("finding this binary for the child trampoline")?;
let models = run::Runtimes {
claude: run::Claude {
program: PathBuf::from(var("CLAUDE")?),
trampoline: trampoline.clone(),
},
acp: acp::Acp {
program: PathBuf::from(var("OPENCODE")?),
trampoline,
cancel_wait: acp::CANCEL_WAIT,
cost: cost::Otel,
cwd: std::env::temp_dir().join("review-classifier-acp-cwd"),
},
};
let secret =
swarm_secret_client::read_with_startup_retry("reading the queue client secret", || {
client_secret(&client_id)
})
.await?;
let reread_id = client_id.clone();
let secret = StoredSecret::new(client_id.clone(), secret, move || {
let client_id = reread_id.clone();
async move { client_secret(&client_id).await.map_err(Into::into) }
});
let queue = swarm_queue_client::connect(QueueConfig {
url: var("NATS_URL")?,
token_endpoint: var("OIDC_TOKEN_ENDPOINT")?,
client_id,
client_secret: ClientSecret::Stored(secret),
ca_file: std::env::var(format!("{PREFIX}_OIDC_CA_FILE"))
.ok()
.map(PathBuf::from),
})
.await?;
let mut requests = queue
.queue_subscribe(SUBJECT, QUEUE_GROUP.to_owned())
.await
.with_context(|| format!("subscribing to {SUBJECT}"))?;
tracing::info!(subject = SUBJECT, "review classifier: awaiting try-outs");
let models = std::sync::Arc::new(models);
while let Some(msg) = requests.next().await {
let Some(reply_to) = msg.reply.clone() else {
tracing::warn!("a try-out arrived with no reply subject; dropping it");
continue;
};
let queue = queue.clone();
let models = std::sync::Arc::clone(&models);
tokio::spawn(async move {
let reply = handle(&msg.payload, &run::Store, &*models).await;
match serde_json::to_vec(&reply) {
Ok(body) => {
if let Err(e) = queue.publish(reply_to, body.into()).await {
tracing::warn!(error = %e, "publishing a try-out's reply failed");
}
}
Err(e) => tracing::warn!(error = %e, "encoding a try-out's reply failed"),
}
});
}
bail!("the subscription to {SUBJECT} ended")
}
/// Decode one request, answer it and log the run.
async fn handle(payload: &[u8], keys: &impl run::KeySource, model: &impl run::Model) -> Reply {
let run: TryOut = match serde_json::from_slice(payload) {
Ok(run) => run,
Err(e) => {
tracing::warn!(error = %e, "an undecodable try-out; answering with the error");
return Reply::Failed {
error: format!("the classifier could not decode the request: {e}"),
};
}
};
let reply = run::answer(keys, model, &run).await;
log_run(&run, &reply);
reply
}
/// The one journal line a run leaves.
fn log_run(run: &TryOut, reply: &Reply) {
match reply {
Reply::Done { rows, overall } => tracing::info!(
operator = %run.operator,
repo = %run.repo,
pr = run.pr,
head_sha = %run.head_sha,
rows = %serde_json::to_string(rows).unwrap_or_default(),
overall = ?overall,
"review classification run"
),
Reply::Failed { error } => tracing::warn!(
operator = %run.operator,
repo = %run.repo,
pr = run.pr,
head_sha = %run.head_sha,
%error,
"review classification run failed"
),
}
}
async fn client_secret(client_id: &str) -> Result<String> {
use swarm_secret_client::review_classifier::{ClientSecret, client_secret_path};
let path = client_secret_path(client_id)?;
let store = swarm_secret_client::SecretStore::from_env(CERT_ROLE)
.await
.context("logging in to the secret store")?;
let stored: Option<ClientSecret> = store
.read_optional(&path)
.await
.with_context(|| format!("reading {path}"))?;
match stored {
Some(s) if !s.value.trim().is_empty() => Ok(s.value.trim().to_owned()),
_ => bail!(
"nothing at {path}: swarm-secret-publish, in authelia's container, has not \
published this service's client secret"
),
}
}