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
|
// no header since swarm-controller started, or an agent that does not
|
||||||
// send the flag — not known, so not "needs login".
|
// send the flag — not known, so not "needs login".
|
||||||
needs_login: boolean | null;
|
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.
|
// 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.
|
// change or a link made here shows wherever else the same row is drawn.
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { Badge } from "@hive/shared/badge.js";
|
import { Badge } from "@hive/shared/badge.js";
|
||||||
|
import { Button } from "../../ui/button/Button.js";
|
||||||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||||
import type { AgentActions } from "../roster/AgentActions.js";
|
import type { AgentActions } from "../roster/AgentActions.js";
|
||||||
import { AgentInference } from "./AgentInference.js";
|
import { AgentInference } from "./AgentInference.js";
|
||||||
|
|
@ -20,7 +21,7 @@ import { AgentPermissions } from "./AgentPermissions.js";
|
||||||
import { AllowedDomains } from "./AllowedDomains.js";
|
import { AllowedDomains } from "./AllowedDomains.js";
|
||||||
import { AgentStateRow } from "./AgentStateRow.js";
|
import { AgentStateRow } from "./AgentStateRow.js";
|
||||||
import { AgentTermPreview } from "../terminal/AgentTermPreview.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 { LinkAccountMenu } from "./LinkAccountMenu.js";
|
||||||
import { LinkedAccounts } from "./LinkedAccounts.js";
|
import { LinkedAccounts } from "./LinkedAccounts.js";
|
||||||
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<dt>state</dt>
|
<dt>state</dt>
|
||||||
|
|
@ -122,6 +129,25 @@ function OverviewRows({ row }: { row: AgentRow }) {
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</dd>
|
</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":
|
case "overview":
|
||||||
return (
|
return (
|
||||||
<dl class="ui-agent-detail-fields">
|
<dl class="ui-agent-detail-fields">
|
||||||
<OverviewRows row={row} />
|
<OverviewRows row={row} actions={actions} />
|
||||||
</dl>
|
</dl>
|
||||||
);
|
);
|
||||||
case "settings":
|
case "settings":
|
||||||
|
|
@ -272,7 +298,7 @@ export function AgentDetail({
|
||||||
<div class="ui-agent-detail">
|
<div class="ui-agent-detail">
|
||||||
<dl class="ui-agent-detail-fields">
|
<dl class="ui-agent-detail-fields">
|
||||||
<HeaderRows row={row} actions={actions} />
|
<HeaderRows row={row} actions={actions} />
|
||||||
<OverviewRows row={row} />
|
<OverviewRows row={row} actions={actions} />
|
||||||
<SettingsRows agent={row.name} />
|
<SettingsRows agent={row.name} />
|
||||||
<AccountsRows row={row} actions={actions} />
|
<AccountsRows row={row} actions={actions} />
|
||||||
</dl>
|
</dl>
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ export interface AgentActions {
|
||||||
selectOffline: (row: AgentRow) => void;
|
selectOffline: (row: AgentRow) => void;
|
||||||
selectPaused: (row: AgentRow) => void;
|
selectPaused: (row: AgentRow) => void;
|
||||||
requestDestroy: (row: AgentRow) => void;
|
requestDestroy: (row: AgentRow) => void;
|
||||||
|
requestCustom: (row: AgentRow) => void;
|
||||||
openLink: (row: AgentRow, kind: LinkKind) => void;
|
openLink: (row: AgentRow, kind: LinkKind) => void;
|
||||||
/** The confirm and link dialogs; render once, anywhere. */
|
/** The confirm and link dialogs; render once, anywhere. */
|
||||||
dialogs: ComponentChildren;
|
dialogs: ComponentChildren;
|
||||||
|
|
@ -94,6 +95,7 @@ export function useAgentActions(
|
||||||
row: AgentRow;
|
row: AgentRow;
|
||||||
state: "offline" | "paused";
|
state: "offline" | "paused";
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [customTarget, setCustomTarget] = useState<AgentRow | null>(null);
|
||||||
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
||||||
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
|
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
|
||||||
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
|
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
|
||||||
|
|
@ -172,6 +174,38 @@ export function useAgentActions(
|
||||||
await declareState(row, "destroyed");
|
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 = (
|
const dialogs = (
|
||||||
<>
|
<>
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|
@ -248,6 +282,22 @@ export function useAgentActions(
|
||||||
? CONFIRM_COPY[confirmTarget.state].message(confirmTarget.row.name)
|
? CONFIRM_COPY[confirmTarget.state].message(confirmTarget.row.name)
|
||||||
: null}
|
: null}
|
||||||
</ConfirmDialog>
|
</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
|
<ConfirmDialog
|
||||||
open={destroyTarget !== null}
|
open={destroyTarget !== null}
|
||||||
label="destroy agent"
|
label="destroy agent"
|
||||||
|
|
@ -276,6 +326,7 @@ export function useAgentActions(
|
||||||
selectOffline: (row) => setConfirmTarget({ row, state: "offline" }),
|
selectOffline: (row) => setConfirmTarget({ row, state: "offline" }),
|
||||||
selectPaused: (row) => setConfirmTarget({ row, state: "paused" }),
|
selectPaused: (row) => setConfirmTarget({ row, state: "paused" }),
|
||||||
requestDestroy: setDestroyTarget,
|
requestDestroy: setDestroyTarget,
|
||||||
|
requestCustom: setCustomTarget,
|
||||||
openLink: (row, kind) => {
|
openLink: (row, kind) => {
|
||||||
if (kind === "matrix") setMatrixTarget(row);
|
if (kind === "matrix") setMatrixTarget(row);
|
||||||
else if (kind === "forge") setForgeTarget(row);
|
else if (kind === "forge") setForgeTarget(row);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@
|
||||||
// imported as it is.
|
// imported as it is.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
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", () => {
|
test("an agent that says it needs a login gets the badge", () => {
|
||||||
assert.equal(showsLoginNeeded({ needs_login: true }), true);
|
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({ needs_login: null }), false);
|
||||||
assert.equal(showsLoginNeeded({}), 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