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

@ -89,6 +89,9 @@ swarmctl forge make-admin mara
If the forge already has a local account with your username, the first If the forge already has a local account with your username, the first
SSO login asks for its forge password once, to link the two. SSO login asks for its forge password once, to link the two.
Agents learn your pronouns from the **Pronouns** field of your forge profile
settings. Leave it empty and agents refer to you by name only.
## 4 · Ruth ## 4 · Ruth
hive-c0re creates no agent at startup. You create ruth, the manager agent, hive-c0re creates no agent at startup. You create ruth, the manager agent,

View file

@ -255,13 +255,21 @@ needs to `chown` a bind mount), once at startup:
`hive-agent/prompts/system.md` by `hive-agent`'s `prompt::render`: `hive-agent/prompts/system.md` by `hive-agent`'s `prompt::render`:
HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`, HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`,
same for `role:manager`) gate the role-specific blocks; both flavors same for `role:manager`) gate the role-specific blocks; both flavors
share everything else. Four placeholders are then share everything else. Five placeholders are then
substituted: `{label}` (short agent name), `{qualified_label}` substituted: `{label}` (short agent name), `{qualified_label}`
(hive-qualified `name@domain` form), (hive-qualified `name@domain` form),
`{hive_identity}` (for example `` on hive `pr1ma` ``; empty when `{hive_identity}` (for example `` on hive `pr1ma` ``; empty when
`HYPERHIVE_HIVE_NAME` is unset), and `{swarm_identity}` (same `HYPERHIVE_HIVE_NAME` is unset), `{swarm_identity}` (same
shape for the swarm). The template names no pronouns for the shape for the swarm), and `{operators}`: a paragraph listing the
operator, and tells agents to call them the operator. swarm's operators with the pronouns from each one's forge profile,
for example `Operators: mara (she/her), sol.`. The harness reads
them from the `operator-profiles` KV bucket, which
`swarm-controller` writes. The harness waits up to five seconds for
them. When the bucket is empty or doesn't answer in time,
`{operators}` is empty and the prompt names no operator. Either way
the template tells agents to say "the operator" in third person. A
pronoun change reaches an agent at its next harness start, since
the prompt renders once per start.
When you enable `services.hyperhive.agent.docs.enable`, `HIVE_DOCS_DIR` is present When you enable `services.hyperhive.agent.docs.enable`, `HIVE_DOCS_DIR` is present
in the environment and `render()` appends a one-sentence pointer in the environment and `render()` appends a one-sentence pointer
telling the agent where the docs live. telling the agent where the docs live.

View file

@ -39,13 +39,15 @@ schemars.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
# `agent-schedules` and `agent-inference`: the schedule ticker and the # `agent-schedules` and `agent-inference`: the schedule ticker and the
# inference poller each read their own key with one direct get. `kv`/`notices` # inference poller each read their own key with one direct get.
# name buckets and streams this harness opens neither end of, and the terminal # `operator-profiles`: the prompt renderer lists the operator-profile bucket.
# publisher is a plain core-subject publish. # `kv`/`notices` name buckets and streams this harness opens neither end of,
# and the terminal publisher is a plain core-subject publish.
swarm-queue-client = { workspace = true, features = [ swarm-queue-client = { workspace = true, features = [
"agent-schedules", "agent-schedules",
"agent-inference", "agent-inference",
"agent-permissions", "agent-permissions",
"operator-profiles",
] } ] }
# This agent's own queue secret, read from the store under its own certificate. # This agent's own queue secret, read from the store under its own certificate.
swarm-secret-client.workspace = true swarm-secret-client.workspace = true

View file

@ -1,4 +1,4 @@
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. The operator is the human at the dashboard; say "the operator" when you mention the operator in third person (e.g. when relaying to a peer or the manager). When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`) so the operator + peers can disambiguate; within your own hive the short form is fine. You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. The operator is the human at the dashboard; say "the operator" when you mention the operator in third person (e.g. when relaying to a peer or the manager). When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`) so the operator + peers can disambiguate; within your own hive the short form is fine.{operators}
Tools (hyperhive surface). Full signature + behavior for each comes from the tool's own MCP description (you already received it via the MCP tool schema) — this is just the map of what exists and which ones are gated, so you know where to look: Tools (hyperhive surface). Full signature + behavior for each comes from the tool's own MCP description (you already received it via the MCP tool schema) — this is just the map of what exists and which ones are gated, so you know where to look:

View file

@ -5,11 +5,17 @@
//! `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`). //! `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`).
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use swarm_queue_client::operator_profiles::{self, OperatorProfile};
/// How long [`write_system_prompt`] waits for the operator list before
/// rendering without it.
const OPERATORS_TIMEOUT: Duration = Duration::from_secs(5);
/// Assemble the system prompt for a given label + optional hive / /// Assemble the system prompt for a given label + optional hive /
/// swarm display names. Pure function — no I/O. Splits out from /// swarm display names + the swarm's operators. Pure function — no I/O. Splits out from
/// [`write_system_prompt`] so the marker logic + substitution is unit-testable /// [`write_system_prompt`] so the marker logic + substitution is unit-testable
/// in isolation. The caller supplies the template body so tests can pass an /// in isolation. The caller supplies the template body so tests can pass an
/// inline fixture and production reads it once at harness startup via /// inline fixture and production reads it once at harness startup via
@ -24,6 +30,7 @@ pub fn render(
hive_name: Option<&str>, hive_name: Option<&str>,
swarm_name: Option<&str>, swarm_name: Option<&str>,
docs_dir: Option<&str>, docs_dir: Option<&str>,
operators: &[OperatorProfile],
) -> String { ) -> String {
let body = filter_role_blocks(template, "agent"); let body = filter_role_blocks(template, "agent");
let qualified = crate::identity::qualify(label); let qualified = crate::identity::qualify(label);
@ -37,7 +44,8 @@ pub fn render(
.replace("{label}", label) .replace("{label}", label)
.replace("{qualified_label}", &qualified) .replace("{qualified_label}", &qualified)
.replace("{hive_identity}", &hive_identity) .replace("{hive_identity}", &hive_identity)
.replace("{swarm_identity}", &swarm_identity); .replace("{swarm_identity}", &swarm_identity)
.replace("{operators}", &operators_line(operators));
// When the reference docs are mounted in-container (`services.hyperhive.agent.docs.enable` // When the reference docs are mounted in-container (`services.hyperhive.agent.docs.enable`
// wires `HIVE_DOCS_DIR` + `claude --add-dir`), append a single pointer // wires `HIVE_DOCS_DIR` + `claude --add-dir`), append a single pointer
// sentence so the agent knows they exist. Additive: it doesn't replace the // sentence so the agent knows they exist. Additive: it doesn't replace the
@ -53,6 +61,55 @@ pub fn render(
} }
} }
/// The `{operators}` substitution: a paragraph naming each operator, with their
/// pronouns where set, or nothing when there are none.
fn operators_line(operators: &[OperatorProfile]) -> String {
if operators.is_empty() {
return String::new();
}
let names: Vec<String> = operators
.iter()
.map(|o| match &o.pronouns {
Some(pronouns) => format!("{} ({pronouns})", o.name),
None => o.name.clone(),
})
.collect();
format!("\n\nOperators: {}.", names.join(", "))
}
/// The swarm's operators, or none when the operator-profile bucket cannot be
/// read within [`OPERATORS_TIMEOUT`]: the prompt then reads as it does with no
/// operator list.
async fn operators() -> Vec<OperatorProfile> {
within_timeout(OPERATORS_TIMEOUT, async {
let client = crate::swarm_queue::client().await;
swarm_queue_client::ensure_connected(&client)?;
operator_profiles::read_all(&client).await
})
.await
}
/// `read`'s profiles, or none when it fails or outlasts `timeout`.
async fn within_timeout<E: std::fmt::Display>(
timeout: Duration,
read: impl Future<Output = std::result::Result<Vec<OperatorProfile>, E>>,
) -> Vec<OperatorProfile> {
match tokio::time::timeout(timeout, read).await {
Ok(Ok(operators)) => operators,
Ok(Err(e)) => {
tracing::warn!(error = %e, "prompt: operator profiles unreadable; rendering without them");
Vec::new()
}
Err(_) => {
tracing::warn!(
timeout_s = timeout.as_secs(),
"prompt: no operator profiles within the timeout; rendering without them"
);
Vec::new()
}
}
}
/// Walk `template` line-by-line, stripping `<!-- role:X -->` / /// Walk `template` line-by-line, stripping `<!-- role:X -->` /
/// `<!-- /role:X -->` marker lines and suppressing the lines inside a block /// `<!-- /role:X -->` marker lines and suppressing the lines inside a block
/// unless `X == target`. A close marker ends the current block. Production /// unless `X == target`. A close marker ends the current block. Production
@ -137,6 +194,7 @@ pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result<PathBuf>
hive_name.as_deref(), hive_name.as_deref(),
swarm_name.as_deref(), swarm_name.as_deref(),
docs_dir.as_deref(), docs_dir.as_deref(),
&operators().await,
); );
let path = parent.join("claude-system-prompt.md"); let path = parent.join("claude-system-prompt.md");
tokio::fs::write(&path, body).await?; tokio::fs::write(&path, body).await?;
@ -234,7 +292,7 @@ shared closer
#[test] #[test]
fn render_substitutes_label_and_names_no_operator_pronouns() { fn render_substitutes_label_and_names_no_operator_pronouns() {
// Real template's first agent line. // Real template's first agent line.
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None); let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]);
assert!(rendered.contains("hyperhive agent `alice`")); assert!(rendered.contains("hyperhive agent `alice`"));
assert!(rendered.contains("say \"the operator\"")); assert!(rendered.contains("say \"the operator\""));
assert!(!rendered.contains("pronoun")); assert!(!rendered.contains("pronoun"));
@ -245,7 +303,7 @@ shared closer
#[test] #[test]
fn render_no_role_markers_in_output() { fn render_no_role_markers_in_output() {
// No raw role markers should survive into the rendered prompt. // No raw role markers should survive into the rendered prompt.
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None); let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]);
assert!(!rendered.contains("<!-- role:")); assert!(!rendered.contains("<!-- role:"));
assert!(!rendered.contains("<!-- /role:")); assert!(!rendered.contains("<!-- /role:"));
// Shared tools appear. // Shared tools appear.
@ -255,13 +313,13 @@ shared closer
#[test] #[test]
fn render_uses_agent_opener() { fn render_uses_agent_opener() {
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None); let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]);
assert!(rendered.starts_with("You are hyperhive agent")); assert!(rendered.starts_with("You are hyperhive agent"));
} }
#[test] #[test]
fn render_points_agents_at_the_work_dir() { fn render_points_agents_at_the_work_dir() {
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None); let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]);
assert!(rendered.contains("`/agents/alice/work/`"), "{rendered}"); assert!(rendered.contains("`/agents/alice/work/`"), "{rendered}");
} }
@ -280,7 +338,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
#[test] #[test]
fn render_substitutes_hive_identity_when_set() { fn render_substitutes_hive_identity_when_set() {
let rendered = render(IDENTITY_FIXTURE, "alice", Some("pr1ma"), None, None); let rendered = render(IDENTITY_FIXTURE, "alice", Some("pr1ma"), None, None, &[]);
assert!(rendered.contains("on hive `pr1ma`"), "{rendered}"); assert!(rendered.contains("on hive `pr1ma`"), "{rendered}");
// swarm clause stays absent when only hive is set. // swarm clause stays absent when only hive is set.
assert!(!rendered.contains("in swarm")); assert!(!rendered.contains("in swarm"));
@ -291,7 +349,14 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
#[test] #[test]
fn render_substitutes_swarm_identity_when_set() { fn render_substitutes_swarm_identity_when_set() {
let rendered = render(IDENTITY_FIXTURE, "ruth", None, Some("constellat1on"), None); let rendered = render(
IDENTITY_FIXTURE,
"ruth",
None,
Some("constellat1on"),
None,
&[],
);
assert!(rendered.contains("in swarm `constellat1on`")); assert!(rendered.contains("in swarm `constellat1on`"));
assert!(!rendered.contains("on hive")); assert!(!rendered.contains("on hive"));
} }
@ -304,6 +369,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
Some("pr1ma"), Some("pr1ma"),
Some("constellat1on"), Some("constellat1on"),
None, None,
&[],
); );
// Order: hive then swarm, both inline before "in a multi-agent // Order: hive then swarm, both inline before "in a multi-agent
// system" — keeps the opener grammar intact. // system" — keeps the opener grammar intact.
@ -314,7 +380,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
fn render_omits_identity_when_unset() { fn render_omits_identity_when_unset() {
// None / None must round-trip the non-identity opener verbatim // None / None must round-trip the non-identity opener verbatim
// — single-hive deployments see zero diff. // — single-hive deployments see zero diff.
let rendered = render(IDENTITY_FIXTURE, "alice", None, None, None); let rendered = render(IDENTITY_FIXTURE, "alice", None, None, None, &[]);
assert!(!rendered.contains("on hive")); assert!(!rendered.contains("on hive"));
assert!(!rendered.contains("in swarm")); assert!(!rendered.contains("in swarm"));
assert!(!rendered.contains("{hive_identity}")); assert!(!rendered.contains("{hive_identity}"));
@ -327,7 +393,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
// through `identity::hive_name()` as None (the accessor // through `identity::hive_name()` as None (the accessor
// filters empty), but `render` should still no-op on a // filters empty), but `render` should still no-op on a
// direct `Some("")` from a test fixture or a future caller. // direct `Some("")` from a test fixture or a future caller.
let rendered = render(IDENTITY_FIXTURE, "alice", Some(""), Some(""), None); let rendered = render(IDENTITY_FIXTURE, "alice", Some(""), Some(""), None, &[]);
assert!(!rendered.contains("on hive")); assert!(!rendered.contains("on hive"));
assert!(!rendered.contains("in swarm")); assert!(!rendered.contains("in swarm"));
} }
@ -340,6 +406,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
None, None,
None, None,
Some("/run/hive-docs"), Some("/run/hive-docs"),
&[],
); );
// Key on a phrase unique to the pointer sentence — "reference docs" // Key on a phrase unique to the pointer sentence — "reference docs"
// alone also appears in the /knowledge blurb of the base template. // alone also appears in the /knowledge blurb of the base template.
@ -352,11 +419,77 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
#[test] #[test]
fn render_no_docs_pointer_when_docs_dir_absent_or_empty() { fn render_no_docs_pointer_when_docs_dir_absent_or_empty() {
for docs in [None, Some("")] { for docs in [None, Some("")] {
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, docs); let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, docs, &[]);
assert!( assert!(
!rendered.contains("mounted read-only at"), !rendered.contains("mounted read-only at"),
"docs pointer must not appear for {docs:?}:\n{rendered}" "docs pointer must not appear for {docs:?}:\n{rendered}"
); );
} }
} }
fn operator(name: &str, pronouns: Option<&str>) -> OperatorProfile {
OperatorProfile {
name: name.to_owned(),
pronouns: pronouns.map(str::to_owned),
}
}
const OPERATORS_FIXTURE: &str = "say \"the operator\".{operators}\n\nnext paragraph\n";
#[test]
fn render_lists_operators_with_their_pronouns() {
let operators = [operator("mara", Some("she/her")), operator("sol", None)];
let rendered = render(OPERATORS_FIXTURE, "alice", None, None, None, &operators);
assert_eq!(
rendered,
"say \"the operator\".\n\nOperators: mara (she/her), sol.\n\nnext paragraph\n"
);
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &operators);
assert!(
rendered.contains("Operators: mara (she/her), sol."),
"{rendered}"
);
}
/// No operators renders the template as it reads without the placeholder,
/// byte for byte.
#[test]
fn render_with_no_operators_is_the_template_without_the_placeholder() {
let rendered = render(OPERATORS_FIXTURE, "alice", None, None, None, &[]);
assert_eq!(rendered, "say \"the operator\".\n\nnext paragraph\n");
assert!(PRODUCTION_TEMPLATE.contains("{operators}"));
assert_eq!(
render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]),
render(
&PRODUCTION_TEMPLATE.replace("{operators}", ""),
"alice",
None,
None,
None,
&[]
)
);
}
/// An unreachable or failing bucket reads as no operators, so the prompt
/// renders as it does with none.
#[tokio::test(start_paused = true)]
async fn an_unread_operator_list_is_empty() {
let hangs = std::future::pending::<std::result::Result<Vec<OperatorProfile>, String>>();
let start = tokio::time::Instant::now();
assert_eq!(within_timeout(OPERATORS_TIMEOUT, hangs).await, []);
assert!(
start.elapsed() < OPERATORS_TIMEOUT * 2,
"{:?}",
start.elapsed()
);
let fails = async { Err::<Vec<OperatorProfile>, _>("no bucket".to_owned()) };
assert_eq!(within_timeout(OPERATORS_TIMEOUT, fails).await, []);
// Control: a read that answers in time is what comes back.
let answers = async { Ok::<_, String>(vec![operator("mara", Some("she/her"))]) };
assert_eq!(
within_timeout(OPERATORS_TIMEOUT, answers).await,
[operator("mara", Some("she/her"))]
);
}
} }

