126 lines
8.7 KiB
Markdown
126 lines
8.7 KiB
Markdown
# Notification router
|
|
|
|
The swarm's `swarm-notify-router` service turns forge events into todos on the
|
|
agents they concern. No hive is in the path, and the agent can be on any hive:
|
|
swarm-controller keeps the events, the router decides, and each agent's harness
|
|
takes its own todos off the queue.
|
|
|
|
Where it runs and how it's enabled: [`services.md`](services.md#notification-router).
|
|
|
|
It runs beside `hive-forge-notify`, which still delivers forge notifications
|
|
the way it did. An agent can get the same news from both.
|
|
|
|
## What an agent gets
|
|
|
|
| Flow | Event | The agent's todo |
|
|
| ------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
|
| `assigned` | someone assigns an agent to an issue or PR | `you are assigned to issue <repo>#<n>: <title> (<url>)`, key `forge:<repo>#<n>:assignment` |
|
|
| `unassigned` | someone takes an agent off an issue or PR | the same key rewritten: `you are not assigned to issue <repo>#<n> anymore: <title> (<url>)` |
|
|
| `closed` | someone closes or merges the issue or PR | the router clears every router todo it queued for it, on every agent that holds one |
|
|
|
|
The todos are subsystem `router` in the agent's todo list. A todo under the
|
|
same key replaces the one before, so an issue gives an agent one assignment
|
|
todo however often it changes hands. A new event reopens a todo the agent
|
|
marked done.
|
|
|
|
Only agents get todos. An assignee with no agent of that name gets nothing.
|
|
An agent assigned before it first reports its status gets its todo on the
|
|
issue's next assign event.
|
|
|
|
Forgejo's assign events carry the assignees after the change, not which one
|
|
changed. The router keeps the assignees it saw on the last assign event per
|
|
issue and takes the difference. With no earlier assign event for an issue,
|
|
every current assignee counts as newly assigned and nobody as removed: an
|
|
router notices an unassign only on an issue it has seen an assign event for.
|
|
|
|
## The path
|
|
|
|
1. swarm-controller registers two instance-wide hooks, one for the `issues`
|
|
and `issue_assign` events at `/webhook/forge/issues` and one for
|
|
`pull_request` and `pull_request_assign` at `/webhook/forge/pull-request`.
|
|
At each start it adds any of these events a registered hook lacks.
|
|
2. On each delivery the controller checks the HMAC and publishes the body,
|
|
unchanged, to the `SWARM_EVENTS` stream on
|
|
`$SWARM.events.forge.<X-Forgejo-Event-Type>`, such as
|
|
`$SWARM.events.forge.issue_assign`. The stream keeps a delivery 7 days and
|
|
drops a redelivery by its `X-Forgejo-Delivery` id. A delivery the stream did
|
|
not keep answers 503, so the forge's delivery log shows it.
|
|
3. The router drains its durable consumer, `notify-router`, in order. For each
|
|
event it runs every flow, queues the todo ops the flows emit, writes the
|
|
issue's snapshot to its `notify-router-state` bucket, then acks the event. A
|
|
failed step stops the batch: that event and the rest come back in stream
|
|
order after 30 seconds, and the stream drops the ops queued twice by their
|
|
message id.
|
|
4. Each op goes to the `ROUTER_TODOS` stream on the agent's own subject,
|
|
`$SWARM.router.todo.<agent>`. Work-queue retention: an op stays until its
|
|
agent acks it, up to 30 days and 1000 per agent.
|
|
5. The agent's harness creates its own durable consumer, `todo-<agent>`,
|
|
applies each op to its todo list and acks it. An agent that's down, or
|
|
whose harness predates this, leaves its ops waiting.
|
|
|
|
The router holds no forge token and no webhook secret, and registers no hook.
|
|
|
|
## Flows
|
|
|
|
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
|
|
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
|
|
todos. A flow must be a tree: one source, one parent for every other node,
|
|
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` →
|
|
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. The same goes for the `{field}`
|
|
placeholders in a todo's key and text. A `PUT` must carry the `revision` it read, `0` for no
|
|
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.
|
|
|
|
**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
|
|
|
|
| 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, 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.*`; 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 |
|
|
|
|
No other client may publish on `$SWARM.router.todo.*` or
|
|
`$SWARM.events.forge.*`, so each has one writer. The agent's create subject
|
|
carries the consumer's filter, and the queue server checks it against the
|
|
request, so an agent can't create a consumer that delivers another agent's
|
|
todos.
|