From d5802d16a0399ffa4a859e376bc2a51be4992563 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 24 Oct 2026 12:25:19 +0200 Subject: [PATCH] notify flows: GET returns a schema of the events, fields, node kinds and limits --- docs/swarm/notify-router.md | 16 ++ swarm-controller/src/forge.rs | 5 +- swarm-controller/src/notify_flows.rs | 8 + swarm-notify-router/src/event.rs | 5 +- swarm-wire/src/notify_flows.rs | 273 ++++++++++++++++++++++++--- 5 files changed, 281 insertions(+), 26 deletions(-) diff --git a/docs/swarm/notify-router.md b/docs/swarm/notify-router.md index dea37de4..afff4ff2 100644 --- a/docs/swarm/notify-router.md +++ b/docs/swarm/notify-router.md @@ -89,6 +89,22 @@ document, and gets 409 when the stored revision is another. Swarm UI's [`/notifications`](ui.md#notification-flows) page shows the document and switches built-ins. +**The schema.** `GET /api/notify/flows` also returns a `schema` object, built +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. +- `node_kinds`: `[{kind, description, fields}]` for `source`, `match` and + `todo`; each field is `{name, description, required, values}`, and `values` + lists the accepted `{name, description}` pairs (the `upsert` and `clear` of a + todo's `op`), empty for other fields. +- `limits`: `{max_nodes, max_depth, max_flows}`. + +A controller that predates `schema` leaves it out of the answer; a client reads +it as optional. + **What the router runs.** It reads the key at most every 10 seconds, when an event arrives, and runs the built-ins you left on, then the custom flows that are on and pass the tree check. It logs a custom flow that fails the check, diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index 6b2854f9..e0e8d108 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -2118,7 +2118,10 @@ mod tests { let mut routed = events(DeliveryKind::Issues); routed.extend(events(DeliveryKind::PullRequest)); routed.sort_unstable(); - let mut sources = swarm_wire::notify_flows::SOURCE_EVENTS.to_vec(); + let mut sources = swarm_wire::notify_flows::SOURCE_EVENTS + .iter() + .map(|(n, _)| *n) + .collect::>(); sources.sort_unstable(); assert_eq!(routed, sources); } diff --git a/swarm-controller/src/notify_flows.rs b/swarm-controller/src/notify_flows.rs index 3a97a521..343c6af7 100644 --- a/swarm-controller/src/notify_flows.rs +++ b/swarm-controller/src/notify_flows.rs @@ -42,6 +42,9 @@ pub(crate) struct FlowsView { builtins: Vec, /// The custom flows. flows: Vec, + /// What this build accepts: source events, fields, node kinds and limits, + /// for an editor to offer. An older controller's answer has none. + schema: notify_flows::Schema, } /// The body of a `PUT`: the whole document. @@ -67,6 +70,7 @@ impl FlowsView { disabled_builtins: doc.disabled_builtins, builtins: notify_flows::builtins(), flows: doc.flows, + schema: notify_flows::schema(), } } } @@ -291,6 +295,10 @@ mod tests { assert_eq!(view.revision, 0); assert!(view.disabled_builtins.is_empty() && view.flows.is_empty()); assert_eq!(view.builtins.len(), 3); + let json = serde_json::to_value(&view).expect("serializes"); + assert_eq!(json["schema"]["limits"]["max_nodes"], 64); + assert_eq!(json["schema"]["node_kinds"][0]["kind"], "source"); + assert_eq!(view.schema, notify_flows::schema()); let Json(saved) = put_flows( State(state.clone()), diff --git a/swarm-notify-router/src/event.rs b/swarm-notify-router/src/event.rs index b2ef0f46..cf36532e 100644 --- a/swarm-notify-router/src/event.rs +++ b/swarm-notify-router/src/event.rs @@ -172,7 +172,10 @@ mod tests { .keys() .copied() .collect(); - let mut want = swarm_wire::notify_flows::FIELDS.to_vec(); + let mut want = swarm_wire::notify_flows::FIELDS + .iter() + .map(|(n, _)| *n) + .collect::>(); got.sort_unstable(); want.sort_unstable(); assert_eq!(got, want, "{event}"); diff --git a/swarm-wire/src/notify_flows.rs b/swarm-wire/src/notify_flows.rs index 4d78682f..5cee077c 100644 --- a/swarm-wire/src/notify_flows.rs +++ b/swarm-wire/src/notify_flows.rs @@ -40,29 +40,57 @@ pub const MAX_FLOWS: usize = 100; /// The longest a flow's name, or a todo's key or text, may be. pub const MAX_TEXT: usize = 500; -/// The forge event types a source node can start from: the ones -/// swarm-controller's hooks deliver to the events stream. -pub const SOURCE_EVENTS: &[&str] = &[ - "issues", - "issue_assign", - "pull_request", - "pull_request_assign", +/// The forge event types a source node can start from, with what each means: +/// the ones swarm-controller's hooks deliver to the events stream. +pub const SOURCE_EVENTS: &[(&str, &str)] = &[ + ( + "issues", + "An issue opened, closed, edited or otherwise changed", + ), + ("issue_assign", "An issue assigned or unassigned"), + ( + "pull_request", + "A pull request opened, closed, edited or otherwise changed", + ), + ( + "pull_request_assign", + "A pull request assigned or unassigned", + ), ]; -/// The fields of an event a condition or a recipient can name. -pub const FIELDS: &[&str] = &[ - "event", - "action", - "kind", - "repo", - "number", - "title", - "url", - "sender", - "assignees", - "assignees_added", - "assignees_removed", - "todo_holders", +/// The fields of an event a condition or a recipient can name, with what each +/// holds. A todo's key and text read them as `{field}` placeholders. +pub const FIELDS: &[(&str, &str)] = &[ + ("event", "The event type, one of the source events"), + ("action", "What happened, such as opened, closed, assigned"), + ("kind", "issue or pull request"), + ("repo", "The repository, owner/name"), + ("number", "The issue or pull request number"), + ("title", "The issue or pull request title"), + ("url", "The web link to the issue or pull request"), + ("sender", "The account that caused the event"), + ("assignees", "The agents assigned after the event (a list)"), + ("assignees_added", "The agents the event assigned (a list)"), + ( + "assignees_removed", + "The agents the event unassigned (a list)", + ), + ( + "todo_holders", + "The agents holding a router todo for the issue or pull request (a list)", + ), +]; + +/// The todo ops a `todo` node's `op` takes, one per [`Op`] variant. +const OPS: &[(&str, &str)] = &[ + ( + "upsert", + "Add the todo, or update the one under the same key", + ), + ( + "clear", + "Remove the todo under the key, or with prefix every one starting with it", + ), ]; /// The value under [`KEY`]. @@ -288,7 +316,134 @@ fn is_id(id: &str) -> bool { } fn is_field(name: &str) -> bool { - FIELDS.contains(&name) + FIELDS.iter().any(|(n, _)| *n == name) +} + +fn is_source_event(name: &str) -> bool { + SOURCE_EVENTS.iter().any(|(n, _)| *n == name) +} + +/// A name with a one-line description. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +pub struct Described { + pub name: String, + pub description: String, +} + +/// One field of a node kind. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +pub struct KindField { + pub name: String, + pub description: String, + /// Whether a node of the kind must set it. + pub required: bool, + /// The accepted values when the field takes one of a fixed few; empty + /// otherwise. + pub values: Vec, +} + +/// One kind of node and the fields it takes besides `id` and `kind`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +pub struct KindSchema { + pub kind: String, + pub description: String, + pub fields: Vec, +} + +/// The limits [`Document::check`] enforces. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +pub struct Limits { + pub max_nodes: usize, + pub max_depth: usize, + pub max_flows: usize, +} + +/// What a flow editor needs to offer only what [`Document::check`] accepts, +/// built from the constants the check reads. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +pub struct Schema { + pub source_events: Vec, + pub fields: Vec, + pub node_kinds: Vec, + pub limits: Limits, +} + +fn described(list: &[(&str, &str)]) -> Vec { + list.iter() + .map(|(name, description)| Described { + name: (*name).to_owned(), + description: (*description).to_owned(), + }) + .collect() +} + +fn kind_field(name: &str, description: &str, required: bool) -> KindField { + KindField { + name: name.to_owned(), + description: description.to_owned(), + required, + values: Vec::new(), + } +} + +/// 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); + Schema { + source_events: described(SOURCE_EVENTS), + fields: described(FIELDS), + node_kinds: vec![ + KindSchema { + kind: "source".to_owned(), + description: "The root: the forge events the flow starts from".to_owned(), + fields: vec![kind_field( + "events", + "One or more of the source events", + true, + )], + }, + KindSchema { + kind: "match".to_owned(), + description: "Passes when every condition holds; each child then runs".to_owned(), + fields: vec![kind_field( + "when", + "Conditions, each {field, equals}; a list field holds when one item equals", + true, + )], + }, + KindSchema { + 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, + ), + op, + kind_field("key", "The todo key, with {field} placeholders", true), + kind_field("prefix", "For a clear: key is a prefix", false), + kind_field( + "text", + "The todo text, with {field} placeholders; required for an upsert, empty for a clear", + false, + ), + ], + }, + ], + limits: Limits { + max_nodes: MAX_NODES, + max_depth: MAX_DEPTH, + max_flows: MAX_FLOWS, + }, + } } /// The `{name}` placeholders in `template`, in order. A `{` with no `}` after @@ -412,10 +567,14 @@ impl NodeKind { if events.is_empty() { return Err("a source with no events".to_owned()); } - if let Some(bad) = events.iter().find(|e| !SOURCE_EVENTS.contains(&e.as_str())) { + if let Some(bad) = events.iter().find(|e| !is_source_event(e)) { return Err(format!( "event {bad:?} is not one of {}", - SOURCE_EVENTS.join(", ") + SOURCE_EVENTS + .iter() + .map(|(n, _)| *n) + .collect::>() + .join(", ") )); } } @@ -590,6 +749,72 @@ mod tests { } } + /// What the schema offers is what the check accepts, and nothing else is. + #[test] + fn the_schema_lists_what_a_flow_may_name() { + let s = schema(); + let with_event = |name: &str| { + let mut f = builtin("assigned"); + f.nodes[0].kind = NodeKind::Source { + events: vec![name.to_owned()], + }; + f.validate() + }; + assert!(!s.source_events.is_empty()); + for e in &s.source_events { + assert_eq!(with_event(&e.name), Ok(()), "{}", e.name); + assert!(!e.description.is_empty(), "{}", e.name); + } + assert!(with_event("no_such_event").is_err(), "control"); + + let with_field = |name: &str| { + let mut f = builtin("assigned"); + f.nodes[1].kind = NodeKind::Match { + when: vec![Condition { + field: name.to_owned(), + equals: "x".to_owned(), + }], + }; + f.validate() + }; + assert!(!s.fields.is_empty()); + for field in &s.fields { + assert_eq!(with_field(&field.name), Ok(()), "{}", field.name); + assert!(!field.description.is_empty(), "{}", field.name); + } + assert!(with_field("no_such_field").is_err(), "control"); + + let kinds: Vec<&str> = s.node_kinds.iter().map(|k| k.kind.as_str()).collect(); + assert_eq!(kinds, ["source", "match", "todo"]); + let op_names = |op: &str| -> Option { + serde_json::from_value(serde_json::Value::String(op.to_owned())).ok() + }; + let todo = s + .node_kinds + .iter() + .find(|k| k.kind == "todo") + .expect("todo"); + let op = todo.fields.iter().find(|f| f.name == "op").expect("op"); + let listed: Vec = op + .values + .iter() + .map(|v| op_names(&v.name).expect("an op")) + .collect(); + // Every `Op` variant is listed: this match stops compiling when one is added. + for o in [Op::Upsert, Op::Clear] { + match o { + Op::Upsert | Op::Clear => assert!(listed.contains(&o), "{o:?}"), + } + } + assert_eq!(listed.len(), 2); + assert!(op_names("nope").is_none(), "control"); + + assert_eq!( + (s.limits.max_nodes, s.limits.max_depth, s.limits.max_flows), + (MAX_NODES, MAX_DEPTH, MAX_FLOWS) + ); + } + #[test] fn a_flow_that_is_not_a_tree_is_refused() { let mut cycle = builtin("assigned");