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
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
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

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
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
`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
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

View file

@ -192,7 +192,12 @@ pub(crate) async fn get_flows(
/// logged and leaves the list empty: the document is still served.
async fn archetype_flows(client: &async_nats::Client) -> Vec<ArchetypeFlowView> {
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();
if let Some(store) = archetypes::open(client).await? {
let sources: BTreeSet<&str> = refs.values().map(|r| r.source.as_str()).collect();
@ -592,6 +597,8 @@ mod tests {
("alice", "triage"),
("bob", "triage"),
("carol", "reviewer"),
// In the bucket but not in the swarm: the router sends it nothing.
("dave", "triage"),
] {
let choice = ArchetypeRef {
source: BUILTIN_SOURCE.to_owned(),
@ -603,6 +610,16 @@ mod tests {
.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");
assert_eq!(view.archetype_flows.len(), 1, "{:?}", view.archetype_flows);
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.
async fn archetype_flows(
client: &async_nats::Client,
js: &jetstream::Context,
document: &swarm_wire::notify_flows::Document,
) -> Vec<crate::flow::ArchetypeFlow> {
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
}
.await;
@ -125,7 +126,7 @@ async fn dry_run_answer(
.check()
.map_err(|why| anyhow::anyhow!("the document fails its check: {why}"))?;
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>)> =
log::newest(js, forge_events::STREAM, DRY_RUN_EVENTS)
.await?

View file

@ -35,7 +35,7 @@ use std::time::{Duration, Instant};
use anyhow::{Context as _, Result, bail};
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 swarm_queue_client::{ClientSecret, QueueConfig};
use swarm_secret_client::stored_secret::StoredSecret;
@ -366,35 +366,18 @@ impl Router {
{
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()));
Ok(agents)
}
}
/// The agents of this swarm, read from the agent-status bucket.
pub(crate) async fn roster_of(js: &jetstream::Context) -> Result<BTreeSet<String>> {
let bucket = swarm_wire::agent_status::BUCKET;
let stream = js
.get_stream(format!("KV_{bucket}"))
pub(crate) async fn roster_of(client: &async_nats::Client) -> Result<BTreeSet<String>> {
swarm_queue_client::agent_status::roster(client)
.await
.context("opening the agent-status bucket")?;
let subjects: Vec<(String, usize)> = stream
.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)
.map_err(|e| anyhow::anyhow!("{e}"))
.context("listing the agents in the agent-status bucket")
}
/// 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
# to live in one of them, and neither end of that bucket is senior to the
# other.
kv = ["async-nats/kv"]
kv = ["async-nats/kv", "dep:futures-util"]
# 🩸 `jetstream` is NOT in async-nats's default feature set here — the
# workspace-level dependency turns default features off entirely (see
# 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.
async-nats.workspace = 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`) —
# `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

View file

@ -20,3 +20,34 @@ pub async fn open_or_create(
)
.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())
}