agent_permissions, agent_term and subagent_term take their wire contracts from swarm-wire
This commit is contained in:
parent
c06b1325e7
commit
839efd1642
27 changed files with 661 additions and 607 deletions
|
|
@ -139,7 +139,7 @@ Swarm-level:
|
||||||
- **`swarm-queue-client/`** — the one authenticated connect to the swarm
|
- **`swarm-queue-client/`** — the one authenticated connect to the swarm
|
||||||
NATS queue.
|
NATS queue.
|
||||||
- **`swarm-wire/`** — serde-only request and reply contracts services
|
- **`swarm-wire/`** — serde-only request and reply contracts services
|
||||||
exchange over the queue (builder, claude login, review classifier, agent inference and login), so naming one links
|
exchange over the queue (builder, claude login, review classifier, agent inference, login, permissions and terminal streams), so naming one links
|
||||||
neither the queue client nor the store client.
|
neither the queue client nor the store client.
|
||||||
- **`swarm-nats-auth/`** — NATS auth-callout responder; without it the
|
- **`swarm-nats-auth/`** — NATS auth-callout responder; without it the
|
||||||
queue is fail-closed and admits nobody.
|
queue is fail-closed and admits nobody.
|
||||||
|
|
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -5090,6 +5090,7 @@ dependencies = [
|
||||||
name = "swarm-wire"
|
name = "swarm-wire"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"hive-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,7 @@ or not it holds `inbox`, and an agent without it still needs to clear them.
|
||||||
|
|
||||||
**Where they live** — in the swarm, not on the hive: the `permissions` field of
|
**Where they live** — in the swarm, not on the hive: the `permissions` field of
|
||||||
the agent's key in the `agent-permissions` KV bucket
|
the agent's key in the `agent-permissions` KV bucket
|
||||||
(`swarm-queue-client/src/agent_permissions.rs`), which swarm-controller alone
|
(`swarm-wire/src/agent_permissions.rs`), which swarm-controller alone
|
||||||
writes. Set them with `swarmctl agent permissions get|set|clear <agent>`
|
writes. Set them with `swarmctl agent permissions get|set|clear <agent>`
|
||||||
(`/api/agents/{name}/permissions`), or on the swarm UI's create-agent screen
|
(`/api/agents/{name}/permissions`), or on the swarm UI's create-agent screen
|
||||||
(`permissions` in `POST /api/agents`). A missing key or an unset field means
|
(`permissions` in `POST /api/agents`). A missing key or an unset field means
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
//!
|
//!
|
||||||
//! **A plain core publish.** The queue keeps the rows in the stream
|
//! **A plain core publish.** The queue keeps the rows in the stream
|
||||||
//! `term-main-<agent>`, which swarm-controller creates
|
//! `term-main-<agent>`, which swarm-controller creates
|
||||||
//! (`swarm_queue_client::agent_term`), so this needs no `JetStream` call and
|
//! (`swarm_wire::agent_term`), so this needs no `JetStream` call and
|
||||||
//! the agent's grant stays a plain publish. A row published before that
|
//! the agent's grant stays a plain publish. A row published before that
|
||||||
//! stream exists reaches live subscribers only.
|
//! stream exists reaches live subscribers only.
|
||||||
//!
|
//!
|
||||||
|
|
@ -50,7 +50,7 @@ pub fn spawn(rx: broadcast::Receiver<BusEvent>) {
|
||||||
|
|
||||||
async fn run(mut rx: broadcast::Receiver<BusEvent>, agent: String) {
|
async fn run(mut rx: broadcast::Receiver<BusEvent>, agent: String) {
|
||||||
let client = crate::swarm_queue::client().await;
|
let client = crate::swarm_queue::client().await;
|
||||||
let subject = swarm_queue_client::term_subject(&agent);
|
let subject = swarm_wire::term_subject(&agent);
|
||||||
tracing::info!(subject, "publishing the agent terminal to the swarm queue");
|
tracing::info!(subject, "publishing the agent terminal to the swarm queue");
|
||||||
|
|
||||||
// One context for the task's whole life — see the module doc.
|
// One context for the task's whole life — see the module doc.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
//! the swarm is the record, this file is the hive's last read of it.
|
//! the swarm is the record, this file is the hive's last read of it.
|
||||||
//!
|
//!
|
||||||
//! Format: agent name → the stored
|
//! Format: agent name → the stored
|
||||||
//! [`swarm_queue_client::agent_permissions::AgentPermissions`]. An agent with
|
//! [`swarm_wire::agent_permissions::AgentPermissions`]. An agent with
|
||||||
//! no entry, or an entry with `permissions` unset, holds
|
//! no entry, or an entry with `permissions` unset, holds
|
||||||
//! [`Permission::DEFAULT`]; `"permissions": []` holds none.
|
//! [`Permission::DEFAULT`]; `"permissions": []` holds none.
|
||||||
//!
|
//!
|
||||||
|
|
@ -18,7 +18,8 @@ use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use hive_types::Permission;
|
use hive_types::Permission;
|
||||||
use swarm_queue_client::agent_permissions::{AgentPermissions, HIVE_COPY_FILE, HiveCopy};
|
use swarm_queue_client::agent_permissions::{HIVE_COPY_FILE, HiveCopy};
|
||||||
|
use swarm_wire::agent_permissions::AgentPermissions;
|
||||||
|
|
||||||
const FILE: &str = "swarm-agent-permissions.json";
|
const FILE: &str = "swarm-agent-permissions.json";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use hive_types::Permission;
|
use hive_types::Permission;
|
||||||
use swarm_queue_client::agent_permissions::{
|
use swarm_wire::agent_permissions::{
|
||||||
self, AgentPermissions, IMPORT_REPLY_DEADLINE, Import, ImportReply,
|
self, AgentPermissions, IMPORT_REPLY_DEADLINE, Import, ImportReply,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use hive_types::Permission;
|
use hive_types::Permission;
|
||||||
use swarm_queue_client::agent_permissions::{self, AgentPermissions, Read};
|
use swarm_queue_client::agent_permissions::{self, Read};
|
||||||
|
use swarm_wire::agent_permissions::AgentPermissions;
|
||||||
|
|
||||||
use crate::coordinator::Coordinator;
|
use crate::coordinator::Coordinator;
|
||||||
use crate::permissions::{self, Stored};
|
use crate::permissions::{self, Stored};
|
||||||
|
|
@ -182,7 +183,7 @@ mod tests {
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use super::{Read, sync_with};
|
use super::{Read, sync_with};
|
||||||
use swarm_queue_client::agent_permissions::AgentPermissions;
|
use swarm_wire::agent_permissions::AgentPermissions;
|
||||||
|
|
||||||
fn record(json: &str) -> AgentPermissions {
|
fn record(json: &str) -> AgentPermissions {
|
||||||
serde_json::from_str(json).expect("decodes")
|
serde_json::from_str(json).expect("decodes")
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ rmcp.workspace = true
|
||||||
schemars.workspace = true
|
schemars.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
# `subagent_term`: the subject each subagent's rows are published on.
|
|
||||||
# `agent-inference`: this agent's `subagent_models`, read with one direct get.
|
# `agent-inference`: this agent's `subagent_models`, read with one direct get.
|
||||||
swarm-queue-client = { workspace = true, features = [
|
swarm-queue-client = { workspace = true, features = [
|
||||||
"agent-inference",
|
"agent-inference",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
//! output reader, so `offer` only queues. One task drains the queue: it
|
//! output reader, so `offer` only queues. One task drains the queue: it
|
||||||
//! classifies each `stream-json` event into the rows the agent's own terminal
|
//! classifies each `stream-json` event into the rows the agent's own terminal
|
||||||
//! carries (`hive_sh4re::stream_enrich`) and publishes them on
|
//! carries (`hive_sh4re::stream_enrich`) and publishes them on
|
||||||
//! `$SWARM.term.<agent>.sub.<subagent>` (`swarm_queue_client::subagent_term`).
|
//! `$SWARM.term.<agent>.sub.<subagent>` (`swarm_wire::subagent_term`).
|
||||||
//!
|
//!
|
||||||
//! **Output only.** Nothing here subscribes: a subagent takes no input from
|
//! **Output only.** Nothing here subscribes: a subagent takes no input from
|
||||||
//! the swarm.
|
//! the swarm.
|
||||||
|
|
@ -33,12 +33,12 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use anyhow::{Context as _, anyhow};
|
use anyhow::{Context as _, anyhow};
|
||||||
use hive_sh4re::term_msg::{ClassifyCtx, Level, TermMsg, fit, iso8601_utc};
|
use hive_sh4re::term_msg::{ClassifyCtx, Level, TermMsg, fit, iso8601_utc};
|
||||||
use swarm_queue_client::subagent_term;
|
|
||||||
use swarm_secret_client::{
|
use swarm_secret_client::{
|
||||||
SecretStore,
|
SecretStore,
|
||||||
client::{DEFAULT_CERT_MOUNT, ENV_ADDR, ENV_CACERT, Settings},
|
client::{DEFAULT_CERT_MOUNT, ENV_ADDR, ENV_CACERT, Settings},
|
||||||
policy, queue,
|
policy, queue,
|
||||||
};
|
};
|
||||||
|
use swarm_wire::subagent_term;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
/// Names the agent. The store role, the store path, the queue token and the
|
/// Names the agent. The store role, the store path, the queue token and the
|
||||||
|
|
|
||||||
|
|
@ -889,7 +889,7 @@ in
|
||||||
# harness mirrors its `AGENTS.md` there for the swarm to read.
|
# harness mirrors its `AGENTS.md` there for the swarm to read.
|
||||||
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$KV.agents-md.{agent}"}"
|
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$KV.agents-md.{agent}"}"
|
||||||
# Its subagents' terminal rows
|
# Its subagents' terminal rows
|
||||||
# (`swarm_queue_client::subagent_term`), publish only and no
|
# (`swarm_wire::subagent_term`), publish only and no
|
||||||
# `$JS.API.*` subject: swarm-controller creates the
|
# `$JS.API.*` subject: swarm-controller creates the
|
||||||
# `term-sub-{agent}` stream that keeps them, so its subjects
|
# `term-sub-{agent}` stream that keeps them, so its subjects
|
||||||
# and limits are never the agent's to choose.
|
# and limits are never the agent's to choose.
|
||||||
|
|
@ -905,7 +905,7 @@ in
|
||||||
# (`swarm_wire::agent_inference`), on the same terms.
|
# (`swarm_wire::agent_inference`), on the same terms.
|
||||||
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$JS.API.DIRECT.GET.KV_agent-inference.\$\$KV.agent-inference.{agent}"}"
|
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$JS.API.DIRECT.GET.KV_agent-inference.\$\$KV.agent-inference.{agent}"}"
|
||||||
# Its own key of the `agent-permissions` KV bucket
|
# Its own key of the `agent-permissions` KV bucket
|
||||||
# (`swarm_queue_client::agent_permissions`), on the same terms:
|
# (`swarm_wire::agent_permissions`), on the same terms:
|
||||||
# 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}"}"
|
||||||
|
|
|
||||||
|
|
@ -138,10 +138,7 @@ mod tests {
|
||||||
&swarm_queue_client::agent_state_subject("iris"),
|
&swarm_queue_client::agent_state_subject("iris"),
|
||||||
&header(Some(true)),
|
&header(Some(true)),
|
||||||
);
|
);
|
||||||
reported.record(
|
reported.record(&swarm_wire::term_subject("atlas"), &header(Some(true)));
|
||||||
&swarm_queue_client::term_subject("atlas"),
|
|
||||||
&header(Some(true)),
|
|
||||||
);
|
|
||||||
assert_eq!(reported.get("iris"), Some(true));
|
assert_eq!(reported.get("iris"), Some(true));
|
||||||
assert_eq!(reported.get("atlas"), None);
|
assert_eq!(reported.get("atlas"), None);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@ use futures_util::StreamExt as _;
|
||||||
use hive_types::Permission;
|
use hive_types::Permission;
|
||||||
use problem_details::ProblemDetails;
|
use problem_details::ProblemDetails;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use swarm_queue_client::agent_permissions::{self, AgentPermissions, Import, ImportReply};
|
use swarm_queue_client::agent_permissions;
|
||||||
|
use swarm_wire::agent_permissions::{
|
||||||
|
AgentPermissions, Import, ImportReply, hive_from_import_subject, import_subject_wildcard,
|
||||||
|
};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::routes::{AppState, error_problem, wanted_error_status};
|
use crate::routes::{AppState, error_problem, wanted_error_status};
|
||||||
|
|
@ -377,7 +380,7 @@ pub(crate) fn spawn(client: async_nats::Client, wanted: Arc<WantedWriter>, hives
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let subject = agent_permissions::import_subject_wildcard();
|
let subject = import_subject_wildcard();
|
||||||
loop {
|
loop {
|
||||||
match client.subscribe(subject.clone()).await {
|
match client.subscribe(subject.clone()).await {
|
||||||
Ok(mut requests) => {
|
Ok(mut requests) => {
|
||||||
|
|
@ -413,7 +416,7 @@ async fn answer_import(
|
||||||
tracing::warn!(subject = %msg.subject, %error, "agent permissions: import refused");
|
tracing::warn!(subject = %msg.subject, %error, "agent permissions: import refused");
|
||||||
ImportReply::Failed { error }
|
ImportReply::Failed { error }
|
||||||
};
|
};
|
||||||
let Some(hive) = agent_permissions::hive_from_import_subject(&msg.subject) else {
|
let Some(hive) = hive_from_import_subject(&msg.subject) else {
|
||||||
return failed(format!("{} is not an import subject", msg.subject));
|
return failed(format!("{} is not an import subject", msg.subject));
|
||||||
};
|
};
|
||||||
if !hives.iter().any(|h| h == hive) {
|
if !hives.iter().any(|h| h == hive) {
|
||||||
|
|
@ -529,7 +532,7 @@ mod tests {
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use hive_types::Permission;
|
use hive_types::Permission;
|
||||||
use swarm_queue_client::agent_permissions::{Import, ImportReply};
|
use swarm_wire::agent_permissions::{Import, ImportReply};
|
||||||
|
|
||||||
async fn published(client: &async_nats::Client, agent: &str) -> Option<String> {
|
async fn published(client: &async_nats::Client, agent: &str) -> Option<String> {
|
||||||
let store = swarm_queue_client::agent_permissions::open_or_create(client)
|
let store = swarm_queue_client::agent_permissions::open_or_create(client)
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ use futures_util::{Stream, StreamExt as _, stream};
|
||||||
use hive_types::term_msg::{SEND_ICON, TURN_ICON, send_recipient, turn_sender};
|
use hive_types::term_msg::{SEND_ICON, TURN_ICON, send_recipient, turn_sender};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use swarm_controller_api::messages::SwarmMessage;
|
use swarm_controller_api::messages::SwarmMessage;
|
||||||
use swarm_queue_client::agent_term;
|
use swarm_wire::agent_term;
|
||||||
use utoipa::IntoParams;
|
use utoipa::IntoParams;
|
||||||
|
|
||||||
use crate::routes::{AppState, error_problem};
|
use crate::routes::{AppState, error_problem};
|
||||||
|
|
@ -104,12 +104,9 @@ pub(crate) async fn stream_messages(
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let subscriber = crate::term_stream::subscribe(
|
let subscriber =
|
||||||
&client,
|
crate::term_stream::subscribe(&client, swarm_wire::term_subject_wildcard(), "messages")
|
||||||
swarm_queue_client::term_subject_wildcard(),
|
.await?;
|
||||||
"messages",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let roster: Arc<HashSet<String>> = Arc::new(roster.into_iter().collect());
|
let roster: Arc<HashSet<String>> = Arc::new(roster.into_iter().collect());
|
||||||
let history = history(&client, &roster, minutes).await;
|
let history = history(&client, &roster, minutes).await;
|
||||||
let seen: HashSet<SwarmMessage> = history.iter().cloned().collect();
|
let seen: HashSet<SwarmMessage> = history.iter().cloned().collect();
|
||||||
|
|
@ -170,7 +167,7 @@ async fn agent_history(
|
||||||
let replayed = crate::term_stream::replay(
|
let replayed = crate::term_stream::replay(
|
||||||
&client,
|
&client,
|
||||||
&agent_term::stream_name(&agent),
|
&agent_term::stream_name(&agent),
|
||||||
swarm_queue_client::term_subject(&agent),
|
swarm_wire::term_subject(&agent),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -203,7 +200,7 @@ fn live(
|
||||||
/// The agent a main terminal subject belongs to. A subagent's subject has
|
/// The agent a main terminal subject belongs to. A subagent's subject has
|
||||||
/// more tokens and names none.
|
/// more tokens and names none.
|
||||||
fn agent_of(subject: &str) -> Option<&str> {
|
fn agent_of(subject: &str) -> Option<&str> {
|
||||||
let agent = subject.strip_prefix(&swarm_queue_client::term_subject(""))?;
|
let agent = subject.strip_prefix(&swarm_wire::term_subject(""))?;
|
||||||
(!agent.is_empty() && !agent.contains('.')).then_some(agent)
|
(!agent.is_empty() && !agent.contains('.')).then_some(agent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,12 +304,12 @@ mod tests {
|
||||||
let nats = TestNats::start();
|
let nats = TestNats::start();
|
||||||
let client = nats.client().await;
|
let client = nats.client().await;
|
||||||
let subscriber = client
|
let subscriber = client
|
||||||
.subscribe(swarm_queue_client::term_subject_wildcard())
|
.subscribe(swarm_wire::term_subject_wildcard())
|
||||||
.await
|
.await
|
||||||
.expect("subscribe");
|
.expect("subscribe");
|
||||||
let mut messages = Box::pin(live(subscriber, roster()));
|
let mut messages = Box::pin(live(subscriber, roster()));
|
||||||
let atlas = swarm_queue_client::term_subject("atlas");
|
let atlas = swarm_wire::term_subject("atlas");
|
||||||
let iris = swarm_queue_client::term_subject("iris");
|
let iris = swarm_wire::term_subject("iris");
|
||||||
|
|
||||||
publish(
|
publish(
|
||||||
&client,
|
&client,
|
||||||
|
|
@ -350,7 +347,7 @@ mod tests {
|
||||||
.await;
|
.await;
|
||||||
publish(
|
publish(
|
||||||
&client,
|
&client,
|
||||||
swarm_queue_client::subagent_term::subject("atlas", "scout"),
|
swarm_wire::subagent_term::subject("atlas", "scout"),
|
||||||
row(SEND_ICON, "send* → iris", "from a subagent"),
|
row(SEND_ICON, "send* → iris", "from a subagent"),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -366,7 +363,7 @@ mod tests {
|
||||||
let nats = TestNats::start();
|
let nats = TestNats::start();
|
||||||
let client = nats.client().await;
|
let client = nats.client().await;
|
||||||
for agent in ["atlas", "iris"] {
|
for agent in ["atlas", "iris"] {
|
||||||
agent_term::open_or_create(&client, agent)
|
swarm_queue_client::agent_term::open_or_create(&client, agent)
|
||||||
.await
|
.await
|
||||||
.expect("the stream");
|
.expect("the stream");
|
||||||
}
|
}
|
||||||
|
|
@ -381,8 +378,8 @@ mod tests {
|
||||||
.as_secs();
|
.as_secs();
|
||||||
let now = iso8601_utc(i64::try_from(secs).expect("fits"));
|
let now = iso8601_utc(i64::try_from(secs).expect("fits"));
|
||||||
let earlier = iso8601_utc(i64::try_from(secs - 600).expect("fits"));
|
let earlier = iso8601_utc(i64::try_from(secs - 600).expect("fits"));
|
||||||
let atlas = swarm_queue_client::term_subject("atlas");
|
let atlas = swarm_wire::term_subject("atlas");
|
||||||
let iris = swarm_queue_client::term_subject("iris");
|
let iris = swarm_wire::term_subject("iris");
|
||||||
publish(&client, atlas, stamped(&now, "send* → iris", "newer")).await;
|
publish(&client, atlas, stamped(&now, "send* → iris", "newer")).await;
|
||||||
publish(
|
publish(
|
||||||
&client,
|
&client,
|
||||||
|
|
|
||||||
|
|
@ -711,7 +711,7 @@ pub(crate) async fn create_agent(
|
||||||
struct CreateSettings {
|
struct CreateSettings {
|
||||||
limits: Option<swarm_wire::agent_limits::AgentCaps>,
|
limits: Option<swarm_wire::agent_limits::AgentCaps>,
|
||||||
inference: Option<swarm_wire::agent_inference::AgentInference>,
|
inference: Option<swarm_wire::agent_inference::AgentInference>,
|
||||||
permissions: Option<swarm_queue_client::agent_permissions::AgentPermissions>,
|
permissions: Option<swarm_wire::agent_permissions::AgentPermissions>,
|
||||||
archetype: Option<swarm_queue_client::agent_archetype::ArchetypeRef>,
|
archetype: Option<swarm_queue_client::agent_archetype::ArchetypeRef>,
|
||||||
agents_md: Option<String>,
|
agents_md: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
@ -794,7 +794,7 @@ impl StoredSettings {
|
||||||
agent: &str,
|
agent: &str,
|
||||||
limits: Option<&swarm_wire::agent_limits::AgentCaps>,
|
limits: Option<&swarm_wire::agent_limits::AgentCaps>,
|
||||||
inference: Option<&swarm_wire::agent_inference::AgentInference>,
|
inference: Option<&swarm_wire::agent_inference::AgentInference>,
|
||||||
permissions: Option<&swarm_queue_client::agent_permissions::AgentPermissions>,
|
permissions: Option<&swarm_wire::agent_permissions::AgentPermissions>,
|
||||||
archetype: Option<&swarm_queue_client::agent_archetype::ArchetypeRef>,
|
archetype: Option<&swarm_queue_client::agent_archetype::ArchetypeRef>,
|
||||||
agents_md: Option<&str>,
|
agents_md: Option<&str>,
|
||||||
) -> Result<Self, problem_details::ProblemDetails> {
|
) -> Result<Self, problem_details::ProblemDetails> {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
use futures_util::{Stream, StreamExt as _, TryStreamExt as _};
|
use futures_util::{Stream, StreamExt as _, TryStreamExt as _};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use swarm_queue_client::{agent_term, subagent_term};
|
use swarm_wire::subagent_term;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::routes::AppState;
|
use crate::routes::AppState;
|
||||||
|
|
@ -72,14 +72,18 @@ pub(crate) fn spawn(client: async_nats::Client, wanted: Arc<WantedWriter>, hives
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for agent in live_agents(&wanted, &hives).await {
|
for agent in live_agents(&wanted, &hives).await {
|
||||||
if let Err(e) = subagent_term::open_or_create(&client, &agent).await {
|
if let Err(e) =
|
||||||
|
swarm_queue_client::subagent_term::open_or_create(&client, &agent).await
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%agent,
|
%agent,
|
||||||
error = %swarm_queue_client::chain(&e),
|
error = %swarm_queue_client::chain(&e),
|
||||||
"subagent streams: creating the agent's stream failed; next pass"
|
"subagent streams: creating the agent's stream failed; next pass"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Err(e) = agent_term::open_or_create(&client, &agent).await {
|
if let Err(e) =
|
||||||
|
swarm_queue_client::agent_term::open_or_create(&client, &agent).await
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%agent,
|
%agent,
|
||||||
error = %swarm_queue_client::chain(&e),
|
error = %swarm_queue_client::chain(&e),
|
||||||
|
|
@ -287,7 +291,7 @@ fn read_failed(agent: &str, e: &dyn std::error::Error) -> problem_details::Probl
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use swarm_queue_client::subagent_term::subagent_names;
|
use swarm_wire::subagent_term::subagent_names;
|
||||||
|
|
||||||
/// The list is what `STREAM.INFO` reports for the stream's subjects, by
|
/// The list is what `STREAM.INFO` reports for the stream's subjects, by
|
||||||
/// name: a subagent that published once and one that published many rows
|
/// name: a subagent that published once and one that published many rows
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
//! **Replay, then the live tail, through one consumer.** `hive-agent`
|
//! **Replay, then the live tail, through one consumer.** `hive-agent`
|
||||||
//! publishes each already-classified `TermMsg` row to the core subject
|
//! publishes each already-classified `TermMsg` row to the core subject
|
||||||
//! `$SWARM.term.{agent}`, and the stream `term-main-{agent}`
|
//! `$SWARM.term.{agent}`, and the stream `term-main-{agent}`
|
||||||
//! (`swarm_queue_client::agent_term`) keeps them. The handler creates an
|
//! (`swarm_wire::agent_term`) keeps them. The handler creates an
|
||||||
//! ephemeral consumer on that stream starting at the newest
|
//! ephemeral consumer on that stream starting at the newest
|
||||||
//! `agent_term::REPLAY_ROWS` rows, so the reader gets those and then every
|
//! `agent_term::REPLAY_ROWS` rows, so the reader gets those and then every
|
||||||
//! new row, in stream order, with no gap and no duplicate between the two.
|
//! new row, in stream order, with no gap and no duplicate between the two.
|
||||||
|
|
@ -35,7 +35,7 @@ use axum::http::HeaderMap;
|
||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
use futures_util::stream::BoxStream;
|
use futures_util::stream::BoxStream;
|
||||||
use futures_util::{Stream, StreamExt as _};
|
use futures_util::{Stream, StreamExt as _};
|
||||||
use swarm_queue_client::agent_term;
|
use swarm_wire::agent_term;
|
||||||
|
|
||||||
use crate::routes::AppState;
|
use crate::routes::AppState;
|
||||||
|
|
||||||
|
|
@ -121,7 +121,7 @@ pub(crate) async fn stream_agent_term(
|
||||||
let stream = replay_or_live(
|
let stream = replay_or_live(
|
||||||
&client,
|
&client,
|
||||||
&agent_term::stream_name(&agent),
|
&agent_term::stream_name(&agent),
|
||||||
swarm_queue_client::term_subject(&agent),
|
swarm_wire::term_subject(&agent),
|
||||||
last_event_id(&headers),
|
last_event_id(&headers),
|
||||||
"term",
|
"term",
|
||||||
)
|
)
|
||||||
|
|
@ -198,8 +198,12 @@ pub(crate) async fn replay(
|
||||||
"term-replay-{}",
|
"term-replay-{}",
|
||||||
deliver_subject.rsplit('.').next().unwrap_or_default()
|
deliver_subject.rsplit('.').next().unwrap_or_default()
|
||||||
);
|
);
|
||||||
let config =
|
let config = swarm_queue_client::agent_term::replay_consumer_config(
|
||||||
agent_term::replay_consumer_config(subject.clone(), consumer_name, deliver_subject, start);
|
subject.clone(),
|
||||||
|
consumer_name,
|
||||||
|
deliver_subject,
|
||||||
|
start,
|
||||||
|
);
|
||||||
let consumer = stream.create_consumer(config).await.map_err(|e| {
|
let consumer = stream.create_consumer(config).await.map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
"creating a consumer on {stream_name}: {}",
|
"creating a consumer on {stream_name}: {}",
|
||||||
|
|
@ -301,10 +305,7 @@ mod tests {
|
||||||
async fn publish_rows(client: &async_nats::Client, rows: std::ops::RangeInclusive<u64>) {
|
async fn publish_rows(client: &async_nats::Client, rows: std::ops::RangeInclusive<u64>) {
|
||||||
for i in rows {
|
for i in rows {
|
||||||
client
|
client
|
||||||
.publish(
|
.publish(swarm_wire::term_subject(AGENT), format!("row{i}").into())
|
||||||
swarm_queue_client::term_subject(AGENT),
|
|
||||||
format!("row{i}").into(),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.expect("publish");
|
.expect("publish");
|
||||||
}
|
}
|
||||||
|
|
@ -335,7 +336,7 @@ mod tests {
|
||||||
replay(
|
replay(
|
||||||
client,
|
client,
|
||||||
&agent_term::stream_name(AGENT),
|
&agent_term::stream_name(AGENT),
|
||||||
swarm_queue_client::term_subject(AGENT),
|
swarm_wire::term_subject(AGENT),
|
||||||
resume_after,
|
resume_after,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -352,7 +353,7 @@ mod tests {
|
||||||
for subagent in ["scout", "builder"] {
|
for subagent in ["scout", "builder"] {
|
||||||
client
|
client
|
||||||
.publish(
|
.publish(
|
||||||
swarm_queue_client::subagent_term::subject(AGENT, subagent),
|
swarm_wire::subagent_term::subject(AGENT, subagent),
|
||||||
format!("{subagent}{i}").into(),
|
format!("{subagent}{i}").into(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -368,8 +369,8 @@ mod tests {
|
||||||
) -> Result<BoxStream<'static, Row>, String> {
|
) -> Result<BoxStream<'static, Row>, String> {
|
||||||
replay(
|
replay(
|
||||||
client,
|
client,
|
||||||
&swarm_queue_client::subagent_term::stream_name(AGENT),
|
&swarm_wire::subagent_term::stream_name(AGENT),
|
||||||
swarm_queue_client::subagent_term::subject(AGENT, "scout"),
|
swarm_wire::subagent_term::subject(AGENT, "scout"),
|
||||||
resume_after,
|
resume_after,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -413,7 +414,7 @@ mod tests {
|
||||||
|
|
||||||
let nats = TestNats::start();
|
let nats = TestNats::start();
|
||||||
let client = nats.client().await;
|
let client = nats.client().await;
|
||||||
let stream = agent_term::open_or_create(&client, AGENT)
|
let stream = swarm_queue_client::agent_term::open_or_create(&client, AGENT)
|
||||||
.await
|
.await
|
||||||
.expect("the stream");
|
.expect("the stream");
|
||||||
publish_rows(&client, 1..=3).await;
|
publish_rows(&client, 1..=3).await;
|
||||||
|
|
@ -445,12 +446,12 @@ mod tests {
|
||||||
async fn ensuring_the_terminal_stream_again_leaves_it_as_it_is() {
|
async fn ensuring_the_terminal_stream_again_leaves_it_as_it_is() {
|
||||||
let nats = TestNats::start();
|
let nats = TestNats::start();
|
||||||
let client = nats.client().await;
|
let client = nats.client().await;
|
||||||
let created = agent_term::open_or_create(&client, AGENT)
|
let created = swarm_queue_client::agent_term::open_or_create(&client, AGENT)
|
||||||
.await
|
.await
|
||||||
.expect("the first ensure creates the stream");
|
.expect("the first ensure creates the stream");
|
||||||
publish_rows(&client, 1..=3).await;
|
publish_rows(&client, 1..=3).await;
|
||||||
stored(&client, 3).await;
|
stored(&client, 3).await;
|
||||||
agent_term::open_or_create(&client, AGENT)
|
swarm_queue_client::agent_term::open_or_create(&client, AGENT)
|
||||||
.await
|
.await
|
||||||
.expect("a second ensure succeeds");
|
.expect("a second ensure succeeds");
|
||||||
// Read back from the server, not from what the ensure returned.
|
// Read back from the server, not from what the ensure returned.
|
||||||
|
|
@ -484,7 +485,7 @@ mod tests {
|
||||||
.expect("an earlier stream");
|
.expect("an earlier stream");
|
||||||
publish_rows(&client, 1..=2).await;
|
publish_rows(&client, 1..=2).await;
|
||||||
stored(&client, 2).await;
|
stored(&client, 2).await;
|
||||||
let opened = agent_term::open_or_create(&client, AGENT)
|
let opened = swarm_queue_client::agent_term::open_or_create(&client, AGENT)
|
||||||
.await
|
.await
|
||||||
.expect("an existing stream opens");
|
.expect("an existing stream opens");
|
||||||
assert_eq!(opened.cached_info().config.max_message_size, 4096);
|
assert_eq!(opened.cached_info().config.max_message_size, 4096);
|
||||||
|
|
@ -497,7 +498,7 @@ mod tests {
|
||||||
async fn the_replay_reaches_the_reader_before_the_live_tail() {
|
async fn the_replay_reaches_the_reader_before_the_live_tail() {
|
||||||
let nats = TestNats::start();
|
let nats = TestNats::start();
|
||||||
let client = nats.client().await;
|
let client = nats.client().await;
|
||||||
agent_term::open_or_create(&client, AGENT)
|
swarm_queue_client::agent_term::open_or_create(&client, AGENT)
|
||||||
.await
|
.await
|
||||||
.expect("the stream");
|
.expect("the stream");
|
||||||
publish_rows(&client, 1..=150).await;
|
publish_rows(&client, 1..=150).await;
|
||||||
|
|
@ -514,7 +515,7 @@ mod tests {
|
||||||
async fn a_reconnect_gets_every_later_row_once() {
|
async fn a_reconnect_gets_every_later_row_once() {
|
||||||
let nats = TestNats::start();
|
let nats = TestNats::start();
|
||||||
let client = nats.client().await;
|
let client = nats.client().await;
|
||||||
agent_term::open_or_create(&client, AGENT)
|
swarm_queue_client::agent_term::open_or_create(&client, AGENT)
|
||||||
.await
|
.await
|
||||||
.expect("the stream");
|
.expect("the stream");
|
||||||
publish_rows(&client, 1..=150).await;
|
publish_rows(&client, 1..=150).await;
|
||||||
|
|
@ -541,7 +542,7 @@ mod tests {
|
||||||
async fn a_subagent_replays_its_own_newest_rows_before_the_live_tail() {
|
async fn a_subagent_replays_its_own_newest_rows_before_the_live_tail() {
|
||||||
let nats = TestNats::start();
|
let nats = TestNats::start();
|
||||||
let client = nats.client().await;
|
let client = nats.client().await;
|
||||||
let stream_name = swarm_queue_client::subagent_term::stream_name(AGENT);
|
let stream_name = swarm_wire::subagent_term::stream_name(AGENT);
|
||||||
swarm_queue_client::subagent_term::open_or_create(&client, AGENT)
|
swarm_queue_client::subagent_term::open_or_create(&client, AGENT)
|
||||||
.await
|
.await
|
||||||
.expect("the stream");
|
.expect("the stream");
|
||||||
|
|
@ -559,7 +560,7 @@ mod tests {
|
||||||
async fn a_subagent_reconnect_gets_every_later_row_once() {
|
async fn a_subagent_reconnect_gets_every_later_row_once() {
|
||||||
let nats = TestNats::start();
|
let nats = TestNats::start();
|
||||||
let client = nats.client().await;
|
let client = nats.client().await;
|
||||||
let stream_name = swarm_queue_client::subagent_term::stream_name(AGENT);
|
let stream_name = swarm_wire::subagent_term::stream_name(AGENT);
|
||||||
swarm_queue_client::subagent_term::open_or_create(&client, AGENT)
|
swarm_queue_client::subagent_term::open_or_create(&client, AGENT)
|
||||||
.await
|
.await
|
||||||
.expect("the stream");
|
.expect("the stream");
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ impl Policy {
|
||||||
subscribe: Some(Self::reader_subscribe(client_id)),
|
subscribe: Some(Self::reader_subscribe(client_id)),
|
||||||
// The answer to a hive's permissions import. NATS lets it
|
// The answer to a hive's permissions import. NATS lets it
|
||||||
// answer only a message it received, once.
|
// answer only a message it received, once.
|
||||||
responses: Some(swarm_queue_client::agent_permissions::IMPORT_REPLY_DEADLINE),
|
responses: Some(swarm_wire::agent_permissions::IMPORT_REPLY_DEADLINE),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if self.review_classifiers.iter().any(|c| c == client_id) {
|
if self.review_classifiers.iter().any(|c| c == client_id) {
|
||||||
|
|
@ -324,15 +324,15 @@ impl Policy {
|
||||||
/// to swarm-ui, every hive's permissions import, and its own reply inbox.
|
/// to swarm-ui, every hive's permissions import, and its own reply inbox.
|
||||||
///
|
///
|
||||||
/// The three families are the publishers' (`hive-agent`'s `swarm_term`
|
/// The three families are the publishers' (`hive-agent`'s `swarm_term`
|
||||||
/// and `swarm_agent_state`, `swarm_queue_client::subagent_term`), spelled
|
/// and `swarm_agent_state`, `swarm_wire::subagent_term`), spelled
|
||||||
/// by the same `swarm_queue_client` helpers. Operator input, deploys and
|
/// by the same `swarm_queue_client` helpers. Operator input, deploys and
|
||||||
/// knowledge are absent: the reader publishes those.
|
/// knowledge are absent: the reader publishes those.
|
||||||
fn reader_subscribe(client_id: &str) -> Vec<String> {
|
fn reader_subscribe(client_id: &str) -> Vec<String> {
|
||||||
vec![
|
vec![
|
||||||
swarm_queue_client::term_subject_wildcard(),
|
swarm_wire::term_subject_wildcard(),
|
||||||
swarm_queue_client::subagent_term::subject_wildcard(),
|
swarm_wire::subagent_term::subject_wildcard(),
|
||||||
swarm_queue_client::agent_state_subject_wildcard(),
|
swarm_queue_client::agent_state_subject_wildcard(),
|
||||||
swarm_queue_client::agent_permissions::import_subject_wildcard(),
|
swarm_wire::agent_permissions::import_subject_wildcard(),
|
||||||
Self::inbox(client_id),
|
Self::inbox(client_id),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -577,11 +577,11 @@ impl Policy {
|
||||||
swarm_wire::agent_limits::STREAM
|
swarm_wire::agent_limits::STREAM
|
||||||
));
|
));
|
||||||
// The permissions the swarm grants those agents
|
// The permissions the swarm grants those agents
|
||||||
// (`swarm_queue_client::agent_permissions`), read the same way and
|
// (`swarm_wire::agent_permissions`), read the same way and
|
||||||
// every key for the same reason.
|
// every key for the same reason.
|
||||||
subjects.push(format!(
|
subjects.push(format!(
|
||||||
"$JS.API.DIRECT.GET.{}.>",
|
"$JS.API.DIRECT.GET.{}.>",
|
||||||
swarm_queue_client::agent_permissions::STREAM
|
swarm_wire::agent_permissions::STREAM
|
||||||
));
|
));
|
||||||
// The archetype an agent was created from
|
// The archetype an agent was created from
|
||||||
// (`swarm_queue_client::agent_archetype`), read the same way and every
|
// (`swarm_queue_client::agent_archetype`), read the same way and every
|
||||||
|
|
@ -593,7 +593,7 @@ impl Policy {
|
||||||
// The one-shot import of the grants this hive's meta repo held, sent
|
// The one-shot import of the grants this hive's meta repo held, sent
|
||||||
// as a request on this hive's own subject: the controller takes the
|
// as a request on this hive's own subject: the controller takes the
|
||||||
// hive from the subject, so one hive cannot import for another.
|
// hive from the subject, so one hive cannot import for another.
|
||||||
subjects.push(swarm_queue_client::agent_permissions::import_subject(hive));
|
subjects.push(swarm_wire::agent_permissions::import_subject(hive));
|
||||||
subjects.extend(
|
subjects.extend(
|
||||||
self.extra_hive_subjects
|
self.extra_hive_subjects
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -701,11 +701,11 @@ impl Policy {
|
||||||
// which this client already subscribes to and reads by
|
// which this client already subscribes to and reads by
|
||||||
// `MSG.GET` above. No bare `CONSUMER.CREATE.<stream>`, whose
|
// `MSG.GET` above. No bare `CONSUMER.CREATE.<stream>`, whose
|
||||||
// filter is unchecked.
|
// filter is unchecked.
|
||||||
swarm_queue_client::agent_term::consumer_create_grant(),
|
swarm_wire::agent_term::consumer_create_grant(),
|
||||||
// The same for one subagent's rows in `term-sub-<agent>`: the
|
// The same for one subagent's rows in `term-sub-<agent>`: the
|
||||||
// filter is `$SWARM.term.<agent>.sub.<subagent>`, two tokens
|
// filter is `$SWARM.term.<agent>.sub.<subagent>`, two tokens
|
||||||
// deeper than the grant above reaches.
|
// deeper than the grant above reaches.
|
||||||
swarm_queue_client::subagent_term::consumer_create_grant(),
|
swarm_wire::subagent_term::consumer_create_grant(),
|
||||||
"$KV.*.>".to_owned(),
|
"$KV.*.>".to_owned(),
|
||||||
]);
|
]);
|
||||||
// The per-agent status bucket, read-side only. Same five subjects as
|
// The per-agent status bucket, read-side only. Same five subjects as
|
||||||
|
|
@ -925,7 +925,7 @@ mod tests {
|
||||||
/// no other, and writes none.
|
/// no other, and writes none.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_hive_reads_every_agents_permissions_and_an_agent_only_its_own() {
|
fn a_hive_reads_every_agents_permissions_and_an_agent_only_its_own() {
|
||||||
use swarm_queue_client::agent_permissions::{STREAM, key_subject};
|
use swarm_wire::agent_permissions::{STREAM, key_subject};
|
||||||
|
|
||||||
let direct_get_subject = |agent: &str| {
|
let direct_get_subject = |agent: &str| {
|
||||||
format!("$JS.API.DIRECT.GET.KV_agent-permissions.$KV.agent-permissions.{agent}")
|
format!("$JS.API.DIRECT.GET.KV_agent-permissions.$KV.agent-permissions.{agent}")
|
||||||
|
|
@ -962,7 +962,7 @@ mod tests {
|
||||||
/// hive's and may answer, and no other role may send one.
|
/// hive's and may answer, and no other role may send one.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_hive_imports_only_under_its_own_name_and_the_reader_answers() {
|
fn a_hive_imports_only_under_its_own_name_and_the_reader_answers() {
|
||||||
use swarm_queue_client::agent_permissions::{
|
use swarm_wire::agent_permissions::{
|
||||||
IMPORT_REPLY_DEADLINE, import_subject, import_subject_wildcard,
|
IMPORT_REPLY_DEADLINE, import_subject, import_subject_wildcard,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1408,7 +1408,7 @@ mod tests {
|
||||||
let inbox = client_inbox_prefix("swarm-controller");
|
let inbox = client_inbox_prefix("swarm-controller");
|
||||||
for allowed in [
|
for allowed in [
|
||||||
"$SWARM.term.atlas".to_owned(),
|
"$SWARM.term.atlas".to_owned(),
|
||||||
swarm_queue_client::subagent_term::subject("atlas", "scout"),
|
swarm_wire::subagent_term::subject("atlas", "scout"),
|
||||||
"$SWARM.agent-state.atlas".to_owned(),
|
"$SWARM.agent-state.atlas".to_owned(),
|
||||||
reply(&inbox),
|
reply(&inbox),
|
||||||
multiplexer(&inbox),
|
multiplexer(&inbox),
|
||||||
|
|
@ -1443,8 +1443,8 @@ mod tests {
|
||||||
.permissions("swarm-controller")
|
.permissions("swarm-controller")
|
||||||
.expect("the reader");
|
.expect("the reader");
|
||||||
for built in [
|
for built in [
|
||||||
swarm_queue_client::term_subject("atlas"),
|
swarm_wire::term_subject("atlas"),
|
||||||
swarm_queue_client::subagent_term::subject("atlas", "scout"),
|
swarm_wire::subagent_term::subject("atlas", "scout"),
|
||||||
swarm_queue_client::agent_state_subject("atlas"),
|
swarm_queue_client::agent_state_subject("atlas"),
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -1778,8 +1778,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_controller_may_replay_terminal_rows_and_no_other_subject() {
|
fn the_controller_may_replay_terminal_rows_and_no_other_subject() {
|
||||||
use swarm_queue_client::agent_term::stream_name;
|
use swarm_wire::agent_term::stream_name;
|
||||||
use swarm_queue_client::{subagent_term, term_subject};
|
use swarm_wire::{subagent_term, term_subject};
|
||||||
|
|
||||||
let p = policy()
|
let p = policy()
|
||||||
.permissions("swarm-controller")
|
.permissions("swarm-controller")
|
||||||
|
|
@ -1983,7 +1983,7 @@ mod tests {
|
||||||
/// the controller's to create, which its own grant covers.
|
/// the controller's to create, which its own grant covers.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_agents_subagent_grant_is_publish_on_its_own_family_only() {
|
fn an_agents_subagent_grant_is_publish_on_its_own_family_only() {
|
||||||
use swarm_queue_client::subagent_term::{stream_name, stream_subjects, subject};
|
use swarm_wire::subagent_term::{stream_name, stream_subjects, subject};
|
||||||
|
|
||||||
let p = policy()
|
let p = policy()
|
||||||
.with_agent_token_subjects(vec!["$SWARM.term.{agent}.sub.>".to_owned()])
|
.with_agent_token_subjects(vec!["$SWARM.term.{agent}.sub.>".to_owned()])
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ operator-profiles = ["async-nats/jetstream"]
|
||||||
# `agent_limits::read`, for hive-c0re, which reads the key of each agent it
|
# `agent_limits::read`, for hive-c0re, which reads the key of each agent it
|
||||||
# runs the same way. Explicit for the same reason as `notices`.
|
# runs the same way. Explicit for the same reason as `notices`.
|
||||||
agent-limits = ["async-nats/jetstream"]
|
agent-limits = ["async-nats/jetstream"]
|
||||||
# `agent_permissions::AgentPermissions` and `read`, for hive-c0re, the agent
|
# `agent_permissions::read` and `HiveCopy`, for hive-c0re, the agent
|
||||||
# harness, the subagent daemon and swarm-controller. Pulls `hive-types` for the
|
# harness, the subagent daemon and swarm-controller. Pulls `hive-types` for the
|
||||||
# `Permission` names, so the crates that only need the bucket name do not link
|
# `Permission` names, so the crates that only need the bucket name do not link
|
||||||
# it.
|
# it.
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,10 @@
|
||||||
//! The per-agent permissions KV bucket: one key per agent, holding the
|
//! Opening and reading the per-agent permissions KV bucket, and the copy
|
||||||
//! [`hive_types::Permission`] set the swarm grants it.
|
//! hive-c0re keeps of a record for the agent's own processes. The names, the
|
||||||
//!
|
//! stored type and the import messages are `swarm_wire::agent_permissions`;
|
||||||
//! **swarm-controller is the only writer**, and creates the bucket when it
|
//! reading needs the `agent-permissions` feature and writing needs `kv`.
|
||||||
//! starts. A missing key or an unset `permissions` field leaves the agent on
|
|
||||||
//! the compiled-in default (`Permission::DEFAULT`); an empty list grants
|
|
||||||
//! nothing.
|
|
||||||
//!
|
|
||||||
//! **Two readers, each enforcing its own part.** The hive running the agent
|
|
||||||
//! applies `manage_root_agent` as bind mounts at container start; its grant
|
|
||||||
//! covers every key of this bucket, read-only, for the reason
|
|
||||||
//! `swarm_wire::agent_limits` gives. The agent itself (its harness and subagent
|
|
||||||
//! daemon) applies the tool permissions; its grant covers only its own key.
|
|
||||||
//! Both read with one `DIRECT.GET` on the agent's key
|
|
||||||
//! ([`crate::agent_permissions::key_subject`]).
|
|
||||||
//!
|
|
||||||
//! Keyed by the agent alone, like `swarm_wire::agent_limits`: an agent can move
|
|
||||||
//! between hives, and its permissions go with it.
|
|
||||||
//!
|
|
||||||
//! Also carries the one-shot import a hive sends of the grants its meta repo
|
|
||||||
//! held ([`crate::agent_permissions::import_subject`]), answered by the
|
|
||||||
//! controller.
|
|
||||||
|
|
||||||
/// The KV bucket permissions are published into, keyed by agent name.
|
|
||||||
pub const BUCKET: &str = "agent-permissions";
|
|
||||||
|
|
||||||
/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`.
|
|
||||||
pub const STREAM: &str = "KV_agent-permissions";
|
|
||||||
|
|
||||||
/// The subject `agent`'s entry is stored under.
|
|
||||||
#[must_use]
|
|
||||||
pub fn key_subject(agent: &str) -> String {
|
|
||||||
format!("$KV.{BUCKET}.{agent}")
|
|
||||||
}
|
|
||||||
|
|
||||||
const IMPORT_SUBJECT_PREFIX: &str = "$SWARM.permissions-import";
|
|
||||||
|
|
||||||
/// The subject `hive` sends its import on. One per hive, so the hive's grant
|
|
||||||
/// names only its own, and the controller takes the hive from the subject
|
|
||||||
/// rather than from the payload.
|
|
||||||
#[must_use]
|
|
||||||
pub fn import_subject(hive: &str) -> String {
|
|
||||||
format!("{IMPORT_SUBJECT_PREFIX}.{hive}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every [`import_subject`] at once, for the controller's subscription.
|
|
||||||
#[must_use]
|
|
||||||
pub fn import_subject_wildcard() -> String {
|
|
||||||
import_subject("*")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The hive an [`import_subject`] names, or `None` for any other subject.
|
|
||||||
#[must_use]
|
|
||||||
pub fn hive_from_import_subject(subject: &str) -> Option<&str> {
|
|
||||||
let hive = subject
|
|
||||||
.strip_prefix(IMPORT_SUBJECT_PREFIX)?
|
|
||||||
.strip_prefix('.')?;
|
|
||||||
(!hive.is_empty() && !hive.contains('.')).then_some(hive)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How long a hive waits for the controller's answer to an import, and the
|
|
||||||
/// queue lets the controller answer.
|
|
||||||
pub const IMPORT_REPLY_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
|
|
||||||
|
|
||||||
/// One agent's permissions: the value under `BUCKET`/`<agent>`. `None` is
|
|
||||||
/// "not set by the swarm", which a hive reads as its default.
|
|
||||||
///
|
|
||||||
/// No `deny_unknown_fields`: a field a newer controller adds must not cost an
|
|
||||||
/// older hive the fields it knows.
|
|
||||||
#[cfg(feature = "agent-permissions")]
|
#[cfg(feature = "agent-permissions")]
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
use swarm_wire::agent_permissions::AgentPermissions;
|
||||||
pub struct AgentPermissions {
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub permissions: Option<Vec<hive_types::Permission>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a hive sends once: the permissions its meta repo held, by agent.
|
|
||||||
#[cfg(feature = "agent-permissions")]
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
||||||
pub struct Import {
|
|
||||||
pub permissions: std::collections::BTreeMap<String, Vec<hive_types::Permission>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The controller's answer to an [`Import`].
|
|
||||||
#[cfg(feature = "agent-permissions")]
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
||||||
#[serde(tag = "state", rename_all = "kebab-case")]
|
|
||||||
pub enum ImportReply {
|
|
||||||
/// Every agent was handled. `written` had no key and got the hive's value;
|
|
||||||
/// `kept` already had a key, which was left alone; `skipped` is not an
|
|
||||||
/// agent the sending hive's wanted state declares.
|
|
||||||
Imported {
|
|
||||||
written: Vec<String>,
|
|
||||||
kept: Vec<String>,
|
|
||||||
skipped: Vec<String>,
|
|
||||||
},
|
|
||||||
/// Nothing is known to be written; the hive sends again later.
|
|
||||||
Failed { error: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the permissions bucket, creating it if nothing has yet.
|
/// Open the permissions bucket, creating it if nothing has yet.
|
||||||
///
|
///
|
||||||
|
|
@ -108,7 +16,7 @@ pub async fn open_or_create(
|
||||||
let js = async_nats::jetstream::new(client.clone());
|
let js = async_nats::jetstream::new(client.clone());
|
||||||
crate::kv::open_or_create(
|
crate::kv::open_or_create(
|
||||||
&js,
|
&js,
|
||||||
BUCKET,
|
swarm_wire::agent_permissions::BUCKET,
|
||||||
"agent-permissions",
|
"agent-permissions",
|
||||||
"Permissions the swarm controller sets for each agent".to_owned(),
|
"Permissions the swarm controller sets for each agent".to_owned(),
|
||||||
)
|
)
|
||||||
|
|
@ -209,8 +117,13 @@ pub async fn read(
|
||||||
use async_nats::jetstream::stream::DirectGetErrorKind;
|
use async_nats::jetstream::stream::DirectGetErrorKind;
|
||||||
|
|
||||||
let js = async_nats::jetstream::new(client.clone());
|
let js = async_nats::jetstream::new(client.clone());
|
||||||
let stream = js.get_stream_no_info(STREAM).await?;
|
let stream = js
|
||||||
let message = match stream.direct_get_last_for_subject(key_subject(agent)).await {
|
.get_stream_no_info(swarm_wire::agent_permissions::STREAM)
|
||||||
|
.await?;
|
||||||
|
let message = match stream
|
||||||
|
.direct_get_last_for_subject(swarm_wire::agent_permissions::key_subject(agent))
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(message) => message,
|
Ok(message) => message,
|
||||||
Err(e) if matches!(e.kind(), DirectGetErrorKind::NotFound) => {
|
Err(e) if matches!(e.kind(), DirectGetErrorKind::NotFound) => {
|
||||||
return Ok(Read::Current(AgentPermissions::default()));
|
return Ok(Read::Current(AgentPermissions::default()));
|
||||||
|
|
@ -245,53 +158,10 @@ fn decode(operation: Option<&str>, payload: &[u8]) -> serde_json::Result<AgentPe
|
||||||
serde_json::from_slice(payload)
|
serde_json::from_slice(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, feature = "agent-permissions"))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// The names the grants in `swarm-nats-auth` are built from.
|
|
||||||
#[test]
|
|
||||||
fn names_match_the_queue_grant() {
|
|
||||||
assert_eq!(STREAM, format!("KV_{BUCKET}"));
|
|
||||||
assert_eq!(key_subject("iris"), "$KV.agent-permissions.iris");
|
|
||||||
assert_eq!(import_subject("pr1ma"), "$SWARM.permissions-import.pr1ma");
|
|
||||||
assert_eq!(import_subject_wildcard(), "$SWARM.permissions-import.*");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_hive_is_read_back_from_its_import_subject_only() {
|
|
||||||
assert_eq!(
|
|
||||||
hive_from_import_subject(&import_subject("pr1ma")),
|
|
||||||
Some("pr1ma")
|
|
||||||
);
|
|
||||||
for other in [
|
|
||||||
"$SWARM.permissions-import",
|
|
||||||
"$SWARM.permissions-import.",
|
|
||||||
"$SWARM.permissions-import.a.b",
|
|
||||||
"$SWARM.permissions-importx.pr1ma",
|
|
||||||
"$SWARM.deploy.pr1ma",
|
|
||||||
] {
|
|
||||||
assert_eq!(hive_from_import_subject(other), None, "{other}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unset is left out on the wire, `{}` is unset, and an empty list stays
|
|
||||||
/// an empty list: "grants nothing" is not "not set".
|
|
||||||
#[cfg(feature = "agent-permissions")]
|
|
||||||
#[test]
|
|
||||||
fn unset_and_empty_are_different_values() {
|
|
||||||
use hive_types::Permission;
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&AgentPermissions::default()).unwrap(),
|
|
||||||
"{}"
|
|
||||||
);
|
|
||||||
let none: AgentPermissions = serde_json::from_str(r#"{"permissions":[]}"#).unwrap();
|
|
||||||
assert_eq!(none.permissions, Some(Vec::new()));
|
|
||||||
let ruth: AgentPermissions =
|
|
||||||
serde_json::from_str(r#"{"permissions":["manage_root_agent"]}"#).unwrap();
|
|
||||||
assert_eq!(ruth.permissions, Some(vec![Permission::ManageRootAgent]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Before a read the stored list stands; after one, a set key wins and an
|
/// Before a read the stored list stands; after one, a set key wins and an
|
||||||
/// unset key is the default unless the import is pending.
|
/// unset key is the default unless the import is pending.
|
||||||
#[cfg(feature = "agent-permissions")]
|
#[cfg(feature = "agent-permissions")]
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,22 @@
|
||||||
//! An agent's own terminal: the stream that keeps its rows, and the consumer
|
//! An agent's own terminal stream: its creation and the consumer a reader
|
||||||
//! a reader replays them through.
|
//! replays it through. The names, subjects, grants and replay start are
|
||||||
|
//! `swarm_wire::agent_term`.
|
||||||
//!
|
//!
|
||||||
//! The agent publishes its classified terminal rows on
|
//! The stream captures exactly one literal subject, which
|
||||||
//! [`crate::term_subject`] with a plain core publish, under its own queue
|
//! `swarm_wire::subagent_term`'s stream never overlaps. The `JetStream` server
|
||||||
//! credential, which may publish there and nothing else of these streams.
|
//! refuses to create a stream whose subjects overlap another's.
|
||||||
//! `swarm-controller` creates one stream per agent with the fixed config
|
|
||||||
//! [`crate::agent_term::open_or_create`] spells, so the queue keeps the rows a
|
|
||||||
//! reader who attaches later replays.
|
|
||||||
//!
|
|
||||||
//! The stream captures exactly one literal subject, `$SWARM.term.<agent>`.
|
|
||||||
//! [`crate::subagent_term`]'s stream captures `$SWARM.term.<agent>.sub.>`,
|
|
||||||
//! which only matches subjects with at least two more tokens, so no subject
|
|
||||||
//! is in both and the two streams never overlap. The `JetStream` server refuses
|
|
||||||
//! to create a stream whose subjects overlap another's.
|
|
||||||
//!
|
|
||||||
//! The names live here because three crates must agree on them: the
|
|
||||||
//! publisher in `hive-agent`, `swarm-controller`, and the reader's consumer
|
|
||||||
//! grant in `swarm-nats-auth`.
|
|
||||||
|
|
||||||
/// The token between the prefix and the agent in [`stream_name`]. It differs
|
use swarm_wire::agent_term::{IDLE_HEARTBEAT, MAX_AGE, stream_name, stream_subject};
|
||||||
/// from `term-sub-` at the same position, so no agent name makes this
|
|
||||||
/// stream's name equal another agent's subagent stream.
|
|
||||||
const STREAM_KIND: &str = "main";
|
|
||||||
|
|
||||||
/// How long the stream keeps a row. The same window as the subagent stream.
|
|
||||||
pub const MAX_AGE: std::time::Duration = std::time::Duration::from_hours(24);
|
|
||||||
|
|
||||||
/// How large one agent's stream may grow before the discard policy reclaims
|
/// How large one agent's stream may grow before the discard policy reclaims
|
||||||
/// space. The same cap as the subagent stream, for the same reason: it
|
/// space. The same cap as the subagent stream, for the same reason: it
|
||||||
/// bounds the disk one agent can claim.
|
/// bounds the disk one agent can claim.
|
||||||
#[cfg(feature = "agent-term")]
|
|
||||||
const MAX_BYTES: i64 = 64 * 1024 * 1024;
|
const MAX_BYTES: i64 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
/// How many of the newest rows a reader that attaches fresh is replayed
|
|
||||||
/// before the live tail. The agent's own web UI loads the same number of
|
|
||||||
/// events as its first history page.
|
|
||||||
pub const REPLAY_ROWS: u64 = 100;
|
|
||||||
|
|
||||||
/// How often the server sends an idle replay consumer a heartbeat. Two missed
|
|
||||||
/// in a row end the reader's row stream, so a consumer the server dropped,
|
|
||||||
/// as on a restart, ends the SSE response and the browser reconnects.
|
|
||||||
pub const IDLE_HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(5);
|
|
||||||
|
|
||||||
/// The stream holding `agent`'s terminal rows: `term-main-<agent>`.
|
|
||||||
#[must_use]
|
|
||||||
pub fn stream_name(agent: &str) -> String {
|
|
||||||
format!("term-{STREAM_KIND}-{agent}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The one subject [`stream_name`]'s stream captures.
|
|
||||||
#[must_use]
|
|
||||||
pub fn stream_subject(agent: &str) -> String {
|
|
||||||
crate::term_subject(agent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The reader's grant to create a replay consumer: on any stream, under any
|
|
||||||
/// consumer name, in the extended create form whose last tokens are the
|
|
||||||
/// consumer's filter.
|
|
||||||
///
|
|
||||||
/// The server refuses a create whose payload filter differs from those
|
|
||||||
/// tokens, so a consumer made under this grant delivers only rows on
|
|
||||||
/// subjects below `$SWARM.term`. The grant's last `*` also matches a literal
|
|
||||||
/// `*` or `>` token, so besides one agent's [`crate::term_subject`] it admits
|
|
||||||
/// `$SWARM.term.*` and `$SWARM.term.>`, which take in every agent's and every
|
|
||||||
/// subagent's terminal. The bare `CONSUMER.CREATE.<stream>` form, whose
|
|
||||||
/// filter rides only in the payload, is not covered.
|
|
||||||
#[must_use]
|
|
||||||
pub fn consumer_create_grant() -> String {
|
|
||||||
format!(
|
|
||||||
"$JS.API.CONSUMER.CREATE.*.*.{}",
|
|
||||||
crate::term_subject_wildcard()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The first stream sequence to replay from.
|
|
||||||
///
|
|
||||||
/// `resume_after` is the last sequence the reader already holds, from a
|
|
||||||
/// reconnecting browser's `Last-Event-ID`; the replay continues right after
|
|
||||||
/// it. A fresh reader gets the newest [`REPLAY_ROWS`] below `last_sequence`,
|
|
||||||
/// the stream's newest row when it was read. A sequence the stream has since
|
|
||||||
/// discarded starts the consumer at the oldest row it still has.
|
|
||||||
///
|
|
||||||
/// A `resume_after` above `last_sequence` names no row this stream ever held,
|
|
||||||
/// as after the stream was created again, and is read as a fresh reader: the
|
|
||||||
/// server accepts a start past the end and delivers nothing until the stream
|
|
||||||
/// reaches it.
|
|
||||||
#[must_use]
|
|
||||||
pub fn replay_start(last_sequence: u64, resume_after: Option<u64>) -> u64 {
|
|
||||||
match resume_after {
|
|
||||||
Some(seen) if seen <= last_sequence => seen + 1,
|
|
||||||
_ => last_sequence.saturating_sub(REPLAY_ROWS - 1).max(1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The config [`open_or_create`] creates `agent`'s stream with.
|
/// The config [`open_or_create`] creates `agent`'s stream with.
|
||||||
///
|
///
|
||||||
/// `max_message_size` tracks the queue's live `max_payload`, as the subagent
|
/// `max_message_size` tracks the queue's live `max_payload`, as the subagent
|
||||||
/// stream's does, so the stream never rejects a row the queue accepted.
|
/// stream's does, so the stream never rejects a row the queue accepted.
|
||||||
#[cfg(feature = "agent-term")]
|
|
||||||
fn stream_config(agent: &str, max_message_size: i32) -> async_nats::jetstream::stream::Config {
|
fn stream_config(agent: &str, max_message_size: i32) -> async_nats::jetstream::stream::Config {
|
||||||
async_nats::jetstream::stream::Config {
|
async_nats::jetstream::stream::Config {
|
||||||
name: stream_name(agent),
|
name: stream_name(agent),
|
||||||
|
|
@ -113,7 +32,6 @@ fn stream_config(agent: &str, max_message_size: i32) -> async_nats::jetstream::s
|
||||||
|
|
||||||
/// Open `agent`'s terminal stream, creating it if it does not exist yet. An
|
/// Open `agent`'s terminal stream, creating it if it does not exist yet. An
|
||||||
/// existing stream is opened as it is.
|
/// existing stream is opened as it is.
|
||||||
#[cfg(feature = "agent-term")]
|
|
||||||
pub async fn open_or_create(
|
pub async fn open_or_create(
|
||||||
client: &async_nats::Client,
|
client: &async_nats::Client,
|
||||||
agent: &str,
|
agent: &str,
|
||||||
|
|
@ -138,15 +56,14 @@ pub async fn open_or_create(
|
||||||
/// The consumer that replays the rows on `filter_subject` from
|
/// The consumer that replays the rows on `filter_subject` from
|
||||||
/// `start_sequence` and then follows new ones, delivered to `deliver_subject`
|
/// `start_sequence` and then follows new ones, delivered to `deliver_subject`
|
||||||
/// in stream order. The filter is an agent's [`stream_subject`] or one
|
/// in stream order. The filter is an agent's [`stream_subject`] or one
|
||||||
/// subagent's [`crate::subagent_term::subject`].
|
/// subagent's `swarm_wire::subagent_term::subject`.
|
||||||
///
|
///
|
||||||
/// Ephemeral and unacknowledged: the server drops it once nothing listens
|
/// Ephemeral and unacknowledged: the server drops it once nothing listens
|
||||||
/// on `deliver_subject`, so a reader that goes away needs no delete grant.
|
/// on `deliver_subject`, so a reader that goes away needs no delete grant.
|
||||||
/// `name` must be set, because the server only takes the filter from the
|
/// `name` must be set, because the server only takes the filter from the
|
||||||
/// subject [`consumer_create_grant`] (or
|
/// subject `swarm_wire::agent_term::consumer_create_grant` (or
|
||||||
/// [`crate::subagent_term::consumer_create_grant`]) covers when the create
|
/// `swarm_wire::subagent_term::consumer_create_grant`) covers when the create
|
||||||
/// names a consumer.
|
/// names a consumer.
|
||||||
#[cfg(feature = "agent-term")]
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn replay_consumer_config(
|
pub fn replay_consumer_config(
|
||||||
filter_subject: String,
|
filter_subject: String,
|
||||||
|
|
@ -171,106 +88,9 @@ pub fn replay_consumer_config(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Token-wise NATS subject overlap: whether some literal subject matches
|
|
||||||
/// both patterns.
|
|
||||||
fn overlaps(a: &str, b: &str) -> bool {
|
|
||||||
let (mut a, mut b) = (a.split('.'), b.split('.'));
|
|
||||||
loop {
|
|
||||||
match (a.next(), b.next()) {
|
|
||||||
(Some(">"), Some(_)) | (Some(_), Some(">")) | (None, None) => return true,
|
|
||||||
(None, _) | (_, None) => return false,
|
|
||||||
(Some(x), Some(y)) if x != "*" && y != "*" && x != y => return false,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn names_match_the_publisher_and_the_grant() {
|
|
||||||
assert_eq!(stream_name("iris"), "term-main-iris");
|
|
||||||
assert_eq!(stream_subject("iris"), "$SWARM.term.iris");
|
|
||||||
assert_eq!(
|
|
||||||
consumer_create_grant(),
|
|
||||||
"$JS.API.CONSUMER.CREATE.*.*.$SWARM.term.*"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The server refuses to create a stream whose subjects overlap
|
|
||||||
/// another's, so this stream must share no subject with any agent's
|
|
||||||
/// subagent stream, its own agent's included.
|
|
||||||
#[test]
|
|
||||||
fn the_subject_is_disjoint_from_every_subagent_stream() {
|
|
||||||
for other in ["iris", "argus", "sub", "iris-2"] {
|
|
||||||
assert!(
|
|
||||||
!overlaps(
|
|
||||||
&stream_subject("iris"),
|
|
||||||
&crate::subagent_term::stream_subjects(other)
|
|
||||||
),
|
|
||||||
"term-main-iris overlaps term-sub-{other}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Control: the checker does report a real overlap.
|
|
||||||
assert!(overlaps(
|
|
||||||
"$SWARM.term.iris.>",
|
|
||||||
&crate::subagent_term::stream_subjects("iris")
|
|
||||||
));
|
|
||||||
assert!(overlaps(&stream_subject("iris"), "$SWARM.term.*"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No agent name makes this stream's name another agent's subagent
|
|
||||||
/// stream: an agent may be called `sub-x`.
|
|
||||||
#[test]
|
|
||||||
fn the_name_never_collides_with_a_subagent_stream() {
|
|
||||||
for (agent, other) in [("sub-x", "x"), ("x", "sub-x"), ("main-x", "x")] {
|
|
||||||
assert_ne!(stream_name(agent), crate::subagent_term::stream_name(other));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The create the controller sends is one the grant covers, and every
|
|
||||||
/// filter the grant admits stays below `$SWARM.term`.
|
|
||||||
#[test]
|
|
||||||
fn the_grant_admits_only_filters_below_the_terminal_prefix() {
|
|
||||||
let grant = consumer_create_grant();
|
|
||||||
let create = |filter: &str| format!("$JS.API.CONSUMER.CREATE.term-main-iris.r1.{filter}");
|
|
||||||
assert!(overlaps(&grant, &create(&stream_subject("iris"))));
|
|
||||||
// The width the doc states: a literal wildcard as the last token.
|
|
||||||
assert!(overlaps(&grant, &create("$SWARM.term.>")));
|
|
||||||
for other in [
|
|
||||||
crate::subagent_term::subject("iris", "scout"),
|
|
||||||
crate::subagent_term::stream_subjects("iris"),
|
|
||||||
crate::agent_state_subject("iris"),
|
|
||||||
] {
|
|
||||||
assert!(!overlaps(&grant, &create(&other)), "{other}");
|
|
||||||
}
|
|
||||||
assert!(!overlaps(&grant, "$JS.API.CONSUMER.CREATE.term-main-iris"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_fresh_reader_replays_the_newest_rows() {
|
|
||||||
assert_eq!(replay_start(1000, None), 1000 - REPLAY_ROWS + 1);
|
|
||||||
assert_eq!(replay_start(5, None), 1);
|
|
||||||
assert_eq!(replay_start(0, None), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_reconnecting_reader_resumes_after_its_last_row() {
|
|
||||||
assert_eq!(replay_start(1000, Some(990)), 991);
|
|
||||||
assert_eq!(replay_start(1000, Some(1000)), 1001);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An id past the stream's newest row, as after the stream was created
|
|
||||||
/// again, replays like a fresh reader instead of waiting for that number.
|
|
||||||
#[test]
|
|
||||||
fn an_id_past_the_newest_row_replays_as_a_fresh_reader() {
|
|
||||||
assert_eq!(replay_start(5, Some(99)), replay_start(5, None));
|
|
||||||
assert_eq!(replay_start(1000, Some(1001)), replay_start(1000, None));
|
|
||||||
assert_eq!(replay_start(0, Some(7)), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The consumer starts at a stored sequence, not at the next new row, so
|
/// The consumer starts at a stored sequence, not at the next new row, so
|
||||||
/// the replay reaches the reader before the live tail, in stream order,
|
/// the replay reaches the reader before the live tail, in stream order,
|
||||||
/// through the one subscription.
|
/// through the one subscription.
|
||||||
#[cfg(feature = "agent-term")]
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_consumer_replays_before_it_follows() {
|
fn the_consumer_replays_before_it_follows() {
|
||||||
let cfg = replay_consumer_config(
|
let cfg = replay_consumer_config(
|
||||||
|
|
@ -299,7 +119,7 @@ mod tests {
|
||||||
|
|
||||||
/// Every limit is read from the subagent stream's own config, so the two
|
/// Every limit is read from the subagent stream's own config, so the two
|
||||||
/// fail this test the moment they drift apart.
|
/// fail this test the moment they drift apart.
|
||||||
#[cfg(all(feature = "agent-term", feature = "subagent-term"))]
|
#[cfg(feature = "subagent-term")]
|
||||||
#[test]
|
#[test]
|
||||||
fn stream_config_matches_the_subagent_streams_limits() {
|
fn stream_config_matches_the_subagent_streams_limits() {
|
||||||
let main = stream_config("iris", 8_388_608);
|
let main = stream_config("iris", 8_388_608);
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue