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.
121 lines
5.5 KiB
Rust
121 lines
5.5 KiB
Rust
//! `GET /api/agents/{name}/state/stream` — a live SSE relay of one agent's
|
|
//! turn-state header, sourced straight from the swarm queue.
|
|
//!
|
|
//! Sibling of [`crate::term_stream`]: the reasoning there for keeping the
|
|
//! hive out of the path applies unchanged here and is not repeated. Unlike
|
|
//! that route this one is a live tail with no replay, because no stream keeps
|
|
//! headers. The two subjects also differ in what they carry: a terminal row
|
|
//! is an event that happened, a header is the agent's current state.
|
|
//!
|
|
//! **That difference is the one thing worth reading twice.** `hive-agent`
|
|
//! publishes a header on transition and re-sends it once a minute, to a core
|
|
//! subject — so a client that attaches mid-idle sees nothing for up to a
|
|
//! minute. A renderer therefore has to open with whatever
|
|
//! `GET /api/agents/status` already gives it and let this stream sharpen
|
|
//! it, rather than treating an empty stream as an empty agent. The
|
|
//! alternative would be a KV bucket written on every turn-state flip, which
|
|
//! is the write rate this feature exists to avoid.
|
|
//!
|
|
//! **The payload is passed through opaquely**, same as the terminal relay:
|
|
//! this relay never inspects a header, so it does not depend on
|
|
//! `hive-agent`'s type to re-serialise one. The bytes on the wire are what
|
|
//! `hive-agent::swarm_agent_state::publish` sent. [`crate::inference`]
|
|
//! reads its own fields out of the same subject separately.
|
|
|
|
use std::convert::Infallible;
|
|
use std::time::SystemTime;
|
|
|
|
use axum::extract::{Path, State};
|
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
|
use futures_util::{Stream, StreamExt as _};
|
|
|
|
use crate::routes::AppState;
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/agents/{name}/state/stream",
|
|
params(("name" = String, Path, description = "agent whose turn state to stream")),
|
|
responses(
|
|
(status = 200, description = "server-sent event stream; each event's `data` is one \
|
|
turn-state header, JSON as hive-agent published it (opaque to this relay) — \
|
|
`turn_state`, `turn_state_since` (ISO 8601 UTC), `agent_state`, `model`, `effort`, \
|
|
`model_source`, `effort_source`, `resolved_model`, `context_window_tokens`, \
|
|
`ctx_usage`, `cost_usage`, `needs_login`. Sent on transition and re-sent once a \
|
|
minute, live, no replay",
|
|
body = String, content_type = "text/event-stream"),
|
|
(status = 400, description = "the agent name is not shaped like an identifier \
|
|
(problem+json)", body = String),
|
|
(status = 404, description = "the agent has never reported to the swarm \
|
|
(problem+json)", body = String),
|
|
(status = 503, description = "no swarm queue is configured on this host, no \
|
|
agent-status reader is wired up, or the queue could not be reached \
|
|
(problem+json)", body = String),
|
|
(status = 500, description = "reading the agent's status failed, or the \
|
|
subscribe itself failed (problem+json)", body = String),
|
|
),
|
|
tag = "agents"
|
|
)]
|
|
pub(crate) async fn stream_agent_state(
|
|
State(state): State<AppState>,
|
|
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::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::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::routes::error_problem(axum::http::StatusCode::BAD_REQUEST, reason)
|
|
})?
|
|
.into_string();
|
|
|
|
// One-agent "roster": reuses `AgentStatusReader::view`'s already-tested
|
|
// row logic rather than a second, narrower lookup that could disagree
|
|
// with it about what "never reported" means.
|
|
agent_status
|
|
.view(std::slice::from_ref(&agent), SystemTime::now())
|
|
.await
|
|
.map_err(|e| {
|
|
let detail = format!("{e:#}");
|
|
tracing::warn!(%agent, error = %detail, "state stream: reading agent status failed");
|
|
crate::routes::error_problem(axum::http::StatusCode::INTERNAL_SERVER_ERROR, &detail)
|
|
})?
|
|
.into_iter()
|
|
.find_map(|row| row.hive)
|
|
.ok_or_else(|| {
|
|
crate::routes::error_problem(
|
|
axum::http::StatusCode::NOT_FOUND,
|
|
&format!("{agent:?} has never reported to the swarm"),
|
|
)
|
|
})?;
|
|
|
|
let client = status.queue_client();
|
|
// Before subscribing: an unconnected client does not fail a subscribe
|
|
// request outright, but there is no point opening a subscription
|
|
// 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::routes::error_problem(
|
|
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
|
&swarm_queue_client::chain(&e),
|
|
)
|
|
})?;
|
|
|
|
let subscriber = crate::term_stream::subscribe(
|
|
&client,
|
|
swarm_queue_client::agent_state_subject(&agent),
|
|
"state",
|
|
)
|
|
.await?;
|
|
let stream =
|
|
subscriber.map(|msg| Ok(Event::default().data(String::from_utf8_lossy(&msg.payload))));
|
|
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
|
|
}
|