View file

@ -3,7 +3,8 @@
# #
# A definition of any of them is ignored and produces a warning, so a host # A definition of any of them is ignored and produces a warning, so a host
# that still sets one keeps evaluating. The agents' system prompt # that still sets one keeps evaluating. The agents' system prompt
# (hive-agent/prompts/system.md) names no pronouns for the operator, the # (hive-agent/prompts/system.md) takes each operator's pronouns from their
# forge profile, the
# harness (hive-agent/src/harness_state.rs) holds the per-model context # harness (hive-agent/src/harness_state.rs) holds the per-model context
# windows it uses until a turn reports the model's own, every OTEL exporter # windows it uses until a turn reports the model's own, every OTEL exporter
# exports every 60 seconds (claude at its SDK default, the rest by a fixed # exports every 60 seconds (claude at its SDK default, the rest by a fixed
@ -44,9 +45,8 @@ in
config.warnings = config.warnings =
removed "c0re.operatorPronouns" cfg.c0re.operatorPronouns '' removed "c0re.operatorPronouns" cfg.c0re.operatorPronouns ''
agents' system prompts name no pronouns for the operator and say "the agents' system prompts take each operator's pronouns from the Pronouns
operator". Pronouns will come from a per-operator profile setting, which field of that operator's forge profile.
is an open hyperhive forge issue.
'' ''
++ removed "c0re.contextWindowTokens" cfg.c0re.contextWindowTokens '' ++ removed "c0re.contextWindowTokens" cfg.c0re.contextWindowTokens ''
the agent harness uses the context window the model reports after each the agent harness uses the context window the model reports after each

