Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx
iris b93eaaff51 swarm-ui: fix the stale refusal comment, guard the create dialog's submit, say what create another keeps
The refusal comment still described collapsed sections the dialog no longer
has. The form's submit now returns while a create is in flight. The docs say
create another keeps only the hive.
2026-10-25 01:44:24 +02:00

551 lines
20 KiB
TypeScript

// <CreateAgentForm> — minimal agent creation form, POSTs to
// swarm-controller's `POST /api/agents`. That endpoint only queues a
// `CreateIdentity` job and returns its node id — the identity isn't
// guaranteed to exist yet when this gets a response — so this shows
// the queued confirmation and links to the Jobs page to watch it
// settle, rather than duplicating JobqGraph's per-node polling here.
//
// Rendered inside a `Dialog` from `AgentsPage`'s own "+ agent" button, not a
// standalone route: the roster it feeds is the natural home for the action
// that populates it. It is a stepper (see `CreateAgentSteps`): identity,
// instructions, permissions, resources and inference, then a summary that
// says what will be sent and holds **create**. Every step is gone through, in
// order; the header, step tabs and footer stay put while the step scrolls.
//
// `hive` field added because agent creation had no way to record which
// hive an agent runs on. `POST /api/agents` now requires it, so the
// roster fetched off `GET /api/hives` backs a required select here
// rather than a free-text field — a spawn target has to be one of the
// swarm's actual hives, same reasoning `Ident.parse` gets client-side
// pattern validation for `name`.
//
// Its name field, hive select, and submit button all come from the
// shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than
// page-scoped input/button chrome — see that kit's own comments for why.
//
// Permissions: checkboxes from `GET /api/permissions`, on the default set plus
// `extraPermissions`; sent only when that differs from the default.
import { useEffect, useState } from "preact/hooks";
import { Link } from "wouter-preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Panel } from "../ui/panel/Panel.js";
import { TextField } from "../ui/text-field/TextField.js";
import {
SelectField,
type SelectOption,
} from "../ui/select-field/SelectField.js";
import { TextAreaField } from "../ui/text-area-field/TextAreaField.js";
import { Button } from "../ui/button/Button.js";
import { capsBody, LimitsFields } from "../agent/detail/AgentLimits.js";
import {
draftBody,
draftProblem,
EMPTY_INFERENCE_DRAFT,
InferenceFields,
type InferenceDraft,
} from "../agent/detail/AgentInference.js";
import { FormField } from "../ui/form-field/FormField.js";
import { MultiselectFilterOptions } from "../ui/multiselect-filter/MultiselectFilter.js";
import {
initialSelection,
requestPermissions,
togglePermission,
} from "./CreateAgentPermissions.js";
import {
archetypeKey,
prefill,
requestArchetype,
type ArchetypeList,
} from "./CreateAgentArchetype.js";
import { agentsMdBody, agentsMdRefusal } from "./CreateAgentAgentsMd.js";
import {
NAME_PATTERN,
nextStep,
previousStep,
STEP_TITLES,
STEPS,
stepProblem,
summaryRows,
type Step,
} from "./CreateAgentSteps.js";
import "./CreateAgentForm.css";
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
// consumes off `/api/hives/status`, but this page hits the plain
// `/api/hives` roster (no status/freshness needed, just "what hives
// exist to spawn into").
interface HiveEntry {
name: string;
domain: string;
}
interface CreateAgentResponse {
node_id: number;
}
// Mirrors swarm-controller's `Catalogue` (`GET /api/permissions`).
interface PermissionCatalogue {
all: string[];
default: string[];
}
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
| { status: "done"; nodeId: number }
| { status: "error"; problem: ProblemDetails };
export function CreateAgentForm({
onClose,
initialName = "",
extraPermissions = [],
}: {
/** The name field's starting value, for a caller that suggests one. */
initialName?: string;
/** Permissions checked on top of the default set when the form opens. */
extraPermissions?: string[];
/** Rendered as the form panel's own close button — this form always
* mounts inside a `plain` `Dialog` (see `AgentsPage`), which no longer
* floats its own. */
onClose?: () => void;
}) {
const [step, setStep] = useState<Step>("identity");
const [name, setName] = useState(initialName);
const [hive, setHive] = useState("");
// `null` = still loading, `[]` = loaded but empty (a real, if unusual,
// swarm state) — distinct from "not fetched yet" so the placeholder
// option's label can tell the two apart.
const [hives, setHives] = useState<HiveEntry[] | null>(null);
const [hivesError, setHivesError] = useState<ProblemDetails | null>(null);
const [result, setResult] = useState<SubmitState>({ status: "idle" });
// The optional settings sections, left empty unless the operator fills them.
const [cpu, setCpu] = useState("");
const [memory, setMemory] = useState("");
const [inference, setInference] = useState<InferenceDraft>(
EMPTY_INFERENCE_DRAFT,
);
const inferenceProblem = draftProblem(inference);
// `null` until `/api/permissions` answers; `selected` starts on its default.
const [catalogue, setCatalogue] = useState<PermissionCatalogue | null>(null);
const [catalogueError, setCatalogueError] = useState<ProblemDetails | null>(
null,
);
const [selected, setSelected] = useState<string[]>([]);
// `null` until `/api/archetypes` answers; `archetype` is the picked one's
// `archetypeKey`, `""` for none.
const [archetypes, setArchetypes] = useState<ArchetypeList | null>(null);
const [archetypesError, setArchetypesError] = useState<ProblemDetails | null>(
null,
);
const [archetype, setArchetype] = useState("");
// The text the agent's `AGENTS.md` starts with; empty sends none.
const [agentsMd, setAgentsMd] = useState("");
const picked = archetypes?.archetypes.find(
(a) => archetypeKey(a) === archetype,
);
// Loaded once, not re-fetched on submit: the roster changes rarely
// enough (a nix-level swarm config change) that staleness within one
// page visit isn't worth a second round-trip per keystroke/submit.
useEffect(() => {
(async () => {
const r = await fetch("/api/hives");
if (!r.ok) {
setHivesError(await readApiError(r));
return;
}
const data = (await r.json()) as HiveEntry[];
setHives(data);
// Single-hive swarms are the common case — default it rather than
// making the operator pick the only option. Multi-hive swarms get
// no default (the placeholder stays selected), so `required` on
// the select below forces an explicit choice per mara's scope.
if (data.length === 1) setHive(data[0].name);
})().catch((e: unknown) => setHivesError({ detail: String(e) }));
}, []);
useEffect(() => {
(async () => {
const r = await fetch("/api/permissions");
if (!r.ok) {
setCatalogueError(await readApiError(r));
return;
}
const data = (await r.json()) as PermissionCatalogue;
setCatalogue(data);
setSelected(initialSelection(data.all, data.default, extraPermissions));
})().catch((e: unknown) => setCatalogueError({ detail: String(e) }));
}, []);
useEffect(() => {
(async () => {
const r = await fetch("/api/archetypes");
if (!r.ok) {
setArchetypesError(await readApiError(r));
return;
}
setArchetypes((await r.json()) as ArchetypeList);
})().catch((e: unknown) => setArchetypesError({ detail: String(e) }));
}, []);
// Fills permissions, caps and inference from the picked archetype; each
// stays editable, and the request carries only those fields.
function pickArchetype(key: string) {
setArchetype(key);
const p = prefill(
archetypes?.archetypes.find((a) => archetypeKey(a) === key),
catalogue?.default ?? [],
);
setSelected(p.permissions);
setCpu(p.cpu);
setMemory(p.memory);
setInference(p.inference);
}
const hiveOptions: SelectOption[] = (hives ?? []).map((h) => ({
value: h.name,
label: h.name,
}));
const hivesReady = hives !== null && hives.length > 0;
// The controller's size refusal of the AGENTS.md text. It shows under the
// instructions box, and `submit` sends the dialog back to that step.
const refusal =
result.status === "error" ? agentsMdRefusal(result.problem) : null;
async function submit() {
setResult({ status: "submitting" });
try {
// A section left empty is left out, so the request is the plain
// name-and-hive one unless the operator set something.
const limits = capsBody(cpu, memory);
const settings = draftBody(inference);
const r = await fetch("/api/agents", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name,
hive,
...(Object.keys(limits).length > 0 ? { limits } : {}),
...(Object.keys(settings).length > 0 ? { inference: settings } : {}),
permissions: catalogue
? requestPermissions(selected, catalogue.default)
: undefined,
archetype: requestArchetype(picked),
...agentsMdBody(agentsMd),
}),
});
if (!r.ok) {
const problem = await readApiError(r);
setResult({ status: "error", problem });
// The text's own refusal shows beside its box, so go back to it.
if (agentsMdRefusal(problem) !== null) setStep("instructions");
return;
}
const data = (await r.json()) as CreateAgentResponse;
setResult({ status: "done", nodeId: data.node_id });
setName("");
setAgentsMd("");
pickArchetype("");
if (catalogue)
setSelected(
initialSelection(catalogue.all, catalogue.default, extraPermissions),
);
} catch (err) {
setResult({ status: "error", problem: { detail: String(err) } });
}
}
const problem = stepProblem(step, {
name,
hive,
permissionsReady: catalogue !== null,
inferenceProblem,
});
const done = result.status === "done";
const at = STEPS.indexOf(step);
// Enter goes on to the next step, and creates only from the summary.
function onSubmit(e: Event) {
e.preventDefault();
if (done || result.status === "submitting") return;
if (step === "summary") void submit();
else if (problem === null) setStep(nextStep(step));
}
function createAnother() {
setResult({ status: "idle" });
setStep("identity");
}
return (
<Panel
title="create agent"
icon="🤖"
class="create-agent-panel"
onClose={onClose}
>
<form class="create-agent-wizard" onSubmit={onSubmit}>
<ol class="create-agent-steps" aria-label="steps">
{STEPS.map((s, i) => (
<li key={s}>
<button
type="button"
class={`create-agent-step-tab${s === step ? " create-agent-step-current" : ""}`}
aria-current={s === step ? "step" : undefined}
disabled={i > at || done}
onClick={() => setStep(s)}
>
{i + 1}. {STEP_TITLES[s]}
</button>
</li>
))}
</ol>
<div class="create-agent-step">
{hivesError && (
<ApiErrorPanel
context="failed to load the hive list"
problem={hivesError}
/>
)}
{catalogueError && (
<ApiErrorPanel
context="failed to load the permission list"
problem={catalogueError}
/>
)}
{archetypesError && (
<ApiErrorPanel
context="failed to load the archetype list"
problem={archetypesError}
/>
)}
{done && (
<div class="create-agent-result create-agent-result-ok">
<p>
Queued as job node #{result.nodeId} —{" "}
<Link href="/jobs">watch it in jobs</Link>.
</p>
</div>
)}
{!done && step === "identity" && (
<div class="create-agent-fields">
<p class="create-agent-intro">
A new agent's swarm-level identity: its name, the hive it runs
on, and optionally an archetype that fills in the settings of
the later steps. A name set up before keeps the settings stored
for it.
</p>
<TextField
id="agent-name"
label="agent name"
value={name}
pattern={NAME_PATTERN}
title="1-63 chars: lowercase letters, digits, hyphens"
required
onInput={setName}
/>
<SelectField
id="agent-hive"
label="hive"
value={hive}
onChange={setHive}
options={hiveOptions}
required
disabled={!hivesReady}
placeholder={
!hives
? hivesError
? "could not load hives"
: "loading…"
: hives.length === 0
? "no hives configured"
: "select a hive"
}
/>
{archetypes && archetypes.archetypes.length > 0 && (
<>
<SelectField
id="agent-archetype"
label="archetype"
value={archetype}
onChange={pickArchetype}
disabled={!catalogue}
options={[
{ value: "", label: "none" },
...archetypes.archetypes.map((a) => ({
value: archetypeKey(a),
label: a.name,
})),
]}
/>
{picked && (
<p class="create-agent-archetype-description">
{picked.description} An agent created from an archetype
has no config repo: its hive builds it from the
archetype's module.
</p>
)}
</>
)}
</div>
)}
{!done && step === "instructions" && (
<div class="create-agent-fields">
<p class="create-agent-intro">
The text the agent's AGENTS.md starts with. Optional: leave it
empty and the agent starts without the file.
</p>
<TextAreaField
id="agent-agents-md"
label="AGENTS.md"
value={agentsMd}
rows={14}
placeholder="what the agent should know and do"
onInput={setAgentsMd}
/>
{refusal !== null && (
<p class="create-agent-field-error">{refusal}</p>
)}
</div>
)}
{!done && step === "permissions" && (
<div class="create-agent-fields">
<p class="create-agent-intro">
What the swarm lets the agent do. Leaving the default set ticked
keeps the agent on the default if that changes.
</p>
<FormField label="permissions" labelId="agent-permissions-label">
<div role="group" aria-labelledby="agent-permissions-label">
{catalogue ? (
<MultiselectFilterOptions
options={catalogue.all}
filter={{ values: selected, negate: false }}
onToggle={(v) =>
setSelected((s) => togglePermission(s, v))
}
/>
) : (
<span>
{catalogueError
? "could not load permissions"
: "loading…"}
</span>
)}
</div>
</FormField>
</div>
)}
{!done && step === "resources" && (
<div class="create-agent-fields">
<p class="create-agent-intro">
Optional: leave a box empty to set nothing, and the hive's or
the agent's own value applies.
</p>
<h3 class="create-agent-heading">resource caps</h3>
<LimitsFields
idPrefix="create-limits"
cpu={cpu}
memory={memory}
onCpu={setCpu}
onMemory={setMemory}
/>
<h3 class="create-agent-heading">inference</h3>
<InferenceFields
idPrefix="create-inference"
draft={inference}
onChange={setInference}
/>
</div>
)}
{!done && step === "summary" && (
<div class="create-agent-fields">
<dl class="create-agent-summary">
{summaryRows({
name,
hive,
archetype: picked?.name,
permissions: catalogue
? requestPermissions(selected, catalogue.default)
: undefined,
caps: capsBody(cpu, memory),
inference: draftBody(inference),
agentsMd,
}).map((row) => (
<div class="create-agent-summary-row" key={row.label}>
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
{/* Describes the intended end state (a running agent), not
today's literal behaviour: mara's call on this copy was to
read as finished, not as a running commentary on partial
implementation. */}
<h3 class="create-agent-heading">what this creates</h3>
<p>
Creating queues everything a new agent needs: a swarm-level
identity, a config repo on the forge with the operator added as
a collaborator, and a container running the agent on the hive
above. An agent created from an archetype gets no config repo:
its container runs the archetype's module. Watch it all settle
on <Link href="/jobs">jobs</Link>.
</p>
{result.status === "error" && refusal === null && (
<ApiErrorPanel
context="failed to queue"
problem={result.problem}
/>
)}
</div>
)}
</div>
<div class="create-agent-footer">
{done ? (
<>
<Button onClick={createAnother}>create another</Button>
{onClose && (
<Button variant="primary" onClick={onClose}>
close
</Button>
)}
</>
) : (
<>
<Button
disabled={at === 0 || result.status === "submitting"}
onClick={() => setStep(previousStep(step))}
>
back
</Button>
{problem !== null && (step !== "identity" || name !== "") && (
<span class="create-agent-hint" role="status">
{problem}
</span>
)}
{step === "summary" ? (
<Button
variant="primary"
type="submit"
disabled={
result.status === "submitting" || !hivesReady || !catalogue
}
>
{result.status === "submitting" ? "creating…" : "create"}
</Button>
) : (
<Button
variant="primary"
type="submit"
disabled={problem !== null}
>
next
</Button>
)}
</>
)}
</div>
</form>
</Panel>
);
}