Watch
0
0
Fork
You've already forked hyperhive
0

notify router: read custom flows and disabled built-ins from a stored document

This commit is contained in:
atlas 2026-10-24 03:44:00 +02:00 • committed by mara
commit bb08fac33c

View file

@ -1,26 +1,17 @@
//! Flows: what the router does with an event.
//! Running a flow over one event.
//!
//! A flow is a graph of nodes joined by edges, rooted at one `source` node.
//! A `match` node passes when every one of its conditions holds, and then
//! each of its children runs, so one event can reach several branches. A
//! `sink` node is a leaf and emits one todo op per recipient.
//!
//! Flows are trees: one source, one parent for every other node, every node
//! reachable from the source. [`Flow::validate`] checks that, and the router
//! runs only flows that pass.
//!
//! The built-in flows ([`builtins`]) are ordinary flows defined in code.
//! The flow types, their checks and the built-in flows are
//! `swarm_wire::notify_flows`; the built-in and the custom flows are the same
//! type and this walk runs both. A `match` node passes when every one of its
//! conditions holds, and then each of its children runs, so one event can
//! reach several branches. A `todo` node emits one op per recipient. The
//! router runs only flows that pass [`Flow::validate`].
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use swarm_wire::notify_flows::{Condition, Flow, Node, NodeKind, Recipients, TodoSink};
use swarm_wire::router_todo::{Op, RouterTodoOp};
/// The most nodes one flow may have.
const MAX_NODES: usize = 64;
/// The deepest a node may sit below the source.
const MAX_DEPTH: usize = 16;
/// One event, as the fields a flow reads.
pub type Fields = BTreeMap<&'static str, Value>;
@ -31,56 +22,6 @@ pub enum Value {
List(Vec<String>),
}
#[derive(Clone, Debug)]
pub struct Flow {
pub id: String,
pub nodes: Vec<Node>,
pub edges: Vec<Edge>,
}
#[derive(Clone, Debug)]
pub struct Node {
pub id: String,
pub kind: NodeKind,
}
/// A directed edge, parent to child.
#[derive(Clone, Debug)]
pub struct Edge {
pub from: String,
pub to: String,
}
#[derive(Clone, Debug)]
pub enum NodeKind {
/// The root: the forge event types the flow starts from.
Source { events: Vec<String> },
/// Passes when every condition holds.
Match { when: Vec<Condition> },
/// Emits a todo op to each recipient.
Todo(TodoSink),
}
/// `field` equals `value`. A list field holds when one of its items does.
#[derive(Clone, Debug)]
pub struct Condition {
pub field: String,
pub equals: String,
}
#[derive(Clone, Debug)]
pub struct TodoSink {
/// The field naming the recipients, a list of agent names.
pub to: String,
pub op: Op,
/// The todo key, with `{field}` placeholders.
pub key: String,
/// For a clear: `key` is a prefix.
pub prefix: bool,
/// The todo text, with `{field}` placeholders. Unused for a clear.
pub text: String,
}
/// One op the walk emitted, for one recipient.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Action {
@ -90,130 +31,61 @@ pub struct Action {
pub op: RouterTodoOp,
}
impl Flow {
/// Check the flow is a tree within the caps, or say why not.
pub fn validate(&self) -> Result<(), String> {
if self.nodes.len() > MAX_NODES {
return Err(format!("{} nodes, more than {MAX_NODES}", self.nodes.len()));
}
let mut ids = BTreeSet::new();
for node in &self.nodes {
if !ids.insert(node.id.as_str()) {
return Err(format!("node id {} is used twice", node.id));
}
}
for edge in &self.edges {
for end in [&edge.from, &edge.to] {
if !ids.contains(end.as_str()) {
return Err(format!("an edge names {end}, which is no node"));
/// Walk `flow` for one event of type `event_type` and return the ops it
/// emits. Assumes [`Flow::validate`] passed.
pub fn evaluate(flow: &Flow, event_type: &str, fields: &Fields) -> Vec<Action> {
let Some(root) = flow
.nodes
.iter()
.find(|n| matches!(&n.kind, NodeKind::Source { events } if events.iter().any(|e| e == event_type)))
else {
return Vec::new();
};
let mut actions = Vec::new();
let mut stack = vec![root.id.as_str()];
while let Some(id) = stack.pop() {
let Some(node) = flow.node(id) else { continue };
match &node.kind {
NodeKind::Source { .. } => stack.extend(flow.children(id)),
NodeKind::Match { when } => {
if when.iter().all(|c| holds(c, fields)) {
stack.extend(flow.children(id));
}
}
NodeKind::Todo(sink) => actions.extend(emit(flow, node, sink, fields)),
}
let roots: Vec<&Node> = self
.nodes
.iter()
.filter(|n| matches!(n.kind, NodeKind::Source { .. }))
.collect();
let [root] = roots.as_slice() else {
return Err(format!("{} source nodes, not one", roots.len()));
};
for node in &self.nodes {
if matches!(node.kind, NodeKind::Todo(_)) && !self.children(&node.id).is_empty() {
return Err(format!("sink {} has children", node.id));
}
}
// From the one root, every node reached exactly once: a node reached
// twice has a second parent or sits on a cycle, and one not reached is
// cut off.
let mut reached = 0;
let mut stack = vec![(root.id.as_str(), 0)];
while let Some((id, depth)) = stack.pop() {
if depth > MAX_DEPTH {
return Err(format!("node {id} is deeper than {MAX_DEPTH}"));
}
reached += 1;
if reached > self.nodes.len() {
return Err(
"a node is reached twice: it has two parents or sits on a cycle".to_owned(),
);
}
stack.extend(self.children(id).into_iter().map(|c| (c, depth + 1)));
}
if reached != self.nodes.len() {
return Err(format!(
"{} of {} nodes are not reachable from the source",
self.nodes.len() - reached,
self.nodes.len()
));
}
Ok(())
}
actions
}
fn children(&self, id: &str) -> Vec<&str> {
self.edges
.iter()
.filter(|e| e.from == id)
.map(|e| e.to.as_str())
.collect()
}
fn node(&self, id: &str) -> Option<&Node> {
self.nodes.iter().find(|n| n.id == id)
}
/// Walk the flow for one event of type `event_type` and return the ops it
/// emits. Assumes [`Self::validate`] passed.
pub fn evaluate(&self, event_type: &str, fields: &Fields) -> Vec<Action> {
let Some(root) = self
.nodes
.iter()
.find(|n| matches!(&n.kind, NodeKind::Source { events } if events.iter().any(|e| e == event_type)))
else {
return Vec::new();
};
let mut actions = Vec::new();
let mut stack = vec![root.id.as_str()];
while let Some(id) = stack.pop() {
let Some(node) = self.node(id) else { continue };
match &node.kind {
NodeKind::Source { .. } => stack.extend(self.children(id)),
NodeKind::Match { when } => {
if when.iter().all(|c| holds(c, fields)) {
stack.extend(self.children(id));
}
}
NodeKind::Todo(sink) => actions.extend(self.emit(node, sink, fields)),
}
}
actions
}
fn emit(&self, node: &Node, sink: &TodoSink, fields: &Fields) -> Vec<Action> {
let recipients = match fields.get(sink.to.as_str()) {
fn emit(flow: &Flow, node: &Node, sink: &TodoSink, fields: &Fields) -> Vec<Action> {
let recipients = match &sink.to {
Recipients::Agent(agent) => vec![agent.clone()],
Recipients::Field(name) => match fields.get(name.as_str()) {
Some(Value::List(items)) => items.clone(),
Some(Value::Text(one)) => vec![one.clone()],
None => Vec::new(),
};
let op = RouterTodoOp {
op: sink.op,
key: expand(&sink.key, fields),
prefix: sink.op == Op::Clear && sink.prefix,
summary: match sink.op {
Op::Upsert => expand(&sink.text, fields),
Op::Clear => String::new(),
},
source: format!("router:{}", self.id),
};
recipients
.into_iter()
.map(|agent| Action {
flow: self.id.clone(),
node: node.id.clone(),
agent,
op: op.clone(),
})
.collect()
}
},
};
let op = RouterTodoOp {
op: sink.op,
key: expand(&sink.key, fields),
prefix: sink.op == Op::Clear && sink.prefix,
summary: match sink.op {
Op::Upsert => expand(&sink.text, fields),
Op::Clear => String::new(),
},
source: format!("router:{}", flow.id),
};
recipients
.into_iter()
.map(|agent| Action {
flow: flow.id.clone(),
node: node.id.clone(),
agent,
op: op.clone(),
})
.collect()
}
fn holds(condition: &Condition, fields: &Fields) -> bool {
@ -252,91 +124,11 @@ pub fn expand(template: &str, fields: &Fields) -> String {
out
}
/// The flows the router ships with.
///
/// - `assigned`: a newly assigned agent gets a todo for the issue or PR.
/// - `unassigned`: an agent taken off it gets that todo rewritten to say so.
/// Both write the same key, so the second replaces the first.
/// - `closed`: closing it clears every router todo the router queued for it.
pub fn builtins() -> Vec<Flow> {
let assignment_key = "forge:{repo}#{number}:assignment";
vec![
chain(
"assigned",
&["issue_assign", "pull_request_assign"],
"assigned",
TodoSink {
to: "assignees_added".to_owned(),
op: Op::Upsert,
key: assignment_key.to_owned(),
prefix: false,
text: "you are assigned to {kind} {repo}#{number}: {title} ({url})".to_owned(),
},
),
chain(
"unassigned",
&["issue_assign", "pull_request_assign"],
"unassigned",
TodoSink {
to: "assignees_removed".to_owned(),
op: Op::Upsert,
key: assignment_key.to_owned(),
prefix: false,
text: "you are not assigned to {kind} {repo}#{number} anymore: {title} ({url})"
.to_owned(),
},
),
chain(
"closed",
&["issues", "pull_request"],
"closed",
TodoSink {
to: "todo_holders".to_owned(),
op: Op::Clear,
key: "forge:{repo}#{number}:".to_owned(),
prefix: true,
text: String::new(),
},
),
]
}
/// source(`events`) → match(`action` = `action`) → `sink`.
fn chain(id: &str, events: &[&str], action: &str, sink: TodoSink) -> Flow {
let node = |id: &str, kind| Node {
id: id.to_owned(),
kind,
};
let edge = |from: &str, to: &str| Edge {
from: from.to_owned(),
to: to.to_owned(),
};
Flow {
id: id.to_owned(),
nodes: vec![
node(
"source",
NodeKind::Source {
events: events.iter().map(|e| (*e).to_owned()).collect(),
},
),
node(
"match",
NodeKind::Match {
when: vec![Condition {
field: "action".to_owned(),
equals: action.to_owned(),
}],
},
),
node("sink", NodeKind::Todo(sink)),
],
edges: vec![edge("source", "match"), edge("match", "sink")],
}
}
#[cfg(test)]
mod tests {
use swarm_wire::notify_flows::{Edge, builtins};
use super::*;
fn text(v: &str) -> Value {
@ -368,16 +160,11 @@ mod tests {
.expect("a built-in")
}
#[test]
fn every_builtin_is_a_valid_tree() {
for f in builtins() {
assert_eq!(f.validate(), Ok(()), "{}", f.id);
}
}
#[test]
fn assigning_queues_a_todo_for_each_newly_assigned_agent_only() {
let got = flow("assigned").evaluate(
let assigned = flow("assigned");
let got = evaluate(
&assigned,
"issue_assign",
&event("assigned", &["atlas", "iris"], &[], &[]),
);
@ -392,21 +179,28 @@ mod tests {
assert_eq!(got[0].op.source, "router:assigned");
// The match gates: an unassign event reaches no assigned sink.
assert!(
flow("assigned")
.evaluate("issue_assign", &event("unassigned", &["atlas"], &[], &[]))
.is_empty()
evaluate(
&assigned,
"issue_assign",
&event("unassigned", &["atlas"], &[], &[])
)
.is_empty()
);
// The source gates: another event type starts nothing.
assert!(
flow("assigned")
.evaluate("issues", &event("assigned", &["atlas"], &[], &[]))
.is_empty()
evaluate(
&assigned,
"issues",
&event("assigned", &["atlas"], &[], &[])
)
.is_empty()
);
}
#[test]
fn unassigning_rewrites_the_same_key_for_the_removed_agent() {
let got = flow("unassigned").evaluate(
let got = evaluate(
&flow("unassigned"),
"pull_request_assign",
&event("unassigned", &[], &["argus"], &[]),
);
@ -423,8 +217,12 @@ mod tests {
#[test]
fn closing_clears_by_prefix_for_every_holder() {
let got =
flow("closed").evaluate("issues", &event("closed", &[], &[], &["atlas", "argus"]));
let closed = flow("closed");
let got = evaluate(
&closed,
"issues",
&event("closed", &[], &[], &["atlas", "argus"]),
);
assert_eq!(got.len(), 2);
for a in &got {
assert_eq!(a.op.op, Op::Clear);
@ -433,9 +231,12 @@ mod tests {
assert_eq!(a.op.summary, "");
}
assert!(
flow("closed")
.evaluate("issues", &event("reopened", &[], &[], &["atlas"]))
.is_empty()
evaluate(
&closed,
"issues",
&event("reopened", &[], &[], &["atlas"])
)
.is_empty()
);
}
@ -450,7 +251,7 @@ mod tests {
f.nodes.push(Node {
id: "second".to_owned(),
kind: NodeKind::Todo(TodoSink {
to: "assignees_removed".to_owned(),
to: Recipients::Field("assignees_removed".to_owned()),
..sink
}),
});
@ -459,7 +260,8 @@ mod tests {
to: "second".to_owned(),
});
assert_eq!(f.validate(), Ok(()));
let got = f.evaluate(
let got = evaluate(
&f,
"issue_assign",
&event("assigned", &["atlas"], &["argus"], &[]),
);
@ -471,40 +273,26 @@ mod tests {
assert_eq!(nodes, [("second", "argus"), ("sink", "atlas")]);
}
/// A custom flow's sink can name one agent, whatever the event holds.
#[test]
fn a_flow_that_is_not_a_tree_is_refused() {
let edge = |from: &str, to: &str| Edge {
from: from.to_owned(),
to: to.to_owned(),
};
let mut cycle = flow("assigned");
cycle.edges[0] = edge("sink", "match");
cycle.edges.push(edge("match", "sink"));
assert!(cycle.validate().is_err(), "a cycle");
let mut orphan = flow("assigned");
orphan.edges.pop();
assert!(orphan.validate().is_err(), "an unreachable sink");
let mut two_parents = flow("assigned");
two_parents.edges.push(edge("source", "sink"));
assert!(two_parents.validate().is_err(), "a sink with two parents");
let mut sink_child = flow("assigned");
sink_child.nodes.push(Node {
id: "m2".to_owned(),
kind: NodeKind::Match { when: Vec::new() },
fn a_sink_to_a_named_agent_reaches_it_once() {
let mut f = flow("assigned");
f.nodes[2].kind = NodeKind::Todo(TodoSink {
to: Recipients::Agent("triage".to_owned()),
op: Op::Upsert,
key: "forge:{repo}#{number}:triage".to_owned(),
prefix: false,
text: "triage {repo}#{number}".to_owned(),
});
sink_child.edges.push(edge("sink", "m2"));
assert!(sink_child.validate().is_err(), "a sink with a child");
let mut dangling = flow("assigned");
dangling.edges.push(edge("match", "nowhere"));
assert!(dangling.validate().is_err(), "an edge to no node");
let mut two_sources = flow("assigned");
two_sources.nodes[1].kind = NodeKind::Source { events: Vec::new() };
assert!(two_sources.validate().is_err(), "two sources");
let got = evaluate(
&f,
"issue_assign",
&event("assigned", &["atlas", "iris"], &[], &[]),
);
assert_eq!(got.len(), 1);
assert_eq!(got[0].agent, "triage");
assert_eq!(got[0].op.summary, "triage hyperhive/hyperhive#7");
assert_eq!(got[0].op.source, "router:assigned");
}
#[test]