Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
Author SHA1 Message Date
iris
f65364759f swarm-ui: retrying a broadcast shows the agents that failed, even ones the filter hides 2026-10-18 02:40:10 +02:00
iris
89b20a5a50 swarm-ui: a broadcast page that sends one message to several agents
Pick agents from a grid of their icons (running or all), type the text, send.
The page posts the names and the text to the controller and shows what
happened to each agent, with a retry that selects just the ones that failed.
The selection rules and the controller's answer are read by plain functions
with tests.
2026-10-18 02:40:10 +02:00
7 changed files with 725 additions and 0 deletions

View file

@ -17,6 +17,7 @@ domain, covers host-level detail for one hive.
| `/alerts` | the alerts the swarm's Grafana holds now (firing, pending, no data, error), read through the controller with a Viewer service-account token | | `/alerts` | the alerts the swarm's Grafana holds now (firing, pending, no data, error), read through the controller with a Viewer service-account token |
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress | | `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
| `/broadcast` | send one message to several agents at once, picked from a grid of their icons |
| `/issues` | a cross-repo issue report | | `/issues` | a cross-repo issue report |
| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings | | `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings |
| `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed | | `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed |
@ -291,6 +292,25 @@ The **Details** link on a `swarm-builder` commit status opens this page as
`/builds?run=<id>`, with that build's card open and in view. The builder keeps `/builds?run=<id>`, with that build's card open and in view. The builder keeps
its newest 100 builds, so a link to an older one shows a note instead. its newest 100 builds, so a link to an older one shows a note instead.
### Broadcasting a message
The **broadcast** page sends one message to several agents at once. Pick the
agents from a grid of their icons. **running** shows the agents that report
themselves running and **all** shows every agent; the browser keeps the choice.
**select all** ticks every agent the filter shows, and **clear** unticks them.
The page skips an agent you ticked and then hid with the filter. Type the
message and press **send to N agents**. The text goes exactly as typed, as an
inbox message from your authelia username, the same as the box under an agent's
terminal. One broadcast may name at most 64 agents, and the button stays off
until you untick the extra ones. A broadcast to many agents can take a few
seconds to answer, and the button reads **sending…** meanwhile.
The page then lists what happened to each agent. An agent that wasn't
listening shows as not sent, with the reason, and the others still got the
message. **select the N that failed** ticks just those, switching to **all** if the
filter hid any, so you can send the same message again. When every agent got the message the box clears; otherwise the
text stays.
### Inference settings ### Inference settings
The agent's detail panel shows its model and effort under **inference**, each The agent's detail panel shows its model and effort under **inference**, each

View file

