docs: notification flows, their API and the notifications page
This commit is contained in:
parent
0512862b3d
commit
7795da44f8
9 changed files with 140 additions and 59 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(_)) = ¤t
|
if let Stored::Unreadable(why @ DecodeError::UnknownVersion(_)) = ¤t {
|
||||||
{
|
|
||||||
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");
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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![]);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue