Watch
0
0
Fork
You've already forked hyperhive
0

swarm-wire, swarm-ui: the flow schema lists the values a field takes, and the editor picks them instead of asking for typed names

The schema's event fields gain values, a values_from marker (agents, repos)
and a closed flag; a node field gains the marker and the flag. kind is now
listed as issue and PR, what the router sends. The flow editor shows a
dropdown where the list is complete, suggestions where it is only examples,
and a plain box where there is none.
This commit is contained in:
iris 2026-10-25 11:38:49 +01:00 • committed by mara
commit b6da2bc9d0
8 changed files with 503 additions and 26 deletions

View file

@ -0,0 +1,96 @@
// What the editor offers for a value the operator would otherwise have to
// know: the server's schema says which values a field takes, or where to ask
// for them. Pure, so `npm test` can run it.
import type { Described, FlowSchema } from "./FlowTypes.js";
// Where each `values_from` marker of the schema is fetched, a list of names.
export const SOURCE_URLS: Record<string, string> = {
agents: "/api/agents",
repos: "/api/repos",
};
// Names fetched so far per marker. A marker is absent until its list arrives,
// and stays absent when the fetch fails.
export type Sources = Record<string, string[]>;
/** What the schema says one field can hold. */
export interface ValueSpec {
values: Described[];
values_from?: string | null;
closed?: boolean;
}
export interface Offer {
options: { value: string; label: string }[];
// The options are every value there is, so a dropdown will do; otherwise
// they are suggestions beside a box that takes any text.
closed: boolean;
}
/** The markers the schema names that this editor can fetch, once each. */
export function markersIn(schema: FlowSchema): string[] {
const specs: ValueSpec[] = [
...schema.fields,
...schema.node_kinds.flatMap((k) => k.fields),
];
const wanted = specs.flatMap((s) => (s.values_from ? [s.values_from] : []));
return [...new Set(wanted)].filter((m) => m in SOURCE_URLS).sort();
}
/** The spec of node field `field` of `kind`, if the schema has one. */
export function kindSpec(
schema: FlowSchema,
kind: string,
field: string,
): ValueSpec | undefined {
return schema.node_kinds
.find((k) => k.kind === kind)
?.fields.find((f) => f.name === field);
}
/** The spec of event field `name`, if the schema has one. */
export function eventSpec(
schema: FlowSchema,
name: string,
): ValueSpec | undefined {
return schema.fields.find((f) => f.name === name);
}
/**
* What to offer for a field that holds `current`, or null when it has no
* known values and stays a plain text box.
*
* A dropdown needs every value, so one whose source has not arrived becomes
* suggestions; a value already set that the list lacks (an agent since
* removed) stays selectable so opening a flow never changes it.
*/
export function offerFor(
spec: ValueSpec | undefined,
sources: Sources,
current: string,
): Offer | null {
if (!spec) return null;
const from = spec.values_from;
const fetched = from ? sources[from] : undefined;
const named = spec.values.map((v) => ({
value: v.name,
label: v.description ? `${v.name} — ${v.description}` : v.name,
}));
const options = [
...named,
...(fetched ?? [])
.filter((n) => !spec.values.some((v) => v.name === n))
.map((n) => ({ value: n, label: n })),
];
if (options.length === 0) return null;
const complete = spec.closed === true && (!from || fetched !== undefined);
if (!complete) return { options, closed: false };
const known = options.some((o) => o.value === current);
return {
options:
current === "" || known
? options
: [{ value: current, label: `${current} (not listed)` }, ...options],
closed: true,
};
}

View file

@ -48,6 +48,7 @@ import {
} from "./FlowDraft.js";
import { useLeaveGuard } from "./useLeaveGuard.js";
import { NodeInspector } from "./NodeInspector.js";
import { markersIn, SOURCE_URLS, type Sources } from "./FlowChoices.js";
import { FALLBACK_SCHEMA, type Flow, type FlowsView } from "./FlowTypes.js";
import { DRY_RUN_URL, hitsOf, type DryRun } from "./RouterLive.js";
import "./NotificationsPage.css";
@ -85,6 +86,7 @@ export function FlowEditorPage({
dry: DryRun;
} | null>(null);
const [previewError, setPreviewError] = useState<ProblemDetails | null>(null);
const [sources, setSources] = useState<Sources>({});
const [busy, setBusy] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
@ -121,6 +123,19 @@ export function FlowEditorPage({
void load(false);
}, [load]);
// The lists the schema's values come from. One that cannot be fetched is
// left out and its field falls back to a box that takes any text.
const markers = view?.schema ? markersIn(view.schema).join(",") : "";
useEffect(() => {
const abort = new AbortController();
for (const marker of markers === "" ? [] : markers.split(",")) {
fetchJson<string[]>(SOURCE_URLS[marker], abort.signal)
.then((names) => setSources((all) => ({ ...all, [marker]: names })))
.catch(() => undefined);
}
return () => abort.abort();
}, [markers]);
const dirty =
view !== null &&
draft !== null &&
@ -377,6 +392,7 @@ export function FlowEditorPage({
node={node}
schema={schema}
limits={limits}
sources={sources}
onEdit={onEdit}
/>
) : null}

View file

@ -83,9 +83,19 @@ export interface Described {
description: string;
}
export interface KindField extends Described {
required: boolean;
// What a field can hold: `values` it is known to take, and `values_from`, a
// marker for where to fetch the rest (see FlowChoices.ts). `closed` says the
// values are every one there is; absent from a controller that predates it.
export interface Choices {
values: Described[];
values_from?: string | null;
closed?: boolean;
}
export interface EventField extends Described, Choices {}
export interface KindField extends Described, Choices {
required: boolean;
}
export interface KindSchema {
@ -96,7 +106,7 @@ export interface KindSchema {
export interface FlowSchema {
source_events: Described[];
fields: Described[];
fields: EventField[];
node_kinds: KindSchema[];
limits: { max_nodes: number; max_depth: number; max_flows: number };
}
@ -123,7 +133,7 @@ export const FALLBACK_SCHEMA: FlowSchema = {
"assignees_added",
"assignees_removed",
"todo_holders",
].map((name) => ({ name, description: "" })),
].map((name) => ({ name, description: "", values: [] })),
node_kinds: [],
limits: { max_nodes: 64, max_depth: 16, max_flows: 100 },
};

View file

@ -19,6 +19,13 @@ import {
type Limits,
withOp,
} from "./FlowDraft.js";
import {
eventSpec,
kindSpec,
offerFor,
type Sources,
type ValueSpec,
} from "./FlowChoices.js";
import {
kindValues,
ordinal,
@ -36,6 +43,47 @@ const OPS_FALLBACK = ["upsert", "clear"];
// The kinds this editor has fields for.
const KNOWN_KINDS: string[] = ["source", "match", "todo", "request_reviewer"];
// A value box: a dropdown when the server lists every value, a box with the
// known ones to pick from when it lists some, a plain box when it lists none.
function ValueField({
id,
label,
value,
spec,
sources,
onChange,
}: {
id: string;
label: string;
value: string;
spec: ValueSpec | undefined;
sources: Sources;
onChange: (value: string) => void;
}) {
const offer = offerFor(spec, sources, value);
if (offer?.closed) {
return (
<SelectField
id={id}
label={label}
value={value}
options={offer.options}
placeholder="pick one"
onChange={onChange}
/>
);
}
return (
<TextField
id={id}
label={label}
value={value}
suggestions={offer?.options}
onInput={onChange}
/>
);
}
function options(list: Described[]) {
return list.map((d) => ({
value: d.name,
@ -48,12 +96,15 @@ export function NodeInspector({
node,
schema,
limits,
sources,
onEdit,
}: {
flow: Flow;
node: FlowNode;
schema: FlowSchema;
limits: Limits;
// The lists the schema's `values_from` markers name, as fetched.
sources: Sources;
// `select` is the node to pick once the edit is applied.
onEdit: (edit: Edit, select?: string) => void;
}) {
@ -111,6 +162,8 @@ export function NodeInspector({
<MatchFields
node={node}
keyPrefix={key}
schema={schema}
sources={sources}
fieldOptions={fieldOptions}
firstField={firstField}
onChange={set}
@ -122,6 +175,7 @@ export function NodeInspector({
node={node}
keyPrefix={key}
schema={schema}
sources={sources}
fieldOptions={fieldOptions}
firstField={firstField}
onChange={set}
@ -131,11 +185,13 @@ export function NodeInspector({
{node.kind === "request_reviewer" ? (
<fieldset>
<legend>asks for a review</legend>
<TextField
<ValueField
id={`${key}-reviewer`}
label="reviewer (a forge login)"
value={node.reviewer}
onInput={(reviewer) => set({ ...node, reviewer })}
spec={kindSpec(schema, "request_reviewer", "reviewer")}
sources={sources}
onChange={(reviewer) => set({ ...node, reviewer })}
/>
</fieldset>
) : null}
@ -198,12 +254,16 @@ export function NodeInspector({
function MatchFields({
node,
keyPrefix,
schema,
sources,
fieldOptions,
firstField,
onChange,
}: {
node: Extract<FlowNode, { kind: "match" }>;
keyPrefix: string;
schema: FlowSchema;
sources: Sources;
fieldOptions: { value: string; label: string }[];
firstField: string;
onChange: (next: FlowNode) => void;
@ -224,11 +284,13 @@ function MatchFields({
setWhen(node.when.map((x, j) => (j === i ? { ...x, field } : x)))
}
/>
<TextField
<ValueField
id={`${keyPrefix}-c${i}-equals`}
label="equals"
value={c.equals}
onInput={(equals) =>
spec={eventSpec(schema, c.field)}
sources={sources}
onChange={(equals) =>
setWhen(node.when.map((x, j) => (j === i ? { ...x, equals } : x)))
}
/>
@ -252,6 +314,7 @@ function TodoFields({
node,
keyPrefix,
schema,
sources,
fieldOptions,
firstField,
onChange,
@ -259,6 +322,7 @@ function TodoFields({
node: TodoNode;
keyPrefix: string;
schema: FlowSchema;
sources: Sources;
fieldOptions: { value: string; label: string }[];
firstField: string;
onChange: (next: FlowNode) => void;
@ -304,11 +368,13 @@ function TodoFields({
onChange={(field) => onChange({ ...node, to: { field } })}
/>
) : (
<TextField
<ValueField
id={`${keyPrefix}-to-agent`}
label="agent"
value={node.to.agent}
onInput={(agent) => onChange({ ...node, to: { agent } })}
spec={kindSpec(schema, "todo", "to")}
sources={sources}
onChange={(agent) => onChange({ ...node, to: { agent } })}
/>
)}
<TextField

View file

@ -0,0 +1,148 @@
// What the flow editor offers for a value: dropdown, suggestions or plain
// text, from the server's schema. Run by `npm test`.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
eventSpec,
kindSpec,
markersIn,
offerFor,
} from "../src/pages/notifications/FlowChoices.ts";
const d = (name, description = "") => ({ name, description });
const schema = {
source_events: [d("issues")],
fields: [
{
...d("kind"),
values: [d("issue"), d("PR", "A pull request")],
closed: true,
},
{ ...d("action"), values: [d("opened"), d("closed")], closed: false },
{ ...d("repo"), values: [], values_from: "repos", closed: false },
{ ...d("title"), values: [] },
],
node_kinds: [
{
kind: "todo",
description: "",
fields: [
{
...d("to"),
required: true,
values: [],
values_from: "agents",
closed: true,
},
{
...d("op"),
required: true,
values: [d("upsert"), d("clear")],
closed: true,
},
],
},
{
kind: "request_reviewer",
description: "",
fields: [
{
...d("reviewer"),
required: true,
values: [],
values_from: "agents",
closed: false,
},
{
...d("later"),
required: false,
values: [],
values_from: "somewhere-new",
},
],
},
],
limits: { max_nodes: 1, max_depth: 1, max_flows: 1 },
};
test("a field with a complete list is a dropdown, its labels carrying the description", () => {
const offer = offerFor(eventSpec(schema, "kind"), {}, "PR");
assert.equal(offer.closed, true);
assert.deepEqual(
offer.options.map((o) => o.value),
["issue", "PR"],
);
assert.equal(offer.options[1].label, "PR — A pull request");
});
test("a list that is only examples stays a text box with suggestions", () => {
const offer = offerFor(eventSpec(schema, "action"), {}, "");
assert.equal(offer.closed, false);
assert.deepEqual(
offer.options.map((o) => o.value),
["opened", "closed"],
);
});
test("a field with no known values is plain text", () => {
assert.equal(offerFor(eventSpec(schema, "title"), {}, ""), null);
assert.equal(offerFor(undefined, {}, ""), null, "no schema entry at all");
});
test("a list fetched from the swarm fills the options, and a closed one becomes a dropdown", () => {
const spec = kindSpec(schema, "todo", "to");
const offer = offerFor(spec, { agents: ["atlas", "iris"] }, "iris");
assert.equal(offer.closed, true);
assert.deepEqual(
offer.options.map((o) => o.value),
["atlas", "iris"],
);
});
test("until its list arrives, or when it cannot be fetched, there is nothing to offer", () => {
const spec = kindSpec(schema, "todo", "to");
assert.equal(offerFor(spec, {}, ""), null);
// Open lists with names fetched are suggestions: any login still goes.
const reviewer = kindSpec(schema, "request_reviewer", "reviewer");
const offer = offerFor(reviewer, { agents: ["argus"] }, "review-classifier");
assert.equal(offer.closed, false);
assert.deepEqual(
offer.options.map((o) => o.value),
["argus"],
);
});
test("a dropdown whose source has not arrived falls back to suggestions", () => {
const spec = {
values: [d("a")],
values_from: "agents",
closed: true,
};
assert.equal(offerFor(spec, {}, "").closed, false);
assert.equal(offerFor(spec, { agents: ["b"] }, "").closed, true);
});
test("a value the list lacks stays selectable, marked as not listed", () => {
const spec = kindSpec(schema, "todo", "to");
const offer = offerFor(spec, { agents: ["atlas"] }, "gone");
assert.equal(offer.options[0].value, "gone");
assert.equal(offer.options[0].label, "gone (not listed)");
assert.equal(offer.options.length, 2);
// Nothing set yet adds nothing.
assert.equal(offerFor(spec, { agents: ["atlas"] }, "").options.length, 1);
});
test("a name in both the fixed values and the fetched list is offered once", () => {
const spec = { values: [d("x")], values_from: "agents" };
const offer = offerFor(spec, { agents: ["x", "y"] }, "");
assert.deepEqual(
offer.options.map((o) => o.value),
["x", "y"],
);
});
test("the markers to fetch are the ones the schema names and the editor knows, once each", () => {
assert.deepEqual(markersIn(schema), ["agents", "repos"]);
assert.deepEqual(markersIn({ ...schema, fields: [], node_kinds: [] }), []);
});