Watch
0
0
Fork
You've already forked hyperhive
0

publish operators' forge-profile pronouns to kv and list them in the agent prompt

swarm-controller reads each member of authelia's operator group's forge
profile every five minutes and keeps one key per operator in the new
operator-profiles bucket. Every agent may list and read that bucket, and
hive-agent renders it into the system prompt at harness start, falling
back to today's prompt when the bucket is empty or doesn't answer within
five seconds.

Fixes #5383
This commit is contained in:
atlas 2026-10-17 11:17:07 +02:00 • committed by mara
commit 6942f8fd27
16 changed files with 681 additions and 35 deletions

View file

@ -71,6 +71,13 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
`review-classifier-settings` KV bucket, which this daemon alone reads and
writes (`GET`/`PUT /api/review-classifier/settings`) →
[`docs/swarm/review-classifier.md`](../docs/swarm/review-classifier.md#swarm-level-settings).
- **Operator profiles** — each operator's name and the pronouns on their
forge profile, one key per member of authelia's operator group in the
`operator-profiles` KV bucket, which this daemon alone writes, at start and
every five minutes. A forge read that fails keeps the stored value, and a
name that leaves the group loses its key. Every agent lists the bucket into
its system prompt →
[`docs/turn-loop/claude-invocation.md`](../docs/turn-loop/claude-invocation.md).
- **Relays** — each agent's terminal and turn-state header as SSE, agent
icons, the cross-repo issue report, and the UI's quick links.
- **Alerts** — the swarm Grafana's current alerts (`GET /api/alerts`), read

View file

@ -53,6 +53,7 @@ pub mod docs_grant;
pub mod hive_token;
pub mod legacy_tokens;
pub mod objects;
pub mod operator_profiles;
pub mod pull_diff;
pub mod runner_token;
pub mod site_admin;

View file

@ -0,0 +1,290 @@
//! Each operator's forge-profile pronouns, published to the `operator-profiles`
//! KV bucket (`swarm_queue_client::operator_profiles`) at start and every
//! [`RECONCILE_INTERVAL`] after. Every agent reads that bucket into its system
//! prompt.
//!
//! An operator is a member of authelia's operator group, read the way
//! `crate::matrix_account::operator_admin` reads it. A pass writes a key only
//! when its value changed and removes the key of a name no longer in the
//! group. An operator whose forge profile could not be read keeps the value
//! the bucket holds; one with no forge account yet is listed with no pronouns.
use std::collections::BTreeSet;
use std::sync::Arc;
use anyhow::{Context, Result};
use async_nats::jetstream::kv::Store;
use futures_util::TryStreamExt as _;
use swarm_queue_client::operator_profiles::{self, OperatorProfile};
use super::Client;
use super::agent_token::is_not_found;
use crate::auth::AuthBridge;
/// How often [`spawn`] re-reads the operator group and their profiles.
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
/// Where an operator's pronouns come from.
pub(crate) trait Profiles {
/// The pronouns on `name`'s forge profile: `None` when unset or when the
/// forge has no such account.
fn pronouns(&self, name: &str) -> impl Future<Output = Result<Option<String>>> + Send;
}
impl Profiles for Client {
async fn pronouns(&self, name: &str) -> Result<Option<String>> {
match self.api.call(|api| api.user_get(name).into_future()).await {
Ok(user) => Ok(user
.pronouns
.map(|p| p.trim().to_owned())
.filter(|p| !p.is_empty())),
Err(e) if is_not_found(&e) => Ok(None),
Err(e) => Err(e).with_context(|| format!("read forge user {name}")),
}
}
}
/// What one pass did.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct Outcome {
written: usize,
removed: usize,
/// Operators whose profile could not be read, left as they were.
kept: usize,
}
/// Whether `name` can be a key: one subject token of the characters NATS
/// accepts in one.
fn is_key(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// Bring `store` to one key per name in `operators`, each holding that
/// operator's pronouns as `forge` reports them.
///
/// # Errors
/// When the bucket cannot be listed, read or written. A forge read that fails
/// is logged and leaves that operator's key alone.
pub(crate) async fn sync(
operators: &[String],
forge: &impl Profiles,
store: &Store,
) -> Result<Outcome> {
let mut stale: BTreeSet<String> = store
.keys()
.await
.context("listing the operator-profiles bucket")?
.try_collect()
.await
.context("listing the operator-profiles bucket")?;
let mut outcome = Outcome::default();
for name in operators {
stale.remove(name);
if !is_key(name) {
tracing::warn!(
operator = %name,
"operator profiles: the username is no legal key; skipped"
);
continue;
}
let pronouns = match forge.pronouns(name).await {
Ok(pronouns) => pronouns,
Err(e) => {
tracing::warn!(
operator = %name,
error = %format!("{e:#}"),
"operator profiles: forge profile unreadable; keeping the stored value"
);
outcome.kept += 1;
continue;
}
};
let profile = OperatorProfile {
name: name.clone(),
pronouns,
};
let stored = store
.get(name)
.await
.with_context(|| format!("reading the profile of {name}"))?
.and_then(|value| serde_json::from_slice::<OperatorProfile>(&value).ok());
if stored.as_ref() != Some(&profile) {
let value = serde_json::to_vec(&profile).context("encoding an operator profile")?;
store
.put(name, value.into())
.await
.with_context(|| format!("writing the profile of {name}"))?;
outcome.written += 1;
}
}
for name in stale {
store
.delete(&name)
.await
.with_context(|| format!("removing the profile of {name}"))?;
outcome.removed += 1;
}
Ok(outcome)
}
/// One pass: read the operator group, then [`sync`] the bucket to it. Fails
/// as a whole, changing nothing, when the group cannot be read.
async fn pass(forge: &Client, bridge: &AuthBridge, queue: &async_nats::Client) -> Result<Outcome> {
let operators = bridge
.list_operators()
.await
.context("reading authelia's operator group")?;
let store = operator_profiles::open_or_create(queue)
.await
.context("opening the operator-profiles bucket")?;
sync(&operators, forge, &store).await
}
/// Publish the operators' profiles now and every [`RECONCILE_INTERVAL`]
/// after. A pass that fails is logged and retried on the next tick; it never
/// stops the daemon.
pub fn spawn(forge: Arc<Client>, bridge: Arc<AuthBridge>, queue: async_nats::Client) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
loop {
ticker.tick().await;
let Some(outcome) =
super::within_forge_deadline("operator profiles", pass(&forge, &bridge, &queue))
.await
else {
continue;
};
match outcome {
Ok(outcome) if outcome.written > 0 || outcome.removed > 0 => tracing::info!(
written = outcome.written,
removed = outcome.removed,
kept = outcome.kept,
"operator profiles: bucket updated"
),
Ok(outcome) => tracing::debug!(kept = outcome.kept, "operator profiles: unchanged"),
Err(e) => tracing::warn!(
error = %format!("{e:#}"),
retry_in_s = RECONCILE_INTERVAL.as_secs(),
"operator profiles: pass failed; retrying next tick"
),
}
}
});
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
/// A forge answering from a table: `Err` for a name whose read fails.
struct Fake(BTreeMap<&'static str, Result<Option<&'static str>, ()>>);
impl Profiles for Fake {
async fn pronouns(&self, name: &str) -> Result<Option<String>> {
match self.0.get(name) {
Some(Ok(p)) => Ok(p.map(str::to_owned)),
Some(Err(())) => anyhow::bail!("the forge refused the read"),
None => Ok(None),
}
}
}
fn names(names: &[&str]) -> Vec<String> {
names.iter().map(|&n| n.to_owned()).collect()
}
async fn stored(store: &Store) -> Vec<OperatorProfile> {
let keys: Vec<String> = store.keys().await.unwrap().try_collect().await.unwrap();
let mut out = Vec::new();
for key in keys {
let value = store
.get(&key)
.await
.unwrap()
.expect("a listed key has a value");
out.push(serde_json::from_slice(&value).unwrap());
}
out.sort_by(|a: &OperatorProfile, b| a.name.cmp(&b.name));
out
}
fn profile(name: &str, pronouns: Option<&str>) -> OperatorProfile {
OperatorProfile {
name: name.to_owned(),
pronouns: pronouns.map(str::to_owned),
}
}
#[tokio::test]
async fn profiles_are_written_and_a_departed_operator_is_removed() {
let nats = crate::test_nats::TestNats::start();
let client = nats.client().await;
let store = operator_profiles::open_or_create(&client).await.unwrap();
let forge = Fake([("mara", Ok(Some("she/her"))), ("sol", Ok(None))].into());
let outcome = sync(&names(&["mara", "sol"]), &forge, &store)
.await
.unwrap();
assert_eq!(outcome.written, 2);
assert_eq!(
stored(&store).await,
[profile("mara", Some("she/her")), profile("sol", None)]
);
// An unchanged profile is not rewritten.
let outcome = sync(&names(&["mara", "sol"]), &forge, &store)
.await
.unwrap();
assert_eq!(outcome, Outcome::default());
let outcome = sync(&names(&["mara"]), &forge, &store).await.unwrap();
assert_eq!(outcome.removed, 1);
assert_eq!(stored(&store).await, [profile("mara", Some("she/her"))]);
}
#[tokio::test]
async fn a_failed_forge_read_keeps_the_stored_value() {
let nats = crate::test_nats::TestNats::start();
let client = nats.client().await;
let store = operator_profiles::open_or_create(&client).await.unwrap();
let reachable = Fake(
[
("mara", Ok(Some("she/her"))),
("sol", Ok(Some("they/them"))),
]
.into(),
);
sync(&names(&["mara", "sol"]), &reachable, &store)
.await
.unwrap();
// Control: the same sync does change a value the forge reads anew.
let failing = Fake([("mara", Err(())), ("sol", Ok(Some("he/him")))].into());
let outcome = sync(&names(&["mara", "sol"]), &failing, &store)
.await
.unwrap();
assert_eq!(outcome.kept, 1);
assert_eq!(outcome.removed, 0);
assert_eq!(
stored(&store).await,
[
profile("mara", Some("she/her")),
profile("sol", Some("he/him"))
]
);
}
#[test]
fn a_dotted_or_empty_name_is_no_key() {
assert!(is_key("mara"));
assert!(is_key("op_2-x"));
assert!(!is_key(""));
assert!(!is_key("ma.ra"));
assert!(!is_key("ma*"));
}
}

View file

@ -2863,14 +2863,20 @@ fn spawn_matrix_account_backfill(
/// `internal/docs` grants, and
/// agents' tokens (which also
/// sweeps legacy tokens; see `forge::legacy_tokens`) —
/// once there is a forge client. Lifted out of `main` for
/// once there is a forge client, and the operators' profiles when the bridge
/// and the queue are wired up too. Lifted out of `main` for
/// `clippy::too_many_lines`.
fn spawn_forge_workers(
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
client: Arc<forge::Client>,
hives: &[HiveEntry],
auth: Option<Arc<auth::AuthBridge>>,
queue: Option<async_nats::Client>,
) {
forge::objects::spawn(Arc::clone(&client));
if let (Some(bridge), Some(queue)) = (auth, queue) {
forge::operator_profiles::spawn(Arc::clone(&client), bridge, queue);
}
forge::runner_token::spawn(Arc::clone(&client));
let sched = Arc::clone(jobq);
let hive_names = hives.iter().map(|h| h.name.clone()).collect();
@ -3417,6 +3423,8 @@ async fn start_forge(
jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
hives: Vec<HiveEntry>,
webhook_secret: Option<Arc<str>>,
auth: Option<Arc<auth::AuthBridge>>,
queue: Option<async_nats::Client>,
) {
let url = match forge::url_from_env() {
Ok(Some(url)) => url,
@ -3427,7 +3435,7 @@ async fn start_forge(
}
};
forge::enable(url, forge_client, move |client| {
spawn_forge_workers(&jobq, Arc::clone(&client), &hives);
spawn_forge_workers(&jobq, Arc::clone(&client), &hives, auth, queue);
config_prs.get_or_init(|| config_pr::spawn(Arc::clone(&client)));
register_swarm_webhooks(client, webhook_secret);
})
@ -3549,6 +3557,8 @@ async fn main() -> Result<()> {
Arc::clone(&jobq),
hives.clone(),
webhook_secret.clone(),
auth.clone(),
status.as_ref().map(|s| s.queue_client()),
)
.await;