Watch
0
0
Fork
You've already forked hyperhive
0

docs: notification flows, their API and the notifications page

This commit is contained in:
atlas 2026-10-24 04:05:05 +02:00 • committed by mara
commit 7795da44f8
9 changed files with 140 additions and 59 deletions

View file

@ -64,20 +64,58 @@ The router holds no forge token and no webhook secret, and registers no hook.
A flow is a graph of nodes joined by edges: one `source` node naming the forge A flow is a graph of nodes joined by edges: one `source` node naming the forge
event types it starts from, `match` nodes that pass when every condition event types it starts from, `match` nodes that pass when every condition
holds, and `sink` nodes that emit one todo op per recipient. A match node can holds, and `todo` nodes that emit one todo op per recipient. A match node can
have more than one child, and each one runs, so one event can reach several have more than one child, and each one runs, so one event can reach several
sinks. A flow must be a tree: one source, one parent for every other node, todos. A flow must be a tree: one source, one parent for every other node,
every node reachable, no children under a sink. every node reachable, no children under a `todo`. The most is 64 nodes, 16
levels deep, and 100 custom flows. A `todo` node sends to the agents an event
field holds (`assignees_added`, `todo_holders`, …) or to one named agent.
The router ships the three flows above, each a source → match on `action` → The router ships the three flows above, each a source → match on `action` →
todo sink. todo. The code defines them (`swarm_wire::notify_flows::builtins`), so a
build that changes one reaches every swarm. A custom flow has the same type and
the same evaluator, so it can do anything a built-in does.
**The stored document.** swarm-controller keeps the rest in one document, the
`all` key of the `notify-flows` bucket: the ids of the built-in flows you
switched off, and the custom flows. Only the controller writes it, through
`GET` and `PUT /api/notify/flows`. A `PUT` replaces the whole document in one
compare-and-set. The controller answers 400 unless every flow passes the tree
check above and reads only what exists: a source event from `issues`,
`issue_assign`, `pull_request` or `pull_request_assign`, and condition and
recipient fields from the event's field list. A `PUT` that carries the
`revision` it read gets 409 when another write landed since. Swarm UI's
[`/notifications`](ui.md#notification-flows) page shows the document and
switches built-ins.
**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,
leaves it out, and runs the rest.
| the key | the router runs |
| -------------------------------------- | ----------------------- |
| absent, deleted, or its bucket missing | the built-in flows |
| a document it reads | the above |
| a document it can't read | the flows it ran before |
| a read that fails | the flows it ran before |
"Can't read" means not JSON, not a flows document, or a `version` other than 1.
Before the router has read any document, "before" means the built-ins, so a bad
document never leaves it with no flows. The controller refuses to overwrite a
document of a version it doesn't know.
**Deploying in any order.** The controller, the router and the UI can be on
different hosts. A controller that predates this leaves the key absent, and the
router runs the built-ins as before. A router that predates this ignores the
document, so the page and the API show a state the router isn't yet running.
## Credentials and grants ## Credentials and grants
| Principal | Grant | | Principal | Grant |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `swarm-notify-router`, in its container | store: `read` on `swarm/services/<router client id>/oidc/client`. Queue: its own consumer on `SWARM_EVENTS` (create, pull, ack), publish on `$SWARM.router.todo.*`, create `ROUTER_TODOS`, its own `notify-router-state` bucket, and `STREAM.INFO` on the agent-status bucket, which lists the agents | | `swarm-notify-router`, in its container | store: `read` on `swarm/services/<router client id>/oidc/client`. Queue: its own consumer on `SWARM_EVENTS` (create, pull, ack), publish on `$SWARM.router.todo.*`, create `ROUTER_TODOS`, its own `notify-router-state` bucket, read on the `notify-flows` bucket, and `STREAM.INFO` on the agent-status bucket, which lists the agents |
| swarm-controller | publish on `$SWARM.events.forge.*` | | swarm-controller | publish on `$SWARM.events.forge.*`; creates and writes the `notify-flows` bucket (every bucket, by its `$KV.*.>` grant) |
| an agent, under its own credential | create its own consumer `todo-<agent>` in the form whose filter is `$SWARM.router.todo.<agent>`, pull from it, and ack what it delivered | | an agent, under its own credential | create its own consumer `todo-<agent>` in the form whose filter is `$SWARM.router.todo.<agent>`, pull from it, and ack what it delivered |
No other client may publish on `$SWARM.router.todo.*` or No other client may publish on `$SWARM.router.todo.*` or

View file

@ -19,6 +19,7 @@ domain, covers host-level detail for one hive.
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
| `/broadcast` | send one message to several agents at once, picked from a grid of their icons | | `/broadcast` | send one message to several agents at once, picked from a grid of their icons |
| `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient | | `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient |
| `/notifications` | the notification router's flows: a switch for each built-in, and every flow drawn as a tree |
| `/issues` | a cross-repo issue report | | `/issues` | a cross-repo issue report |
| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings | | `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings |
| `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed | | `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed |
@ -216,6 +217,19 @@ Only an operator can change a schedule here or with `swarmctl schedule`; an
agent has no way to add one. The page needs the controller's `/api/schedules` agent has no way to add one. The page needs the controller's `/api/schedules`
routes. routes.
### Notification flows
`/notifications` lists the [notification router's flows](notify-router.md#flows)
in two groups. Each **built-in** flow has an on/off switch; switching one off
stops the router running it from its next event, and switching it on again
restores it. Each **custom** flow shows its name and whether it's on. Every
flow draws as an indented tree: the forge events it starts from, the
conditions under it, and the todo each branch ends in.
The page edits nothing else. Add custom flows with `PUT /api/notify/flows`.
A switch sends the whole flows document with the revision the page read, so if
another change landed since, the controller refuses it and the page shows why.
### Sending a message to an agent ### Sending a message to an agent
Under an agent's terminal, in the preview and in its own tab, is a **message** Under an agent's terminal, in the preview and in its own tab, is a **message**

View file

@ -82,6 +82,11 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
`review-classifier-settings` KV bucket, which this daemon alone reads and `review-classifier-settings` KV bucket, which this daemon alone reads and
writes (`GET`/`PUT /api/review-classifier/settings`) → writes (`GET`/`PUT /api/review-classifier/settings`) →
[`docs/swarm/review-classifier.md`](../docs/swarm/review-classifier.md#swarm-level-settings). [`docs/swarm/review-classifier.md`](../docs/swarm/review-classifier.md#swarm-level-settings).
- **Notification flows** — which of the notification router's built-in flows are
switched off, and the custom flows, as one document in the `notify-flows`
KV bucket, which this daemon alone writes (`GET`/`PUT /api/notify/flows`);
the router reads it →
[`docs/swarm/notify-router.md`](../docs/swarm/notify-router.md#flows).
- **Operator profiles** — each operator's name and the pronouns on their - **Operator profiles** — each operator's name and the pronouns on their
forge profile, for every member of authelia's operator group, as one list forge profile, for every member of authelia's operator group, as one list
under the one key of the `operator-profiles` KV bucket. This daemon alone under the one key of the `operator-profiles` KV bucket. This daemon alone

View file

@ -201,8 +201,7 @@ pub(crate) async fn put_flows(
let (current, revision) = stored(&store).await.map_err(|e| queue_problem(&e))?; let (current, revision) = stored(&store).await.map_err(|e| queue_problem(&e))?;
// An unreadable document is replaced only when it is not a version // An unreadable document is replaced only when it is not a version
// this build does not know: a newer build's document is kept for it. // this build does not know: a newer build's document is kept for it.
if let Stored::Unreadable(why @ DecodeError::UnknownVersion(_)) = &current if let Stored::Unreadable(why @ DecodeError::UnknownVersion(_)) = &current {
{
return Err(unreadable(why)); return Err(unreadable(why));
} }
if put.revision.is_some() && put.revision != revision { if put.revision.is_some() && put.revision != revision {
@ -269,16 +268,12 @@ mod tests {
} }
} }
fn body( fn body(revision: Option<u64>, disabled: &[&str], flows: Vec<Flow>) -> Json<PutFlows> {
revision: Option<u64>, Json(PutFlows {
disabled: &[&str],
flows: Vec<Flow>,
) -> Result<Json<PutFlows>, JsonRejection> {
Ok(Json(PutFlows {
revision, revision,
disabled_builtins: disabled.iter().map(|s| (*s).to_owned()).collect(), disabled_builtins: disabled.iter().map(|s| (*s).to_owned()).collect(),
flows, flows,
})) })
} }
#[tokio::test] #[tokio::test]
@ -295,7 +290,7 @@ mod tests {
let Json(saved) = put_flows( let Json(saved) = put_flows(
State(state.clone()), State(state.clone()),
body(None, &["unassigned"], vec![custom("triage")]), Ok(body(None, &["unassigned"], vec![custom("triage")])),
) )
.await .await
.expect("put"); .expect("put");
@ -307,18 +302,22 @@ mod tests {
// The stored bytes are the document the router decodes. // The stored bytes are the document the router decodes.
let store = open(&client).await.expect("bucket"); let store = open(&client).await.expect("bucket");
let bytes = store.get(notify_flows::KEY).await.expect("read").expect("stored"); let bytes = store
.get(notify_flows::KEY)
.await
.expect("read")
.expect("stored");
let doc = Document::decode(&bytes).expect("the router reads it"); let doc = Document::decode(&bytes).expect("the router reads it");
assert_eq!(doc.active().0.len(), 3, "2 built-ins and the custom flow"); assert_eq!(doc.active().0.len(), 3, "2 built-ins and the custom flow");
// A refused save changes nothing: a flow with a cycle-free orphan. // A refused save changes nothing: an orphan node, and a built-in that does not exist.
let mut orphan = custom("orphan"); let mut orphan = custom("orphan");
orphan.edges.clear(); orphan.edges.clear();
let refused = put_flows(State(state.clone()), body(None, &[], vec![orphan])) let refused = put_flows(State(state.clone()), Ok(body(None, &[], vec![orphan])))
.await .await
.expect_err("an orphan node"); .expect_err("an orphan node");
assert_eq!(refused.status, Some(StatusCode::BAD_REQUEST), "{refused:?}"); assert_eq!(refused.status, Some(StatusCode::BAD_REQUEST), "{refused:?}");
let unknown = put_flows(State(state.clone()), body(None, &["nope"], vec![])) let unknown = put_flows(State(state.clone()), Ok(body(None, &["nope"], vec![])))
.await .await
.expect_err("no such built-in"); .expect_err("no such built-in");
assert_eq!(unknown.status, Some(StatusCode::BAD_REQUEST), "{unknown:?}"); assert_eq!(unknown.status, Some(StatusCode::BAD_REQUEST), "{unknown:?}");
@ -326,14 +325,14 @@ mod tests {
assert_eq!(after, saved); assert_eq!(after, saved);
// A stale revision is refused; the current one lands. // A stale revision is refused; the current one lands.
let stale = put_flows( let outdated = put_flows(
State(state.clone()), State(state.clone()),
body(Some(revision + 100), &[], vec![]), Ok(body(Some(revision + 100), &[], vec![])),
) )
.await .await
.expect_err("stale"); .expect_err("stale");
assert_eq!(stale.status, Some(StatusCode::CONFLICT), "{stale:?}"); assert_eq!(outdated.status, Some(StatusCode::CONFLICT), "{outdated:?}");
let Json(next) = put_flows(State(state.clone()), body(Some(revision), &[], vec![])) let Json(next) = put_flows(State(state.clone()), Ok(body(Some(revision), &[], vec![])))
.await .await
.expect("current revision"); .expect("current revision");
assert!(next.revision > Some(revision)); assert!(next.revision > Some(revision));
@ -350,16 +349,25 @@ mod tests {
let store = open(&client).await.expect("bucket"); let store = open(&client).await.expect("bucket");
store store
.put(notify_flows::KEY, br#"{"version":2,"flows":[]}"#.to_vec().into()) .put(
notify_flows::KEY,
br#"{"version":2,"flows":[]}"#.to_vec().into(),
)
.await .await
.expect("seed"); .expect("seed");
let got = get_flows(State(state.clone())).await.expect_err("version 2"); let got = get_flows(State(state.clone()))
.await
.expect_err("version 2");
assert_eq!(got.status, Some(StatusCode::CONFLICT), "{got:?}"); assert_eq!(got.status, Some(StatusCode::CONFLICT), "{got:?}");
let refused = put_flows(State(state.clone()), body(None, &[], vec![])) let refused = put_flows(State(state.clone()), Ok(body(None, &[], vec![])))
.await .await
.expect_err("version 2 is kept"); .expect_err("version 2 is kept");
assert_eq!(refused.status, Some(StatusCode::CONFLICT), "{refused:?}"); assert_eq!(refused.status, Some(StatusCode::CONFLICT), "{refused:?}");
let kept = store.get(notify_flows::KEY).await.expect("read").expect("stored"); let kept = store
.get(notify_flows::KEY)
.await
.expect("read")
.expect("stored");
assert_eq!(&kept[..], br#"{"version":2,"flows":[]}"#); assert_eq!(&kept[..], br#"{"version":2,"flows":[]}"#);
// Control: garbage that names no version is replaced by a save. // Control: garbage that names no version is replaced by a save.
@ -367,7 +375,7 @@ mod tests {
.put(notify_flows::KEY, b"garbage".to_vec().into()) .put(notify_flows::KEY, b"garbage".to_vec().into())
.await .await
.expect("seed"); .expect("seed");
let _ = put_flows(State(state.clone()), body(None, &["closed"], vec![])) let _ = put_flows(State(state.clone()), Ok(body(None, &["closed"], vec![])))
.await .await
.expect("replaces garbage"); .expect("replaces garbage");
let Json(view) = get_flows(State(state)).await.expect("readable again"); let Json(view) = get_flows(State(state)).await.expect("readable again");

View file

@ -31,8 +31,8 @@ use crate::{
agent_control, agent_icon, agent_input, agent_limits, agent_login, agent_permissions, agent_control, agent_icon, agent_input, agent_limits, agent_login, agent_permissions,
agent_renewal, agent_state_stream, agent_status, agents_md, alerts, archetypes, auth, builder, agent_renewal, agent_state_stream, agent_status, agents_md, alerts, archetypes, auth, builder,
claude_login, config_pr, forge, forge_account, github_account, inference, issue_report, claude_login, config_pr, forge, forge_account, github_account, inference, issue_report,
linked_accounts, matrix_account, message_stream, notify_flows, pulls, queue_identity, review_classifier, linked_accounts, matrix_account, message_stream, notify_flows, pulls, queue_identity,
schedules, status, subagent_term, term_stream, wanted, webhook, review_classifier, schedules, status, subagent_term, term_stream, wanted, webhook,
}; };
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`. /// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.

View file

@ -1829,7 +1829,10 @@ mod tests {
"$JS.API.STREAM.INFO.KV_agent-status".to_owned(), "$JS.API.STREAM.INFO.KV_agent-status".to_owned(),
// The flows document: read it. // The flows document: read it.
format!("$JS.API.STREAM.INFO.KV_{flows}"), format!("$JS.API.STREAM.INFO.KV_{flows}"),
format!("$JS.API.DIRECT.GET.KV_{flows}.$KV.{flows}.{}", notify_flows::KEY), format!(
"$JS.API.DIRECT.GET.KV_{flows}.$KV.{flows}.{}",
notify_flows::KEY
),
] { ] {
assert!(may(&allowed), "the router may not reach {allowed}"); assert!(may(&allowed), "the router may not reach {allowed}");
} }

