//! An agent's subagents: which ones the swarm has rows for, and a live SSE //! relay of one subagent's terminal. //! //! The agent's subagent daemon publishes each subagent's classified rows on //! `$SWARM.term.{agent}.sub.{subagent}` under the agent's own queue credential, //! into the stream `term-sub-{agent}` it creates itself //! (`swarm_queue_client::subagent_term`). Subagents are spawned on demand //! under caller-chosen names, so the list is the set of subjects that stream //! holds rows for, read with `STREAM.INFO`. This daemon never creates the //! stream: a missing one is an agent with no subagent output yet, and lists //! nothing. //! //! **No hive lookup.** Unlike [`crate::term_stream`], there is no //! hive-scoped subject to follow: only the agent's own credential is granted //! these subjects. //! //! **Live tail only, output only.** The relay is a core subscription, the //! same as the agent's own terminal route, so a reader sees rows published //! while it is attached. Nothing is sent toward a subagent: subagents take no //! input from the swarm. use std::convert::Infallible; use axum::Json; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::sse::{Event, KeepAlive, Sse}; use futures_util::{Stream, StreamExt as _, TryStreamExt as _}; use swarm_queue_client::subagent_term; use crate::AppState; #[utoipa::path( get, path = "/api/agents/{name}/subagents", params(("name" = String, Path, description = "agent whose subagents to list")), responses( (status = 200, description = "names of the agent's subagents with terminal rows \ in the swarm queue, sorted; empty when the agent has published none", body = Vec), (status = 400, description = "the agent name is not shaped like an identifier \ (problem+json)", body = String), (status = 503, description = "no swarm queue is configured on this host, or the \ queue could not be reached (problem+json)", body = String), (status = 500, description = "reading the agent's subagent stream failed \ (problem+json)", body = String), ), tag = "agents" )] pub(crate) async fn list_subagents( State(state): State, Path(agent): Path, ) -> Result>, problem_details::ProblemDetails> { let agent = ident(&agent).map_err(bad_request)?; let client = connected_client(&state).map_err(|e| unavailable(&e))?; let js = async_nats::jetstream::new(client); let stream_name = subagent_term::stream_name(&agent); let stream = match js.get_stream(&stream_name).await { Ok(stream) => stream, Err(e) if is_stream_not_found(&e) => return Ok(Json(Vec::new())), Err(e) => return Err(read_failed(&agent, &e)), }; let subjects: Vec<(String, usize)> = stream .info_with_subjects(subagent_term::stream_subjects(&agent)) .await .map_err(|e| read_failed(&agent, &e))? .try_collect() .await .map_err(|e| read_failed(&agent, &e))?; Ok(Json(subagent_term::subagent_names( &agent, subjects.iter().map(|(s, _)| s.as_str()), ))) } #[utoipa::path( get, path = "/api/agents/{name}/subagents/{subagent}/term/stream", params( ("name" = String, Path, description = "agent the subagent belongs to"), ("subagent" = String, Path, description = "subagent whose terminal to stream"), ), responses( (status = 200, description = "server-sent event stream; each event's `data` is \ one already-classified TermMsg row, JSON as the agent's subagent daemon \ published it (opaque to this daemon) — live only, no replay", body = String, content_type = "text/event-stream"), (status = 400, description = "the agent or subagent name is not shaped like an \ identifier (problem+json)", body = String), (status = 503, description = "no swarm queue is configured on this host, or the \ queue could not be reached (problem+json)", body = String), (status = 500, description = "the subscribe itself failed (problem+json)", body = String), ), tag = "agents" )] pub(crate) async fn stream_subagent_term( State(state): State, Path((agent, subagent)): Path<(String, String)>, ) -> Result>>, problem_details::ProblemDetails> { let agent = ident(&agent).map_err(bad_request)?; let subagent = ident(&subagent).map_err(bad_request)?; let client = connected_client(&state).map_err(|e| unavailable(&e))?; let subscriber = crate::term_stream::subscribe_all( &client, &[subagent_term::subject(&agent, &subagent)], "subagent term", ) .await?; let stream = subscriber.map(|msg| Ok(Event::default().data(String::from_utf8_lossy(&msg.payload)))); Ok(Sse::new(stream).keep_alive(KeepAlive::default())) } /// The queue client, or why there is none: the 503 every queue-backed route /// answers. fn connected_client(state: &AppState) -> Result { let status = state .status .as_ref() .ok_or_else(|| "no swarm queue is configured on this host".to_owned())?; let client = status.queue_client(); swarm_queue_client::ensure_connected(&client).map_err(|e| swarm_queue_client::chain(&e))?; Ok(client) } /// A path segment as a single subject token, or why it is not one. fn ident(name: &str) -> Result { hive_types::Ident::parse(name).map(hive_types::Ident::into_string) } fn bad_request(reason: &str) -> problem_details::ProblemDetails { crate::error_problem(StatusCode::BAD_REQUEST, reason) } fn unavailable(detail: &str) -> problem_details::ProblemDetails { crate::error_problem(StatusCode::SERVICE_UNAVAILABLE, detail) } fn is_stream_not_found(e: &async_nats::jetstream::context::GetStreamError) -> bool { matches!( e.kind(), async_nats::jetstream::context::GetStreamErrorKind::JetStream(js) if js.error_code() == async_nats::jetstream::ErrorCode::STREAM_NOT_FOUND ) } fn read_failed(agent: &str, e: &dyn std::error::Error) -> problem_details::ProblemDetails { let detail = swarm_queue_client::chain(e); tracing::warn!(%agent, error = %detail, "listing subagents: reading the stream failed"); crate::error_problem(StatusCode::INTERNAL_SERVER_ERROR, &detail) } #[cfg(test)] mod tests { use swarm_queue_client::subagent_term::subagent_names; /// The list is what `STREAM.INFO` reports for the stream's subjects, by /// name: a subagent that published once and one that published many rows /// list the same way, and the order is the name's, not the stream's. #[test] fn the_list_is_the_streams_subjects_by_name() { let reported = [ ("$SWARM.term.atlas.sub.scout".to_owned(), 12), ("$SWARM.term.atlas.sub.builder".to_owned(), 1), ]; assert_eq!( subagent_names("atlas", reported.iter().map(|(s, _)| s.as_str())), ["builder", "scout"] ); } }