Watch
0
0
Fork
You've already forked hyperhive
0

archetypes: create an agent from an archetype with no config repo

POST /api/agents takes an archetype the stored list holds, records it in
the new agent-archetype bucket, and queues no CreateRepo, AddRepoMember or
InitAgentConfigRepo for it. A destroy clears the key. Hives may read the
bucket.

Refs #5386
This commit is contained in:
atlas 2026-10-17 08:32:48 +02:00 • committed by mara
commit b199c83899
7 changed files with 645 additions and 37 deletions

View file

@ -0,0 +1,159 @@
//! The per-agent archetype KV bucket: one key per agent created from an
//! archetype, holding which archetype that is.
//!
//! **swarm-controller is the only writer**: `POST /api/agents` stores the key
//! before it queues the agent's creation, and a destroy deletes it. An agent
//! with no key is a custom agent, built from its own `agent-configs/<name>`
//! repo.
//!
//! **The hive running the agent reads the key** when it provisions or
//! rebuilds the agent, and renders the archetype's module into its meta flake
//! in place of a config repo. Its grant covers every key of this bucket,
//! read-only, for the reason [`crate::agent_limits`] gives.
//!
//! Keyed by the agent alone, like [`crate::agent_limits`]: an agent can move
//! between hives, and its archetype goes with it.
/// The KV bucket archetype choices are published into, keyed by agent name.
pub const BUCKET: &str = "agent-archetype";
/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`.
pub const STREAM: &str = "KV_agent-archetype";
/// The subject `agent`'s entry is stored under.
#[must_use]
pub fn key_subject(agent: &str) -> String {
format!("$KV.{BUCKET}.{agent}")
}
/// The request subject that reads `agent`'s current entry.
#[must_use]
pub fn direct_get_subject(agent: &str) -> String {
format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject(agent))
}
/// Which archetype an agent was created from: the value under
/// `BUCKET`/`<agent>`. `source` is a key of [`crate::archetypes::BUCKET`]
/// and `name` an archetype in that source's record.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ArchetypeRef {
pub source: String,
pub name: String,
}
/// Open the archetype-choice bucket for writing, creating it if nothing has
/// yet.
///
/// **Controller-side only.** `history: 1`: a hive 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,
"agent-archetype",
"The archetype each agent was created from, set by the swarm controller".to_owned(),
)
.await
}
/// What reading one agent's key found.
#[cfg(feature = "agent-archetype")]
#[derive(Debug)]
pub enum Read {
/// The bucket answered: the agent's archetype, or `None` for an absent or
/// deleted key.
Current(Option<ArchetypeRef>),
/// Nothing answers for the bucket's stream: no archetype agent has been
/// created yet, or `JetStream` is not serving it right now.
NoBucket,
}
/// Read `agent`'s current archetype with one direct get.
///
/// # 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 = "agent-archetype")]
pub async fn read(
client: &async_nats::Client,
agent: &str,
) -> 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(agent)).await {
Ok(message) => message,
Err(e) if matches!(e.kind(), DirectGetErrorKind::NotFound) => {
return Ok(Read::Current(None));
}
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()),
};
Ok(Read::Current(decode(
message
.headers
.get("KV-Operation")
.map(async_nats::HeaderValue::as_str),
&message.payload,
)?))
}
/// One stored message as a value: a KV delete or purge is stored as an empty
/// marker carrying the `KV-Operation` header, and reads as no archetype.
#[cfg(feature = "agent-archetype")]
fn decode(operation: Option<&str>, payload: &[u8]) -> serde_json::Result<Option<ArchetypeRef>> {
if matches!(operation, Some("DEL" | "PURGE")) {
return Ok(None);
}
serde_json::from_slice(payload).map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
/// The names the grant in `swarm-nats-auth` is built from.
#[test]
fn names_match_the_queue_grant() {
assert_eq!(STREAM, format!("KV_{BUCKET}"));
assert_eq!(key_subject("iris"), "$KV.agent-archetype.iris");
assert_eq!(
direct_get_subject("iris"),
"$JS.API.DIRECT.GET.KV_agent-archetype.$KV.agent-archetype.iris"
);
}
/// A stored choice reads back, a field a newer controller adds is
/// ignored, and a deleted key is no archetype.
#[cfg(feature = "agent-archetype")]
#[test]
fn decoding_reads_the_choice_and_a_delete_as_none() {
let android = ArchetypeRef {
source: "hyperhive".to_owned(),
name: "android-dev".to_owned(),
};
assert_eq!(
decode(
None,
br#"{"source":"hyperhive","name":"android-dev","later":1}"#
)
.unwrap(),
Some(android)
);
assert_eq!(decode(Some("DEL"), b"").unwrap(), None);
assert_eq!(decode(Some("PURGE"), b"").unwrap(), None);
}
}

View file

@ -232,6 +232,12 @@ pub mod agent_permissions;
/// unconditional; writing needs `kv` and reading needs `operator-profiles`.
pub mod operator_profiles;
/// The bucket swarm-controller records each archetype agent's archetype in
/// — one key per agent, read by the hive that runs it. The names and the
/// stored type are unconditional; writing needs `kv` and reading needs
/// `agent-archetype`.
pub mod agent_archetype;
/// The login flag in an agent's turn-state header. No bucket: it travels
/// on [`agent_state_subject`] only.
pub mod agent_login;