Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: show how recent each subagent is in the details panel

The subagent list endpoint now returns {name, last_row_unix} per
subagent, the publish time of its newest terminal row, read from the
queue controller-side so agents publish nothing new. The panel sorts
newest first, shows a ticking age beside each name, and dims one quiet
for 15 minutes.

Closes #4928
This commit is contained in:
iris 2026-10-04 16:21:12 +02:00 • committed by mara
commit 84c89de71c
4 changed files with 124 additions and 30 deletions

View file

@ -66,7 +66,8 @@ The agent's subagent daemon publishes each subagent's terminal rows on
into a stream named `term-sub-<agent>` that swarm-controller creates for every
declared agent. The stream keeps rows for 24 hours. swarm-controller serves the list as
`GET /api/agents/<name>/subagents`, the subagents named by the subjects in that
stream, and relays one subagent's rows as SSE on
stream, each with `last_row_unix`, the stream's publish time of its newest row,
and relays one subagent's rows as SSE on
`GET /api/agents/<name>/subagents/<subagent>/term/stream`. An agent with no
store identity, or no queue address, publishes no subagent terminals.

View file

@ -11,6 +11,21 @@
gap: 0.5em;
}
.ui-subagent-terms-item {
display: inline-flex;
align-items: center;
gap: 0.35em;
}
.ui-subagent-terms-item-stale {
opacity: 0.6;
}
.ui-subagent-terms-age {
color: var(--muted);
font-size: 0.85em;
}
.ui-subagent-terms-heading {
color: var(--muted);
}

View file

@ -1,9 +1,11 @@
// <SubagentTerms> — an agent's subagents, under its own terminal preview in
// `AgentsPage`'s detail panel. The list is `GET /api/agents/{name}/subagents`:
// the subagents the swarm queue holds terminal rows for. Subagents are
// spawned on demand, so it is refetched every `REFRESH_MS`. Picking one
// shows its terminal in `AgentTermPreview`, expand-to-tab included. Read-only
// like the agent's own: subagents take no input.
// the subagents the swarm queue holds terminal rows for, each with the
// publish time of its newest row. Newest first, with a ticking age beside
// the name; one quiet for `STALE_S` is dimmed. Subagents are spawned on
// demand, so it is refetched every `REFRESH_MS`. Picking one shows its
// terminal in `AgentTermPreview`, expand-to-tab included. Read-only like the
// agent's own: subagents take no input.
//
// Mount it keyed by agent name: the refresh timer is armed once per mount.
import { useState } from "preact/hooks";
@ -12,12 +14,27 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { AgentTermPreview } from "./AgentTermPreview.js";
import { useRefreshInterval } from "../../ui/refresh-interval/RefreshInterval.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import "./SubagentTerms.css";
const REFRESH_MS = 10_000;
const STALE_S = 15 * 60;
interface Subagent {
name: string;
last_row_unix: number | null;
}
// Newest row first; a subagent whose time could not be read sorts last.
function byRecency(a: Subagent, b: Subagent): number {
const ta = a.last_row_unix ?? -Infinity;
const tb = b.last_row_unix ?? -Infinity;
if (ta !== tb) return tb > ta ? 1 : -1;
return a.name.localeCompare(b.name);
}
export function SubagentTerms({ agentName }: { agentName: string }) {
const [names, setNames] = useState<string[] | null>(null);
const [subagents, setSubagents] = useState<Subagent[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [selected, setSelected] = useState<string | null>(null);
@ -29,7 +46,7 @@ export function SubagentTerms({ agentName }: { agentName: string }) {
setError(await readApiError(res));
return;
}
setNames((await res.json()) as string[]);
setSubagents(((await res.json()) as Subagent[]).sort(byRecency));
setError(null);
}
@ -40,26 +57,41 @@ export function SubagentTerms({ agentName }: { agentName: string }) {
// An agent with no subagents shows nothing here — no heading, no
// "none" line — and so does the first fetch while it is in flight. A
// failed fetch still shows, so the error is not swallowed.
if (!error && (names === null || names.length === 0)) return null;
if (!error && (subagents === null || subagents.length === 0)) return null;
const nowS = Date.now() / 1000;
return (
<section class="ui-subagent-terms" aria-label={`${agentName}'s subagents`}>
<div class="ui-subagent-terms-list">
<span class="ui-subagent-terms-heading">subagents</span>
{names?.map((name) => (
<Badge
key={name}
variant={name === selected ? "default" : "quiet"}
value={name}
expanded={name === selected}
title={
name === selected
? `hide ${name}'s terminal`
: `show ${name}'s terminal`
}
onClick={() => setSelected(name === selected ? null : name)}
/>
))}
{subagents?.map(({ name, last_row_unix }) => {
const stale =
last_row_unix !== null && nowS - last_row_unix > STALE_S;
return (
<span
key={name}
class={`ui-subagent-terms-item${stale ? " ui-subagent-terms-item-stale" : ""}`}
>
<Badge
variant={name === selected ? "default" : "quiet"}
value={name}
expanded={name === selected}
title={
name === selected
? `hide ${name}'s terminal`
: `show ${name}'s terminal`
}
onClick={() => setSelected(name === selected ? null : name)}
/>
{last_row_unix !== null && (
<span class="ui-subagent-terms-age">
<RelativeTime epochMs={last_row_unix * 1000} />
</span>
)}
</span>
);
})}
</div>
{error && (
<ApiErrorPanel

View file

@ -8,6 +8,8 @@
//! `swarm_queue_client::subagent_term` spells. 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`. A missing stream lists nothing.
//! Each subagent carries the stream's own publish time of its newest row, so
//! a reader can tell a subagent that is running from one that has gone quiet.
//!
//! **No hive lookup.** Unlike [`crate::term_stream`], there is no
//! hive-scoped subject to follow: only the agent's own credential is granted
@ -28,7 +30,9 @@ 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 serde::Serialize;
use swarm_queue_client::subagent_term;
use utoipa::ToSchema;
use crate::AppState;
use crate::wanted::WantedWriter;
@ -84,14 +88,25 @@ async fn live_agents(wanted: &WantedWriter, hives: &[String]) -> BTreeSet<String
crate::agent_renewal::live_agents(&declarations)
}
/// One subagent of an agent, as the list endpoint reports it.
#[derive(Debug, PartialEq, Eq, Serialize, ToSchema)]
pub(crate) struct SubagentEntry {
/// The subagent's name, as it appears in its stream subject.
pub name: String,
/// When its newest row in the stream was published, unix seconds, by the
/// stream's own clock. `None` when that row could not be read.
pub last_row_unix: Option<i64>,
}
#[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<String>),
(status = 200, description = "the agent's subagents with terminal rows in the \
swarm queue, sorted by name, each with the publish time of its newest row; \
empty when the agent has published none",
body = Vec<SubagentEntry>),
(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 \
@ -104,7 +119,7 @@ async fn live_agents(wanted: &WantedWriter, hives: &[String]) -> BTreeSet<String
pub(crate) async fn list_subagents(
State(state): State<AppState>,
Path(agent): Path<String>,
) -> Result<Json<Vec<String>>, problem_details::ProblemDetails> {
) -> Result<Json<Vec<SubagentEntry>>, 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);
@ -121,10 +136,41 @@ pub(crate) async fn list_subagents(
.try_collect()
.await
.map_err(|e| read_failed(&agent, &e))?;
Ok(Json(subagent_term::subagent_names(
&agent,
subjects.iter().map(|(s, _)| s.as_str()),
)))
let names = subagent_term::subagent_names(&agent, subjects.iter().map(|(s, _)| s.as_str()));
let mut entries = Vec::with_capacity(names.len());
for name in names {
let last_row_unix = last_row_unix(&stream, &agent, &name).await;
entries.push(SubagentEntry {
name,
last_row_unix,
});
}
Ok(Json(entries))
}
/// The publish time of a subagent's newest row. A row that cannot be read,
/// aged out between the listing and this lookup say, is a missing time and
/// not a failed list.
async fn last_row_unix(
stream: &async_nats::jetstream::stream::Stream,
agent: &str,
subagent: &str,
) -> Option<i64> {
match stream
.get_last_raw_message_by_subject(&subagent_term::subject(agent, subagent))
.await
{
Ok(row) => Some(row.time.unix_timestamp()),
Err(e) => {
tracing::debug!(
%agent,
%subagent,
error = %swarm_queue_client::chain(&e),
"listing subagents: reading the newest row failed"
);
None
}
}
}
#[utoipa::path(