archetypes: build an archetype agent from its archetype's module on the hive
The hive reads the agent-archetype key when it provisions and rebuilds an agent and keeps it in swarm-agent-archetypes.json. For an archetype agent the meta flake has no agent-<name> input and mkAgent imports hyperhive.hyperhiveArchetypes.<name>.module; provisioning, rebuilds, the meta-input cascade, a deploy's rev and the startup migration skip the config repos. The harness gets HIVE_ARCHETYPE and the system prompt says the agent has no config repo. The create form sends the picked archetype. Adds the android-dev built-in. Refs #5386
This commit is contained in:
parent
b199c83899
commit
553ed8376c
24 changed files with 557 additions and 92 deletions
143
hive-c0re/src/agent_config/archetypes.rs
Normal file
143
hive-c0re/src/agent_config/archetypes.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
//! The archetype each of this hive's archetype agents is built from, as last
|
||||
//! read from the swarm's `agent-archetype` bucket
|
||||
//! ([`swarm_queue_client::agent_archetype`]). Stored at
|
||||
//! `/var/lib/hyperhive/swarm-agent-archetypes.json`, outside the meta repo:
|
||||
//! the meta render runs at boot, possibly before the queue answers, so it
|
||||
//! reads this file rather than the bucket.
|
||||
//!
|
||||
//! Format: agent name → [`ArchetypeRef`]. An agent with an entry has no config
|
||||
//! repo: the meta flake builds it from the archetype's module, and the paths
|
||||
//! that seed, relock or advance `applied/<name>` skip it. An agent with no
|
||||
//! entry is a custom agent.
|
||||
//!
|
||||
//! [`refresh`] reads one agent's key; provisioning and every rebuild's meta
|
||||
//! sync call it before they render the meta flake.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use swarm_queue_client::agent_archetype::{self, ArchetypeRef, Read};
|
||||
|
||||
const FILE: &str = "swarm-agent-archetypes.json";
|
||||
|
||||
pub type Stored = BTreeMap<String, ArchetypeRef>;
|
||||
|
||||
#[must_use]
|
||||
pub fn path() -> PathBuf {
|
||||
crate::paths::state_root().join(FILE)
|
||||
}
|
||||
|
||||
/// Read the stored archetypes. An absent file is the empty map: every agent
|
||||
/// custom. A file that exists but can't be read or parsed is an error.
|
||||
pub fn read_at(path: &Path) -> std::io::Result<Stored> {
|
||||
super::read_map(path)
|
||||
}
|
||||
|
||||
/// `agent`'s archetype per the file at [`path`], `None` for a custom agent.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// As [`read_at`].
|
||||
pub fn of(agent: &str) -> std::io::Result<Option<ArchetypeRef>> {
|
||||
Ok(read_at(&path())?.remove(agent))
|
||||
}
|
||||
|
||||
/// Read `agent`'s key and store what it holds. No queue, or a bucket that
|
||||
/// does not answer, leaves the stored entry as it is.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The queue is configured but not connected, the read failed, the value
|
||||
/// names a source or archetype that is not an identifier, or the file could
|
||||
/// not be read or written.
|
||||
pub async fn refresh(agent: &str) -> Result<()> {
|
||||
let Some(client) = crate::swarm_queue::client().await else {
|
||||
return Ok(());
|
||||
};
|
||||
// An unconnected client does not fail a JetStream request, it hangs on it.
|
||||
swarm_queue_client::ensure_connected(&client)?;
|
||||
let read = agent_archetype::read(&client, agent)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))
|
||||
.with_context(|| format!("reading the swarm's archetype for {agent}"))?;
|
||||
store_read(&path(), agent, read)
|
||||
}
|
||||
|
||||
/// Store `read`, the value of `agent`'s key, in the file at `path`. Written
|
||||
/// only when it changes the entry.
|
||||
fn store_read(path: &Path, agent: &str, read: Read) -> Result<()> {
|
||||
let value = match read {
|
||||
Read::NoBucket => {
|
||||
tracing::debug!(%agent, "archetype: the agent-archetype bucket does not answer");
|
||||
return Ok(());
|
||||
}
|
||||
Read::Current(value) => value,
|
||||
};
|
||||
// Both are rendered into the meta flake as nix strings.
|
||||
if let Some(ArchetypeRef { source, name }) = &value {
|
||||
for part in [source, name] {
|
||||
hive_types::Ident::parse(part)
|
||||
.map_err(|why| anyhow::anyhow!("archetype {part:?} of {agent}: {why}"))?;
|
||||
}
|
||||
}
|
||||
let mut stored = read_at(path).context("reading the stored archetypes")?;
|
||||
let changed = match value {
|
||||
Some(value) => stored.insert(agent.to_owned(), value.clone()) != Some(value),
|
||||
None => stored.remove(agent).is_some(),
|
||||
};
|
||||
if changed {
|
||||
super::write_map(path, &stored).context("storing the archetypes")?;
|
||||
tracing::info!(%agent, archetype = ?stored.get(agent), "archetype: stored the swarm's archetype");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn reviewer() -> ArchetypeRef {
|
||||
ArchetypeRef {
|
||||
source: "hyperhive".to_owned(),
|
||||
name: "reviewer".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_sets_or_clears_the_entry_and_no_bucket_keeps_it() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join(FILE);
|
||||
assert!(read_at(&path).expect("missing is empty").is_empty());
|
||||
|
||||
store_read(&path, "atlas", Read::Current(Some(reviewer()))).expect("store");
|
||||
store_read(&path, "iris", Read::Current(None)).expect("store");
|
||||
assert_eq!(
|
||||
read_at(&path).expect("read"),
|
||||
Stored::from([("atlas".to_owned(), reviewer())])
|
||||
);
|
||||
|
||||
store_read(&path, "atlas", Read::NoBucket).expect("store");
|
||||
assert_eq!(
|
||||
read_at(&path).expect("read").get("atlas"),
|
||||
Some(&reviewer())
|
||||
);
|
||||
|
||||
store_read(&path, "atlas", Read::Current(None)).expect("store");
|
||||
assert!(read_at(&path).expect("read").is_empty());
|
||||
}
|
||||
|
||||
/// The value is rendered into nix, so a name that is not an identifier is
|
||||
/// refused and nothing is stored.
|
||||
#[test]
|
||||
fn a_name_that_is_not_an_identifier_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join(FILE);
|
||||
let bad = ArchetypeRef {
|
||||
source: "hyperhive".to_owned(),
|
||||
name: "x\"; y".to_owned(),
|
||||
};
|
||||
store_read(&path, "atlas", Read::Current(Some(bad))).expect_err("refused");
|
||||
assert!(read_at(&path).expect("read").is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
//! Per-agent configuration registries: resource limits, topology (JSON files
|
||||
//! under `/var/lib/hyperhive/meta/`), the permissions the swarm grants
|
||||
//! (outside the meta repo) and the shared wire-protocol size limits. Each
|
||||
//! submodule is re-exported at the crate root, so `crate::topology::…` etc.
|
||||
//! keep working unchanged.
|
||||
//! under `/var/lib/hyperhive/meta/`), the permissions and archetypes the
|
||||
//! swarm sets (outside the meta repo) and the shared wire-protocol size
|
||||
//! limits. Each submodule is re-exported at the crate root, so
|
||||
//! `crate::topology::…` etc. keep working unchanged.
|
||||
//!
|
||||
//! Note the two similarly-named modules: [`limits`] caps inline
|
||||
//! *message body* sizes on the sockets, while [`resource_limits`] holds
|
||||
//! per-agent CPU/memory caps for the container drop-in.
|
||||
|
||||
pub mod archetypes;
|
||||
pub mod limits;
|
||||
pub mod permissions;
|
||||
pub mod resource_limits;
|
||||
|
|
|
|||
Loading…
Reference in a new issue