@ -6,6 +6,7 @@ import { AgentsPage } from "./pages/agents/AgentsPage.js";
import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js"; import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
import { AlertsPage } from "./pages/AlertsPage.js"; import { AlertsPage } from "./pages/AlertsPage.js";
import { BuildsPage } from "./pages/builds/BuildsPage.js"; import { BuildsPage } from "./pages/builds/BuildsPage.js";
import { BroadcastPage } from "./pages/broadcast/BroadcastPage.js";
import { ComponentsPage } from "./pages/ComponentsPage.js"; import { ComponentsPage } from "./pages/ComponentsPage.js";
import { JobsPage } from "./pages/JobsPage.js"; import { JobsPage } from "./pages/JobsPage.js";
import { HivesPage } from "./pages/HivesPage.js"; import { HivesPage } from "./pages/HivesPage.js";
@ -39,6 +40,7 @@ export function App() {
<Route path="/jobs" component={JobsPage} /> <Route path="/jobs" component={JobsPage} />
<Route path="/schedules" component={SchedulesPage} /> <Route path="/schedules" component={SchedulesPage} />
<Route path="/builds" component={BuildsPage} /> <Route path="/builds" component={BuildsPage} />
<Route path="/broadcast" component={BroadcastPage} />
<Route path="/issues" component={IssueReportPage} /> <Route path="/issues" component={IssueReportPage} />
<Route path="/review" component={ReviewPage} /> <Route path="/review" component={ReviewPage} />
<Route path="/components" component={ComponentsPage} /> <Route path="/components" component={ComponentsPage} />

View file

@ -0,0 +1,166 @@
/* <BroadcastPage>: the agent picker over the message box, then the per-agent
result. One column; the grid wraps to however many icons fit. */
.ui-broadcast {
display: flex;
flex-direction: column;
gap: 1em;
}
.ui-broadcast-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em 1em;
}
/* Same segmented pair as the agents page's view switcher. */
.ui-broadcast-filter {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 0.4em;
overflow: hidden;
}
.ui-broadcast-filter button {
border: none;
background: none;
color: var(--muted);
padding: 0.45em 0.75em;
font: inherit;
font-size: 0.9em;
cursor: pointer;
}
.ui-broadcast-filter button + button {
border-left: 1px solid var(--border);
}
.ui-broadcast-filter button.active {
background: var(--bg-elev);
color: var(--fg);
}
.ui-broadcast-filter button:not(.active):hover {
color: var(--fg);
}
.ui-broadcast-link {
border: none;
background: none;
padding: 0.2em 0;
color: var(--blue);
font: inherit;
font-size: 0.9em;
cursor: pointer;
text-decoration: underline;
}
.ui-broadcast-link:disabled {
color: var(--muted);
text-decoration: none;
cursor: default;
}
.ui-broadcast-count {
margin-left: auto;
color: var(--muted);
font-size: 0.9em;
}
.ui-broadcast-empty {
color: var(--muted);
text-align: center;
padding: 1.25em 0.75em;
margin: 0;
}
/* The picker: each agent a labelled checkbox card, icon over name. */
.ui-broadcast-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8em, 1fr));
gap: 0.6em;
list-style: none;
margin: 0;
padding: 0;
}
.ui-broadcast-agent {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35em;
padding: 0.7em 0.5em 0.6em;
border: 1px solid var(--border);
border-radius: 0.5em;
background: var(--bg);
cursor: pointer;
}
.ui-broadcast-agent:hover {
border-color: var(--muted);
}
.ui-broadcast-agent-on {
border-color: var(--purple);
background: var(--bg-elev);
}
.ui-broadcast-agent:focus-within {
outline: 2px solid var(--purple);
outline-offset: 2px;
}
.ui-broadcast-check {
position: absolute;
top: 0.5em;
left: 0.5em;
margin: 0;
}
.ui-broadcast-name {
max-width: 100%;
overflow-wrap: anywhere;
text-align: center;
}
.ui-broadcast-state {
color: var(--muted);
font-size: 0.8em;
}
.ui-broadcast-state-up {
color: var(--green);
}
.ui-broadcast-send {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75em;
}
.ui-broadcast-cap {
color: var(--red);
font-size: 0.9em;
}
/* What came back, one line per agent. */
.ui-broadcast-result {
margin-top: 1em;
}
.ui-broadcast-summary {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5em 1em;
margin: 0 0 0.5em;
}
.ui-broadcast-outcomes {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35em;
}
.ui-broadcast-outcomes li {
display: flex;
align-items: center;
gap: 0.6em;
}
.ui-broadcast-outcome-name {
font-weight: 600;
}
.ui-broadcast-ok {
color: var(--green);
}
.ui-broadcast-fail {
color: var(--red);
}
.ui-broadcast-ok .ui-broadcast-outcome-name,
.ui-broadcast-fail .ui-broadcast-outcome-name {
color: var(--fg);
}

View file

@ -0,0 +1,314 @@
// <BroadcastPage> — one message to several agents at once. Pick agents from a
// grid of their icons (every agent, or just the ones running), type the
// message, send. The controller does the fan-out: `POST /api/agents/broadcast`
// answers one outcome per agent, so one that is down does not stop the rest and
// the page can say which ones did not get it and offer a retry to just those.
//
// The roster is `GET /api/agents/status` on a refresh interval like the agents
// page. The ticks and the message are this page's own state, so a refresh only
// replaces the agent list: what is typed and picked stays. A ticked agent that
// the filter hides is kept but not sent to (`selectedVisible`).
//
// The message goes exactly as typed, as the signed-in operator (the
// controller takes the sender from the gateway, as for the single-agent box).
import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { AgentIcon } from "../../agent/AgentIcon.js";
import { Button } from "../../ui/button/Button.js";
import { Panel } from "../../ui/panel/Panel.js";
import {
RefreshIntervalPicker,
useRefreshInterval,
type RefreshIntervalMs,
} from "../../ui/refresh-interval/RefreshInterval.js";
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
import {
failedAgents,
filterShowing,
isRunning,
parseResults,
selectedVisible,
MAX_BROADCAST,
sendLabel,
tickAll,
toggled,
visibleAgents,
type AgentFilter,
type BroadcastAgent,
type BroadcastResult,
} from "./broadcast-types.js";
import "./BroadcastPage.css";
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
const FILTER_KEY = "swarm-ui:broadcast:filter";
type SendState =
| { status: "idle" }
| { status: "sending" }
| { status: "done"; results: BroadcastResult[] }
| { status: "error"; problem: ProblemDetails };
function storedFilter(): AgentFilter {
try {
return localStorage.getItem(FILTER_KEY) === "all" ? "all" : "running";
} catch {
return "running";
}
}
export function BroadcastPage() {
const [agents, setAgents] = useState<BroadcastAgent[] | null>(null);
const [loadError, setLoadError] = useState<ProblemDetails | null>(null);
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const [filter, setFilterState] = useState<AgentFilter>(storedFilter);
const [ticked, setTicked] = useState<string[]>([]);
const [body, setBody] = useState("");
const [send, setSend] = useState<SendState>({ status: "idle" });
function setFilter(next: AgentFilter) {
setFilterState(next);
try {
localStorage.setItem(FILTER_KEY, next);
} catch {
/* the choice lasts for the visit only */
}
}
async function refresh() {
const res = await fetch("/api/agents/status");
if (!res.ok) {
setLoadError(await readApiError(res));
return;
}
setAgents((await res.json()) as BroadcastAgent[]);
setLoadError(null);
}
useRefreshInterval(intervalMs, () => {
refresh().catch((e: unknown) => setLoadError({ detail: String(e) }));
});
const all = agents ?? [];
const shown = visibleAgents(all, filter);
const targets = selectedVisible(ticked, shown);
const runningCount = all.filter(isRunning).length;
const sending = send.status === "sending";
const overCap = targets.length > MAX_BROADCAST;
const canSend =
targets.length > 0 && !overCap && body.trim() !== "" && !sending;
async function submit(e: Event) {
e.preventDefault();
if (!canSend) return;
setSend({ status: "sending" });
try {
const res = await fetch("/api/agents/broadcast", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ agents: targets, body }),
});
if (!res.ok) {
setSend({ status: "error", problem: await readApiError(res) });
return;
}
const results = parseResults(await res.json());
if (results === null) {
setSend({
status: "error",
problem: { detail: "the controller's answer was not understood" },
});
return;
}
setSend({ status: "done", results });
// Everyone got it: the text has served its purpose. A partial send keeps
// it so the retry to the rest sends the same words.
if (failedAgents(results).length === 0) setBody("");
} catch (err) {
setSend({ status: "error", problem: { detail: String(err) } });
}
}
function retryFailed(results: BroadcastResult[]) {
const failed = failedAgents(results);
// Only this page's view changes; the saved choice stays as it was.
setFilterState(filterShowing(filter, all, failed));
setTicked(failed);
setSend({ status: "idle" });
}
return (
<Panel
title="broadcast"
icon="📣"
actions={
<RefreshIntervalPicker
id="broadcast-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
}
>
{loadError && (
<ApiErrorPanel context="failed to load agents" problem={loadError} />
)}
<form class="ui-broadcast" onSubmit={submit}>
<div class="ui-broadcast-bar">
<div class="ui-broadcast-filter" role="group" aria-label="show">
<button
type="button"
aria-pressed={filter === "running"}
class={filter === "running" ? "active" : undefined}
onClick={() => setFilter("running")}
>
running {agents ? runningCount : ""}
</button>
<button
type="button"
aria-pressed={filter === "all"}
class={filter === "all" ? "active" : undefined}
onClick={() => setFilter("all")}
>
all {agents ? all.length : ""}
</button>
</div>
<button
type="button"
class="ui-broadcast-link"
disabled={shown.length === 0}
onClick={() => setTicked((prev) => tickAll(prev, shown))}
>
select all
</button>
<button
type="button"
class="ui-broadcast-link"
disabled={targets.length === 0}
onClick={() => setTicked([])}
>
clear
</button>
<span class="ui-broadcast-count" role="status">
{targets.length} selected
</span>
</div>
{agents !== null && shown.length === 0 ? (
<p class="ui-broadcast-empty">
{filter === "running" && all.length > 0
? "no agent is running right now. Show all to pick a stopped one."
: "no agents."}
</p>
) : (
<ul class="ui-broadcast-grid" aria-label="agents">
{shown.map((agent) => {
const on = targets.includes(agent.name);
return (
<li key={agent.name}>
<label
class={
"ui-broadcast-agent" +
(on ? " ui-broadcast-agent-on" : "")
}
>
<input
type="checkbox"
class="ui-broadcast-check"
checked={on}
onChange={() =>
setTicked((prev) => toggled(prev, agent.name))
}
/>
<AgentIcon name={agent.name} />
<span class="ui-broadcast-name">{agent.name}</span>
<span
class={
"ui-broadcast-state" +
(isRunning(agent) ? " ui-broadcast-state-up" : "")
}
>
{isRunning(agent) ? "running" : "not running"}
</span>
</label>
</li>
);
})}
</ul>
)}
<TextAreaField
id="broadcast-body"
label="message"
value={body}
rows={5}
placeholder="sent exactly as typed, from you, to each selected agent"
onInput={setBody}
/>
<div class="ui-broadcast-send">
<Button variant="primary" type="submit" disabled={!canSend}>
{sending ? "sending…" : sendLabel(targets.length)}
</Button>
{overCap && (
<span class="ui-broadcast-cap" role="status">
at most {MAX_BROADCAST} agents at once, untick{" "}
{targets.length - MAX_BROADCAST}
</span>
)}
</div>
</form>
<div class="ui-broadcast-result" aria-live="polite">
{send.status === "error" && (
<ApiErrorPanel context="failed to send" problem={send.problem} />
)}
{send.status === "done" && (
<SendResults results={send.results} onRetry={retryFailed} />
)}
</div>
</Panel>
);
}
function SendResults({
results,
onRetry,
}: {
results: BroadcastResult[];
onRetry: (results: BroadcastResult[]) => void;
}) {
const failed = failedAgents(results);
const sent = results.length - failed.length;
return (
<section class="ui-broadcast-results" aria-label="send result">
<p class="ui-broadcast-summary">
sent to {sent} of {results.length}
{failed.length > 0 && (
<button
type="button"
class="ui-broadcast-link"
onClick={() => onRetry(results)}
>
select the {failed.length} that failed
</button>
)}
</p>
<ul class="ui-broadcast-outcomes">
{results.map((r) => (
<li
key={r.agent}
class={r.sent ? "ui-broadcast-ok" : "ui-broadcast-fail"}
>
<AgentIcon name={r.agent} small />
<span class="ui-broadcast-outcome-name">{r.agent}</span>
<span>
{r.sent
? "✓ sent"
: `✗ not sent${r.reason ? `: ${r.reason}` : ""}`}
</span>
</li>
))}
</ul>
</section>
);
}

