Watch
0
0
Fork
You've already forked hyperhive
0

archetype flows on the board list the agents the router sends to, and the docs say a switch-off key outlives its flow

This commit is contained in:
atlas 2026-11-11 20:45:07 +01:00 • committed by mara
commit 13ff33ead0
7 changed files with 66 additions and 30 deletions

View file

@ -163,7 +163,9 @@ within about 10 seconds, and the card has no `edit` button.
flow can also request a review, which an archetype's flow can't. Switch the flow can also request a review, which an archetype's flow can't. Switch the
archetype's flow off to replace it with your copy. archetype's flow off to replace it with your copy.
- A key for a flow or agent that no longer exists stays in the list and does nothing, so - A key for a flow or agent that no longer exists stays in the list and does nothing, so
editing an archetype never breaks the stored flows. editing an archetype never breaks the stored flows. The key stays
after its flow goes: a later flow with the same archetype and id comes up
already switched off; switch it on from its card.
## How the list stays current ## How the list stays current

View file

@ -303,7 +303,7 @@ it refuses any id that's no built-in.
`[{archetype, key, flow, members}]`, one per enabled archetype flow that passes `[{archetype, key, flow, members}]`, one per enabled archetype flow that passes
the archetype check and has an agent, with the flow as the archetype declares it the archetype check and has an agent, with the flow as the archetype declares it
(a `todo` to `"members"`). The controller reads the archetype records and the (a `todo` to `"members"`). The controller reads the archetype records and the
`agent-archetype` keys itself; a read that fails leaves the list empty and the `agent-archetype` keys itself, and lists as members the agents the router sends to: those with a key that are in the agent-status roster. A read that fails leaves the list empty and the
document still comes back. The page draws a card for each, with the `on` switch (the document still comes back. The page draws a card for each, with the `on` switch (the
key), a checkbox per member (the key with `@<agent>`) and `duplicate`, which key), a checkbox per member (the key with `@<agent>`) and `duplicate`, which
opens the editor on `?from=<key>`. The copy replaces `"members"` with the one opens the editor on `?from=<key>`. The copy replaces `"members"` with the one

View file

@ -192,7 +192,12 @@ pub(crate) async fn get_flows(
/// logged and leaves the list empty: the document is still served. /// logged and leaves the list empty: the document is still served.
async fn archetype_flows(client: &async_nats::Client) -> Vec<ArchetypeFlowView> { async fn archetype_flows(client: &async_nats::Client) -> Vec<ArchetypeFlowView> {
let found = async { let found = async {
let refs = crate::agent_archetype::all(client).await?; // The router's members: the archetype keys of the agents in the swarm.
let roster = swarm_queue_client::agent_status::roster(client)
.await
.map_err(|e| anyhow::anyhow!("listing the agents: {e}"))?;
let mut refs = crate::agent_archetype::all(client).await?;
refs.retain(|agent, _| roster.contains(agent));
let mut records = BTreeMap::new(); let mut records = BTreeMap::new();
if let Some(store) = archetypes::open(client).await? { if let Some(store) = archetypes::open(client).await? {
let sources: BTreeSet<&str> = refs.values().map(|r| r.source.as_str()).collect(); let sources: BTreeSet<&str> = refs.values().map(|r| r.source.as_str()).collect();
@ -592,6 +597,8 @@ mod tests {
("alice", "triage"), ("alice", "triage"),
("bob", "triage"), ("bob", "triage"),
("carol", "reviewer"), ("carol", "reviewer"),
// In the bucket but not in the swarm: the router sends it nothing.
("dave", "triage"),
] { ] {
let choice = ArchetypeRef { let choice = ArchetypeRef {
source: BUILTIN_SOURCE.to_owned(), source: BUILTIN_SOURCE.to_owned(),
@ -603,6 +610,16 @@ mod tests {
.expect("put the choice"); .expect("put the choice");
} }
let status = swarm_queue_client::agent_status::open_or_create(&client)
.await
.expect("status");
for agent in ["alice", "bob", "carol"] {
status
.put(format!("hive.{agent}"), "{}".into())
.await
.expect("put the status");
}
let Json(view) = get_flows(State(state.clone())).await.expect("get"); let Json(view) = get_flows(State(state.clone())).await.expect("get");
assert_eq!(view.archetype_flows.len(), 1, "{:?}", view.archetype_flows); assert_eq!(view.archetype_flows.len(), 1, "{:?}", view.archetype_flows);
let listed = &view.archetype_flows[0]; let listed = &view.archetype_flows[0];

View file

@ -95,11 +95,12 @@ async fn deliveries_answer(js: &jetstream::Context, payload: &[u8]) -> Vec<u8> {
/// cannot be read leaves them out of the counts. /// cannot be read leaves them out of the counts.
async fn archetype_flows( async fn archetype_flows(
client: &async_nats::Client, client: &async_nats::Client,
js: &jetstream::Context,
document: &swarm_wire::notify_flows::Document, document: &swarm_wire::notify_flows::Document,
) -> Vec<crate::flow::ArchetypeFlow> { ) -> Vec<crate::flow::ArchetypeFlow> {
let found = async { let found = async {
let roster = crate::roster_of(js).await.map_err(|e| format!("{e:#}"))?; let roster = crate::roster_of(client)
.await
.map_err(|e| format!("{e:#}"))?;
crate::archetype_flows::load(client, &roster).await crate::archetype_flows::load(client, &roster).await
} }
.await; .await;
@ -125,7 +126,7 @@ async fn dry_run_answer(
.check() .check()
.map_err(|why| anyhow::anyhow!("the document fails its check: {why}"))?; .map_err(|why| anyhow::anyhow!("the document fails its check: {why}"))?;
let (flows, _) = request.document.active(); let (flows, _) = request.document.active();
let archetype = archetype_flows(client, js, &request.document).await; let archetype = archetype_flows(client, &request.document).await;
let mut events: Vec<(String, Vec<u8>)> = let mut events: Vec<(String, Vec<u8>)> =
log::newest(js, forge_events::STREAM, DRY_RUN_EVENTS) log::newest(js, forge_events::STREAM, DRY_RUN_EVENTS)
.await? .await?

View file

@ -35,7 +35,7 @@ use std::time::{Duration, Instant};
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use async_nats::jetstream::{self, consumer::pull, kv, message::PublishMessage}; use async_nats::jetstream::{self, consumer::pull, kv, message::PublishMessage};
use futures_util::{StreamExt as _, TryStreamExt as _}; use futures_util::StreamExt as _;
use metrics::Handled; use metrics::Handled;
use swarm_queue_client::{ClientSecret, QueueConfig}; use swarm_queue_client::{ClientSecret, QueueConfig};
use swarm_secret_client::stored_secret::StoredSecret; use swarm_secret_client::stored_secret::StoredSecret;
@ -366,35 +366,18 @@ impl Router {
{ {
return Ok(agents.clone()); return Ok(agents.clone());
} }
let agents = roster_of(&self.js).await?; let agents = roster_of(&self.nats).await?;
self.roster = Some((Instant::now(), agents.clone())); self.roster = Some((Instant::now(), agents.clone()));
Ok(agents) Ok(agents)
} }
} }
/// The agents of this swarm, read from the agent-status bucket. /// The agents of this swarm, read from the agent-status bucket.
pub(crate) async fn roster_of(js: &jetstream::Context) -> Result<BTreeSet<String>> { pub(crate) async fn roster_of(client: &async_nats::Client) -> Result<BTreeSet<String>> {
let bucket = swarm_wire::agent_status::BUCKET; swarm_queue_client::agent_status::roster(client)
let stream = js
.get_stream(format!("KV_{bucket}"))
.await .await
.context("opening the agent-status bucket")?; .map_err(|e| anyhow::anyhow!("{e}"))
let subjects: Vec<(String, usize)> = stream .context("listing the agents in the agent-status bucket")
.info_with_subjects(format!("$KV.{bucket}.>"))
.await
.context("listing agent-status keys")?
.try_collect()
.await
.context("listing agent-status keys")?;
let prefix = format!("$KV.{bucket}.");
let agents: BTreeSet<String> = subjects
.into_iter()
.filter_map(|(subject, _)| {
let key = subject.strip_prefix(&prefix)?;
swarm_wire::agent_status::split_key(key).map(|(_, agent)| agent.to_owned())
})
.collect();
Ok(agents)
} }
/// Why an event was not handled. /// Why an event was not handled.

View file

@ -16,7 +16,7 @@ edition.workspace = true
# client; the exception exists because an agreement between two crates has # client; the exception exists because an agreement between two crates has
# to live in one of them, and neither end of that bucket is senior to the # to live in one of them, and neither end of that bucket is senior to the
# other. # other.
kv = ["async-nats/kv"] kv = ["async-nats/kv", "dep:futures-util"]
# 🩸 `jetstream` is NOT in async-nats's default feature set here — the # 🩸 `jetstream` is NOT in async-nats's default feature set here — the
# workspace-level dependency turns default features off entirely (see # workspace-level dependency turns default features off entirely (see
# root `Cargo.toml`: `server_2_14`/`nkeys`/`ring` only). `kv` above works # root `Cargo.toml`: `server_2_14`/`nkeys`/`ring` only). `kv` above works
@ -83,6 +83,8 @@ router-todo = ["async-nats/jetstream"]
# above - the connect itself needs none of them. # above - the connect itself needs none of them.
async-nats.workspace = true async-nats.workspace = true
hive-types = { workspace = true, optional = true } hive-types = { workspace = true, optional = true }
# `agent_status::roster` collects the stream's subject listing.
futures-util = { workspace = true, optional = true }
# `blocking` on top of the workspace default (`form`/`json`/`rustls`) — # `blocking` on top of the workspace default (`form`/`json`/`rustls`) —
# `mint_token_for_blocking` needs `reqwest::blocking::Client` for a caller # `mint_token_for_blocking` needs `reqwest::blocking::Client` for a caller
# with no tokio reactor to `.await` an async request on (an OTLP exporter's # with no tokio reactor to `.await` an async request on (an OTLP exporter's

View file

@ -20,3 +20,34 @@ pub async fn open_or_create(
) )
.await .await
} }
/// The agents of the swarm: the agent names in the bucket's keys
/// (`<hive>.<agent>`), read from the stream's subject list. This is the
/// roster the notification router sends to.
///
/// # Errors
///
/// The bucket's stream cannot be opened or its subjects listed.
#[cfg(feature = "kv")]
pub async fn roster(
client: &async_nats::Client,
) -> Result<std::collections::BTreeSet<String>, Box<dyn std::error::Error + Send + Sync>> {
use futures_util::TryStreamExt as _;
let bucket = swarm_wire::agent_status::BUCKET;
let js = async_nats::jetstream::new(client.clone());
let stream = js.get_stream(format!("KV_{bucket}")).await?;
let subjects: Vec<(String, usize)> = stream
.info_with_subjects(format!("$KV.{bucket}.>"))
.await?
.try_collect()
.await?;
let prefix = format!("$KV.{bucket}.");
Ok(subjects
.into_iter()
.filter_map(|(subject, _)| {
let key = subject.strip_prefix(&prefix)?;
swarm_wire::agent_status::split_key(key).map(|(_, agent)| agent.to_owned())
})
.collect())
}