Watch
0
0
Fork
You've already forked hyperhive
0

request the review classifier's review when a PR's head moves

The router gets a request_reviewer node and a pull_request_sync source;
the built-in head-classifier flow asks swarm-controller, over
$SWARM.router.sink.request-reviewer, to request review-classifier on
every push, unless its review of the head already stands. The controller
subscribes to pull_request_sync on its pull-request hook and answers
the router; nats-auth grants the router the publish and the controller
the subscribe.
This commit is contained in:
atlas 2026-10-24 14:28:27 +02:00
commit 0b1669a364
20 changed files with 1231 additions and 64 deletions

View file

@ -293,6 +293,10 @@ impl Policy {
/// controller creates the bucket and is its one writer.
/// - **The roster.** `STREAM.INFO` on the agent-status bucket, which lists
/// its keys (`<hive>.<agent>`) and writes none.
/// - **Review requests.** Publish on the one sink subject
/// `swarm_wire::router_sink::REQUEST_REVIEWER_SUBJECT` and hear the answer
/// on its own inbox. The controller is the subject's one subscriber and
/// holds the forge token; no wildcard over `$SWARM.router.sink`.
fn notify_router_permissions(client_id: &str) -> Permissions {
use swarm_wire::{forge_events, notify_flows, notify_router, router_todo};
@ -320,6 +324,7 @@ impl Policy {
format!("$JS.API.STREAM.INFO.{flows}"),
format!("$JS.API.DIRECT.GET.{flows}.>"),
format!("$JS.API.STREAM.INFO.{}", Self::agent_status_stream()),
swarm_wire::router_sink::REQUEST_REVIEWER_SUBJECT.to_owned(),
Self::inbox(client_id),
]);
Permissions {
@ -347,7 +352,8 @@ impl Policy {
/// What a reader may subscribe to: every agent's terminal, its
/// subagents' terminals and its turn state, which the controller relays
/// to swarm-ui, every hive's permissions import, and its own reply inbox.
/// to swarm-ui, every hive's permissions import, the notification router's
/// review requests, and its own reply inbox.
///
/// The three families are the publishers' (`hive-agent`'s `swarm_term`
/// and `swarm_agent_state`, `swarm_wire::subagent_term`), spelled
@ -359,6 +365,7 @@ impl Policy {
swarm_wire::subagent_term::subject_wildcard(),
swarm_queue_client::agent_state_subject_wildcard(),
swarm_wire::agent_permissions::import_subject_wildcard(),
swarm_wire::router_sink::REQUEST_REVIEWER_SUBJECT.to_owned(),
Self::inbox(client_id),
]
}
@ -1737,7 +1744,7 @@ mod tests {
#[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_flows, notify_router, router_todo};
use swarm_wire::{forge_events, notify_flows, notify_router, router_sink, router_todo};
let p = with_router()
.permissions(NOTIFY_ROUTER)
@ -1758,6 +1765,7 @@ mod tests {
router_todo::subject("atlas"),
format!("$KV.{state}.42.5571"),
format!("$JS.API.DIRECT.GET.KV_{state}.$KV.{state}.42.5571"),
router_sink::REQUEST_REVIEWER_SUBJECT.to_owned(),
"$JS.API.STREAM.INFO.KV_agent-status".to_owned(),
// The flows document: read it.
format!("$JS.API.STREAM.INFO.KV_{flows}"),
@ -1783,6 +1791,8 @@ mod tests {
forge_events::subject("issues"),
"$KV.agent-status.alpha.atlas".to_owned(),
"$KV.agent-schedules.atlas".to_owned(),
"$SWARM.router.sink.other".to_owned(),
"$SWARM.router.sink.>".to_owned(),
// The flows document: not write it, nor make the bucket.
format!("$KV.{flows}.{}", notify_flows::KEY),
format!("$JS.API.STREAM.CREATE.KV_{flows}"),
@ -1798,6 +1808,48 @@ mod tests {
assert_eq!(policy().permissions(NOTIFY_ROUTER), None);
}
/// The router asks and the controller answers: nobody else may send the
/// request, and nobody else may hear it, since the answer acts under the
/// controller's forge token.
#[test]
fn only_the_router_may_send_a_review_request_and_only_the_reader_may_hear_it() {
use swarm_wire::router_sink::{REPLY_DEADLINE, REQUEST_REVIEWER_SUBJECT};
let p = with_router();
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 sends = grant
.publish
.iter()
.any(|g| covers(g, REQUEST_REVIEWER_SUBJECT));
let hears = may_subscribe(grant, REQUEST_REVIEWER_SUBJECT);
assert_eq!(sends, *who == "the router", "{who} sends: {sends}");
assert_eq!(hears, *who == "the reader", "{who} hears: {hears}");
}
// The controller answers on the reply subject for as long as the
// router waits.
let reader = &grants[0].1;
assert!(
reader.responses >= Some(REPLY_DEADLINE),
"{:?}",
reader.responses
);
}
#[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};