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.
551 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|