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
112 lines
3.8 KiB
Rust
112 lines
3.8 KiB
Rust
//! The router's todo queue: one work-queue stream, one subject and one
|
|
//! durable consumer per agent, and the op each message carries.
|
|
//!
|
|
//! The notification router publishes a [`RouterTodoOp`] on [`subject`] for
|
|
//! the agent it addresses. That agent's harness drains its own consumer,
|
|
//! [`consumer_name`], applies each op to its todo store as subsystem
|
|
//! [`TODO_SUBSYSTEM`], and acks it, which removes it from the stream. An
|
|
//! agent that is down finds its ops waiting, up to [`MAX_AGE`].
|
|
//!
|
|
//! The store dedups on `(subsystem, key)`, so a redelivered op is harmless.
|
|
//!
|
|
//! Three crates name these: the router publishes, the harness consumes, and
|
|
//! the auth-callout responder grants each its side.
|
|
|
|
use std::time::Duration;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// The stream the ops are queued in. The router creates it.
|
|
pub const STREAM: &str = "ROUTER_TODOS";
|
|
|
|
/// Every subject [`STREAM`] captures sits below this, one token per agent.
|
|
pub const SUBJECT_PREFIX: &str = "$SWARM.router.todo";
|
|
|
|
/// How long an op waits for its agent to take it.
|
|
pub const MAX_AGE: Duration = Duration::from_hours(30 * 24);
|
|
|
|
/// How many ops one agent's subject holds before the oldest go.
|
|
pub const MAX_MSGS_PER_SUBJECT: i64 = 1000;
|
|
|
|
/// The router's publish dedup window: an op republished with the same
|
|
/// `Nats-Msg-Id` inside it is dropped by the stream.
|
|
pub const DUPLICATE_WINDOW: Duration = Duration::from_mins(2);
|
|
|
|
/// The todo subsystem every op is applied under, so the router's todos never
|
|
/// collide with another producer's keys.
|
|
pub const TODO_SUBSYSTEM: &str = "router";
|
|
|
|
/// The subject the router queues `agent`'s ops on.
|
|
#[must_use]
|
|
pub fn subject(agent: &str) -> String {
|
|
format!("{SUBJECT_PREFIX}.{agent}")
|
|
}
|
|
|
|
/// Every [`subject`] at once: the stream's subjects and the router's publish
|
|
/// grant.
|
|
#[must_use]
|
|
pub fn subject_wildcard() -> String {
|
|
subject("*")
|
|
}
|
|
|
|
/// The durable consumer `agent`'s harness drains: `todo-<agent>`.
|
|
#[must_use]
|
|
pub fn consumer_name(agent: &str) -> String {
|
|
format!("todo-{agent}")
|
|
}
|
|
|
|
/// What an op does to the agent's todo list.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Op {
|
|
/// Add the todo, or update the one under the same key.
|
|
Upsert,
|
|
/// Remove the todo under the key, or with [`RouterTodoOp::prefix`] every
|
|
/// router todo whose key starts with it.
|
|
Clear,
|
|
}
|
|
|
|
/// One change to one agent's router todos.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct RouterTodoOp {
|
|
pub op: Op,
|
|
/// The todo's key under [`TODO_SUBSYSTEM`], such as
|
|
/// `forge:o/r#7:assignment`.
|
|
pub key: String,
|
|
/// For [`Op::Clear`]: `key` is a prefix. Ignored for an upsert.
|
|
#[serde(default)]
|
|
pub prefix: bool,
|
|
/// The line the agent reads. Empty for a clear.
|
|
#[serde(default)]
|
|
pub summary: String,
|
|
/// Which flow produced the op: `router:<flow-id>`.
|
|
pub source: String,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_names_are_the_agreed_ones() {
|
|
assert_eq!(subject("atlas"), "$SWARM.router.todo.atlas");
|
|
assert_eq!(subject_wildcard(), "$SWARM.router.todo.*");
|
|
assert_eq!(consumer_name("atlas"), "todo-atlas");
|
|
}
|
|
|
|
#[test]
|
|
fn an_op_reads_back_with_defaults_for_what_a_clear_leaves_out() {
|
|
let op: RouterTodoOp = serde_json::from_str(
|
|
r#"{"op":"clear","key":"forge:o/r#1:","prefix":true,"source":"router:closed"}"#,
|
|
)
|
|
.expect("decodes");
|
|
assert_eq!(op.op, Op::Clear);
|
|
assert!(op.prefix);
|
|
assert_eq!(op.summary, "");
|
|
let upsert: RouterTodoOp = serde_json::from_str(
|
|
r#"{"op":"upsert","key":"k","summary":"s","source":"router:assigned"}"#,
|
|
)
|
|
.expect("decodes");
|
|
assert!(!upsert.prefix);
|
|
}
|
|
}
|