Compare commits
17 changed files with 36 additions and 735 deletions
|
|
@ -89,9 +89,6 @@ swarmctl forge make-admin mara
|
|||
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.
|
||||
|
||||
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
|
||||
|
||||
hive-c0re creates no agent at startup. You create ruth, the manager agent,
|
||||
|
|
|
|||
|
|
@ -255,21 +255,13 @@ needs to `chown` a bind mount), once at startup:
|
|||
`hive-agent/prompts/system.md` by `hive-agent`'s `prompt::render`:
|
||||
HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`,
|
||||
same for `role:manager`) gate the role-specific blocks; both flavors
|
||||
share everything else. Five placeholders are then
|
||||
share everything else. Four placeholders are then
|
||||
substituted: `{label}` (short agent name), `{qualified_label}`
|
||||
(hive-qualified `name@domain` form),
|
||||
`{hive_identity}` (for example `` on hive `pr1ma` ``; empty when
|
||||
`HYPERHIVE_HIVE_NAME` is unset), `{swarm_identity}` (same
|
||||
shape for the swarm), and `{operators}`: a paragraph listing the
|
||||
swarm's operators with the pronouns from each one's forge profile,
|
||||
for example `Operators: mara (she/her), sol.`. The harness reads
|
||||
them with one direct get on the one key of 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.
|
||||
`HYPERHIVE_HIVE_NAME` is unset), and `{swarm_identity}` (same
|
||||
shape for the swarm). The template names no pronouns for the
|
||||
operator, and tells agents to call them the operator.
|
||||
When you enable `services.hyperhive.agent.docs.enable`, `HIVE_DOCS_DIR` is present
|
||||
in the environment and `render()` appends a one-sentence pointer
|
||||
telling the agent where the docs live.
|
||||
|
|
|
|||
|
|
@ -38,16 +38,14 @@ rusqlite.workspace = true
|
|||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
# `agent-schedules`, `agent-inference` and `operator-profiles`: the schedule
|
||||
# ticker and the inference poller each read their own key, and the prompt
|
||||
# renderer the operator list, with one direct get.
|
||||
# `kv`/`notices` name buckets and streams this harness opens neither end of,
|
||||
# and the terminal publisher is a plain core-subject publish.
|
||||
# `agent-schedules` and `agent-inference`: the schedule ticker and the
|
||||
# inference poller each read their own key with one direct get. `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 = [
|
||||
"agent-schedules",
|
||||
"agent-inference",
|
||||
"agent-permissions",
|
||||
"operator-profiles",
|
||||
] }
|
||||
# This agent's own queue secret, read from the store under its own certificate.
|
||||
swarm-secret-client.workspace = true
|
||||
|
|
|
|||
|
|
@ -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.{operators}
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
|
|
|
|||
|
|
@ -5,17 +5,11 @@
|
|||
//! `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
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 /
|
||||
/// swarm display names + the swarm's operators. Pure function — no I/O. Splits out from
|
||||
/// swarm display names. Pure function — no I/O. Splits out from
|
||||
/// [`write_system_prompt`] so the marker logic + substitution is unit-testable
|
||||
/// in isolation. The caller supplies the template body so tests can pass an
|
||||
/// inline fixture and production reads it once at harness startup via
|
||||
|
|
@ -30,7 +24,6 @@ pub fn render(
|
|||
hive_name: Option<&str>,
|
||||
swarm_name: Option<&str>,
|
||||
docs_dir: Option<&str>,
|
||||
operators: &[OperatorProfile],
|
||||
) -> String {
|
||||
let body = filter_role_blocks(template, "agent");
|
||||
let qualified = crate::identity::qualify(label);
|
||||
|
|
@ -44,8 +37,7 @@ pub fn render(
|
|||
.replace("{label}", label)
|
||||
.replace("{qualified_label}", &qualified)
|
||||
.replace("{hive_identity}", &hive_identity)
|
||||
.replace("{swarm_identity}", &swarm_identity)
|
||||
.replace("{operators}", &operators_line(operators));
|
||||
.replace("{swarm_identity}", &swarm_identity);
|
||||
// When the reference docs are mounted in-container (`services.hyperhive.agent.docs.enable`
|
||||
// wires `HIVE_DOCS_DIR` + `claude --add-dir`), append a single pointer
|
||||
// sentence so the agent knows they exist. Additive: it doesn't replace the
|
||||
|
|
@ -61,63 +53,6 @@ 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, read_operators()).await
|
||||
}
|
||||
|
||||
/// One read of the operator list over this agent's queue connection.
|
||||
async fn read_operators()
|
||||
-> std::result::Result<Vec<OperatorProfile>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = crate::swarm_queue::client().await;
|
||||
swarm_queue_client::ensure_connected(&client)?;
|
||||
match operator_profiles::read(&client).await? {
|
||||
operator_profiles::Read::Current(operators) => Ok(operators),
|
||||
operator_profiles::Read::NoBucket => {
|
||||
Err("the operator-profiles bucket does not answer".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `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 -->` /
|
||||
/// `<!-- /role:X -->` marker lines and suppressing the lines inside a block
|
||||
/// unless `X == target`. A close marker ends the current block. Production
|
||||
|
|
@ -202,7 +137,6 @@ pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result<PathBuf>
|
|||
hive_name.as_deref(),
|
||||
swarm_name.as_deref(),
|
||||
docs_dir.as_deref(),
|
||||
&operators().await,
|
||||
);
|
||||
let path = parent.join("claude-system-prompt.md");
|
||||
tokio::fs::write(&path, body).await?;
|
||||
|
|
@ -300,7 +234,7 @@ shared closer
|
|||
#[test]
|
||||
fn render_substitutes_label_and_names_no_operator_pronouns() {
|
||||
// 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("say \"the operator\""));
|
||||
assert!(!rendered.contains("pronoun"));
|
||||
|
|
@ -311,7 +245,7 @@ shared closer
|
|||
#[test]
|
||||
fn render_no_role_markers_in_output() {
|
||||
// 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:"));
|
||||
// Shared tools appear.
|
||||
|
|
@ -321,13 +255,13 @@ shared closer
|
|||
|
||||
#[test]
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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}");
|
||||
}
|
||||
|
||||
|
|
@ -346,7 +280,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
|
||||
#[test]
|
||||
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}");
|
||||
// swarm clause stays absent when only hive is set.
|
||||
assert!(!rendered.contains("in swarm"));
|
||||
|
|
@ -357,14 +291,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
|
||||
#[test]
|
||||
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("on hive"));
|
||||
}
|
||||
|
|
@ -377,7 +304,6 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
Some("pr1ma"),
|
||||
Some("constellat1on"),
|
||||
None,
|
||||
&[],
|
||||
);
|
||||
// Order: hive then swarm, both inline before "in a multi-agent
|
||||
// system" — keeps the opener grammar intact.
|
||||
|
|
@ -388,7 +314,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
fn render_omits_identity_when_unset() {
|
||||
// None / None must round-trip the non-identity opener verbatim
|
||||
// — 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("in swarm"));
|
||||
assert!(!rendered.contains("{hive_identity}"));
|
||||
|
|
@ -401,7 +327,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
// through `identity::hive_name()` as None (the accessor
|
||||
// filters empty), but `render` should still no-op on a
|
||||
// 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("in swarm"));
|
||||
}
|
||||
|
|
@ -414,7 +340,6 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
None,
|
||||
None,
|
||||
Some("/run/hive-docs"),
|
||||
&[],
|
||||
);
|
||||
// Key on a phrase unique to the pointer sentence — "reference docs"
|
||||
// alone also appears in the /knowledge blurb of the base template.
|
||||
|
|
@ -427,77 +352,11 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
|
|||
#[test]
|
||||
fn render_no_docs_pointer_when_docs_dir_absent_or_empty() {
|
||||
for docs in [None, Some("")] {
|
||||
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, docs, &[]);
|
||||
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, docs);
|
||||
assert!(
|
||||
!rendered.contains("mounted read-only at"),
|
||||
"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"))]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
#
|
||||
# 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
|
||||
# (hive-agent/prompts/system.md) takes each operator's pronouns from their
|
||||
# forge profile, the
|
||||
# (hive-agent/prompts/system.md) names no pronouns for the operator, the
|
||||
# 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
|
||||
# exports every 60 seconds (claude at its SDK default, the rest by a fixed
|
||||
|
|
@ -45,8 +44,9 @@ in
|
|||
|
||||
config.warnings =
|
||||
removed "c0re.operatorPronouns" cfg.c0re.operatorPronouns ''
|
||||
agents' system prompts take each operator's pronouns from the Pronouns
|
||||
field of that operator's forge profile.
|
||||
agents' system prompts name no pronouns for the operator and say "the
|
||||
operator". Pronouns will come from a per-operator profile setting, which
|
||||
is an open hyperhive forge issue.
|
||||
''
|
||||
++ removed "c0re.contextWindowTokens" cfg.c0re.contextWindowTokens ''
|
||||
the agent harness uses the context window the model reports after each
|
||||
|
|
|
|||
|
|
@ -905,10 +905,6 @@ in
|
|||
# the harness and the subagent daemon read the tool permissions
|
||||
# it holds.
|
||||
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$JS.API.DIRECT.GET.KV_agent-permissions.\$\$KV.agent-permissions.{agent}"}"
|
||||
# Every agent also reads the operator list, the one key of the
|
||||
# `operator-profiles` bucket, with one direct get. 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}"
|
||||
];
|
||||
# The store identity arrives by `LoadCredential` and is named
|
||||
|
|
|
|||
|
|
@ -150,8 +150,6 @@ tempfile = "3"
|
|||
tower = { version = "0.5", features = ["util"] }
|
||||
# A layer that captures `review_classifier::runs`'s log event in a test.
|
||||
tracing-subscriber.workspace = true
|
||||
# `operator_profiles::read`, the agent's read, run against what `sync` wrote.
|
||||
swarm-queue-client = { workspace = true, features = ["operator-profiles"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -71,14 +71,6 @@ 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, for every member of authelia's operator group, as one list
|
||||
under the one key of the `operator-profiles` KV bucket. This daemon alone
|
||||
writes it, at start and every five minutes, and only when the list changed.
|
||||
A forge read that fails keeps that operator's stored entry, and a name that
|
||||
leaves the group drops out of the list. Every agent reads the key 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
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -1,287 +0,0 @@
|
|||
//! Each operator's forge-profile pronouns, published as one list under the
|
||||
//! `operator-profiles` KV bucket's one key
|
||||
//! (`swarm_queue_client::operator_profiles`) at start and every
|
||||
//! [`RECONCILE_INTERVAL`] after. Every agent reads that key 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 the key
|
||||
//! only when the list changed, so a name no longer in the group drops out of
|
||||
//! it. An operator whose forge profile could not be read keeps the entry the
|
||||
//! stored list holds (name only when it holds none); one with no forge account
|
||||
//! yet is listed with no pronouns.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_nats::jetstream::kv::Store;
|
||||
use swarm_queue_client::operator_profiles::{self, KEY, 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 {
|
||||
/// The list differed from the stored one and was written.
|
||||
written: bool,
|
||||
/// Operators whose profile could not be read, left as stored.
|
||||
kept: usize,
|
||||
}
|
||||
|
||||
/// Bring [`KEY`] in `store` to the list of `operators`, each with its
|
||||
/// pronouns as `forge` reports them, sorted by name.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the key cannot be read or written. A forge read that fails is logged
|
||||
/// and keeps that operator's stored entry. A stored value this build cannot
|
||||
/// decode reads as an empty list, so the pass replaces it.
|
||||
pub(crate) async fn sync(
|
||||
operators: &[String],
|
||||
forge: &impl Profiles,
|
||||
store: &Store,
|
||||
) -> Result<Outcome> {
|
||||
let stored: Vec<OperatorProfile> = store
|
||||
.get(KEY)
|
||||
.await
|
||||
.context("reading the operator list")?
|
||||
.and_then(|value| serde_json::from_slice(&value).ok())
|
||||
.unwrap_or_default();
|
||||
let mut outcome = Outcome::default();
|
||||
let mut list = Vec::with_capacity(operators.len());
|
||||
for name in operators {
|
||||
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 entry"
|
||||
);
|
||||
outcome.kept += 1;
|
||||
stored
|
||||
.iter()
|
||||
.find(|o| o.name == *name)
|
||||
.and_then(|o| o.pronouns.clone())
|
||||
}
|
||||
};
|
||||
list.push(OperatorProfile {
|
||||
name: name.clone(),
|
||||
pronouns,
|
||||
});
|
||||
}
|
||||
list.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
list.dedup_by(|a, b| a.name == b.name);
|
||||
if list != stored {
|
||||
let value = serde_json::to_vec(&list).context("encoding the operator list")?;
|
||||
store
|
||||
.put(KEY, value.into())
|
||||
.await
|
||||
.context("writing the operator list")?;
|
||||
outcome.written = true;
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// One pass: read the operator group, then [`sync`] the key 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 => tracing::info!(
|
||||
kept = outcome.kept,
|
||||
"operator profiles: operator list 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 value = store.get(KEY).await.unwrap().expect("the list is written");
|
||||
serde_json::from_slice(&value).unwrap()
|
||||
}
|
||||
|
||||
fn profile(name: &str, pronouns: Option<&str>) -> OperatorProfile {
|
||||
OperatorProfile {
|
||||
name: name.to_owned(),
|
||||
pronouns: pronouns.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_list_is_written_once_and_a_departed_operator_drops_out() {
|
||||
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(&["sol", "mara"]), &forge, &store)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(outcome.written);
|
||||
assert_eq!(
|
||||
stored(&store).await,
|
||||
[profile("mara", Some("she/her")), profile("sol", None)]
|
||||
);
|
||||
let revision = store.entry(KEY).await.unwrap().unwrap().revision;
|
||||
|
||||
// An unchanged list is not rewritten.
|
||||
let outcome = sync(&names(&["mara", "sol"]), &forge, &store)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome, Outcome::default());
|
||||
assert_eq!(store.entry(KEY).await.unwrap().unwrap().revision, revision);
|
||||
|
||||
let outcome = sync(&names(&["mara"]), &forge, &store).await.unwrap();
|
||||
assert!(outcome.written);
|
||||
assert_eq!(stored(&store).await, [profile("mara", Some("she/her"))]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_forge_read_keeps_the_stored_entry() {
|
||||
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.
|
||||
// `ren` is new and unreadable, so it is listed by name only.
|
||||
let failing = Fake(
|
||||
[
|
||||
("mara", Err(())),
|
||||
("sol", Ok(Some("he/him"))),
|
||||
("ren", Err(())),
|
||||
]
|
||||
.into(),
|
||||
);
|
||||
let outcome = sync(&names(&["mara", "sol", "ren"]), &failing, &store)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.kept, 2);
|
||||
assert_eq!(
|
||||
stored(&store).await,
|
||||
[
|
||||
profile("mara", Some("she/her")),
|
||||
profile("ren", None),
|
||||
profile("sol", Some("he/him"))
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The agent's read (`operator_profiles::read`, one direct get) finds
|
||||
/// what a pass wrote, an empty list for a bucket with no key yet, and
|
||||
/// `NoBucket` before the bucket exists.
|
||||
#[tokio::test]
|
||||
async fn the_agent_read_finds_the_list_and_falls_back_without_one() {
|
||||
use swarm_queue_client::operator_profiles::{Read, read};
|
||||
|
||||
let nats = crate::test_nats::TestNats::start();
|
||||
let client = nats.client().await;
|
||||
assert!(matches!(read(&client).await.unwrap(), Read::NoBucket));
|
||||
|
||||
let store = operator_profiles::open_or_create(&client).await.unwrap();
|
||||
let Read::Current(empty) = read(&client).await.unwrap() else {
|
||||
panic!("the bucket exists");
|
||||
};
|
||||
assert_eq!(empty, []);
|
||||
|
||||
let forge = Fake([("mara", Ok(Some("she/her")))].into());
|
||||
sync(&names(&["mara"]), &forge, &store).await.unwrap();
|
||||
let Read::Current(list) = read(&client).await.unwrap() else {
|
||||
panic!("the bucket exists");
|
||||
};
|
||||
assert_eq!(list, [profile("mara", Some("she/her"))]);
|
||||
|
||||
store.delete(KEY).await.unwrap();
|
||||
let Read::Current(deleted) = read(&client).await.unwrap() else {
|
||||
panic!("the bucket exists");
|
||||
};
|
||||
assert_eq!(deleted, []);
|
||||
}
|
||||
}
|
||||
|
|
@ -2863,20 +2863,14 @@ 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, and the operators' profiles when the bridge
|
||||
/// and the queue are wired up too. Lifted out of `main` for
|
||||
/// once there is a forge client. 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();
|
||||
|
|
@ -3423,8 +3417,6 @@ 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,
|
||||
|
|
@ -3435,7 +3427,7 @@ async fn start_forge(
|
|||
}
|
||||
};
|
||||
forge::enable(url, forge_client, move |client| {
|
||||
spawn_forge_workers(&jobq, Arc::clone(&client), &hives, auth, queue);
|
||||
spawn_forge_workers(&jobq, Arc::clone(&client), &hives);
|
||||
config_prs.get_or_init(|| config_pr::spawn(Arc::clone(&client)));
|
||||
register_swarm_webhooks(client, webhook_secret);
|
||||
})
|
||||
|
|
@ -3557,8 +3549,6 @@ async fn main() -> Result<()> {
|
|||
Arc::clone(&jobq),
|
||||
hives.clone(),
|
||||
webhook_secret.clone(),
|
||||
auth.clone(),
|
||||
status.as_ref().map(|s| s.queue_client()),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
|
|||
|
|
@ -118,8 +118,6 @@ mod tests {
|
|||
"$SWARM.term.atlas".to_owned(),
|
||||
"$SWARM.agent-state.atlas".to_owned(),
|
||||
"$KV.agent-icons.atlas".to_owned(),
|
||||
"$JS.API.DIRECT.GET.KV_operator-profiles.$KV.operator-profiles.operators"
|
||||
.to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,22 +173,12 @@ impl Policy {
|
|||
/// must already be a single `[A-Za-z0-9_-]` segment, which the token parse
|
||||
/// guarantees, so it cannot widen a subject with `.`, `*` or `>`.
|
||||
pub fn agent_token_permissions(&self, agent: &str) -> Option<Permissions> {
|
||||
let mut publish: Vec<String> = self
|
||||
let publish: Vec<String> = self
|
||||
.agent_token_subjects
|
||||
.iter()
|
||||
.map(|s| s.replace(AGENT_PLACEHOLDER, agent))
|
||||
.collect();
|
||||
if publish.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// The operator list (`swarm_queue_client::operator_profiles`), which
|
||||
// every agent renders into its system prompt: one direct get on the
|
||||
// bucket's one key, so no `STREAM.INFO`, no `CONSUMER.CREATE` and no
|
||||
// `$KV.` write. Every agent reads the same key, so it is no `{agent}`
|
||||
// template and is granted here rather than through
|
||||
// `--agent-token-publish-subject`.
|
||||
publish.push(swarm_queue_client::operator_profiles::direct_get_subject());
|
||||
Some(Permissions {
|
||||
(!publish.is_empty()).then(|| Permissions {
|
||||
publish,
|
||||
subscribe: Some(vec![
|
||||
format!("{}.>", swarm_queue_client::agent_inbox_prefix(agent)),
|
||||
|
|
@ -734,18 +724,6 @@ mod tests {
|
|||
.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 = swarm_queue_client::operator_profiles::direct_get_subject();
|
||||
grant
|
||||
.publish
|
||||
.iter()
|
||||
.filter(|s| **s != shared)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reader_can_open_and_list_the_agent_status_bucket() {
|
||||
let subjects = policy().reader_subjects();
|
||||
|
|
@ -1900,8 +1878,6 @@ mod tests {
|
|||
vec![
|
||||
"$SWARM.term.atlas".to_owned(),
|
||||
"$SWARM.agent-state.atlas".to_owned(),
|
||||
"$JS.API.DIRECT.GET.KV_operator-profiles.$KV.operator-profiles.operators"
|
||||
.to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -1915,12 +1891,12 @@ mod tests {
|
|||
.with_agent_token_subjects(vec!["$KV.agent-icons.{agent}".to_owned()])
|
||||
.expect("a per-agent template is valid");
|
||||
let atlas = p.agent_token_permissions("atlas").expect("configured");
|
||||
assert_eq!(templated(&atlas), vec![subject("atlas")]);
|
||||
assert_eq!(atlas.publish, vec![subject("atlas")]);
|
||||
assert!(!atlas.publish.contains(&subject("argus")));
|
||||
// Control: the same template does grant argus its own key, so atlas
|
||||
// lacking it is atlas's grant being scoped.
|
||||
let argus = p.agent_token_permissions("argus").expect("configured");
|
||||
assert_eq!(templated(&argus), vec![subject("argus")]);
|
||||
assert_eq!(argus.publish, vec![subject("argus")]);
|
||||
}
|
||||
|
||||
/// The subagent template as `swarm-nats.nix` spells it expands to the
|
||||
|
|
@ -1934,10 +1910,10 @@ mod tests {
|
|||
.with_agent_token_subjects(vec!["$SWARM.term.{agent}.sub.>".to_owned()])
|
||||
.expect("a per-agent template is valid");
|
||||
let g = p.agent_token_permissions("atlas").expect("configured");
|
||||
assert_eq!(templated(&g), vec![stream_subjects("atlas")]);
|
||||
assert_eq!(g.publish, vec![stream_subjects("atlas")]);
|
||||
assert!(subject("atlas", "scout").starts_with(g.publish[0].trim_end_matches('>')));
|
||||
let argus = p.agent_token_permissions("argus").expect("configured");
|
||||
assert_eq!(templated(&argus), vec![stream_subjects("argus")]);
|
||||
assert_eq!(argus.publish, vec![stream_subjects("argus")]);
|
||||
assert_ne!(g.publish, argus.publish);
|
||||
|
||||
let stream = stream_name("atlas");
|
||||
|
|
@ -1965,11 +1941,11 @@ mod tests {
|
|||
])
|
||||
.expect("a per-agent template is valid");
|
||||
let atlas = p.agent_token_permissions("atlas").expect("configured");
|
||||
assert_eq!(templated(&atlas), vec![direct_get_subject("atlas")]);
|
||||
assert_eq!(atlas.publish, vec![direct_get_subject("atlas")]);
|
||||
// Control: argus's own expansion is a different subject, so atlas
|
||||
// lacking it is the grant being scoped, not the template being empty.
|
||||
let argus = p.agent_token_permissions("argus").expect("configured");
|
||||
assert_eq!(templated(&argus), vec![direct_get_subject("argus")]);
|
||||
assert_eq!(argus.publish, vec![direct_get_subject("argus")]);
|
||||
assert!(!atlas.publish.contains(&direct_get_subject("argus")));
|
||||
|
||||
for refused in [
|
||||
|
|
@ -2013,9 +1989,9 @@ mod tests {
|
|||
])
|
||||
.expect("a per-agent template is valid");
|
||||
let atlas = p.agent_token_permissions("atlas").expect("configured");
|
||||
assert_eq!(templated(&atlas), vec![direct_get_subject("atlas")]);
|
||||
assert_eq!(atlas.publish, vec![direct_get_subject("atlas")]);
|
||||
let argus = p.agent_token_permissions("argus").expect("configured");
|
||||
assert_eq!(templated(&argus), vec![direct_get_subject("argus")]);
|
||||
assert_eq!(argus.publish, vec![direct_get_subject("argus")]);
|
||||
|
||||
for refused in [
|
||||
direct_get_subject("argus"),
|
||||
|
|
@ -2046,47 +2022,4 @@ mod tests {
|
|||
fn with_no_agent_token_subject_configured_an_agent_token_is_refused() {
|
||||
assert!(policy().agent_token_permissions("atlas").is_none());
|
||||
}
|
||||
|
||||
/// Every agent reads the operator list with one direct get on its one key,
|
||||
/// and nothing else of the bucket: no lookup, no listing, no other key and
|
||||
/// no write.
|
||||
#[test]
|
||||
fn an_agent_may_read_only_the_operator_list_key() {
|
||||
use swarm_queue_client::operator_profiles::{
|
||||
BUCKET, STREAM, direct_get_subject, key_subject,
|
||||
};
|
||||
|
||||
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");
|
||||
assert!(atlas.publish.contains(&direct_get_subject()));
|
||||
let may = |subject: &str| atlas.publish.iter().any(|g| covers(g, subject));
|
||||
for refused in [
|
||||
key_subject(),
|
||||
format!("$JS.API.DIRECT.GET.{STREAM}"),
|
||||
format!("$JS.API.DIRECT.GET.{STREAM}.$KV.{BUCKET}.mara"),
|
||||
format!("$JS.API.STREAM.INFO.{STREAM}"),
|
||||
format!("$JS.API.STREAM.CREATE.{STREAM}"),
|
||||
format!("$JS.API.STREAM.UPDATE.{STREAM}"),
|
||||
format!("$JS.API.STREAM.DELETE.{STREAM}"),
|
||||
format!("$JS.API.STREAM.PURGE.{STREAM}"),
|
||||
format!("$JS.API.STREAM.MSG.GET.{STREAM}"),
|
||||
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
|
||||
format!("$JS.API.CONSUMER.CREATE.{STREAM}.c1.$KV.{BUCKET}.>"),
|
||||
] {
|
||||
assert!(!may(&refused), "atlas may publish {refused}");
|
||||
}
|
||||
// Control: the matcher does match, so the refusals above are not
|
||||
// vacuous.
|
||||
assert!(may(&direct_get_subject()));
|
||||
// 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, &direct_get_subject()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,10 +44,6 @@ 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`, 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"]
|
||||
|
|
|
|||
|
|
@ -227,11 +227,6 @@ pub mod agent_limits;
|
|||
/// and reading need `agent-permissions`.
|
||||
pub mod agent_permissions;
|
||||
|
||||
/// The bucket swarm-controller publishes each operator's name and forge-profile
|
||||
/// 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;
|
||||
|
||||
/// The login flag in an agent's turn-state header. No bucket: it travels
|
||||
/// on [`agent_state_subject`] only.
|
||||
pub mod agent_login;
|
||||
|
|
|
|||
|
|
@ -1,155 +0,0 @@
|
|||
//! 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; one who
|
||||
//! leaves it drops out of the list on the controller's next pass.
|
||||
//!
|
||||
//! **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.
|
||||
pub const BUCKET: &str = "operator-profiles";
|
||||
|
||||
/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`.
|
||||
pub const STREAM: &str = "KV_operator-profiles";
|
||||
|
||||
/// 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.
|
||||
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
|
||||
}
|
||||
|
||||
/// 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 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(
|
||||
client: &async_nats::Client,
|
||||
) -> Result<Read, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use async_nats::jetstream::stream::DirectGetErrorKind;
|
||||
|
||||
let js = async_nats::jetstream::new(client.clone());
|
||||
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()));
|
||||
}
|
||||
Ok(Read::Current(serde_json::from_slice(&message.payload)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The names the auth responder grants agents from.
|
||||
#[test]
|
||||
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 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!(
|
||||
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