swarm-ui: turn an archetype agent into a custom one from its detail
This commit is contained in:
parent
0737798d78
commit
83bb47d134
4 changed files with 118 additions and 5 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue