agents get a todo when assigned to an issue or PR, rewritten when unassigned and cleared on close
A new swarm service, swarm-notify-router, in its own container on the host
that sets deploy.notify-router.enable (defaulted on by allSwarmServices where
the host has the router's store leaf, like the claude login minter).
- swarm-controller registers instance-wide hooks for `issues` +
`issue_assign` and `pull_request` + `pull_request_assign`, updates the
event list of a hook already registered with fewer (leaving its active flag as it is), and keeps each
verified body unchanged in the SWARM_EVENTS stream on
$SWARM.events.forge.<X-Forgejo-Event-Type>, deduped by X-Forgejo-Delivery.
A delivery the stream did not keep answers 503.
- swarm-notify-router drains its durable consumer on that stream, runs its
flows (source -> match -> todo sink, stored as nodes plus edges and checked
to be a tree) and queues each op on $SWARM.router.todo.<agent> in the
work-queue stream ROUTER_TODOS. Built-in flows: assigned, unassigned (same
key, rewritten) and closed (prefix-clears every holder's router todos). A
per-issue snapshot in its notify-router-state bucket holds the last
assignees and the todo holders; only agents in the agent-status bucket get
ops, and an assignee that was not one is left out of the snapshot so the
next assign event retries it. After the first event that must be retried,
the rest of the batch is nakked so redelivery keeps stream order.
- hive-agent creates its own durable consumer todo-<agent> and applies each
op to its todo store as subsystem `router`, then acks; a refused op stops
the rest of its batch the same way. The store's (subsystem, key) dedup
makes a redelivery harmless. New Todos::clear_prefix.
- swarm-nats-auth: a --notify-router-client arm; the controller may publish
$SWARM.events.forge.*; each agent gets three {agent} templates (extended
consumer create whose filter the server checks, MSG.NEXT, ACK).
- Credentials: a notify-router store leaf minted by glue-bao-tls, a
swarm-notify-router bao policy (read on its own queue client secret), an
authelia machine client, the secret publisher entry, a journal forwarder
route. No manual step.
hive-forge-notify is unchanged, so agents also still get forge's own
notifications.
Refs #5571
This commit is contained in:
parent
156dd541e6
commit
0f7b403803
53 changed files with 3587 additions and 96 deletions
87
swarm-wire/src/forge_events.rs
Normal file
87
swarm-wire/src/forge_events.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//! The forge events stream: every verified `issues` and `pull_request`
|
||||
//! delivery swarm-controller receives, kept for the notification router.
|
||||
//!
|
||||
//! The controller checks a delivery's HMAC and publishes its body unchanged
|
||||
//! on [`subject`], one subject per Forgejo event type (the
|
||||
//! `X-Forgejo-Event-Type` header: `issues`, `issue_assign`, `pull_request`,
|
||||
//! `pull_request_sync`, …). The router reads the stream with one durable
|
||||
//! consumer, so it holds no webhook secret and registers no hook.
|
||||
//!
|
||||
//! Three crates name these: the controller publishes, the router consumes,
|
||||
//! and the auth-callout responder grants each its side.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// The stream the deliveries are kept in.
|
||||
pub const STREAM: &str = "SWARM_EVENTS";
|
||||
|
||||
/// Every subject [`STREAM`] captures sits below this.
|
||||
pub const SUBJECT_PREFIX: &str = "$SWARM.events.forge";
|
||||
|
||||
/// How long the stream keeps a delivery.
|
||||
pub const MAX_AGE: Duration = Duration::from_hours(7 * 24);
|
||||
|
||||
/// The router's durable consumer on [`STREAM`].
|
||||
pub const ROUTER_CONSUMER: &str = "notify-router";
|
||||
|
||||
/// The subject a delivery of Forgejo event type `event_type` is kept under.
|
||||
#[must_use]
|
||||
pub fn subject(event_type: &str) -> String {
|
||||
format!("{SUBJECT_PREFIX}.{event_type}")
|
||||
}
|
||||
|
||||
/// Every [`subject`] at once: the stream's subjects, the router's consumer
|
||||
/// filter and the controller's publish grant.
|
||||
#[must_use]
|
||||
pub fn subject_wildcard() -> String {
|
||||
subject("*")
|
||||
}
|
||||
|
||||
/// The event type a [`subject`] names, or `None` for a subject not below
|
||||
/// [`SUBJECT_PREFIX`].
|
||||
#[must_use]
|
||||
pub fn event_type(subject: &str) -> Option<&str> {
|
||||
subject
|
||||
.strip_prefix(SUBJECT_PREFIX)?
|
||||
.strip_prefix('.')
|
||||
.filter(|t| !t.is_empty() && !t.contains('.'))
|
||||
}
|
||||
|
||||
/// Whether `event_type` can be one token of a subject. The value comes from
|
||||
/// a request header, and a `.`, a wildcard or whitespace would publish
|
||||
/// somewhere other than one leaf below [`SUBJECT_PREFIX`].
|
||||
#[must_use]
|
||||
pub fn is_subject_token(event_type: &str) -> bool {
|
||||
!event_type.is_empty()
|
||||
&& event_type
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_subject_round_trips_to_its_event_type() {
|
||||
assert_eq!(subject("issue_assign"), "$SWARM.events.forge.issue_assign");
|
||||
assert_eq!(subject_wildcard(), "$SWARM.events.forge.*");
|
||||
assert_eq!(
|
||||
event_type("$SWARM.events.forge.issue_assign"),
|
||||
Some("issue_assign")
|
||||
);
|
||||
assert_eq!(event_type("$SWARM.events.forge."), None);
|
||||
assert_eq!(event_type("$SWARM.events.forge.a.b"), None);
|
||||
assert_eq!(event_type("$SWARM.term.atlas"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_plain_event_type_is_a_subject_token() {
|
||||
for good in ["issues", "issue_assign", "pull_request_review_approved"] {
|
||||
assert!(is_subject_token(good), "{good}");
|
||||
}
|
||||
for bad in ["", "a.b", "*", ">", "a b", "Issues"] {
|
||||
assert!(!is_subject_token(bad), "{bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue