swarm-nats-auth: a review classifier hears try-outs and answers by reply only
swarm-queue-client gains review_classifier: the try-out subject, the request and reply that travel on it, and the deadlines both ends share. The controller's reader grant may publish the subject; a client named with --review-classifier-client may subscribe to it and its inbox, and answers under NATS allow_responses (resp in the user JWT) within the run deadline. Every other client id is still denied.
This commit is contained in:
parent
fb5eb9edc1
commit
98c873965b
8 changed files with 354 additions and 13 deletions
|
|
@ -392,6 +392,11 @@ pub mod agent_term;
|
|||
/// feature; the names are unconditional, like [`agent_icon`]'s.
|
||||
pub mod subagent_term;
|
||||
|
||||
/// The review classifier's try-out subject and the request and reply it
|
||||
/// carries. The `schema` feature adds `ToSchema` for an HTTP API serving the
|
||||
/// same shapes.
|
||||
pub mod review_classifier;
|
||||
|
||||
/// Only the fields this needs; authelia returns several.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TokenResponse {
|
||||
|
|
|
|||
168
swarm-queue-client/src/review_classifier.rs
Normal file
168
swarm-queue-client/src/review_classifier.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
//! The review classifier's try-out: the subject swarm-controller sends a run
|
||||
//! on, and what travels each way.
|
||||
//!
|
||||
//! Three crates name these: the controller sends the request, the classifier
|
||||
//! answers it, and the auth-callout responder grants the one subject to each.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The subject a try-out request goes to. The controller is its one
|
||||
/// publisher and the classifier its one subscriber; the classifier answers
|
||||
/// on the request's reply subject.
|
||||
pub const SUBJECT: &str = "$SWARM.review-classifier.try-out";
|
||||
|
||||
/// The queue group the classifier subscribes in, so a second instance shares
|
||||
/// the requests instead of running each one twice.
|
||||
pub const QUEUE_GROUP: &str = "review-classifier";
|
||||
|
||||
/// The most classifications one run carries.
|
||||
pub const MAX_CLASSIFICATIONS: usize = 10;
|
||||
|
||||
/// The longest one classification's model run may take before it is stopped
|
||||
/// and its row carries a timeout.
|
||||
pub const CLASSIFICATION_DEADLINE: Duration = Duration::from_mins(3);
|
||||
|
||||
/// The longest a whole run may take: every classification at its deadline,
|
||||
/// run one after another, plus a minute for the rest. The controller waits
|
||||
/// this long for the reply, and the queue lets the classifier answer for
|
||||
/// this long.
|
||||
pub const RUN_DEADLINE: Duration = Duration::from_mins(31);
|
||||
|
||||
/// One run: the PR as the controller read it, and the classifications to
|
||||
/// answer about it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TryOut {
|
||||
/// The swarm UI user who started the run, as the gateway named them.
|
||||
pub operator: String,
|
||||
/// `owner/name`.
|
||||
pub repo: String,
|
||||
pub pr: u64,
|
||||
/// The commit the commits and the diff were read at.
|
||||
pub head_sha: String,
|
||||
/// Oldest first.
|
||||
pub commits: Vec<Commit>,
|
||||
/// The PR's unified diff, as the forge serves it.
|
||||
pub diff: String,
|
||||
pub classifications: Vec<Classification>,
|
||||
}
|
||||
|
||||
/// One commit of the PR.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Commit {
|
||||
pub sha: String,
|
||||
/// The full message, subject and body.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// One yes/no rule to check the PR against.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
pub struct Classification {
|
||||
pub name: String,
|
||||
/// The yes/no question the model answers about the PR.
|
||||
pub description: String,
|
||||
pub polarity: Polarity,
|
||||
/// The certainty, 0 to 100, a verdict needs to count.
|
||||
pub threshold: u8,
|
||||
}
|
||||
|
||||
/// Which verdict is the wanted one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Polarity {
|
||||
/// A match is the wanted outcome.
|
||||
Positive,
|
||||
/// A match is a violation.
|
||||
Negative,
|
||||
}
|
||||
|
||||
/// The classifier's answer to one [`TryOut`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "state", rename_all = "kebab-case")]
|
||||
pub enum Reply {
|
||||
Done {
|
||||
rows: Vec<Row>,
|
||||
overall: Overall,
|
||||
},
|
||||
/// Nothing ran to a verdict: no API key, or every classification failed.
|
||||
Failed {
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// One classification's result, as the result table shows it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
pub struct Row {
|
||||
pub name: String,
|
||||
pub polarity: Polarity,
|
||||
pub verdict: Verdict,
|
||||
pub certainty: u8,
|
||||
pub threshold: u8,
|
||||
/// Whether the row counts toward the overall verdict: applicable, and
|
||||
/// its certainty reached the threshold.
|
||||
pub counted: bool,
|
||||
/// For a counted row, whether the verdict is on the wanted side of the
|
||||
/// polarity. `false` for every row that is not counted.
|
||||
pub passed: bool,
|
||||
pub reason: String,
|
||||
/// Why this classification has no answer. Such a row is never counted.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Verdict {
|
||||
Match,
|
||||
NoMatch,
|
||||
NotApplicable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Overall {
|
||||
Approve,
|
||||
RequestChanges,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_wire_spells_the_enums_as_the_api_does() {
|
||||
let row = Row {
|
||||
name: "datetime".to_owned(),
|
||||
polarity: Polarity::Positive,
|
||||
verdict: Verdict::NotApplicable,
|
||||
certainty: 95,
|
||||
threshold: 70,
|
||||
counted: false,
|
||||
passed: false,
|
||||
reason: "The PR adds no timestamp.".to_owned(),
|
||||
error: None,
|
||||
};
|
||||
let json = serde_json::to_value(Reply::Done {
|
||||
rows: vec![row],
|
||||
overall: Overall::RequestChanges,
|
||||
})
|
||||
.expect("encodes");
|
||||
assert_eq!(json["state"], "done");
|
||||
assert_eq!(json["overall"], "request-changes");
|
||||
assert_eq!(json["rows"][0]["polarity"], "positive");
|
||||
assert_eq!(json["rows"][0]["verdict"], "not-applicable");
|
||||
assert!(json["rows"][0].get("error").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_run_deadline_covers_every_classification_at_its_deadline() {
|
||||
let all = CLASSIFICATION_DEADLINE * u32::try_from(MAX_CLASSIFICATIONS).expect("small");
|
||||
assert!(RUN_DEADLINE > all);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue