swarm: list an agent's bash tasks from their published status
GET /api/agents/{name}/bash-tasks reads the agent's keys of the bash-task-status
bucket and reports each task's state, command and times, with a live task whose
daemon stopped reporting as stale. The swarm UI shows them in a background tasks
section of the agent card, running ones first. The task output stays unread.
This commit is contained in:
parent
cc7b298cf3
commit
5de20f31dd
6 changed files with 625 additions and 11 deletions
|
|
@ -42,6 +42,7 @@ import {
|
|||
startsOpen,
|
||||
type DetailSection,
|
||||
} from "./sections.js";
|
||||
import { BashTasks } from "../terminal/BashTasks.js";
|
||||
import { SubagentTerms } from "../terminal/SubagentTerms.js";
|
||||
import { WantedMenu } from "../roster/WantedMenu.js";
|
||||
import "./AgentDetail.css";
|
||||
|
|
@ -313,6 +314,8 @@ function SectionBody({
|
|||
return <AgentTermPreview agentName={row.name} />;
|
||||
case "subagents":
|
||||
return <SubagentTerms key={row.name} agentName={row.name} />;
|
||||
case "tasks":
|
||||
return <BashTasks key={row.name} agentName={row.name} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -324,7 +327,7 @@ export function AgentDetail({
|
|||
}: {
|
||||
row: AgentRow;
|
||||
actions: AgentActions;
|
||||
/** Add the live terminal and subagents sections. */
|
||||
/** Add the live terminal, subagents and background tasks sections. */
|
||||
withTerminal?: boolean;
|
||||
/**
|
||||
* An opened card, whose header already shows the state rows. Without it
|
||||
|
|
|
|||
|
|
@ -3,24 +3,30 @@
|
|||
// agent's state, message and wanted state, so the sections hold the rest.
|
||||
|
||||
/** One folded block of an opened agent card. */
|
||||
export type DetailSection = "status" | "terminal" | "subagents" | "settings";
|
||||
export type DetailSection =
|
||||
| "status"
|
||||
| "terminal"
|
||||
| "subagents"
|
||||
| "tasks"
|
||||
| "settings";
|
||||
|
||||
/** What each section's header says. */
|
||||
export const SECTION_TITLE: Record<DetailSection, string> = {
|
||||
status: "status",
|
||||
terminal: "terminal",
|
||||
subagents: "subagents",
|
||||
tasks: "background tasks",
|
||||
settings: "settings",
|
||||
};
|
||||
|
||||
/**
|
||||
* The sections a card shows, top to bottom. The terminal and subagents are
|
||||
* The sections a card shows, top to bottom. The terminal, subagents and tasks are
|
||||
* left out where the page is already beside a terminal (the terminal page's
|
||||
* details popover).
|
||||
*/
|
||||
export function sectionsFor(withTerminal: boolean): DetailSection[] {
|
||||
return withTerminal
|
||||
? ["status", "terminal", "subagents", "settings"]
|
||||
? ["status", "terminal", "subagents", "tasks", "settings"]
|
||||
: ["status", "settings"];
|
||||
}
|
||||
|
||||
|
|
|
|||
38
frontend/packages/swarm-ui/src/agent/terminal/BashTasks.css
Normal file
38
frontend/packages/swarm-ui/src/agent/terminal/BashTasks.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/* <BashTasks> — the rows of an agent's background bash tasks. */
|
||||
.ui-bash-tasks-count {
|
||||
margin: 0 0 0.5em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ui-bash-tasks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ui-bash-tasks-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: baseline;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
.ui-bash-tasks-item-over {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ui-bash-tasks-cmd {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-bash-tasks-id,
|
||||
.ui-bash-tasks-age {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
60
frontend/packages/swarm-ui/src/agent/terminal/BashTasks.tsx
Normal file
60
frontend/packages/swarm-ui/src/agent/terminal/BashTasks.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// <BashTasks> — an agent's background bash tasks, in its card's "background
|
||||
// tasks" section: the running ones first, each with its command, what state it
|
||||
// is in and since when. The list is `GET /api/agents/{name}/bash-tasks`
|
||||
// (`useBashTasks`), which swarm-controller builds from the status each task's
|
||||
// bash MCP publishes. Read-only; a task's output is not shown here.
|
||||
//
|
||||
// Mount it keyed by agent name: the refresh timer is armed once per mount.
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { Hint } from "../../ui/hint/Hint.js";
|
||||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||
import { isLive, runningTasksLabel, stateWord } from "./bash-tasks.js";
|
||||
import { useBashTasks } from "./useBashTasks.js";
|
||||
import "./BashTasks.css";
|
||||
|
||||
export function BashTasks({ agentName }: { agentName: string }) {
|
||||
const { tasks, error } = useBashTasks(agentName);
|
||||
return (
|
||||
<section class="ui-bash-tasks" aria-label={`${agentName}'s bash tasks`}>
|
||||
{error && (
|
||||
<ApiErrorPanel
|
||||
problem={error}
|
||||
context={`failed to list ${agentName}'s bash tasks`}
|
||||
/>
|
||||
)}
|
||||
{tasks === null ? (
|
||||
error ? null : (
|
||||
<Hint as="span">…</Hint>
|
||||
)
|
||||
) : tasks.length === 0 ? (
|
||||
<Hint as="span">no background tasks</Hint>
|
||||
) : (
|
||||
<>
|
||||
<p class="ui-bash-tasks-count">{runningTasksLabel(tasks)}</p>
|
||||
<ul class="ui-bash-tasks-list">
|
||||
{tasks.map((t) => (
|
||||
<li
|
||||
key={t.task}
|
||||
class={`ui-bash-tasks-item${isLive(t) ? "" : " ui-bash-tasks-item-over"}`}
|
||||
>
|
||||
<code class="ui-bash-tasks-cmd" title={t.cmd}>
|
||||
{t.cmd}
|
||||
</code>
|
||||
<span class="ui-bash-tasks-id">{t.task}</span>
|
||||
<span class="ui-bash-tasks-age">
|
||||
{stateWord(t)}
|
||||
{t.state === "stale" ? null : (
|
||||
<>
|
||||
{" "}
|
||||
<RelativeTime epochMs={t.since_unix * 1000} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
75
frontend/packages/swarm-ui/src/agent/terminal/bash-tasks.ts
Normal file
75
frontend/packages/swarm-ui/src/agent/terminal/bash-tasks.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// What an agent's bash task list means, without a DOM: the wire row, the words
|
||||
// for a state, which tasks count as running, and the count's wording. The list
|
||||
// is swarm-controller's `GET /api/agents/{name}/bash-tasks`, from the status
|
||||
// each task's bash MCP published.
|
||||
|
||||
export type TaskState =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "stale"
|
||||
| "done"
|
||||
| "timed_out"
|
||||
| "interrupted"
|
||||
| "killed";
|
||||
|
||||
/** One row of `GET /api/agents/{name}/bash-tasks`. */
|
||||
export interface BashTask {
|
||||
task: string;
|
||||
/** As of the request: a live task whose daemon stopped reporting is `stale`. */
|
||||
state: TaskState;
|
||||
/** The command, cut to 200 characters. */
|
||||
cmd: string;
|
||||
/** Unix seconds when the task was accepted. */
|
||||
created_unix: number;
|
||||
/** Unix seconds when it entered `state`. */
|
||||
since_unix: number;
|
||||
/** The exit status once the process exited by itself. */
|
||||
exit: number | null;
|
||||
}
|
||||
|
||||
/** Whether the task is still going: queued or running, and reporting. */
|
||||
export function isLive(task: Pick<BashTask, "state">): boolean {
|
||||
return task.state === "pending" || task.state === "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* The word before a task's age, saying what the age counts from. `done` shows
|
||||
* the exit status when there is one and it is not 0.
|
||||
*/
|
||||
export function stateWord(task: BashTask): string {
|
||||
switch (task.state) {
|
||||
case "pending":
|
||||
return "queued";
|
||||
case "running":
|
||||
return "started";
|
||||
case "stale":
|
||||
return "stopped reporting";
|
||||
case "done":
|
||||
return task.exit === null || task.exit === 0
|
||||
? "finished"
|
||||
: `exited ${task.exit}`;
|
||||
case "timed_out":
|
||||
return "timed out";
|
||||
case "interrupted":
|
||||
return "interrupted";
|
||||
case "killed":
|
||||
return "killed";
|
||||
}
|
||||
}
|
||||
|
||||
/** Live tasks first, then the rest, each newest first; ties by id. */
|
||||
export function sortTasks(tasks: BashTask[]): BashTask[] {
|
||||
return [...tasks].sort((a, b) => {
|
||||
const live = Number(isLive(b)) - Number(isLive(a));
|
||||
if (live !== 0) return live;
|
||||
if (a.created_unix !== b.created_unix)
|
||||
return b.created_unix - a.created_unix;
|
||||
return a.task.localeCompare(b.task);
|
||||
});
|
||||
}
|
||||
|
||||
/** The count of live tasks in words: `1 running task`, `3 running tasks`. */
|
||||
export function runningTasksLabel(tasks: BashTask[]): string {
|
||||
const n = tasks.filter(isLive).length;
|
||||
return n === 1 ? "1 running task" : `${n} running tasks`;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// An agent's bash tasks, refetched on a timer: `GET /api/agents/{name}/bash-tasks`.
|
||||
// `null` until the first answer; a failed fetch sets `error` and keeps the last
|
||||
// list. Mount per agent name: the poll belongs to one agent.
|
||||
import type { ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
||||
import { sortTasks, type BashTask } from "./bash-tasks.js";
|
||||
|
||||
const REFRESH_MS = 10_000;
|
||||
|
||||
export function useBashTasks(agentName: string): {
|
||||
tasks: BashTask[] | null;
|
||||
error: ProblemDetails | null;
|
||||
} {
|
||||
const { data, error } = usePolled(REFRESH_MS, async (signal) =>
|
||||
sortTasks(
|
||||
await fetchJson<BashTask[]>(
|
||||
`/api/agents/${encodeURIComponent(agentName)}/bash-tasks`,
|
||||
signal,
|
||||
),
|
||||
),
|
||||
);
|
||||
return { tasks: data, error };
|
||||
}
|
||||
Loading…
Reference in a new issue