From b6da2bc9d04774a0ee1a07593154961bde2ebd8c Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 25 Oct 2026 11:38:49 +0100 Subject: [PATCH] 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. --- docs/swarm/notify-router.md | 15 +- docs/swarm/ui.md | 12 +- .../src/pages/notifications/FlowChoices.ts | 96 ++++++++++++ .../pages/notifications/FlowEditorPage.tsx | 16 ++ .../src/pages/notifications/FlowTypes.ts | 18 ++- .../src/pages/notifications/NodeInspector.tsx | 78 ++++++++- .../swarm-ui/test/flow-choices.test.mjs | 148 ++++++++++++++++++ swarm-wire/src/notify_flows.rs | 146 +++++++++++++++-- 8 files changed, 503 insertions(+), 26 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/pages/notifications/FlowChoices.ts create mode 100644 frontend/packages/swarm-ui/test/flow-choices.test.mjs diff --git a/docs/swarm/notify-router.md b/docs/swarm/notify-router.md index 50d9ce5a..a854872b 100644 --- a/docs/swarm/notify-router.md +++ b/docs/swarm/notify-router.md @@ -102,12 +102,21 @@ from the constants the check reads, so an editor offers only what a `PUT` accepts: - `source_events`: `[{name, description}]`, the source events. -- `fields`: `[{name, description}]`, the condition and recipient fields, and - the `{field}` placeholders. +- `fields`: `[{name, description, values, values_from?, closed}]`, the condition + and recipient fields, and the `{field}` placeholders. `values` lists what the + field holds, as far as the controller knows, as `{name, description}` pairs, such as the `issue` and + `PR` of `kind`. - `node_kinds`: `[{kind, description, fields}]` for `source`, `match`, `todo` - and `request_reviewer`; each field is `{name, description, required, values}`, and `values` + and `request_reviewer`; each field is + `{name, description, required, values, values_from?, closed}`, and `values` lists the accepted `{name, description}` pairs (the `upsert` and `clear` of a todo's `op`), empty for other fields. +- `values_from` on a field says where an editor asks for values that are only + known live: `agents` is the names `GET /api/agents` lists, `repos` the ones + `GET /api/repos` lists. `closed` says `values` and that source are every value + the field takes, which an editor can show as a dropdown. When false, they + are suggestions: the field still takes any other text, such as an `action` the + forge adds later or the login of a reviewer who is no agent. - `limits`: `{max_nodes, max_depth, max_flows}`. A controller that predates `schema` leaves it out of the answer; a client reads diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 9fa5515d..09d13461 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -260,7 +260,7 @@ the ends. Pick a node to edit it in the panel beside the canvas: - a **source** picks the forge events the flow starts from; - a **match** lists conditions, each an event field and the value it must equal; -- a **todo** says who gets it (the agents an event field names, or one agent), +- a **todo** says who gets it (the agents an event field names, or one agent, picked from the agents the swarm has), whether it sets or clears a todo, the todo's key, and its text. The key and the text can use `{field}` placeholders; - a **request_reviewer** names the forge login the router asks to review the @@ -296,6 +296,16 @@ editor offers to reload and drop your edits, or to keep them and replace this flow's latest version with them. For any other refusal the editor shows the controller's own message. The router runs a saved flow from its next event. +Where the controller's schema knows the values, the editor offers them instead of +asking you to type a name: `kind` and `event` conditions and the todo's `op` are +dropdowns, `action` and `repo` conditions and a reviewer are boxes that list the +known values to pick from and still take any other text, and a todo's agent is a +dropdown of the swarm's agents. A value set in a flow that the list no longer +holds stays selected, marked "not listed." If a list fails to load, or the +controller predates the schema, the box is plain text. The values come from the +controller's schema ([notify-router](notify-router.md)), so a new one shows up +without a UI change. + **preview** runs the draft, saved or not, over the newest forge events and shows on each box how many of them reached it, as a dry run does for the saved flows in the list. It needs no save, so you can try a half-built flow. The diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowChoices.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowChoices.ts new file mode 100644 index 00000000..e5e6400b --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowChoices.ts @@ -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 = { + 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; + +/** 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, + }; +} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx index fefd37e9..4cb3fc4e 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx @@ -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(null); + const [sources, setSources] = useState({}); 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(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} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts index 9362f91c..1ec956ae 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts @@ -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 }, }; diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx b/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx index 6bdd58dd..46b18448 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx @@ -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 ( + + ); + } + return ( + + ); +} + 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({ asks for a review - set({ ...node, reviewer })} + spec={kindSpec(schema, "request_reviewer", "reviewer")} + sources={sources} + onChange={(reviewer) => set({ ...node, reviewer })} /> ) : null} @@ -198,12 +254,16 @@ export function NodeInspector({ function MatchFields({ node, keyPrefix, + schema, + sources, fieldOptions, firstField, onChange, }: { node: Extract; 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))) } /> - + 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 } })} /> ) : ( - onChange({ ...node, to: { agent } })} + spec={kindSpec(schema, "todo", "to")} + sources={sources} + onChange={(agent) => onChange({ ...node, to: { agent } })} /> )} ({ 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: [] }), []); +}); diff --git a/swarm-wire/src/notify_flows.rs b/swarm-wire/src/notify_flows.rs index af64870c..9407ef3d 100644 --- a/swarm-wire/src/notify_flows.rs +++ b/swarm-wire/src/notify_flows.rs @@ -358,6 +358,48 @@ pub struct Described { pub description: String, } +/// `values_from` marker: the names of the agents, as `GET /api/agents` lists +/// them. +pub const VALUES_FROM_AGENTS: &str = "agents"; + +/// `values_from` marker: the repositories, as `GET /api/repos` lists them. +pub const VALUES_FROM_REPOS: &str = "repos"; + +/// The `kind` field's values: what the router puts there, which is not the +/// words of the field's description. +const KINDS: &[(&str, &str)] = &[("issue", "An issue"), ("PR", "A pull request")]; + +/// Some of what the forge sends as `action`. Others exist, so it is a list of +/// suggestions only. +const ACTIONS: &[(&str, &str)] = &[ + ("opened", "Created"), + ("closed", "Closed, or a pull request merged"), + ("reopened", "Opened again after being closed"), + ("edited", "Title or text changed"), + ("assigned", "An assignee added"), + ("unassigned", "An assignee removed"), + ("synchronized", "New commits pushed to a pull request"), +]; + +/// One event field a condition or recipient can name, and what it can hold. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +pub struct EventField { + pub name: String, + pub description: String, + /// Values the field is known to hold; empty when none are listed. + pub values: Vec, + /// Where an editor asks for the values only known live: one of + /// [`VALUES_FROM_AGENTS`], [`VALUES_FROM_REPOS`]. Absent when there is + /// none. + #[serde(skip_serializing_if = "Option::is_none")] + pub values_from: Option, + /// Whether `values` or the source is every value the field can hold, + /// which an editor can show as a dropdown. When false they are + /// suggestions and the editor still takes any other text. + pub closed: bool, +} + /// One field of a node kind. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] @@ -369,6 +411,13 @@ pub struct KindField { /// The accepted values when the field takes one of a fixed few; empty /// otherwise. pub values: Vec, + /// Where an editor asks for the values only known live, as + /// [`EventField::values_from`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub values_from: Option, + /// Whether the values are every one the field accepts, as + /// [`EventField::closed`]. + pub closed: bool, } /// One kind of node and the fields it takes besides `id` and `kind`. @@ -395,7 +444,7 @@ pub struct Limits { #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] pub struct Schema { pub source_events: Vec, - pub fields: Vec, + pub fields: Vec, pub node_kinds: Vec, pub limits: Limits, } @@ -415,17 +464,56 @@ fn kind_field(name: &str, description: &str, required: bool) -> KindField { description: description.to_owned(), required, values: Vec::new(), + values_from: None, + closed: false, } } +/// [`FIELDS`] with what each is known to hold. +fn event_fields() -> Vec { + FIELDS + .iter() + .map(|(name, description)| { + let (values, values_from, closed) = match *name { + "event" => (described(SOURCE_EVENTS), None, true), + "kind" => (described(KINDS), None, true), + "action" => (described(ACTIONS), None, false), + "repo" => (Vec::new(), Some(VALUES_FROM_REPOS), false), + "assignees" | "assignees_added" | "assignees_removed" | "todo_holders" => { + (Vec::new(), Some(VALUES_FROM_AGENTS), false) + } + _ => (Vec::new(), None, false), + }; + EventField { + name: (*name).to_owned(), + description: (*description).to_owned(), + values, + values_from: values_from.map(str::to_owned), + closed, + } + }) + .collect() +} + /// The schema of this build's flows. #[must_use] pub fn schema() -> Schema { let mut op = kind_field("op", "What the todo does to the recipient's list", true); op.values = described(OPS); + op.closed = true; + let mut to = kind_field( + "to", + "The recipients: {field: } or {agent: }", + true, + ); + to.values_from = Some(VALUES_FROM_AGENTS.to_owned()); + to.closed = true; + let mut reviewer = kind_field("reviewer", "The forge login to request a review from", true); + // A login need not belong to an agent: the classifier's does not. + reviewer.values_from = Some(VALUES_FROM_AGENTS.to_owned()); Schema { source_events: described(SOURCE_EVENTS), - fields: described(FIELDS), + fields: event_fields(), node_kinds: vec![ KindSchema { kind: "source".to_owned(), @@ -449,11 +537,7 @@ pub fn schema() -> Schema { kind: "todo".to_owned(), description: "A leaf: emits one todo op per recipient".to_owned(), fields: vec![ - kind_field( - "to", - "The recipients: {field: } or {agent: }", - true, - ), + to, op, kind_field("key", "The todo key, with {field} placeholders", true), kind_field("prefix", "For a clear: key is a prefix", false), @@ -467,11 +551,7 @@ pub fn schema() -> Schema { KindSchema { kind: "request_reviewer".to_owned(), description: "A leaf: asks the forge for a review of the pull request the event concerns, unless the reviewer's review of its head already stands; does nothing for an issue".to_owned(), - fields: vec![kind_field( - "reviewer", - "The forge login to request a review from", - true, - )], + fields: vec![reviewer], }, ], limits: Limits { @@ -906,6 +986,48 @@ mod tests { assert_eq!(listed.len(), 2); assert!(op_names("nope").is_none(), "control"); + // What an editor offers for a field is what the router puts there. + let field = |name: &str| s.fields.iter().find(|f| f.name == name).expect(name); + let names = + |f: &EventField| -> Vec { f.values.iter().map(|v| v.name.clone()).collect() }; + assert_eq!(names(field("kind")), ["issue", "PR"]); + assert!(field("kind").closed); + let events: Vec = s.source_events.iter().map(|e| e.name.clone()).collect(); + assert_eq!(names(field("event")), events); + assert!(field("event").closed); + assert!(!field("action").closed, "the forge may send more actions"); + assert!(names(field("action")).contains(&"closed".to_owned())); + assert_eq!( + field("repo").values_from.as_deref(), + Some(VALUES_FROM_REPOS) + ); + assert_eq!( + field("assignees").values_from.as_deref(), + Some(VALUES_FROM_AGENTS) + ); + assert!(field("title").values.is_empty() && field("title").values_from.is_none()); + // A marker is one an editor knows to resolve. + for f in &s.fields { + if let Some(from) = &f.values_from { + assert!([VALUES_FROM_AGENTS, VALUES_FROM_REPOS].contains(&from.as_str())); + } + } + let kind_field_of = |kind: &str, name: &str| { + s.node_kinds + .iter() + .find(|k| k.kind == kind) + .and_then(|k| k.fields.iter().find(|f| f.name == name)) + .expect(name) + }; + assert!(kind_field_of("todo", "op").closed); + assert_eq!( + kind_field_of("todo", "to").values_from.as_deref(), + Some(VALUES_FROM_AGENTS) + ); + let reviewer = kind_field_of("request_reviewer", "reviewer"); + assert_eq!(reviewer.values_from.as_deref(), Some(VALUES_FROM_AGENTS)); + assert!(!reviewer.closed, "any forge login is allowed"); + assert_eq!( (s.limits.max_nodes, s.limits.max_depth, s.limits.max_flows), (MAX_NODES, MAX_DEPTH, MAX_FLOWS)