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

@ -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<Described>,
/// 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<String>,
/// 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<Described>,
/// 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<String>,
/// 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<Described>,
pub fields: Vec<Described>,
pub fields: Vec<EventField>,
pub node_kinds: Vec<KindSchema>,
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<EventField> {
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: <event field>} or {agent: <agent name>}",
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: <event field>} or {agent: <agent name>}",
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<String> { f.values.iter().map(|v| v.name.clone()).collect() };
assert_eq!(names(field("kind")), ["issue", "PR"]);
assert!(field("kind").closed);
let events: Vec<String> = 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)