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:
parent
a2b4320cc4
commit
6942f8fd27
16 changed files with 681 additions and 35 deletions
96
swarm-queue-client/src/operator_profiles.rs
Normal file
96
swarm-queue-client/src/operator_profiles.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//! The operator-profile KV bucket: one key per operator, holding the name and
|
||||
//! the pronouns from that operator's own forge profile.
|
||||
//!
|
||||
//! **swarm-controller is the only writer**, and creates the bucket on its
|
||||
//! first write. An operator is a member of authelia's operator group, and the
|
||||
//! controller removes the key of one who leaves it.
|
||||
//!
|
||||
//! **Every agent reads the whole bucket**, read-only, when it renders its
|
||||
//! system prompt (`read_all`). Unlike the per-agent buckets, nothing here is
|
||||
//! one agent's, so the grant covers every key and the listing a `keys()`
|
||||
//! consumer needs.
|
||||
|
||||
/// The KV bucket operator profiles are published into, keyed by operator name.
|
||||
pub const BUCKET: &str = "operator-profiles";
|
||||
|
||||
/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`.
|
||||
pub const STREAM: &str = "KV_operator-profiles";
|
||||
|
||||
/// One operator's profile: the value under `BUCKET`/`<name>`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct OperatorProfile {
|
||||
/// The operator's authelia username, which is also the key.
|
||||
pub name: String,
|
||||
/// The pronouns set on the operator's forge profile. `None` when unset or
|
||||
/// when the operator has no forge account yet.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pronouns: Option<String>,
|
||||
}
|
||||
|
||||
/// Open the profile bucket for writing, creating it if nothing has yet.
|
||||
///
|
||||
/// **Controller-side only.** `history: 1`: an agent wants the current value.
|
||||
#[cfg(feature = "kv")]
|
||||
pub async fn open_or_create(
|
||||
client: &async_nats::Client,
|
||||
) -> Result<async_nats::jetstream::kv::Store, crate::Error> {
|
||||
let js = async_nats::jetstream::new(client.clone());
|
||||
crate::kv::open_or_create(
|
||||
&js,
|
||||
BUCKET,
|
||||
"operator-profiles",
|
||||
"Each operator's name and forge-profile pronouns, set by the swarm controller".to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Every operator's profile, sorted by name.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// A missing bucket, a failed request or an undecodable value. A request the
|
||||
/// queue refused for permissions reaches the client as a timeout, not as a
|
||||
/// refusal.
|
||||
#[cfg(feature = "operator-profiles")]
|
||||
pub async fn read_all(
|
||||
client: &async_nats::Client,
|
||||
) -> Result<Vec<OperatorProfile>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use futures_util::TryStreamExt as _;
|
||||
|
||||
let js = async_nats::jetstream::new(client.clone());
|
||||
let store = js.get_key_value(BUCKET).await?;
|
||||
let keys: Vec<String> = store.keys().await?.try_collect().await?;
|
||||
let mut profiles = Vec::with_capacity(keys.len());
|
||||
for key in keys {
|
||||
if let Some(value) = store.get(&key).await? {
|
||||
profiles.push(serde_json::from_slice::<OperatorProfile>(&value)?);
|
||||
}
|
||||
}
|
||||
profiles.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The names the auth responder grants agents from.
|
||||
#[test]
|
||||
fn the_stream_is_the_bucket_s() {
|
||||
assert_eq!(STREAM, format!("KV_{BUCKET}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_pronouns_are_left_out_of_the_value() {
|
||||
let profile = OperatorProfile {
|
||||
name: "mara".to_owned(),
|
||||
pronouns: None,
|
||||
};
|
||||
let json = serde_json::to_string(&profile).unwrap();
|
||||
assert_eq!(json, r#"{"name":"mara"}"#);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<OperatorProfile>(&json).unwrap(),
|
||||
profile
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue