Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-notify-router/src/flow.rs
atlas 1efbb72fdd an archetype carries its own notification-router flows for its agents
ArchetypeMeta gets flows, checked when the controller stores the record: every todo must go to the new members recipient, which the router expands to the agents of the archetype. The operator's flows document refuses members. The router reads the archetype records and each roster agent's archetype key, and its NATS grant gains read on both buckets.
2026-11-11 21:40:17 +01:00

550 lines
19 KiB
Rust

//! Running a flow over one event.
//!
//! 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, in edge order, so a clear listed before an upsert
//! is queued before it. A `todo` node emits one op per recipient and a
//! `request_reviewer` node one request for a review. The router runs only
//! flows that pass [`Flow::validate`].
use std::collections::BTreeMap;
use swarm_wire::notify_flows::{Condition, Flow, Node, NodeKind, Recipients, TodoSink};
use swarm_wire::router_todo::{Op, RouterTodoOp};
/// One event, as the fields a flow reads.
pub type Fields = BTreeMap<&'static str, Value>;
/// A field's value.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Value {
Text(String),
List(Vec<String>),
}
/// One op the walk emitted, for one recipient.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Action {
pub flow: String,
pub node: String,
pub agent: String,
pub op: RouterTodoOp,
}
/// One request for `reviewer`'s review of pull request `number` of `repo`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReviewRequest {
pub flow: String,
pub node: String,
pub repo: String,
pub number: u64,
pub reviewer: String,
}
/// Walk `flow` for one event of type `event_type`, calling `visit` on each node
/// the event reaches: the `source`, each `match` it passes, and the `todo` and
/// `request_reviewer` nodes below. Assumes [`Flow::validate`] passed.
fn walk<'f>(flow: &'f Flow, event_type: &str, fields: &Fields, mut visit: impl FnMut(&'f Node)) {
let Some(root) = flow
.nodes
.iter()
.find(|n| matches!(&n.kind, NodeKind::Source { events } if events.iter().any(|e| e == event_type)))
else {
return;
};
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 { .. } => {
visit(node);
stack.extend(flow.children(id).into_iter().rev());
}
NodeKind::Match { when } => {
if when.iter().all(|c| holds(c, fields)) {
visit(node);
stack.extend(flow.children(id).into_iter().rev());
}
}
NodeKind::Todo(_) | NodeKind::RequestReviewer { .. } => visit(node),
}
}
}
/// The ids of the nodes of `flow` that one event of type `event_type` reaches,
/// a `match` node counting only when it passes. Emits nothing: this is the
/// dry run's view of the walk that [`evaluate`] and [`requests`] make.
pub fn reached<'f>(flow: &'f Flow, event_type: &str, fields: &Fields) -> Vec<&'f str> {
let mut ids = Vec::new();
walk(flow, event_type, fields, |node| ids.push(node.id.as_str()));
ids
}
/// A flow an archetype carries, with the agents of that archetype: who a
/// `members` recipient expands to.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArchetypeFlow {
pub flow: Flow,
pub members: Vec<String>,
}
/// Walk `flow` for one event of type `event_type` and return the todo ops it
/// emits. Assumes [`Flow::validate`] passed, so it has no `members` recipient;
/// [`evaluate_for`] runs an archetype's flow.
pub fn evaluate(flow: &Flow, event_type: &str, fields: &Fields) -> Vec<Action> {
evaluate_for(flow, event_type, fields, &[])
}
/// [`evaluate`] for a flow that passed [`Flow::validate_archetype`]: a
/// `members` recipient expands to `members`.
pub fn evaluate_for(
flow: &Flow,
event_type: &str,
fields: &Fields,
members: &[String],
) -> Vec<Action> {
let mut actions = Vec::new();
walk(flow, event_type, fields, |node| {
if let NodeKind::Todo(sink) = &node.kind {
actions.extend(emit(flow, node, sink, fields, members));
}
});
actions
}
/// Walk `flow` for one event of type `event_type` and return the review
/// requests it makes. An event that is not about a pull request makes none.
/// Assumes [`Flow::validate`] passed.
pub fn requests(flow: &Flow, event_type: &str, fields: &Fields) -> Vec<ReviewRequest> {
let mut requests = Vec::new();
walk(flow, event_type, fields, |node| {
if let NodeKind::RequestReviewer { reviewer } = &node.kind
&& let Some(request) = request(flow, node, reviewer, fields)
{
requests.push(request);
}
});
requests
}
fn request(flow: &Flow, node: &Node, reviewer: &str, fields: &Fields) -> Option<ReviewRequest> {
let text = |name: &str| match fields.get(name) {
Some(Value::Text(v)) => Some(v.as_str()),
_ => None,
};
if text("kind") != Some("PR") {
return None;
}
Some(ReviewRequest {
flow: flow.id.clone(),
node: node.id.clone(),
repo: text("repo")?.to_owned(),
number: text("number")?.parse().ok()?,
reviewer: reviewer.to_owned(),
})
}
fn emit(
flow: &Flow,
node: &Node,
sink: &TodoSink,
fields: &Fields,
members: &[String],
) -> Vec<Action> {
let recipients = match &sink.to {
Recipients::Agent(agent) => vec![agent.clone()],
Recipients::Members => members.to_vec(),
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 rendered = expand(&sink.key, fields).and_then(|key| {
let summary = match sink.op {
Op::Upsert => expand(&sink.text, fields)?,
Op::Clear => String::new(),
};
Ok((key, summary))
});
let (key, summary) = match rendered {
Ok(done) => done,
Err(why) => {
tracing::warn!(flow = %flow.id, node = %node.id, %why, "notify router: a todo node names a placeholder the event lacks; nothing sent");
return Vec::new();
}
};
let op = RouterTodoOp {
op: sink.op,
key,
prefix: sink.op == Op::Clear && sink.prefix,
summary,
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 {
match fields.get(condition.field.as_str()) {
Some(Value::Text(v)) => *v == condition.equals,
Some(Value::List(items)) => items.contains(&condition.equals),
None => false,
}
}
/// `template`, a Jinja template, rendered over the event's fields; a list field
/// reads as its items joined with `, `. A name that is no field is an error.
pub fn expand(template: &str, fields: &Fields) -> Result<String, String> {
let context: BTreeMap<&str, String> = fields
.iter()
.map(|(name, value)| {
let text = match value {
Value::Text(v) => v.clone(),
Value::List(items) => items.join(", "),
};
(*name, text)
})
.collect();
hive_template::render(template, &context)
}
#[cfg(test)]
mod tests {
use swarm_wire::notify_flows::{Edge, builtins};
use super::*;
fn text(v: &str) -> Value {
Value::Text(v.to_owned())
}
fn list(v: &[&str]) -> Value {
Value::List(v.iter().map(|s| (*s).to_owned()).collect())
}
fn event(action: &str, added: &[&str], removed: &[&str], holders: &[&str]) -> Fields {
Fields::from([
("action", text(action)),
("kind", text("issue")),
("repo", text("hyperhive/hyperhive")),
("number", text("7")),
("title", text("router")),
("url", text("https://forge/7")),
("draft", text("false")),
("undrafted", text("false")),
("assignees_added", list(added)),
("assignees_removed", list(removed)),
("todo_holders", list(holders)),
])
}
fn flow(id: &str) -> Flow {
builtins()
.into_iter()
.find(|f| f.id == id)
.expect("a built-in")
}
#[test]
fn assigning_queues_a_todo_for_each_newly_assigned_agent_only() {
let assigned = flow("assigned");
let got = evaluate(
&assigned,
"issue_assign",
&event("assigned", &["atlas", "iris"], &[], &[]),
);
let agents: Vec<&str> = got.iter().map(|a| a.agent.as_str()).collect();
assert_eq!(agents, ["atlas", "iris"]);
assert_eq!(got[0].op.op, Op::Upsert);
assert_eq!(got[0].op.key, "forge:hyperhive/hyperhive#7:assignment");
assert_eq!(
got[0].op.summary,
"you are assigned to issue hyperhive/hyperhive#7: router (https://forge/7)"
);
assert_eq!(got[0].op.source, "router:assigned");
// The match gates: an unassign event reaches no assigned sink.
assert!(
evaluate(
&assigned,
"issue_assign",
&event("unassigned", &["atlas"], &[], &[])
)
.is_empty()
);
// The source gates: another event type starts nothing.
assert!(
evaluate(
&assigned,
"issues",
&event("assigned", &["atlas"], &[], &[])
)
.is_empty()
);
}
#[test]
fn unassigning_rewrites_the_same_key_for_the_removed_agent() {
let got = evaluate(
&flow("unassigned"),
"pull_request_assign",
&event("unassigned", &[], &["argus"], &[]),
);
assert_eq!(got.len(), 1);
assert_eq!(got[0].agent, "argus");
assert_eq!(got[0].op.key, "forge:hyperhive/hyperhive#7:assignment");
assert!(
got[0]
.op
.summary
.starts_with("you are not assigned to issue")
);
}
#[test]
fn closing_clears_by_prefix_for_every_holder() {
let closed = flow("closed");
let got = evaluate(
&closed,
"issues",
&event("closed", &[], &[], &["atlas", "argus"]),
);
let got: Vec<_> = got.into_iter().filter(|a| a.node == "sink").collect();
assert_eq!(got.len(), 2);
for a in &got {
assert_eq!(a.op.op, Op::Clear);
assert!(a.op.prefix);
assert_eq!(a.op.key, "forge:hyperhive/hyperhive#7:");
assert_eq!(a.op.summary, "");
}
assert!(evaluate(&closed, "issues", &event("reopened", &[], &[], &["atlas"])).is_empty());
}
/// The clear runs before the `closed` todo, so its prefix does not take the
/// todo with it, and both read the one holders set.
#[test]
fn closing_clears_then_tells_each_holder() {
let closed = flow("closed");
let got = evaluate(
&closed,
"pull_request",
&event("closed", &[], &[], &["atlas", "argus"]),
);
let seen: Vec<(&str, &str, Op)> = got
.iter()
.map(|a| (a.node.as_str(), a.agent.as_str(), a.op.op))
.collect();
assert_eq!(
seen,
[
("sink", "atlas", Op::Clear),
("sink", "argus", Op::Clear),
("inform", "atlas", Op::Upsert),
("inform", "argus", Op::Upsert),
]
);
let tell = &got[2].op;
assert_eq!(tell.key, "forge:hyperhive/hyperhive#7:closed");
assert!(!tell.prefix);
assert_eq!(
tell.summary,
"issue hyperhive/hyperhive#7 was closed: router (https://forge/7)"
);
assert_eq!(tell.source, "router:closed");
// The clear's prefix covers the todo's key: the order is what saves it.
assert!(tell.key.starts_with(&got[0].op.key));
// No holders, no todos.
assert!(evaluate(&closed, "issues", &event("closed", &[], &[], &[])).is_empty());
}
/// A match node with two children runs both: the branching the tree
/// allows.
#[test]
fn a_match_with_two_children_runs_both() {
let mut f = flow("assigned");
let NodeKind::Todo(sink) = f.nodes[2].kind.clone() else {
panic!("the third node is the sink")
};
f.nodes.push(Node {
id: "second".to_owned(),
kind: NodeKind::Todo(TodoSink {
to: Recipients::Field("assignees_removed".to_owned()),
..sink
}),
});
f.edges.push(Edge {
from: "match".to_owned(),
to: "second".to_owned(),
});
assert_eq!(f.validate(), Ok(()));
let got = evaluate(
&f,
"issue_assign",
&event("assigned", &["atlas"], &["argus"], &[]),
);
let mut nodes: Vec<(&str, &str)> = got
.iter()
.map(|a| (a.node.as_str(), a.agent.as_str()))
.collect();
nodes.sort_unstable();
assert_eq!(nodes, [("second", "argus"), ("sink", "atlas")]);
}
/// A custom flow's sink can name one agent, whatever the event holds.
#[test]
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(),
});
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");
}
/// A request sits under the same match gating as a todo, and names the
/// pull request the event concerns.
#[test]
fn a_reviewer_request_runs_under_its_match_and_only_for_a_pull_request() {
let mut f = flow("assigned");
f.nodes[0].kind = NodeKind::Source {
events: vec!["pull_request_sync".to_owned()],
};
f.nodes[2].kind = NodeKind::RequestReviewer {
reviewer: "argus".to_owned(),
};
assert_eq!(f.validate(), Ok(()));
let pull = |action: &str, kind: &'static str| {
let mut fields = event(action, &[], &[], &[]);
fields.insert("kind", text(kind));
fields
};
let got = requests(&f, "pull_request_sync", &pull("assigned", "PR"));
assert_eq!(
got,
[ReviewRequest {
flow: "assigned".to_owned(),
node: "sink".to_owned(),
repo: "hyperhive/hyperhive".to_owned(),
number: 7,
reviewer: "argus".to_owned(),
}]
);
assert!(requests(&f, "pull_request_sync", &pull("opened", "PR")).is_empty());
assert!(requests(&f, "pull_request_sync", &pull("assigned", "issue")).is_empty());
assert!(requests(&f, "pull_request", &pull("assigned", "PR")).is_empty());
// A request is no todo.
assert!(evaluate(&f, "pull_request_sync", &pull("assigned", "PR")).is_empty());
}
/// The built-in classifier flow requests on a push, an open and a reopen,
/// and on no other action of a `pull_request` event.
#[test]
fn the_classifier_flow_requests_on_open_reopen_and_push_only() {
let f = swarm_wire::notify_flows::builtins()
.into_iter()
.find(|f| f.id == "head-classifier")
.expect("the built-in");
assert_eq!(f.validate(), Ok(()));
let pull = |action: &str| {
let mut fields = event(action, &[], &[], &[]);
fields.insert("kind", text("PR"));
fields
};
let got = |event_type: &str, action: &str| requests(&f, event_type, &pull(action)).len();
assert_eq!(got("pull_request", "opened"), 1);
assert_eq!(got("pull_request", "reopened"), 1);
assert_eq!(got("pull_request_sync", "synchronized"), 1);
assert_eq!(got("pull_request", "closed"), 0);
assert_eq!(got("pull_request", "edited"), 0);
assert_eq!(got("pull_request_assign", "opened"), 0);
}
/// A draft PR gets no request on open, reopen or push; the edit that takes
/// it out of draft gets exactly one, and a title edit that does not does not.
#[test]
fn the_classifier_flow_skips_a_draft_and_requests_once_when_it_leaves_draft() {
let f = swarm_wire::notify_flows::builtins()
.into_iter()
.find(|f| f.id == "head-classifier")
.expect("the built-in");
let pull = |action: &str, draft: bool, undrafted: bool| {
let mut fields = event(action, &[], &[], &[]);
fields.insert("kind", text("PR"));
fields.insert("draft", text(if draft { "true" } else { "false" }));
fields.insert("undrafted", text(if undrafted { "true" } else { "false" }));
fields
};
let got = |event_type: &str, fields: &Fields| requests(&f, event_type, fields);
for (event_type, action) in [
("pull_request", "opened"),
("pull_request", "reopened"),
("pull_request_sync", "synchronized"),
] {
assert!(
got(event_type, &pull(action, true, false)).is_empty(),
"{action}"
);
assert_eq!(
got(event_type, &pull(action, false, false)).len(),
1,
"{action}"
);
}
let left = got("pull_request", &pull("edited", false, true));
assert_eq!(left.len(), 1);
assert_eq!(left[0].node, "request-undrafted");
assert!(got("pull_request", &pull("edited", false, false)).is_empty());
assert!(got("pull_request", &pull("edited", true, false)).is_empty());
}
#[test]
fn a_placeholder_expands_to_its_field_and_an_unknown_one_is_an_error() {
let fields = event("assigned", &["a", "b"], &[], &[]);
assert_eq!(
expand("{{ repo }}#{{ number }} {{ assignees_added }} {", &fields),
Ok("hyperhive/hyperhive#7 a, b {".to_owned())
);
assert!(expand("x {{ nope }}", &fields).is_err());
}
/// A sink naming a placeholder the event lacks sends nothing, rather
/// than a todo under a literal `{name}` key.
#[test]
fn a_sink_with_an_unknown_placeholder_emits_nothing() {
let mut f = flow("assigned");
f.nodes[2].kind = NodeKind::Todo(TodoSink {
to: Recipients::Agent("triage".to_owned()),
op: Op::Upsert,
key: "forge:{{ repo }}#{{ numbr }}:triage".to_owned(),
prefix: false,
text: "t".to_owned(),
});
let fields = event("assigned", &["atlas"], &[], &[]);
assert!(evaluate(&f, "issue_assign", &fields).is_empty());
// Control: spelled right, the same flow emits.
if let NodeKind::Todo(s) = &mut f.nodes[2].kind {
s.key = "forge:{{ repo }}#{{ number }}:triage".to_owned();
}
assert_eq!(evaluate(&f, "issue_assign", &fields).len(), 1);
}
}