archetypes: prefill the create form from flake-declared agent archetypes
A flake declares agent archetypes in its `hyperhiveArchetypes` output,
`<name> = { module; meta; }`. The hyperhive flake ships `reviewer`
(default permissions without `execution`).
swarm-builder answers `$SWARM.builder.archetypes` with the `meta` of
every archetype its own hyperhive source declares. swarm-controller asks
once the queue connects and every 10 minutes, checks each archetype as
`POST /api/agents` checks its settings, and stores the result in the
`agent-archetypes` KV bucket when it changed. `GET /api/archetypes`
serves it, and the create form's archetype select prefills permissions,
resource caps and inference from it.
The agent created is an ordinary agent with its own config repo; the
archetype's module is not applied yet.
Refs #5386
This commit is contained in:
parent
eb92e83c95
commit
51035eac1d
22 changed files with 1244 additions and 30 deletions
117
swarm-queue-client/src/archetypes.rs
Normal file
117
swarm-queue-client/src/archetypes.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
//! The agent archetype KV bucket: one key per archetype source flake, holding
|
||||
//! the `meta` of every archetype that flake's `hyperhiveArchetypes` output
|
||||
//! declares, as swarm-controller last checked it.
|
||||
//!
|
||||
//! **swarm-controller is the only writer and the only reader.** The swarm
|
||||
//! builder evaluates the source and answers on
|
||||
//! [`crate::builder::ARCHETYPES_SUBJECT`]; the controller checks each
|
||||
//! archetype's settings the way `POST /api/agents` checks them and writes the
|
||||
//! result here, so the create screen lists archetypes from a value that a
|
||||
//! create would accept.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::agent_inference::AgentInference;
|
||||
use crate::agent_limits::AgentCaps;
|
||||
|
||||
/// The KV bucket archetype sources are published into, keyed by source name.
|
||||
pub const BUCKET: &str = "agent-archetypes";
|
||||
|
||||
/// The key of the archetypes the hyperhive flake itself ships, the one source
|
||||
/// there is so far.
|
||||
pub const BUILTIN_SOURCE: &str = "hyperhive";
|
||||
|
||||
/// One source's archetypes: the value under `BUCKET`/`<source>`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SourceRecord {
|
||||
/// The flake reference the builder evaluated.
|
||||
pub source: String,
|
||||
/// Every archetype whose `meta` passed the controller's checks, by name.
|
||||
#[serde(default)]
|
||||
pub archetypes: BTreeMap<String, ArchetypeMeta>,
|
||||
/// Every archetype whose `meta` did not, by name, with the reason.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub refused: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// One archetype's `meta`, as checked: the settings a create takes, in the
|
||||
/// shapes the per-agent buckets store them in.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ArchetypeMeta {
|
||||
pub description: String,
|
||||
/// Permission names, `snake_case`. Absent leaves the default set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permissions: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub limits: Option<AgentCaps>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub inference: Option<AgentInference>,
|
||||
}
|
||||
|
||||
/// Open the archetype bucket for writing, creating it if nothing has yet.
|
||||
///
|
||||
/// **Controller-side only.** `history: 1`: a reader 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-archetypes",
|
||||
"Agent archetypes the swarm builder evaluated, one key per source flake".to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Open the archetype bucket for reading, or `None` when nothing has created
|
||||
/// it yet, which reads the same as no archetypes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`crate::Error::OpenBucket`] for every failure other than the bucket not
|
||||
/// existing.
|
||||
#[cfg(feature = "kv")]
|
||||
pub async fn open(
|
||||
client: &async_nats::Client,
|
||||
) -> Result<Option<async_nats::jetstream::kv::Store>, crate::Error> {
|
||||
let js = async_nats::jetstream::new(client.clone());
|
||||
match js.get_key_value(BUCKET).await {
|
||||
Ok(store) => Ok(Some(store)),
|
||||
Err(e) if crate::wanted::is_bucket_not_found(&e) => Ok(None),
|
||||
Err(source) => Err(crate::Error::OpenBucket {
|
||||
bucket: BUCKET.to_owned(),
|
||||
source,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An unset setting is left out on the wire, and so is an empty refusal
|
||||
/// list.
|
||||
#[test]
|
||||
fn unset_fields_are_absent() {
|
||||
let mut archetypes = BTreeMap::new();
|
||||
archetypes.insert(
|
||||
"reviewer".to_owned(),
|
||||
ArchetypeMeta {
|
||||
description: "reads".to_owned(),
|
||||
permissions: Some(vec!["messaging".to_owned()]),
|
||||
..ArchetypeMeta::default()
|
||||
},
|
||||
);
|
||||
let record = SourceRecord {
|
||||
source: "/nix/store/h".to_owned(),
|
||||
archetypes,
|
||||
refused: BTreeMap::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&record).unwrap(),
|
||||
r#"{"source":"/nix/store/h","archetypes":{"reviewer":{"description":"reads","permissions":["messaging"]}}}"#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
//! The swarm builder's builds: the subject swarm-controller sends a build on,
|
||||
//! and what travels each way.
|
||||
//! The swarm builder's requests: the subjects swarm-controller sends a build
|
||||
//! and an archetype evaluation on, and what travels each way.
|
||||
//!
|
||||
//! Three crates name these: the controller sends the request, the builder
|
||||
//! answers it, and the auth-callout responder grants the one subject to each.
|
||||
//! Three crates name these: the controller sends the requests, the builder
|
||||
//! answers them, and the auth-callout responder grants the subjects to each.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
@ -49,6 +49,35 @@ pub enum Reply {
|
|||
Failed { error_tail: String },
|
||||
}
|
||||
|
||||
/// The subject an archetype evaluation goes to: the controller asks, the
|
||||
/// builder evaluates the `meta` of every `hyperhiveArchetypes` entry of its own
|
||||
/// hyperhive source and answers on the request's reply subject.
|
||||
pub const ARCHETYPES_SUBJECT: &str = "$SWARM.builder.archetypes";
|
||||
|
||||
/// The longest one archetype evaluation may take, and how long the controller
|
||||
/// waits for its reply.
|
||||
pub const ARCHETYPES_DEADLINE: Duration = Duration::from_mins(2);
|
||||
|
||||
/// One archetype evaluation. No field yet: the builder evaluates the hyperhive
|
||||
/// source it was deployed with.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EvalArchetypes {}
|
||||
|
||||
/// The builder's answer to one [`EvalArchetypes`].
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "state", rename_all = "kebab-case")]
|
||||
pub enum ArchetypesReply {
|
||||
/// `source` is the flake evaluated; `archetypes` maps each archetype's
|
||||
/// name to its `meta`, as `nix eval --json` printed it and unchecked.
|
||||
Evaluated {
|
||||
source: String,
|
||||
archetypes: serde_json::Map<String, serde_json::Value>,
|
||||
},
|
||||
/// The evaluation did not finish: the last lines `nix` printed, or why the
|
||||
/// builder could not start it.
|
||||
Failed { error_tail: String },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -72,4 +101,19 @@ mod tests {
|
|||
fn the_reply_deadline_outlasts_the_build() {
|
||||
assert!(REPLY_DEADLINE > BUILD_DEADLINE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_archetypes_reply_is_tagged_by_state() {
|
||||
let json = serde_json::to_value(ArchetypesReply::Evaluated {
|
||||
source: "/nix/store/h".to_owned(),
|
||||
archetypes: serde_json::Map::new(),
|
||||
})
|
||||
.expect("encodes");
|
||||
assert_eq!(json["state"], "evaluated");
|
||||
let json = serde_json::to_value(ArchetypesReply::Failed {
|
||||
error_tail: "e".to_owned(),
|
||||
})
|
||||
.expect("encodes");
|
||||
assert_eq!(json["state"], "failed");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,6 +231,11 @@ pub mod agent_permissions;
|
|||
/// on [`agent_state_subject`] only.
|
||||
pub mod agent_login;
|
||||
|
||||
/// The bucket swarm-controller keeps the evaluated agent archetypes in — one
|
||||
/// key per source flake. The names and the stored type are unconditional;
|
||||
/// opening needs `kv`.
|
||||
pub mod archetypes;
|
||||
|
||||
#[cfg(feature = "kv")]
|
||||
mod kv;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue