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
|
|
@ -181,6 +181,11 @@ struct Args {
|
|||
#[arg(long = "claude-login-client")]
|
||||
claude_login_clients: Vec<String>,
|
||||
|
||||
/// Client ids that route forge events to agents' todo queues.
|
||||
/// Repeatable, empty by default, which denies such a client.
|
||||
#[arg(long = "notify-router-client")]
|
||||
notify_router_clients: Vec<String>,
|
||||
|
||||
/// Role on the secret store's `cert` auth mount this responder logs in
|
||||
/// as to read the callout keys, its own OIDC client secret and agent
|
||||
/// credentials. The store is found
|
||||
|
|
@ -247,7 +252,8 @@ async fn serve(args: Args) -> anyhow::Result<()> {
|
|||
.with_agent_token_subjects(args.agent_token_publish_subjects.clone())?
|
||||
.with_review_classifiers(args.review_classifier_clients.clone())
|
||||
.with_builders(args.builder_clients.clone())
|
||||
.with_claude_logins(args.claude_login_clients.clone());
|
||||
.with_claude_logins(args.claude_login_clients.clone())
|
||||
.with_notify_routers(args.notify_router_clients.clone());
|
||||
let http = reqwest::Client::new();
|
||||
let settings = swarm_secret_client::client::Settings::from_env()
|
||||
.context("reading the secret store's address and this responder's identity")?;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ pub struct Policy {
|
|||
review_classifiers: Vec<String>,
|
||||
builders: Vec<String>,
|
||||
claude_logins: Vec<String>,
|
||||
notify_routers: Vec<String>,
|
||||
}
|
||||
|
||||
/// Placeholder replaced with the hive's own name in `extra_hive_subjects`.
|
||||
|
|
@ -124,6 +125,7 @@ impl Policy {
|
|||
review_classifiers: Vec::new(),
|
||||
builders: Vec::new(),
|
||||
claude_logins: Vec::new(),
|
||||
notify_routers: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -166,6 +168,13 @@ impl Policy {
|
|||
self
|
||||
}
|
||||
|
||||
/// Set the client ids that route forge events to agents' todo queues.
|
||||
#[must_use]
|
||||
pub fn with_notify_routers(mut self, clients: Vec<String>) -> Self {
|
||||
self.notify_routers = clients;
|
||||
self
|
||||
}
|
||||
|
||||
/// The permissions for an agent whose own credential was verified, or
|
||||
/// `None` when none are configured.
|
||||
///
|
||||
|
|
@ -253,6 +262,9 @@ impl Policy {
|
|||
if self.claude_logins.iter().any(|c| c == client_id) {
|
||||
return Some(Self::claude_login_permissions(client_id));
|
||||
}
|
||||
if self.notify_routers.iter().any(|c| c == client_id) {
|
||||
return Some(Self::notify_router_permissions(client_id));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
|
|
@ -303,6 +315,56 @@ impl Policy {
|
|||
}
|
||||
}
|
||||
|
||||
/// What the notification router may do: drain its own consumer on the
|
||||
/// forge events stream, queue todo ops for any agent, keep its own state
|
||||
/// bucket, and list which agents exist.
|
||||
///
|
||||
/// - **Forge events.** Open or create the stream, create its one durable
|
||||
/// in the extended form whose last tokens are the filter (the server
|
||||
/// checks them against the payload), pull from it and ack. No bare
|
||||
/// `CONSUMER.CREATE`, no `CONSUMER.INFO`, no publish on the events: the
|
||||
/// controller is their one publisher.
|
||||
/// - **Todo queue.** Open or create `ROUTER_TODOS` and publish on every
|
||||
/// agent's subject: one per agent, so a wildcard, for the no-roster
|
||||
/// reason the reader's input grant gives. No other grant names these
|
||||
/// subjects, which is what makes the router their sole writer.
|
||||
/// - **Its state bucket.** Create, look up, read and write
|
||||
/// `notify-router-state`, and no other bucket.
|
||||
/// - **The roster.** `STREAM.INFO` on the agent-status bucket, which lists
|
||||
/// its keys (`<hive>.<agent>`) and writes none.
|
||||
fn notify_router_permissions(client_id: &str) -> Permissions {
|
||||
use swarm_wire::{forge_events, notify_router, router_todo};
|
||||
|
||||
let events = forge_events::STREAM;
|
||||
let consumer = forge_events::ROUTER_CONSUMER;
|
||||
let state = format!("KV_{}", notify_router::STATE_BUCKET);
|
||||
let mut publish = Self::jetstream_minimum().to_vec();
|
||||
publish.extend([
|
||||
format!("$JS.API.STREAM.INFO.{events}"),
|
||||
format!("$JS.API.STREAM.CREATE.{events}"),
|
||||
format!(
|
||||
"$JS.API.CONSUMER.CREATE.{events}.{consumer}.{}",
|
||||
forge_events::subject_wildcard()
|
||||
),
|
||||
format!("$JS.API.CONSUMER.MSG.NEXT.{events}.{consumer}"),
|
||||
format!("$JS.ACK.{events}.{consumer}.>"),
|
||||
format!("$JS.API.STREAM.INFO.{}", router_todo::STREAM),
|
||||
format!("$JS.API.STREAM.CREATE.{}", router_todo::STREAM),
|
||||
router_todo::subject_wildcard(),
|
||||
format!("$JS.API.STREAM.INFO.{state}"),
|
||||
format!("$JS.API.STREAM.CREATE.{state}"),
|
||||
format!("$JS.API.DIRECT.GET.{state}.>"),
|
||||
format!("$KV.{}.>", notify_router::STATE_BUCKET),
|
||||
format!("$JS.API.STREAM.INFO.{}", Self::agent_status_stream()),
|
||||
Self::inbox(client_id),
|
||||
]);
|
||||
Permissions {
|
||||
publish,
|
||||
subscribe: Some(vec![Self::inbox(client_id)]),
|
||||
responses: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a hive may subscribe to: the two events the controller sends it,
|
||||
/// and its own reply inbox.
|
||||
///
|
||||
|
|
@ -666,6 +728,10 @@ impl Policy {
|
|||
swarm_wire::builder::ARCHETYPES_SUBJECT.to_owned(),
|
||||
// An operator's claude login step, sent as a request the same way.
|
||||
swarm_wire::claude_login::SUBJECT.to_owned(),
|
||||
// Verified forge deliveries, kept in the forge events stream for
|
||||
// the notification router. This client is their one publisher: the
|
||||
// router reads them and no hive or agent grant names them.
|
||||
swarm_wire::forge_events::subject_wildcard(),
|
||||
// The wanted-state buckets — one per hive, all created and written
|
||||
// by this single client.
|
||||
//
|
||||
|
|
@ -1642,6 +1708,159 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
const NOTIFY_ROUTER: &str = "swarm-notify-router";
|
||||
|
||||
/// The three router-todo templates as `swarm-nats.nix` spells them.
|
||||
const ROUTER_TODO_TEMPLATES: [&str; 3] = [
|
||||
"$JS.API.CONSUMER.CREATE.ROUTER_TODOS.todo-{agent}.$SWARM.router.todo.{agent}",
|
||||
"$JS.API.CONSUMER.MSG.NEXT.ROUTER_TODOS.todo-{agent}",
|
||||
"$JS.ACK.ROUTER_TODOS.todo-{agent}.>",
|
||||
];
|
||||
|
||||
fn with_router() -> Policy {
|
||||
with_classifier()
|
||||
.with_builders(vec![BUILDER.to_owned()])
|
||||
.with_claude_logins(vec![CLAUDE_LOGIN.to_owned()])
|
||||
.with_notify_routers(vec![NOTIFY_ROUTER.to_owned()])
|
||||
.with_agent_token_subjects(ROUTER_TODO_TEMPLATES.map(str::to_owned).to_vec())
|
||||
.expect("per-agent templates are valid")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_notify_router_drains_its_own_consumer_and_writes_only_its_own_state() {
|
||||
use swarm_queue_client::client_inbox_prefix;
|
||||
use swarm_wire::{forge_events, notify_router, router_todo};
|
||||
|
||||
let p = with_router()
|
||||
.permissions(NOTIFY_ROUTER)
|
||||
.expect("a configured router is admitted");
|
||||
let may = |subject: &str| p.publish.iter().any(|g| covers(g, subject));
|
||||
let events = forge_events::STREAM;
|
||||
let state = notify_router::STATE_BUCKET;
|
||||
for allowed in [
|
||||
format!(
|
||||
"$JS.API.CONSUMER.CREATE.{events}.notify-router.{}",
|
||||
forge_events::subject_wildcard()
|
||||
),
|
||||
format!("$JS.API.CONSUMER.MSG.NEXT.{events}.notify-router"),
|
||||
format!("$JS.ACK.{events}.notify-router.1.5.5.1700000000.0"),
|
||||
format!("$JS.API.STREAM.INFO.{events}"),
|
||||
format!("$JS.API.STREAM.CREATE.{}", router_todo::STREAM),
|
||||
router_todo::subject("atlas"),
|
||||
format!("$KV.{state}.42.5571"),
|
||||
format!("$JS.API.DIRECT.GET.KV_{state}.$KV.{state}.42.5571"),
|
||||
"$JS.API.STREAM.INFO.KV_agent-status".to_owned(),
|
||||
] {
|
||||
assert!(may(&allowed), "the router may not reach {allowed}");
|
||||
}
|
||||
for refused in [
|
||||
// The bare form, whose filter the server does not check.
|
||||
format!("$JS.API.CONSUMER.CREATE.{events}"),
|
||||
format!("$JS.API.CONSUMER.CREATE.{events}.other.$SWARM.events.forge.*"),
|
||||
format!("$JS.API.CONSUMER.DELETE.{events}.notify-router"),
|
||||
format!("$JS.API.STREAM.DELETE.{}", router_todo::STREAM),
|
||||
format!("$JS.API.STREAM.UPDATE.{}", router_todo::STREAM),
|
||||
format!(
|
||||
"$JS.API.CONSUMER.CREATE.{}.todo-atlas.{}",
|
||||
router_todo::STREAM,
|
||||
router_todo::subject("atlas")
|
||||
),
|
||||
forge_events::subject("issues"),
|
||||
"$KV.agent-status.alpha.atlas".to_owned(),
|
||||
"$KV.agent-schedules.atlas".to_owned(),
|
||||
swarm_queue_client::agent_input_subject("atlas"),
|
||||
"$JS.API.STREAM.CREATE.KV_agent-status".to_owned(),
|
||||
] {
|
||||
assert!(!may(&refused), "the router may reach {refused}");
|
||||
}
|
||||
let inbox = client_inbox_prefix(NOTIFY_ROUTER);
|
||||
assert_eq!(p.subscribe, Some(vec![format!("{inbox}.>")]));
|
||||
assert_eq!(p.responses, None);
|
||||
assert_eq!(policy().permissions(NOTIFY_ROUTER), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_router_may_queue_an_agents_todo_and_only_the_reader_may_keep_a_forge_event() {
|
||||
use swarm_wire::{forge_events, router_todo};
|
||||
|
||||
let p = with_router();
|
||||
let todo = router_todo::subject("atlas");
|
||||
let event = forge_events::subject("issue_assign");
|
||||
let grants = [
|
||||
(
|
||||
"the reader",
|
||||
p.permissions("swarm-controller").expect("reader"),
|
||||
),
|
||||
("a hive", p.permissions("hive-alpha").expect("a hive")),
|
||||
("the router", p.permissions(NOTIFY_ROUTER).expect("router")),
|
||||
("the minter", p.permissions(CLAUDE_LOGIN).expect("minter")),
|
||||
("the builder", p.permissions(BUILDER).expect("builder")),
|
||||
(
|
||||
"the classifier",
|
||||
p.permissions(CLASSIFIER).expect("classifier"),
|
||||
),
|
||||
("atlas", p.agent_token_permissions("atlas").expect("atlas")),
|
||||
];
|
||||
for (who, grant) in &grants {
|
||||
let queues = grant.publish.iter().any(|g| covers(g, &todo));
|
||||
let keeps = grant.publish.iter().any(|g| covers(g, &event));
|
||||
assert_eq!(queues, *who == "the router", "{who} queues: {queues}");
|
||||
assert_eq!(keeps, *who == "the reader", "{who} keeps events: {keeps}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_may_drain_only_its_own_router_todo_consumer() {
|
||||
use swarm_wire::router_todo::{STREAM, consumer_name, subject};
|
||||
|
||||
let p = with_router();
|
||||
let atlas = p.agent_token_permissions("atlas").expect("configured");
|
||||
let own_create = format!(
|
||||
"$JS.API.CONSUMER.CREATE.{STREAM}.{}.{}",
|
||||
consumer_name("atlas"),
|
||||
subject("atlas")
|
||||
);
|
||||
assert_eq!(
|
||||
templated(&atlas),
|
||||
vec![
|
||||
own_create.clone(),
|
||||
"$JS.API.CONSUMER.MSG.NEXT.ROUTER_TODOS.todo-atlas".to_owned(),
|
||||
"$JS.ACK.ROUTER_TODOS.todo-atlas.>".to_owned(),
|
||||
]
|
||||
);
|
||||
// Control: argus's expansion names argus.
|
||||
let argus = p.agent_token_permissions("argus").expect("configured");
|
||||
assert!(
|
||||
argus
|
||||
.publish
|
||||
.contains(&"$JS.API.CONSUMER.MSG.NEXT.ROUTER_TODOS.todo-argus".to_owned())
|
||||
);
|
||||
let may = |s: &str| atlas.publish.iter().any(|g| covers(g, s));
|
||||
assert!(may(&own_create));
|
||||
assert!(may("$JS.ACK.ROUTER_TODOS.todo-atlas.1.3.3.1700000000.0"));
|
||||
for refused in [
|
||||
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
|
||||
format!("$JS.API.CONSUMER.CREATE.{STREAM}.todo-atlas"),
|
||||
format!(
|
||||
"$JS.API.CONSUMER.CREATE.{STREAM}.todo-atlas.{}",
|
||||
subject("argus")
|
||||
),
|
||||
format!("$JS.API.CONSUMER.CREATE.{STREAM}.todo-atlas.$SWARM.router.todo.*"),
|
||||
format!(
|
||||
"$JS.API.CONSUMER.CREATE.{STREAM}.todo-argus.{}",
|
||||
subject("argus")
|
||||
),
|
||||
format!("$JS.API.CONSUMER.MSG.NEXT.{STREAM}.todo-argus"),
|
||||
format!("$JS.API.CONSUMER.DELETE.{STREAM}.todo-atlas"),
|
||||
format!("$JS.API.CONSUMER.INFO.{STREAM}.todo-atlas"),
|
||||
format!("$JS.ACK.{STREAM}.todo-argus.1.3.3.1700000000.0"),
|
||||
format!("$JS.API.STREAM.INFO.{STREAM}"),
|
||||
subject("atlas"),
|
||||
] {
|
||||
assert!(!may(&refused), "atlas's grant covers {refused}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prefix_alone_names_no_hive() {
|
||||
// `hive-` would otherwise produce `$KV.hive-status.`, a subject nobody
|
||||
|
|
|
|||
Loading…
Reference in a new issue