View file

@ -0,0 +1,114 @@
// What the broadcast page decides without a DOM: which agents the filter
// shows, which of the ticked ones a send really goes to, and how the
// controller's per-agent answer is read. Kept out of `BroadcastPage.tsx` so it
// is plain functions of its arguments and can be tested.
/** The part of an `/api/agents/status` row the page reads. */
export interface BroadcastAgent {
name: string;
snapshot: { running: boolean } | null;
}
export type AgentFilter = "all" | "running";
/** An agent that has not reported a snapshot is not known to be running. */
export function isRunning(agent: Pick<BroadcastAgent, "snapshot">): boolean {
return agent.snapshot?.running === true;
}
/** The agents the filter shows, by name. */
export function visibleAgents<T extends BroadcastAgent>(
agents: T[],
filter: AgentFilter,
): T[] {
const shown = filter === "running" ? agents.filter(isRunning) : agents;
return [...shown].sort((a, b) => a.name.localeCompare(b.name));
}
/**
* The ticked agents a send goes to: only the ones the filter shows. An agent
* ticked and then hidden by the filter is kept in `ticked`, so switching back
* brings its tick back, but it is never sent to while hidden.
*/
export function selectedVisible(
ticked: string[],
visible: BroadcastAgent[],
): string[] {
const shown = new Set(visible.map((a) => a.name));
return ticked.filter((name) => shown.has(name));
}
/**
* The filter to show `names` under: `filter` itself when it shows them all,
* otherwise `all`. A resend to agents that failed must show them ticked, and
* the failures are often the agents the `running` filter hides.
*/
export function filterShowing(
filter: AgentFilter,
agents: BroadcastAgent[],
names: string[],
): AgentFilter {
const shown = new Set(visibleAgents(agents, filter).map((a) => a.name));
return names.every((n) => shown.has(n)) ? filter : "all";
}
/** `ticked` with `name` flipped. */
export function toggled(ticked: string[], name: string): string[] {
return ticked.includes(name)
? ticked.filter((n) => n !== name)
: [...ticked, name];
}
/** `ticked` plus every shown agent, without repeating a name. */
export function tickAll(ticked: string[], visible: BroadcastAgent[]): string[] {
const out = [...ticked];
for (const { name } of visible) {
if (!out.includes(name)) out.push(name);
}
return out;
}
/** Most agents one broadcast may name; the controller refuses more. */
export const MAX_BROADCAST = 64;
/** The send button's text. */
export function sendLabel(count: number): string {
if (count === 0) return "send";
return count === 1 ? "send to 1 agent" : `send to ${count} agents`;
}
/** One agent's outcome in the controller's answer. */
export interface BroadcastResult {
agent: string;
sent: boolean;
/** Why it was not sent, when the controller said. */
reason: string | null;
}
/**
* The controller's answer, `{ results: [{ agent, sent, reason? }] }`, or
* `null` when it is not that shape, so the page can say so rather than show
* nothing.
*/
export function parseResults(json: unknown): BroadcastResult[] | null {
if (typeof json !== "object" || json === null) return null;
const results = (json as { results?: unknown }).results;
if (!Array.isArray(results)) return null;
const out: BroadcastResult[] = [];
for (const entry of results) {
if (typeof entry !== "object" || entry === null) return null;
const { agent, sent, reason } = entry as Record<string, unknown>;
if (typeof agent !== "string" || typeof sent !== "boolean") return null;
out.push({
agent,
sent,
reason: typeof reason === "string" && reason !== "" ? reason : null,
});
}
return out;
}
/** The agents a retry should go to: the ones that did not get it. */
export function failedAgents(results: BroadcastResult[]): string[] {
return results.filter((r) => !r.sent).map((r) => r.agent);
}

View file

@ -54,6 +54,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ href: "/alerts", label: "alerts", accent: "var(--red)" }, { href: "/alerts", label: "alerts", accent: "var(--red)" },
{ href: "/jobs", label: "jobs", accent: "var(--pink)" }, { href: "/jobs", label: "jobs", accent: "var(--pink)" },
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" }, { href: "/schedules", label: "schedules", accent: "var(--cyan)" },
{ href: "/broadcast", label: "broadcast", accent: "var(--blue)" },
{ href: "/issues", label: "issues", accent: "var(--yellow)" }, { href: "/issues", label: "issues", accent: "var(--yellow)" },
{ href: "/review", label: "review", accent: "var(--amber)" }, { href: "/review", label: "review", accent: "var(--amber)" },
{ href: "/builds", label: "builds", accent: "var(--green)" }, { href: "/builds", label: "builds", accent: "var(--green)" },

View file

@ -0,0 +1,108 @@
// The broadcast page's selection and answer rules. Run by `npm test`, which
// Node runs with its built-in test runner and type stripping, so the `.ts`
// module is imported as it is.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
failedAgents,
filterShowing,
MAX_BROADCAST,
isRunning,
parseResults,
selectedVisible,
sendLabel,
tickAll,
toggled,
visibleAgents,
} from "../src/pages/broadcast/broadcast-types.ts";
const up = { running: true };
const down = { running: false };
const agents = [
{ name: "zed", snapshot: up },
{ name: "bob", snapshot: down },
{ name: "ann", snapshot: null },
{ name: "cy", snapshot: up },
];
test("only an agent that reported running counts as running", () => {
assert.equal(isRunning({ snapshot: up }), true);
assert.equal(isRunning({ snapshot: down }), false);
assert.equal(isRunning({ snapshot: null }), false);
});
test("the filter shows every agent or just the running ones, by name", () => {
assert.deepEqual(
visibleAgents(agents, "all").map((a) => a.name),
["ann", "bob", "cy", "zed"],
);
assert.deepEqual(
visibleAgents(agents, "running").map((a) => a.name),
["cy", "zed"],
);
});
test("a ticked agent the filter hides is not sent to, and comes back with the filter", () => {
const ticked = ["bob", "cy"];
const running = visibleAgents(agents, "running");
assert.deepEqual(selectedVisible(ticked, running), ["cy"]);
assert.deepEqual(selectedVisible(ticked, visibleAgents(agents, "all")), [
"bob",
"cy",
]);
});
test("toggling flips one name and keeps the rest", () => {
assert.deepEqual(toggled(["a", "b"], "c"), ["a", "b", "c"]);
assert.deepEqual(toggled(["a", "b"], "a"), ["b"]);
});
test("tick all adds the shown agents once", () => {
const shown = visibleAgents(agents, "running");
assert.deepEqual(tickAll(["cy", "bob"], shown), ["cy", "bob", "zed"]);
});
test("the send button says how many it goes to", () => {
assert.equal(sendLabel(0), "send");
assert.equal(sendLabel(1), "send to 1 agent");
assert.equal(sendLabel(3), "send to 3 agents");
});
test("the controller's answer is read per agent", () => {
const results = parseResults({
results: [
{ agent: "a", sent: true },
{ agent: "b", sent: false, reason: "no subscriber" },
{ agent: "c", sent: false, reason: "" },
],
});
assert.deepEqual(results, [
{ agent: "a", sent: true, reason: null },
{ agent: "b", sent: false, reason: "no subscriber" },
{ agent: "c", sent: false, reason: null },
]);
assert.deepEqual(failedAgents(results ?? []), ["b", "c"]);
});
test("an answer that is not the agreed shape is null, not an empty list", () => {
assert.equal(parseResults(null), null);
assert.equal(parseResults({}), null);
assert.equal(parseResults({ results: "x" }), null);
assert.equal(parseResults({ results: [{ agent: "a" }] }), null);
assert.equal(parseResults({ results: [null] }), null);
});
test("one broadcast may name at most 64 agents", () => {
assert.equal(MAX_BROADCAST, 64);
});
test("a retry shows the failed agents even when the filter hides them", () => {
const agents = [
{ name: "a", snapshot: { running: true } },
{ name: "b", snapshot: { running: false } },
];
assert.equal(filterShowing("running", agents, ["a"]), "running");
assert.equal(filterShowing("running", agents, ["a", "b"]), "all");
assert.equal(filterShowing("running", agents, ["b"]), "all");
assert.equal(filterShowing("all", agents, ["b"]), "all");
});