Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: turn an archetype agent into a custom one from its detail

This commit is contained in:
atlas 2026-11-01 12:08:38 +01:00 • committed by mara
commit 83bb47d134
4 changed files with 118 additions and 5 deletions

View file

@ -43,6 +43,17 @@ export interface AgentRow {
// no header since swarm-controller started, or an agent that does not
// send the flag — not known, so not "needs login".
needs_login: boolean | null;
// The archetype the agent is built from; `null` for a custom agent.
archetype: { source: string; name: string } | null;
}
// "Turn into custom" is offered to an archetype agent whose hive is known and
// that has not been destroyed: the controller places the conversion on that
// hive.
export function canTurnIntoCustom(
row: Pick<AgentRow, "archetype" | "hive" | "wanted">,
): boolean {
return !!row.archetype && !!row.hive && row.wanted !== "destroyed";
}
// Only an agent that said so gets the badge; `null` shows nothing.

View file

@ -11,6 +11,7 @@
// change or a link made here shows wherever else the same row is drawn.
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge } from "@hive/shared/badge.js";
import { Button } from "../../ui/button/Button.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import type { AgentActions } from "../roster/AgentActions.js";
import { AgentInference } from "./AgentInference.js";
@ -20,7 +21,7 @@ import { AgentPermissions } from "./AgentPermissions.js";
import { AllowedDomains } from "./AllowedDomains.js";
import { AgentStateRow } from "./AgentStateRow.js";
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
import { FRESHNESS, type AgentRow } from "../AgentTypes.js";
import { canTurnIntoCustom, FRESHNESS, type AgentRow } from "../AgentTypes.js";
import { LinkAccountMenu } from "./LinkAccountMenu.js";
import { LinkedAccounts } from "./LinkedAccounts.js";
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
@ -90,7 +91,13 @@ function HeaderRows({
);
}
function OverviewRows({ row }: { row: AgentRow }) {
function OverviewRows({
row,
actions,
}: {
row: AgentRow;
actions: AgentActions;
}) {
return (
<>
<dt>state</dt>
@ -122,6 +129,25 @@ function OverviewRows({ row }: { row: AgentRow }) {
"—"
)}
</dd>
{row.archetype ? (
<>
<dt>archetype</dt>
<dd>
{row.archetype.source}/{row.archetype.name}
{canTurnIntoCustom(row) ? (
<>
{" "}
<Button
disabled={actions.pendingAgents.has(row.name)}
onClick={() => actions.requestCustom(row)}
>
turn into custom
</Button>
</>
) : null}
</dd>
</>
) : null}
</>
);
}
@ -211,7 +237,7 @@ function SectionBody({
case "overview":
return (
<dl class="ui-agent-detail-fields">
<OverviewRows row={row} />
<OverviewRows row={row} actions={actions} />
</dl>
);
case "settings":
@ -272,7 +298,7 @@ export function AgentDetail({
<div class="ui-agent-detail">
<dl class="ui-agent-detail-fields">
<HeaderRows row={row} actions={actions} />
<OverviewRows row={row} />
<OverviewRows row={row} actions={actions} />
<SettingsRows agent={row.name} />
<AccountsRows row={row} actions={actions} />
</dl>

View file

@ -71,6 +71,7 @@ export interface AgentActions {
selectOffline: (row: AgentRow) => void;
selectPaused: (row: AgentRow) => void;
requestDestroy: (row: AgentRow) => void;
requestCustom: (row: AgentRow) => void;
openLink: (row: AgentRow, kind: LinkKind) => void;
/** The confirm and link dialogs; render once, anywhere. */
dialogs: ComponentChildren;
@ -94,6 +95,7 @@ export function useAgentActions(
row: AgentRow;
state: "offline" | "paused";
} | null>(null);
const [customTarget, setCustomTarget] = useState<AgentRow | null>(null);
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
@ -172,6 +174,38 @@ export function useAgentActions(
await declareState(row, "destroyed");
}
// The conversion is queued as a job graph; the roster shows the agent as
// custom once it has run, so a success needs no handling here.
async function turnIntoCustom(row: AgentRow) {
setCustomTarget(null);
setPendingAgents((prev) => new Set(prev).add(row.name));
setActionErrors((prev) => {
const next = new Map(prev);
next.delete(row.name);
return next;
});
try {
const res = await fetch(
`/api/agents/${encodeURIComponent(row.name)}/custom`,
{ method: "POST" },
);
if (!res.ok) {
const problem = await readApiError(res);
setActionErrors((prev) => new Map(prev).set(row.name, problem));
}
} catch (e: unknown) {
setActionErrors((prev) =>
new Map(prev).set(row.name, { detail: String(e) }),
);
} finally {
setPendingAgents((prev) => {
const next = new Set(prev);
next.delete(row.name);
return next;
});
}
}
const dialogs = (
<>
<Dialog
@ -248,6 +282,22 @@ export function useAgentActions(
? CONFIRM_COPY[confirmTarget.state].message(confirmTarget.row.name)
: null}
</ConfirmDialog>
<ConfirmDialog
open={customTarget !== null}
label="turn into custom"
onCancel={() => setCustomTarget(null)}
onConfirm={() => customTarget && void turnIntoCustom(customTarget)}
confirmLabel="turn into custom"
>
{customTarget ? (
<p>
Turn <strong>{customTarget.name}</strong> into a custom agent? It
gets its own config repo, which imports the archetype it runs today,
and is rebuilt from that repo. Keeping the config current is then up
to the repo's owner. This is not reversible from here.
</p>
) : null}
</ConfirmDialog>
<ConfirmDialog
open={destroyTarget !== null}
label="destroy agent"
@ -276,6 +326,7 @@ export function useAgentActions(
selectOffline: (row) => setConfirmTarget({ row, state: "offline" }),
selectPaused: (row) => setConfirmTarget({ row, state: "paused" }),
requestDestroy: setDestroyTarget,
requestCustom: setCustomTarget,
openLink: (row, kind) => {
if (kind === "matrix") setMatrixTarget(row);
else if (kind === "forge") setForgeTarget(row);

View file

@ -3,7 +3,10 @@
// imported as it is.
import assert from "node:assert/strict";
import { test } from "node:test";
import { showsLoginNeeded } from "../src/agent/AgentTypes.ts";
import {
canTurnIntoCustom,
showsLoginNeeded,
} from "../src/agent/AgentTypes.ts";
test("an agent that says it needs a login gets the badge", () => {
assert.equal(showsLoginNeeded({ needs_login: true }), true);
@ -17,3 +20,25 @@ test("an agent that has not said gets none", () => {
assert.equal(showsLoginNeeded({ needs_login: null }), false);
assert.equal(showsLoginNeeded({}), false);
});
test("turn into custom is offered to a placed archetype agent only", () => {
const archetype = { source: "hyperhive", name: "reviewer" };
assert.equal(
canTurnIntoCustom({ archetype, hive: "alpha", wanted: "up" }),
true,
);
// A custom agent has no archetype to turn from.
assert.equal(
canTurnIntoCustom({ archetype: null, hive: "alpha", wanted: "up" }),
false,
);
// The controller places the conversion on a hive, so one is needed.
assert.equal(
canTurnIntoCustom({ archetype, hive: null, wanted: "up" }),
false,
);
assert.equal(
canTurnIntoCustom({ archetype, hive: "alpha", wanted: "destroyed" }),
false,
);
});