View file

@ -905,6 +905,9 @@ in
# the harness and the subagent daemon read the tool permissions # the harness and the subagent daemon read the tool permissions
# it holds. # it holds.
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$JS.API.DIRECT.GET.KV_agent-permissions.\$\$KV.agent-permissions.{agent}"}" "--agent-token-publish-subject ${lib.escapeShellArg "\$\$JS.API.DIRECT.GET.KV_agent-permissions.\$\$KV.agent-permissions.{agent}"}"
# Every agent also reads the whole `operator-profiles` bucket.
# That grant names no agent, so the responder adds it itself
# (`swarm-nats-auth`'s `Policy::agent_token_permissions`).
"--store-cert-role ${lib.escapeShellArg authCertRole}" "--store-cert-role ${lib.escapeShellArg authCertRole}"
]; ];
# The store identity arrives by `LoadCredential` and is named # The store identity arrives by `LoadCredential` and is named

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 `review-classifier-settings` KV bucket, which this daemon alone reads and
writes (`GET`/`PUT /api/review-classifier/settings`) → writes (`GET`/`PUT /api/review-classifier/settings`) →
[`docs/swarm/review-classifier.md`](../docs/swarm/review-classifier.md#swarm-level-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 - **Relays** — each agent's terminal and turn-state header as SSE, agent
icons, the cross-repo issue report, and the UI's quick links. icons, the cross-repo issue report, and the UI's quick links.
- **Alerts** — the swarm Grafana's current alerts (`GET /api/alerts`), read - **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 hive_token;
pub mod legacy_tokens; pub mod legacy_tokens;
pub mod objects; pub mod objects;
pub mod operator_profiles;
pub mod pull_diff; pub mod pull_diff;
pub mod runner_token; pub mod runner_token;
pub mod site_admin; 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 /// `internal/docs` grants, and
/// agents' tokens (which also /// agents' tokens (which also
/// sweeps legacy tokens; see `forge::legacy_tokens`) — /// 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`. /// `clippy::too_many_lines`.
fn spawn_forge_workers( fn spawn_forge_workers(
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>, jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
client: Arc<forge::Client>, client: Arc<forge::Client>,
hives: &[HiveEntry], hives: &[HiveEntry],
auth: Option<Arc<auth::AuthBridge>>,
queue: Option<async_nats::Client>,
) { ) {
forge::objects::spawn(Arc::clone(&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)); forge::runner_token::spawn(Arc::clone(&client));
let sched = Arc::clone(jobq); let sched = Arc::clone(jobq);
let hive_names = hives.iter().map(|h| h.name.clone()).collect(); 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>>>, jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
hives: Vec<HiveEntry>, hives: Vec<HiveEntry>,
webhook_secret: Option<Arc<str>>, webhook_secret: Option<Arc<str>>,
auth: Option<Arc<auth::AuthBridge>>,
queue: Option<async_nats::Client>,
) { ) {
let url = match forge::url_from_env() { let url = match forge::url_from_env() {
Ok(Some(url)) => url, Ok(Some(url)) => url,
@ -3427,7 +3435,7 @@ async fn start_forge(
} }
}; };
forge::enable(url, forge_client, move |client| { 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))); config_prs.get_or_init(|| config_pr::spawn(Arc::clone(&client)));
register_swarm_webhooks(client, webhook_secret); register_swarm_webhooks(client, webhook_secret);
}) })
@ -3549,6 +3557,8 @@ async fn main() -> Result<()> {
Arc::clone(&jobq), Arc::clone(&jobq),
hives.clone(), hives.clone(),
webhook_secret.clone(), webhook_secret.clone(),
auth.clone(),
status.as_ref().map(|s| s.queue_client()),
) )
.await; .await;

View file

@ -118,6 +118,10 @@ mod tests {
"$SWARM.term.atlas".to_owned(), "$SWARM.term.atlas".to_owned(),
"$SWARM.agent-state.atlas".to_owned(), "$SWARM.agent-state.atlas".to_owned(),
"$KV.agent-icons.atlas".to_owned(), "$KV.agent-icons.atlas".to_owned(),
"$JS.API.STREAM.INFO.KV_operator-profiles".to_owned(),
"$JS.API.CONSUMER.CREATE.KV_operator-profiles".to_owned(),
"$JS.API.CONSUMER.CREATE.KV_operator-profiles.>".to_owned(),
"$JS.API.DIRECT.GET.KV_operator-profiles.>".to_owned(),
] ]
); );
} }

View file

@ -173,12 +173,16 @@ impl Policy {
/// must already be a single `[A-Za-z0-9_-]` segment, which the token parse /// must already be a single `[A-Za-z0-9_-]` segment, which the token parse
/// guarantees, so it cannot widen a subject with `.`, `*` or `>`. /// guarantees, so it cannot widen a subject with `.`, `*` or `>`.
pub fn agent_token_permissions(&self, agent: &str) -> Option<Permissions> { pub fn agent_token_permissions(&self, agent: &str) -> Option<Permissions> {
let publish: Vec<String> = self let mut publish: Vec<String> = self
.agent_token_subjects .agent_token_subjects
.iter() .iter()
.map(|s| s.replace(AGENT_PLACEHOLDER, agent)) .map(|s| s.replace(AGENT_PLACEHOLDER, agent))
.collect(); .collect();
(!publish.is_empty()).then(|| Permissions { if publish.is_empty() {
return None;
}
publish.extend(Self::operator_profiles_read());
Some(Permissions {
publish, publish,
subscribe: Some(vec![ subscribe: Some(vec![
format!("{}.>", swarm_queue_client::agent_inbox_prefix(agent)), format!("{}.>", swarm_queue_client::agent_inbox_prefix(agent)),
@ -196,6 +200,26 @@ impl Policy {
}) })
} }
/// Reading every key of the operator-profile bucket
/// (`swarm_queue_client::operator_profiles::read_all`), which an agent
/// renders into its system prompt.
///
/// The whole bucket, since every agent reads every operator. Not an
/// `{agent}` template, so it is granted here rather than through
/// `--agent-token-publish-subject`. `STREAM.INFO` opens the bucket, both
/// `CONSUMER.CREATE` forms are the `keys()` listing (an ephemeral consumer
/// carries no name, and `>` never matches zero tokens), and `DIRECT.GET`
/// reads each key. No `$KV.` subject: the controller is the only writer.
fn operator_profiles_read() -> [String; 4] {
let stream = swarm_queue_client::operator_profiles::STREAM;
[
format!("$JS.API.STREAM.INFO.{stream}"),
format!("$JS.API.CONSUMER.CREATE.{stream}"),
format!("$JS.API.CONSUMER.CREATE.{stream}.>"),
format!("$JS.API.DIRECT.GET.{stream}.>"),
]
}
/// The permissions for `client_id`, or `None` when no rule matches. /// The permissions for `client_id`, or `None` when no rule matches.
/// ///
/// `None` is a denial. It is not "grant nothing and let them connect": /// `None` is a denial. It is not "grant nothing and let them connect":
@ -724,6 +748,18 @@ mod tests {
.expect("the default policy is valid") .expect("the default policy is valid")
} }
/// An agent grant's publish list without the operator-profile read every
/// agent holds, so a test can compare the templated part exactly.
fn templated(grant: &Permissions) -> Vec<String> {
let shared = Policy::operator_profiles_read();
grant
.publish
.iter()
.filter(|s| !shared.contains(s))
.cloned()
.collect()
}
#[test] #[test]
fn the_reader_can_open_and_list_the_agent_status_bucket() { fn the_reader_can_open_and_list_the_agent_status_bucket() {
let subjects = policy().reader_subjects(); let subjects = policy().reader_subjects();
@ -1878,6 +1914,10 @@ mod tests {
vec![ vec![
"$SWARM.term.atlas".to_owned(), "$SWARM.term.atlas".to_owned(),
"$SWARM.agent-state.atlas".to_owned(), "$SWARM.agent-state.atlas".to_owned(),
"$JS.API.STREAM.INFO.KV_operator-profiles".to_owned(),
"$JS.API.CONSUMER.CREATE.KV_operator-profiles".to_owned(),
"$JS.API.CONSUMER.CREATE.KV_operator-profiles.>".to_owned(),
"$JS.API.DIRECT.GET.KV_operator-profiles.>".to_owned(),
] ]
); );
} }
@ -1891,12 +1931,12 @@ mod tests {
.with_agent_token_subjects(vec!["$KV.agent-icons.{agent}".to_owned()]) .with_agent_token_subjects(vec!["$KV.agent-icons.{agent}".to_owned()])
.expect("a per-agent template is valid"); .expect("a per-agent template is valid");
let atlas = p.agent_token_permissions("atlas").expect("configured"); let atlas = p.agent_token_permissions("atlas").expect("configured");
assert_eq!(atlas.publish, vec![subject("atlas")]); assert_eq!(templated(&atlas), vec![subject("atlas")]);
assert!(!atlas.publish.contains(&subject("argus"))); assert!(!atlas.publish.contains(&subject("argus")));
// Control: the same template does grant argus its own key, so atlas // Control: the same template does grant argus its own key, so atlas
// lacking it is atlas's grant being scoped. // lacking it is atlas's grant being scoped.
let argus = p.agent_token_permissions("argus").expect("configured"); let argus = p.agent_token_permissions("argus").expect("configured");
assert_eq!(argus.publish, vec![subject("argus")]); assert_eq!(templated(&argus), vec![subject("argus")]);
} }
/// The subagent template as `swarm-nats.nix` spells it expands to the /// The subagent template as `swarm-nats.nix` spells it expands to the
@ -1910,10 +1950,10 @@ mod tests {
.with_agent_token_subjects(vec!["$SWARM.term.{agent}.sub.>".to_owned()]) .with_agent_token_subjects(vec!["$SWARM.term.{agent}.sub.>".to_owned()])
.expect("a per-agent template is valid"); .expect("a per-agent template is valid");
let g = p.agent_token_permissions("atlas").expect("configured"); let g = p.agent_token_permissions("atlas").expect("configured");
assert_eq!(g.publish, vec![stream_subjects("atlas")]); assert_eq!(templated(&g), vec![stream_subjects("atlas")]);
assert!(subject("atlas", "scout").starts_with(g.publish[0].trim_end_matches('>'))); assert!(subject("atlas", "scout").starts_with(g.publish[0].trim_end_matches('>')));
let argus = p.agent_token_permissions("argus").expect("configured"); let argus = p.agent_token_permissions("argus").expect("configured");
assert_eq!(argus.publish, vec![stream_subjects("argus")]); assert_eq!(templated(&argus), vec![stream_subjects("argus")]);
assert_ne!(g.publish, argus.publish); assert_ne!(g.publish, argus.publish);
let stream = stream_name("atlas"); let stream = stream_name("atlas");
@ -1941,11 +1981,11 @@ mod tests {
]) ])
.expect("a per-agent template is valid"); .expect("a per-agent template is valid");
let atlas = p.agent_token_permissions("atlas").expect("configured"); let atlas = p.agent_token_permissions("atlas").expect("configured");
assert_eq!(atlas.publish, vec![direct_get_subject("atlas")]); assert_eq!(templated(&atlas), vec![direct_get_subject("atlas")]);
// Control: argus's own expansion is a different subject, so atlas // Control: argus's own expansion is a different subject, so atlas
// lacking it is the grant being scoped, not the template being empty. // lacking it is the grant being scoped, not the template being empty.
let argus = p.agent_token_permissions("argus").expect("configured"); let argus = p.agent_token_permissions("argus").expect("configured");
assert_eq!(argus.publish, vec![direct_get_subject("argus")]); assert_eq!(templated(&argus), vec![direct_get_subject("argus")]);
assert!(!atlas.publish.contains(&direct_get_subject("argus"))); assert!(!atlas.publish.contains(&direct_get_subject("argus")));
for refused in [ for refused in [
@ -1989,9 +2029,9 @@ mod tests {
]) ])
.expect("a per-agent template is valid"); .expect("a per-agent template is valid");
let atlas = p.agent_token_permissions("atlas").expect("configured"); let atlas = p.agent_token_permissions("atlas").expect("configured");
assert_eq!(atlas.publish, vec![direct_get_subject("atlas")]); assert_eq!(templated(&atlas), vec![direct_get_subject("atlas")]);
let argus = p.agent_token_permissions("argus").expect("configured"); let argus = p.agent_token_permissions("argus").expect("configured");
assert_eq!(argus.publish, vec![direct_get_subject("argus")]); assert_eq!(templated(&argus), vec![direct_get_subject("argus")]);
for refused in [ for refused in [
direct_get_subject("argus"), direct_get_subject("argus"),
@ -2022,4 +2062,44 @@ mod tests {
fn with_no_agent_token_subject_configured_an_agent_token_is_refused() { fn with_no_agent_token_subject_configured_an_agent_token_is_refused() {
assert!(policy().agent_token_permissions("atlas").is_none()); assert!(policy().agent_token_permissions("atlas").is_none());
} }
/// Every agent reads every operator's profile, and only reads it: the
/// bucket opens, lists and answers a direct get, and nothing writes,
/// reshapes or deletes it.
#[test]
fn an_agent_may_list_and_read_every_operator_profile_and_write_none() {
use swarm_queue_client::operator_profiles::{BUCKET, STREAM};
let p = policy()
.with_agent_token_subjects(vec!["$SWARM.term.{agent}".to_owned()])
.expect("a per-agent template is valid");
let atlas = p.agent_token_permissions("atlas").expect("configured");
let may = |subject: &str| atlas.publish.iter().any(|g| covers(g, subject));
for allowed in [
format!("$JS.API.STREAM.INFO.{STREAM}"),
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
format!("$JS.API.CONSUMER.CREATE.{STREAM}.c1.$KV.{BUCKET}.>"),
format!("$JS.API.DIRECT.GET.{STREAM}.$KV.{BUCKET}.mara"),
] {
assert!(may(&allowed), "atlas may not publish {allowed}");
}
for refused in [
format!("$KV.{BUCKET}.mara"),
format!("$JS.API.STREAM.CREATE.{STREAM}"),
format!("$JS.API.STREAM.UPDATE.{STREAM}"),
format!("$JS.API.STREAM.DELETE.{STREAM}"),
format!("$JS.API.STREAM.PURGE.{STREAM}"),
"$JS.API.DIRECT.GET.KV_agent-inference.$KV.agent-inference.argus".to_owned(),
] {
assert!(!may(&refused), "atlas may publish {refused}");
}
// The hive's own client is not the reader and gets none of it.
let hive = p.permissions("hive-alpha").expect("admitted");
assert!(
!hive
.publish
.iter()
.any(|g| covers(g, &format!("$JS.API.STREAM.INFO.{STREAM}")))
);
}
} }

View file

@ -44,6 +44,10 @@ agent-schedules = ["async-nats/jetstream"]
# `agent_inference::read_own`, for the agent harness, which reads its own # `agent_inference::read_own`, for the agent harness, which reads its own
# inference key the same way. Explicit for the same reason as `notices`. # inference key the same way. Explicit for the same reason as `notices`.
agent-inference = ["async-nats/jetstream"] 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"]
# `ToSchema` on the review classifier's shared types, for swarm-controller, # `ToSchema` on the review classifier's shared types, for swarm-controller,
# whose HTTP API serves the same shapes the queue carries. # whose HTTP API serves the same shapes the queue carries.
schema = ["dep:utoipa"] schema = ["dep:utoipa"]

View file

@ -227,6 +227,11 @@ pub mod agent_limits;
/// and reading need `agent-permissions`. /// and reading need `agent-permissions`.
pub mod agent_permissions; 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
/// unconditional; writing needs `kv` and reading needs `operator-profiles`.
pub mod operator_profiles;
/// The login flag in an agent's turn-state header. No bucket: it travels /// The login flag in an agent's turn-state header. No bucket: it travels
/// on [`agent_state_subject`] only. /// on [`agent_state_subject`] only.
pub mod agent_login; pub mod agent_login;

View 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
);
}
}