View file

@ -124,7 +124,6 @@ pub fn expand(template: &str, fields: &Fields) -> String {
out out
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use swarm_wire::notify_flows::{Edge, builtins}; use swarm_wire::notify_flows::{Edge, builtins};
@ -230,14 +229,7 @@ mod tests {
assert_eq!(a.op.key, "forge:hyperhive/hyperhive#7:"); assert_eq!(a.op.key, "forge:hyperhive/hyperhive#7:");
assert_eq!(a.op.summary, ""); assert_eq!(a.op.summary, "");
} }
assert!( assert!(evaluate(&closed, "issues", &event("reopened", &[], &[], &["atlas"])).is_empty());
evaluate(
&closed,
"issues",
&event("reopened", &[], &[], &["atlas"])
)
.is_empty()
);
} }
/// A match node with two children runs both: the branching the tree /// A match node with two children runs both: the branching the tree

View file

@ -18,9 +18,9 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use async_nats::jetstream::ErrorCode;
use async_nats::jetstream::context::GetStreamErrorKind; use async_nats::jetstream::context::GetStreamErrorKind;
use async_nats::jetstream::{self, kv}; use async_nats::jetstream::{self, kv};
use async_nats::jetstream::ErrorCode;
use swarm_wire::notify_flows::{self, Document, Flow}; use swarm_wire::notify_flows::{self, Document, Flow};
/// How long a read of the key is reused. /// How long a read of the key is reused.
@ -31,7 +31,10 @@ const REFRESH: Duration = Duration::from_secs(10);
pub enum Read { pub enum Read {
/// No key, a deleted key, or no bucket. /// No key, a deleted key, or no bucket.
Absent, Absent,
Document { revision: u64, bytes: Vec<u8> }, Document {
revision: u64,
bytes: Vec<u8>,
},
Failed(String), Failed(String),
} }
@ -62,7 +65,9 @@ impl Flows {
Read::Failed(why) => self.note(format!("reading the key failed: {why}")), Read::Failed(why) => self.note(format!("reading the key failed: {why}")),
Read::Absent => { Read::Absent => {
if self.revision.take().is_some() { if self.revision.take().is_some() {
tracing::info!("notify router: the flows document is gone; running the built-in flows"); tracing::info!(
"notify router: the flows document is gone; running the built-in flows"
);
} }
self.active = Document::default().active().0; self.active = Document::default().active().0;
self.problem = None; self.problem = None;

View file

@ -42,7 +42,12 @@ pub const MAX_TEXT: usize = 500;
/// The forge event types a source node can start from: the ones /// The forge event types a source node can start from: the ones
/// swarm-controller's hooks deliver to the events stream. /// swarm-controller's hooks deliver to the events stream.
pub const SOURCE_EVENTS: &[&str] = &["issues", "issue_assign", "pull_request", "pull_request_assign"]; pub const SOURCE_EVENTS: &[&str] = &[
"issues",
"issue_assign",
"pull_request",
"pull_request_assign",
];
/// The fields of an event a condition or a recipient can name. /// The fields of an event a condition or a recipient can name.
pub const FIELDS: &[&str] = &[ pub const FIELDS: &[&str] = &[
@ -204,8 +209,8 @@ impl Document {
/// Not the expected shape, or a [`Document::version`] this build does not /// Not the expected shape, or a [`Document::version`] this build does not
/// know. The reader keeps what it had. /// know. The reader keeps what it had.
pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> { pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
let value: serde_json::Value = let value: serde_json::Value = serde_json::from_slice(bytes)
serde_json::from_slice(bytes).map_err(|e| DecodeError::Malformed(format!("not JSON: {e}")))?; .map_err(|e| DecodeError::Malformed(format!("not JSON: {e}")))?;
if let Some(found) = value.get("version") if let Some(found) = value.get("version")
&& found != VERSION && found != VERSION
{ {
@ -225,15 +230,14 @@ impl Document {
return Err(format!("version {} is not {VERSION}", self.version)); return Err(format!("version {} is not {VERSION}", self.version));
} }
if self.flows.len() > MAX_FLOWS { if self.flows.len() > MAX_FLOWS {
return Err(format!( return Err(format!("{} flows, more than {MAX_FLOWS}", self.flows.len()));
"{} flows, more than {MAX_FLOWS}",
self.flows.len()
));
} }
let builtin = builtins(); let builtin = builtins();
for id in &self.disabled_builtins { for id in &self.disabled_builtins {
if !builtin.iter().any(|b| b.id == *id) { if !builtin.iter().any(|b| b.id == *id) {
return Err(format!("disabled_builtins names {id}, which is no built-in flow")); return Err(format!(
"disabled_builtins names {id}, which is no built-in flow"
));
} }
} }
let mut ids: BTreeSet<&str> = builtin.iter().map(|b| b.id.as_str()).collect(); let mut ids: BTreeSet<&str> = builtin.iter().map(|b| b.id.as_str()).collect();
@ -244,7 +248,8 @@ impl Document {
flow.id flow.id
)); ));
} }
flow.validate().map_err(|why| format!("flow {}: {why}", flow.id))?; flow.validate()
.map_err(|why| format!("flow {}: {why}", flow.id))?;
} }
Ok(()) Ok(())
} }
@ -401,7 +406,10 @@ impl NodeKind {
} }
Self::Match { when } => { Self::Match { when } => {
if let Some(bad) = when.iter().find(|c| !is_field(&c.field)) { if let Some(bad) = when.iter().find(|c| !is_field(&c.field)) {
return Err(format!("condition on {:?}, which is no event field", bad.field)); return Err(format!(
"condition on {:?}, which is no event field",
bad.field
));
} }
} }
Self::Todo(sink) => sink.validate()?, Self::Todo(sink) => sink.validate()?,
@ -534,7 +542,10 @@ mod tests {
} }
fn builtin(id: &str) -> Flow { fn builtin(id: &str) -> Flow {
builtins().into_iter().find(|f| f.id == id).expect("a built-in") builtins()
.into_iter()
.find(|f| f.id == id)
.expect("a built-in")
} }
/// A custom flow: opened issue or PR → a todo for `triage`. /// A custom flow: opened issue or PR → a todo for `triage`.
@ -704,7 +715,12 @@ mod tests {
); );
let mut bad = custom("bad"); let mut bad = custom("bad");
bad.edges.pop(); bad.edges.pop();
assert!(doc(&[], vec![bad]).check().unwrap_err().starts_with("flow bad:")); assert!(
doc(&[], vec![bad])
.check()
.unwrap_err()
.starts_with("flow bad:")
);
let many = (0..=MAX_FLOWS).map(|i| custom(&format!("f{i}"))).collect(); let many = (0..=MAX_FLOWS).map(|i| custom(&format!("f{i}"))).collect();
assert!(doc(&[], many).check().is_err(), "flow count"); assert!(doc(&[], many).check().is_err(), "flow count");
let mut old = doc(&[], vec![]); let mut old = doc(&[], vec![]);