swarm UI: send a message to an agent, without its hive
The agent view in the swarm UI gets a message box. The text reaches the
agent's harness straight from swarm-controller over the swarm queue and
becomes an inbox turn from the operator's authelia username. hive-c0re is
not in the path.
- gateway: `/api/` forwards authelia's `Remote-User`, replacing any the
client sent.
- swarm-controller: `POST /api/agents/{name}/input` takes that header as
the sender (401 without it) and sends `{from, body}` as a NATS request
on `$SWARM.input.<agent>`. No subscriber is a 503.
- swarm-nats-auth: the controller is granted publish on `$SWARM.input.*`;
no hive or agent grant names the family, so the harness can trust
`from`.
- hive-agent: a task subscribes to the agent's own input subject and
pushes each message into the serve loop's existing reminder channel,
renamed `local_msg_*` since it now carries both.
The harness does not reply to the request: no agent grant covers a reply
subject. The controller therefore answers 202 once the request times out
(2s) and 503 on the server's no-responders status.
Closes #4828
This commit is contained in:
parent
5971b51e65
commit
48ac4ac423
13 changed files with 664 additions and 24 deletions
300
swarm-controller/src/agent_input.rs
Normal file
300
swarm-controller/src/agent_input.rs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
//! `POST /api/agents/{name}/input` — text the operator types in the swarm UI,
|
||||
//! handed to one agent's harness as an inbox turn. No hive is in the path: the
|
||||
//! agent subscribes to [`swarm_queue_client::agent_input_subject`] itself.
|
||||
//!
|
||||
//! **The sender is the `Remote-User` header**, which the gateway sets on
|
||||
//! `/api/` from authelia's auth-request answer and so overwrites whatever the
|
||||
//! client sent (`nix/host-modules/swarm-ui.nix`). A request without one is
|
||||
//! refused rather than sent anonymously.
|
||||
//!
|
||||
//! **Sent as a NATS request** so that an agent with no subscriber is a 503
|
||||
//! instead of text that silently goes nowhere. The agent does not reply: no
|
||||
//! agent grant covers a reply subject. A request that times out therefore
|
||||
//! means a subscriber held the message, and that is all "sent" means. Core
|
||||
//! NATS, no stream: an agent that restarts before its turn loses the text.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use problem_details::ProblemDetails;
|
||||
use serde::Deserialize;
|
||||
use swarm_queue_client::AgentInput;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{AppState, error_problem};
|
||||
|
||||
/// The header the gateway names the session's user in.
|
||||
const SENDER_HEADER: &str = "remote-user";
|
||||
|
||||
/// How long to wait for the server's no-responders answer. It comes back
|
||||
/// within a round trip when nothing subscribes; a subscriber sends nothing,
|
||||
/// so a send to a live agent always waits this long.
|
||||
const NO_RESPONDERS_WINDOW: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Text for one agent.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct PostAgentInputRequest {
|
||||
/// What the agent's turn shows as the message body, verbatim. Not logged.
|
||||
body: String,
|
||||
}
|
||||
|
||||
/// What became of one message.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Delivery {
|
||||
/// A subscriber held it.
|
||||
Sent,
|
||||
/// Nothing subscribes to the agent's input subject.
|
||||
NotRunning,
|
||||
/// The queue refused or failed the request.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Where a message goes: the queue in production, a fake in the tests.
|
||||
pub(crate) trait InputSink {
|
||||
fn deliver(&self, subject: String, payload: Vec<u8>) -> impl Future<Output = Delivery> + Send;
|
||||
}
|
||||
|
||||
impl InputSink for async_nats::Client {
|
||||
async fn deliver(&self, subject: String, payload: Vec<u8>) -> Delivery {
|
||||
use async_nats::RequestErrorKind;
|
||||
|
||||
let request = async_nats::Request::new()
|
||||
.payload(payload.into())
|
||||
.timeout(Some(NO_RESPONDERS_WINDOW));
|
||||
match self.send_request(subject, request).await {
|
||||
Ok(_) => Delivery::Sent,
|
||||
Err(e) => match e.kind() {
|
||||
RequestErrorKind::TimedOut => Delivery::Sent,
|
||||
RequestErrorKind::NoResponders => Delivery::NotRunning,
|
||||
_ => Delivery::Failed(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send text to an agent as the logged-in operator.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/agents/{name}/input",
|
||||
params(("name" = String, Path, description = "agent to send the text to")),
|
||||
request_body = PostAgentInputRequest,
|
||||
responses(
|
||||
(status = 202, description = "the agent's harness is subscribed and was handed the \
|
||||
message; it becomes a turn from the sender when the harness next picks one up"),
|
||||
(status = 400, description = "the agent name is not shaped like an identifier, or \
|
||||
the body is empty (problem+json)", body = String),
|
||||
(status = 401, description = "the request carries no `Remote-User`, so there is no \
|
||||
sender to name (problem+json)", body = String),
|
||||
(status = 503, description = "the agent's harness is not subscribed — not running, \
|
||||
or not connected to the swarm queue — or no swarm queue is configured on this \
|
||||
host or it could not be reached (problem+json)", body = String),
|
||||
(status = 500, description = "the queue refused or failed the request (problem+json)",
|
||||
body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub(crate) async fn post_agent_input(
|
||||
State(state): State<AppState>,
|
||||
Path(agent): Path<String>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<PostAgentInputRequest>,
|
||||
) -> Result<StatusCode, ProblemDetails> {
|
||||
let (agent, input) =
|
||||
message(&headers, &agent, req.body).map_err(|(s, d)| error_problem(s, d))?;
|
||||
let Some(status) = state.status.as_ref() else {
|
||||
return Err(error_problem(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"no swarm queue is configured on this host",
|
||||
));
|
||||
};
|
||||
let client = status.queue_client();
|
||||
swarm_queue_client::ensure_connected(&client).map_err(|e| {
|
||||
error_problem(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
&swarm_queue_client::chain(&e),
|
||||
)
|
||||
})?;
|
||||
send(&client, &agent, &input).await
|
||||
}
|
||||
|
||||
/// The agent and the message, or the refusal. Everything decided before the
|
||||
/// queue is touched.
|
||||
fn message(
|
||||
headers: &HeaderMap,
|
||||
agent: &str,
|
||||
body: String,
|
||||
) -> Result<(String, AgentInput), (StatusCode, &'static str)> {
|
||||
let from = headers
|
||||
.get(SENDER_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"no Remote-User header: the gateway names the sender, and this request \
|
||||
did not come through it",
|
||||
))?
|
||||
.to_owned();
|
||||
let agent = hive_types::Ident::parse(agent)
|
||||
.map_err(|reason| (StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
if body.trim().is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "body is empty"));
|
||||
}
|
||||
Ok((agent, AgentInput { from, body }))
|
||||
}
|
||||
|
||||
async fn send(
|
||||
sink: &impl InputSink,
|
||||
agent: &str,
|
||||
input: &AgentInput,
|
||||
) -> Result<StatusCode, ProblemDetails> {
|
||||
let subject = swarm_queue_client::agent_input_subject(agent);
|
||||
let payload = serde_json::to_vec(input).map_err(|e| {
|
||||
error_problem(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("encoding the input failed: {e}"),
|
||||
)
|
||||
})?;
|
||||
match sink.deliver(subject.clone(), payload).await {
|
||||
Delivery::Sent => {
|
||||
tracing::info!(%agent, from = %input.from, "agent input sent");
|
||||
Ok(StatusCode::ACCEPTED)
|
||||
}
|
||||
Delivery::NotRunning => Err(error_problem(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
&format!(
|
||||
"{agent} is not listening for input: its harness is not running or not connected to the swarm queue"
|
||||
),
|
||||
)),
|
||||
Delivery::Failed(e) => {
|
||||
tracing::warn!(%agent, %subject, error = %e, "agent input failed");
|
||||
Err(error_problem(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("sending to {subject} failed: {e}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use swarm_queue_client::AgentInput;
|
||||
|
||||
use super::{Delivery, InputSink, message, send};
|
||||
|
||||
/// Answers every message with one fixed outcome and records what it got.
|
||||
struct FakeSink {
|
||||
answer: fn() -> Delivery,
|
||||
got: Mutex<Vec<(String, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
impl FakeSink {
|
||||
fn new(answer: fn() -> Delivery) -> Self {
|
||||
Self {
|
||||
answer,
|
||||
got: Mutex::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InputSink for FakeSink {
|
||||
async fn deliver(&self, subject: String, payload: Vec<u8>) -> Delivery {
|
||||
self.got
|
||||
.lock()
|
||||
.expect("unpoisoned")
|
||||
.push((subject, payload));
|
||||
(self.answer)()
|
||||
}
|
||||
}
|
||||
|
||||
fn from(user: &str) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("Remote-User", HeaderValue::from_str(user).expect("ascii"));
|
||||
h
|
||||
}
|
||||
|
||||
fn status_of(r: Result<(String, AgentInput), (StatusCode, &'static str)>) -> StatusCode {
|
||||
r.expect_err("refused").0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sender_is_the_remote_user_header() {
|
||||
let (agent, input) = message(&from("mara"), "atlas", "hi".to_owned()).expect("valid");
|
||||
assert_eq!(agent, "atlas");
|
||||
assert_eq!(
|
||||
input,
|
||||
AgentInput {
|
||||
from: "mara".to_owned(),
|
||||
body: "hi".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_sender_is_refused_before_anything_else() {
|
||||
// A bad agent and an empty body as well: the missing sender is the
|
||||
// answer, not whichever check happens to run first.
|
||||
assert_eq!(
|
||||
status_of(message(&HeaderMap::new(), "../x", String::new())),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
status_of(message(&from(" "), "atlas", "hi".to_owned())),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_agent_or_an_empty_body_is_refused() {
|
||||
assert_eq!(
|
||||
status_of(message(&from("mara"), "../x", "hi".to_owned())),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(
|
||||
status_of(message(&from("mara"), "atlas", " \n".to_owned())),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_held_message_is_202_on_the_agent_s_own_subject() {
|
||||
let sink = FakeSink::new(|| Delivery::Sent);
|
||||
let input = AgentInput {
|
||||
from: "mara".to_owned(),
|
||||
body: "hi".to_owned(),
|
||||
};
|
||||
let status = send(&sink, "atlas", &input).await.expect("sent");
|
||||
assert_eq!(status, StatusCode::ACCEPTED);
|
||||
let got = sink.got.lock().expect("unpoisoned");
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].0, "$SWARM.input.atlas");
|
||||
let decoded: AgentInput = serde_json::from_slice(&got[0].1).expect("an AgentInput");
|
||||
assert_eq!(decoded, input);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_subscriber_is_503_and_a_queue_failure_is_500() {
|
||||
let input = AgentInput {
|
||||
from: "mara".to_owned(),
|
||||
body: "hi".to_owned(),
|
||||
};
|
||||
let not_running = send(&FakeSink::new(|| Delivery::NotRunning), "atlas", &input)
|
||||
.await
|
||||
.expect_err("nobody listens");
|
||||
assert_eq!(not_running.status, Some(StatusCode::SERVICE_UNAVAILABLE));
|
||||
let failed = send(
|
||||
&FakeSink::new(|| Delivery::Failed("refused".to_owned())),
|
||||
"atlas",
|
||||
&input,
|
||||
)
|
||||
.await
|
||||
.expect_err("the queue failed");
|
||||
assert_eq!(failed.status, Some(StatusCode::INTERNAL_SERVER_ERROR));
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ use utoipa_axum::{router::OpenApiRouter, routes};
|
|||
|
||||
mod agent_icon;
|
||||
mod agent_identity;
|
||||
mod agent_input;
|
||||
mod agent_renewal;
|
||||
mod agent_state_stream;
|
||||
mod agent_status;
|
||||
|
|
@ -3007,6 +3008,7 @@ fn build_app(state: AppState) -> axum::Router {
|
|||
.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!(issue_report::get_repos))
|
||||
.routes(routes!(issue_report::get_issue_report_all))
|
||||
.routes(routes!(issue_report::get_issue_report))
|
||||
|
|
|
|||
Loading…
Reference in a new issue