diff --git a/frontend/packages/swarm-ui/src/agent/AgentTypes.ts b/frontend/packages/swarm-ui/src/agent/AgentTypes.ts index dbcc29d4..1969c63b 100644 --- a/frontend/packages/swarm-ui/src/agent/AgentTypes.ts +++ b/frontend/packages/swarm-ui/src/agent/AgentTypes.ts @@ -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, +): boolean { + return !!row.archetype && !!row.hive && row.wanted !== "destroyed"; } // Only an agent that said so gets the badge; `null` shows nothing. diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx index 70cdaf42..bae373ef 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx @@ -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 ( <>
state
@@ -122,6 +129,25 @@ function OverviewRows({ row }: { row: AgentRow }) { "—" )} + {row.archetype ? ( + <> +
archetype
+
+ {row.archetype.source}/{row.archetype.name} + {canTurnIntoCustom(row) ? ( + <> + {" "} + + + ) : null} +
+ + ) : null} ); } @@ -211,7 +237,7 @@ function SectionBody({ case "overview": return (
- +
); case "settings": @@ -272,7 +298,7 @@ export function AgentDetail({
- +
diff --git a/frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx b/frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx index 2e0e8f50..f56abea7 100644 --- a/frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx +++ b/frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx @@ -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(null); const [matrixTarget, setMatrixTarget] = useState(null); const [forgeTarget, setForgeTarget] = useState(null); const [githubTarget, setGithubTarget] = useState(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 = ( <> + setCustomTarget(null)} + onConfirm={() => customTarget && void turnIntoCustom(customTarget)} + confirmLabel="turn into custom" + > + {customTarget ? ( +

+ Turn {customTarget.name} 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. +

+ ) : null} +
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); diff --git a/frontend/packages/swarm-ui/test/agent-types.test.mjs b/frontend/packages/swarm-ui/test/agent-types.test.mjs index 758d8dc6..ea059531 100644 --- a/frontend/packages/swarm-ui/test/agent-types.test.mjs +++ b/frontend/packages/swarm-ui/test/agent-types.test.mjs @@ -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, + ); +});