keep the operator list under one key and read it with one direct get
The agent read the operator-profiles bucket with a keys() listing, an ordered consumer whose grant (STREAM.INFO, CONSUMER.CREATE) and flow-control reply had never run against a permissioned queue. The controller now writes the whole list as one JSON array under one key, only when it changed, and the agent reads it with one DIRECT.GET the way agent_inference::read_own reads its key. The agent grant shrinks to that one subject.
This commit is contained in:
parent
6942f8fd27
commit
49015eb553
12 changed files with 254 additions and 201 deletions
|
|
@ -44,10 +44,10 @@ agent-schedules = ["async-nats/jetstream"]
|
|||
# `agent_inference::read_own`, for the agent harness, which reads its own
|
||||
# inference key the same way. Explicit for the same reason as `notices`.
|
||||
agent-inference = ["async-nats/jetstream"]
|
||||
# `operator_profiles::read_all`, for the agent harness, which lists every
|
||||
# operator's profile when it renders its system prompt: a `keys()` listing, so
|
||||
# the KV client rather than a bare direct get.
|
||||
operator-profiles = ["async-nats/kv"]
|
||||
# `operator_profiles::read`, for the agent harness, which reads the operator
|
||||
# list with one direct get when it renders its system prompt. Explicit for the
|
||||
# same reason as `notices`.
|
||||
operator-profiles = ["async-nats/jetstream"]
|
||||
# `ToSchema` on the review classifier's shared types, for swarm-controller,
|
||||
# whose HTTP API serves the same shapes the queue carries.
|
||||
schema = ["dep:utoipa"]
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ pub mod agent_limits;
|
|||
pub mod agent_permissions;
|
||||
|
||||
/// The bucket swarm-controller publishes each operator's name and forge-profile
|
||||
/// pronouns in — one key per operator, read by every agent. The names are
|
||||
/// pronouns in — one key holding the whole list, read by every agent. The names are
|
||||
/// unconditional; writing needs `kv` and reading needs `operator-profiles`.
|
||||
pub mod operator_profiles;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,44 @@
|
|||
//! The operator-profile KV bucket: one key per operator, holding the name and
|
||||
//! the pronouns from that operator's own forge profile.
|
||||
//! The operator-profile KV bucket: one key,
|
||||
//! [`crate::operator_profiles::KEY`], holding every operator's name and the
|
||||
//! pronouns from that operator's own forge profile, as one JSON array.
|
||||
//!
|
||||
//! **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.
|
||||
//! first write. An operator is a member of authelia's operator group; one who
|
||||
//! leaves it drops out of the list on the controller's next pass.
|
||||
//!
|
||||
//! **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.
|
||||
//! **Every agent reads the key with one `DIRECT.GET`**, read-only, when it
|
||||
//! renders its system prompt
|
||||
//! ([`crate::operator_profiles::direct_get_subject`]). One key rather than one
|
||||
//! per operator so that the read is a single request on one literal subject,
|
||||
//! the shape of [`crate::agent_inference`]'s read: no `STREAM.INFO`, no
|
||||
//! consumer.
|
||||
|
||||
/// The KV bucket operator profiles are published into, keyed by operator name.
|
||||
/// The KV bucket operator profiles are published into.
|
||||
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>`.
|
||||
/// The one key in [`BUCKET`]: the whole operator list.
|
||||
pub const KEY: &str = "operators";
|
||||
|
||||
/// The subject [`KEY`] is stored under.
|
||||
#[must_use]
|
||||
pub fn key_subject() -> String {
|
||||
format!("$KV.{BUCKET}.{KEY}")
|
||||
}
|
||||
|
||||
/// The request subject that reads [`KEY`]'s current entry, and so the one
|
||||
/// subject the queue grants every agent in this bucket.
|
||||
#[must_use]
|
||||
pub fn direct_get_subject() -> String {
|
||||
format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject())
|
||||
}
|
||||
|
||||
/// One operator's profile: an element of the array under [`KEY`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct OperatorProfile {
|
||||
/// The operator's authelia username, which is also the key.
|
||||
/// The operator's authelia username.
|
||||
pub name: String,
|
||||
/// The pronouns set on the operator's forge profile. `None` when unset or
|
||||
/// when the operator has no forge account yet.
|
||||
|
|
@ -44,30 +63,56 @@ pub async fn open_or_create(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Every operator's profile, sorted by name.
|
||||
/// What reading [`KEY`] found.
|
||||
#[cfg(feature = "operator-profiles")]
|
||||
#[derive(Debug)]
|
||||
pub enum Read {
|
||||
/// The bucket answered; an absent or deleted key is no operators.
|
||||
Current(Vec<OperatorProfile>),
|
||||
/// Nothing answers for the bucket's stream: the controller has not written
|
||||
/// it yet, or `JetStream` is not serving it right now.
|
||||
NoBucket,
|
||||
}
|
||||
|
||||
/// Read the operator list with the one subject the agent grant covers.
|
||||
///
|
||||
/// # 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.
|
||||
/// 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(
|
||||
pub async fn read(
|
||||
client: &async_nats::Client,
|
||||
) -> Result<Vec<OperatorProfile>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use futures_util::TryStreamExt as _;
|
||||
) -> Result<Read, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use async_nats::jetstream::stream::DirectGetErrorKind;
|
||||
|
||||
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)?);
|
||||
let stream = js.get_stream_no_info(STREAM).await?;
|
||||
let message = match stream.direct_get_last_for_subject(key_subject()).await {
|
||||
Ok(message) => message,
|
||||
Err(e) if matches!(e.kind(), DirectGetErrorKind::NotFound) => {
|
||||
return Ok(Read::Current(Vec::new()));
|
||||
}
|
||||
Err(e)
|
||||
if matches!(
|
||||
e.kind(),
|
||||
DirectGetErrorKind::ErrorResponse(status, _)
|
||||
if status == async_nats::StatusCode::NO_RESPONDERS
|
||||
) =>
|
||||
{
|
||||
return Ok(Read::NoBucket);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
// A KV delete or purge is stored as an empty marker carrying this header.
|
||||
let removed = message
|
||||
.headers
|
||||
.get("KV-Operation")
|
||||
.is_some_and(|op| matches!(op.as_str(), "DEL" | "PURGE"));
|
||||
if removed {
|
||||
return Ok(Read::Current(Vec::new()));
|
||||
}
|
||||
profiles.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(profiles)
|
||||
Ok(Read::Current(serde_json::from_slice(&message.payload)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -76,21 +121,35 @@ mod tests {
|
|||
|
||||
/// The names the auth responder grants agents from.
|
||||
#[test]
|
||||
fn the_stream_is_the_bucket_s() {
|
||||
fn names_match_the_queue_grant() {
|
||||
assert_eq!(STREAM, format!("KV_{BUCKET}"));
|
||||
assert_eq!(key_subject(), "$KV.operator-profiles.operators");
|
||||
assert_eq!(
|
||||
direct_get_subject(),
|
||||
"$JS.API.DIRECT.GET.KV_operator-profiles.$KV.operator-profiles.operators"
|
||||
);
|
||||
}
|
||||
|
||||
#[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"}"#);
|
||||
let list = vec![
|
||||
OperatorProfile {
|
||||
name: "mara".to_owned(),
|
||||
pronouns: Some("she/her".to_owned()),
|
||||
},
|
||||
OperatorProfile {
|
||||
name: "sol".to_owned(),
|
||||
pronouns: None,
|
||||
},
|
||||
];
|
||||
let json = serde_json::to_string(&list).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<OperatorProfile>(&json).unwrap(),
|
||||
profile
|
||||
json,
|
||||
r#"[{"name":"mara","pronouns":"she/her"},{"name":"sol"}]"#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Vec<OperatorProfile>>(&json).unwrap(),
|
||||
list
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue