From 0737798d78003551c93662f9d908543cf02efc53 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 1 Nov 2026 12:02:38 +0100 Subject: [PATCH] controller: turn an archetype agent into a custom one --- hive-c0re/src/swarm_status.rs | 11 +- hive-meta-flake/src/lib.rs | 104 +++- swarm-controller/src/agent_archetype.rs | 16 + swarm-controller/src/agent_custom.rs | 611 ++++++++++++++++++++++++ swarm-controller/src/agent_status.rs | 10 + swarm-controller/src/forge.rs | 47 +- swarm-controller/src/jobq.rs | 196 ++++++++ swarm-controller/src/main.rs | 1 + swarm-controller/src/meta_flake.rs | 21 +- swarm-controller/src/routes/agents.rs | 16 + swarm-controller/src/startup.rs | 13 +- swarm-wire/src/hive_status.rs | 6 + 12 files changed, 1008 insertions(+), 44 deletions(-) create mode 100644 swarm-controller/src/agent_custom.rs diff --git a/hive-c0re/src/swarm_status.rs b/hive-c0re/src/swarm_status.rs index 48fb9aa0..49053fe8 100644 --- a/hive-c0re/src/swarm_status.rs +++ b/hive-c0re/src/swarm_status.rs @@ -407,7 +407,10 @@ struct Snapshot { fn snapshot() -> Snapshot { Snapshot { readiness: crate::warnings::readiness(), - capabilities: &[swarm_wire::hive_status::TOLERANT_PERMISSIONS], + capabilities: &[ + swarm_wire::hive_status::TOLERANT_PERMISSIONS, + swarm_wire::hive_status::NO_CONFIG_CHECKOUT, + ], } } @@ -449,12 +452,16 @@ mod snapshot_tests { /// The controller reads the capability out of the published bytes with /// the wire type, and the readiness keeps its own top-level fields. #[test] - fn the_snapshot_advertises_tolerant_permissions_beside_the_readiness() { + fn the_snapshot_advertises_its_capabilities_beside_the_readiness() { let value = serde_json::to_value(snapshot()).unwrap(); assert!( swarm_wire::hive_status::Capabilities::of_snapshot(&value) .has(swarm_wire::hive_status::TOLERANT_PERMISSIONS) ); + assert!( + swarm_wire::hive_status::Capabilities::of_snapshot(&value) + .has(swarm_wire::hive_status::NO_CONFIG_CHECKOUT) + ); assert!(value.get("status").is_some() && value.get("warnings").is_some()); } } diff --git a/hive-meta-flake/src/lib.rs b/hive-meta-flake/src/lib.rs index 8b35b185..ed40b9dd 100644 --- a/hive-meta-flake/src/lib.rs +++ b/hive-meta-flake/src/lib.rs @@ -43,12 +43,12 @@ pub fn pinned_git_url(forge_git_url: &str, rev: Option<&str>) -> Result Ok(format!("git+{forge_git_url}?rev={rev}")) } -/// Canonical inputs meta knows how to dedup. An agent that declares one -/// of these as a top-level input in its own `flake.nix` will get a +/// Canonical inputs meta knows how to dedup. An agent whose `flake.lock` or +/// `flake.nix` declares one of these as a top-level input gets a /// `follows = ""` line emitted in meta — collapsing the -/// otherwise-separate-but-identical `nixpkgs_N` nodes into a single -/// meta-level reference. -pub const CANONICAL_INPUTS: &[&str] = &["nixpkgs"]; +/// otherwise-separate-but-identical `nixpkgs_N` and `hyperhive_N` nodes into +/// a single meta-level reference. +pub const CANONICAL_INPUTS: &[&str] = &["nixpkgs", "hyperhive"]; /// Env vars the meta flake sets in every agent container, from the /// `(name, value)` pairs [`render_flake`] is given. A caller passes only the @@ -143,6 +143,32 @@ pub fn push_forwarded_var_options(out: &mut String, vars: &[(&'static str, Strin } } +/// The subset of `CANONICAL_INPUTS` that an agent config declares, in its +/// `flake.lock` text `lock` or its `flake.nix` text `flake_nix`. A repo that +/// was only just seeded has no lock yet, so its `flake.nix` counts too: a line +/// starting `.url` or `inputs..url`. +/// +/// A `hyperhive` node in a lock does not count: it can be a leftover from an +/// input the config dropped from its `flake.nix`, and a `follows` for an input +/// the flake does not declare makes nix warn. +#[must_use] +pub fn canonical_inputs_declared(lock: Option<&str>, flake_nix: Option<&str>) -> Vec<&'static str> { + let locked = lock.map(canonical_inputs_in_lock).unwrap_or_default(); + CANONICAL_INPUTS + .iter() + .copied() + .filter(|canon| { + (*canon != "hyperhive" && locked.contains(canon)) + || flake_nix.is_some_and(|text| { + text.lines().map(str::trim_start).any(|l| { + l.starts_with(&format!("{canon}.url")) + || l.starts_with(&format!("inputs.{canon}.url")) + }) + }) + }) + .collect() +} + /// The subset of `CANONICAL_INPUTS` that the `flake.lock` text `raw` declares /// as direct (root-level) inputs. Empty when the lock is unparsable — safe /// degradation, the worst case is no dedup for that agent. @@ -460,17 +486,26 @@ pub fn is_rev(rev: &str) -> bool { rev.len() == 40 && rev.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) } +/// The URL of `input` in a flake [`render_flake`] rendered: the text of its +/// `.url = "";` line. `None` when the flake has no such input. +#[must_use] +pub fn input_url<'a>(flake_nix: &'a str, input: &str) -> Option<&'a str> { + let prefix = format!(" {input}.url = \""); + Some( + flake_nix + .lines() + .find_map(|l| l.strip_prefix(prefix.as_str()))? + .split_once('"')? + .0, + ) +} + /// The commit `input` is pinned to in a flake [`render_flake`] rendered: /// the `` of its `.url = "git+?rev=";` line. `None` when /// the flake has no such input, or its rev is not a commit id. #[must_use] pub fn pinned_rev<'a>(flake_nix: &'a str, input: &str) -> Option<&'a str> { - let prefix = format!(" {input}.url = \""); - let url = flake_nix - .lines() - .find_map(|l| l.strip_prefix(prefix.as_str()))? - .split_once('"')? - .0; + let url = input_url(flake_nix, input)?; let (_, rev) = url.split_once("?rev=")?; let rev = rev.split('&').next()?; is_rev(rev).then_some(rev) @@ -695,4 +730,51 @@ mod tests { assert!(!builds_agent_at(&flake, "gus", None, Some(ARGUS))); assert!(!builds_agent_at(&flake, "v", Some(&reviewer()), None)); } + /// An agent that declares `hyperhive` follows meta's own pin, so the + /// lock holds one hyperhive; one that declares nothing gets no line (the + /// control that the line is the declared input's, not every agent's). + #[test] + fn a_declared_hyperhive_input_follows_meta_s_own() { + let url = |repo: &str, rev: &str| { + pinned_git_url(&format!("https://f.t/{repo}.git"), Some(rev)).unwrap() + }; + let agents = |canonical_inputs: Vec<&'static str>| { + render_flake( + &url("hyperhive/hyperhive", HYPERHIVE), + &[spec( + "argus", + None, + Some(AgentPin { + url: url("agent-configs/argus", ARGUS), + canonical_inputs, + }), + )], + &[], + false, + ) + .unwrap() + }; + let line = "agent-argus.inputs.hyperhive.follows = \"hyperhive\";"; + assert!(agents(vec!["hyperhive"]).contains(line)); + assert!(!agents(vec![]).contains(line)); + } + + /// A seeded repo has only a `flake.nix`; its input counts, from either + /// spelling, and a lock-declared input still does. + #[test] + fn canonical_inputs_are_read_from_the_flake_text_and_the_lock() { + let flake = "{\n inputs = {\n hyperhive.url = \"git+x\";\n };\n}\n"; + assert_eq!(canonical_inputs_declared(None, Some(flake)), ["hyperhive"]); + let dotted = "{\n inputs.nixpkgs.url = \"x\";\n}\n"; + assert_eq!(canonical_inputs_declared(None, Some(dotted)), ["nixpkgs"]); + let lock = r#"{"root":"root","nodes":{"root":{"inputs":{"nixpkgs":"n"}}}}"#; + assert_eq!(canonical_inputs_declared(Some(lock), None), ["nixpkgs"]); + assert!(canonical_inputs_declared(None, Some("{ inputs = { }; }")).is_empty()); + // A leftover `hyperhive` node in a lock declares nothing. + let stale = r#"{"root":"root","nodes":{"root":{"inputs":{"hyperhive":"hyperhive"}}}}"#; + assert!(canonical_inputs_declared(Some(stale), Some("{ inputs = { }; }")).is_empty()); + // Another archetype source is not canonical: meta does not follow it. + let other = "{\n inputs = {\n extra.url = \"git+x\";\n };\n}\n"; + assert!(canonical_inputs_declared(None, Some(other)).is_empty()); + } } diff --git a/swarm-controller/src/agent_archetype.rs b/swarm-controller/src/agent_archetype.rs index d793a150..d355634b 100644 --- a/swarm-controller/src/agent_archetype.rs +++ b/swarm-controller/src/agent_archetype.rs @@ -114,6 +114,22 @@ pub(crate) async fn restore(client: &async_nats::Client, agent: &str, replaced: } } +/// The archetype of each agent in `rows` that has one. +pub(crate) async fn of_agents( + client: &async_nats::Client, + rows: &[crate::agent_status::AgentStatusRow], +) -> anyhow::Result> { + let store = agent_archetype::open_or_create(client).await?; + let mut found = std::collections::HashMap::new(); + for row in rows { + let (archetype, _, _) = read_or_default::>(&store, &row.name).await?; + if let Some(archetype) = archetype { + found.insert(row.name.clone(), archetype); + } + } + Ok(found) +} + /// Delete a destroyed agent's key, so the next agent of that name is not /// built from this one's archetype. A failure is logged and does not stop the /// destroy, as in `agent_permissions::clear_on_destroy`. diff --git a/swarm-controller/src/agent_custom.rs b/swarm-controller/src/agent_custom.rs new file mode 100644 index 00000000..1b9a9cdf --- /dev/null +++ b/swarm-controller/src/agent_custom.rs @@ -0,0 +1,611 @@ +//! `POST /api/agents/{name}/custom`: turn an archetype agent into a custom +//! one, with its own `agent-configs/` repo. +//! +//! The repo's `flake.nix` declares the archetype's source flake as an input, at +//! the URL `internal/meta` holds for that source, and imports the archetype's +//! module from it. Nothing is copied: any source `internal/meta` has a pinned +//! input for works. The URL's `rev` is the only pin, since the seeded repo has +//! no lock. The meta flake points the agent's `hyperhive` input at its own, so +//! the lock holds one hyperhive. From then on the repo's owner keeps the config +//! current. The job graph is [`crate::jobq::declare_custom_job`]: repo and seed +//! first, the archetype key deleted only after the seed succeeded, then the +//! meta update and the deploy. A failure before the delete leaves the agent +//! archetype-built and working, and the same request can be sent again. A +//! failure after it leaves the agent custom, and the same request then only +//! re-queues the meta update and the deploy. + +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use problem_details::ProblemDetails; +use serde::Serialize; +use swarm_queue_client::agent_archetype::ArchetypeRef; +use utoipa::ToSchema; + +use crate::routes::agents::{claiming_hives, read_declarations}; +use crate::routes::{AppState, error_problem}; +use swarm_wire::hive_status::{Capabilities, NO_CONFIG_CHECKOUT}; + +use crate::status::{Freshness, HiveStatus}; +use crate::{forge, meta_flake, schedules}; + +/// Answer of a queued conversion. +#[derive(Serialize, ToSchema)] +pub struct ConvertResponse { + /// A node of the queued graph; `/api/jobq/graph` serves the rest. + node_id: u64, +} + +/// Turn an archetype agent into a custom agent. +#[utoipa::path( + post, + path = "/api/agents/{name}/custom", + params(("name" = String, Path, description = "archetype agent to turn into a custom one")), + responses( + (status = 200, description = "the conversion is queued", body = ConvertResponse), + (status = 400, description = "the agent name is not shaped like an identifier (problem+json)", body = String), + (status = 409, description = "the agent is placed on no hive or on several, or its hive has not reported the capability a converted agent's rebuild needs (problem+json)", body = String), + (status = 503, description = "no swarm queue is configured here or it is not connected (problem+json)", body = String), + (status = 500, description = "the queue or the job graph failed (problem+json)", body = String), + ), + tag = "agents" +)] +pub(crate) async fn post_agent_custom( + State(state): State, + Path(agent): Path, +) -> Result, ProblemDetails> { + let agent = hive_types::Ident::parse(&agent) + .map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))? + .into_string(); + let client = schedules::queue(&state).map_err(schedules::refused)?; + let internal = + |e: anyhow::Error| error_problem(StatusCode::INTERNAL_SERVER_ERROR, &format!("{e:#}")); + + let store = swarm_queue_client::agent_archetype::open_or_create(&client) + .await + .map_err(|e| internal(e.into()))?; + let (archetype, _, _) = schedules::read_or_default::>(&store, &agent) + .await + .map_err(internal)?; + let declared = read_declarations(&state, state.hives.iter()) + .await + .map_err(|(hive, e)| { + internal(e.context(format!("read where hive {hive} places agents"))) + })?; + let Some(status) = state.status.as_ref() else { + return Err(error_problem( + StatusCode::SERVICE_UNAVAILABLE, + "no swarm queue is configured on this host", + )); + }; + let rows = status + .view(&state.hives, std::time::SystemTime::now()) + .await + .map_err(internal)?; + let (archetype, hive) = plan(&agent, archetype, &declared, &rows) + .map_err(|why| error_problem(StatusCode::CONFLICT, &why))?; + + let queued = { + let mut sched = state + .jobq + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + sched.insert_job(None, |b| { + crate::jobq::declare_custom_job(b, &agent, &hive, archetype.as_ref()) + }) + }; + let ids = queued.map_err(|e| internal(anyhow::anyhow!("{e}")))?; + let [id] = ids[..] else { + unreachable!("exactly one handle was asked for"); + }; + tracing::info!(%agent, %hive, ?archetype, "turn into custom queued"); + Ok(Json(ConvertResponse { node_id: id.get() })) +} + +/// What the request does, or why it is refused: `archetype` is the agent's +/// stored one, and `None` for a custom agent, which a request only re-deploys +/// (the retry of a conversion whose build failed after the key was deleted). +/// The agent's hive is the one hive that places it, and must pass +/// [`offers_no_config_checkout`]. +fn plan( + agent: &str, + archetype: Option, + declared: &[(String, swarm_wire::wanted::HiveWanted)], + rows: &[HiveStatus], +) -> Result<(Option, String), String> { + let hive = match claiming_hives(agent, declared).as_slice() { + [hive] => hive.clone(), + other => { + return Err(format!( + "agent {agent:?} is placed on {} hives, so its hive is unknown", + other.len() + )); + } + }; + offers_no_config_checkout(rows, &hive)?; + Ok((archetype, hive)) +} + +/// Whether `hive`'s latest status advertises [`NO_CONFIG_CHECKOUT`]. An older +/// hive keeps a checkout of a custom agent's config and would create it for a +/// converted agent under the running container's mount, so a conversion waits +/// for this. +/// +/// # Errors +/// The reason, naming the hive: it has no recent status, or its status does +/// not list it. An older hive lists no capabilities at all. +fn offers_no_config_checkout(rows: &[HiveStatus], hive: &str) -> Result<(), String> { + let capability = NO_CONFIG_CHECKOUT; + let fresh = rows + .iter() + .find(|row| row.name == hive && row.freshness == Freshness::Fresh); + let Some(snapshot) = fresh.and_then(|row| row.snapshot.as_ref()) else { + return Err(format!( + "hive {hive:?} has no recent status, so whether it can rebuild a converted agent is \ + unknown; retry once it reports" + )); + }; + if Capabilities::of_snapshot(snapshot).has(capability) { + return Ok(()); + } + Err(format!( + "hive {hive:?} does not advertise the {capability:?} capability, so a converted agent \ + would get a config checkout on it; deploy this release to hive {hive:?} first" + )) +} + +/// What [`seed`] reads and writes on the forge: a fake in the tests. +pub(crate) trait SeedForge { + /// `internal/meta`'s `flake.nix` at its head. + fn meta_flake(&self) -> impl Future>> + Send; + /// `path` on the default branch of `agent`'s config repo; `None` when the + /// repo is empty or has no such file. + fn config_file( + &self, + agent: &str, + path: &str, + ) -> impl Future>> + Send; + /// Commit `files` to the default branch of `agent`'s config repo. + fn create_files( + &self, + agent: &str, + files: &[(&str, String)], + ) -> impl Future> + Send; +} + +impl SeedForge for forge::Client { + async fn meta_flake(&self) -> anyhow::Result> { + let head = self + .default_branch_files( + forge::objects::SHARED_ORG, + meta_flake::META_REPO, + &["flake.nix"], + ) + .await?; + Ok(head + .and_then(|h| h.files.into_iter().next()) + .map(|f| String::from_utf8_lossy(&f.bytes).into_owned())) + } + + async fn config_file(&self, agent: &str, path: &str) -> anyhow::Result> { + let head = self + .default_branch_files(forge::CONFIG_ORG, agent, &[path]) + .await?; + Ok(head + .and_then(|h| h.files.into_iter().next()) + .map(|f| String::from_utf8_lossy(&f.bytes).into_owned())) + } + + async fn create_files(&self, agent: &str, files: &[(&str, String)]) -> anyhow::Result<()> { + self.create_config_files(agent, files, "swarm-controller: turn into custom") + .await + } +} + +/// A nix identifier, so the names can be written as attribute names unquoted. +fn is_nix_ident(name: &str) -> bool { + let mut chars = name.chars(); + chars + .next() + .is_some_and(|c| c.is_ascii_lowercase() || c == '_') + && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') +} + +/// The `flake.nix` of a converted agent: the shape every config repo has, plus +/// the archetype's source as an input at `url`, and the archetype's module +/// among the imports. The module is imported here, from `inputs`, because a +/// module cannot take its own imports from `_module.args`. +fn converted_flake_nix(archetype: &ArchetypeRef, url: &str) -> String { + let ArchetypeRef { source, name } = archetype; + format!( + "{{\n description = \"hyperhive agent\";\n inputs = {{\n {source}.url = \"{url}\";\n }};\n outputs =\n {{ self, ... }}@inputs:\n {{\n nixosModules.default = {{\n imports = [\n ./agent.nix\n inputs.{source}.hyperhiveArchetypes.{name}.module\n ];\n _module.args.flakeInputs = builtins.removeAttrs inputs [ \"self\" ];\n }};\n }};\n}}\n" + ) +} + +/// The `agent.nix` of a converted agent: an empty module for the owner's edits. +fn converted_agent_nix(agent: &str, archetype: &ArchetypeRef) -> String { + let ArchetypeRef { source, name } = archetype; + format!( + "{{ config, pkgs, lib, ... }}:\n{{\n # Per-agent overrides for {agent}. flake.nix imports the {source} archetype\n # {name}; keeping that input current is up to this repo's owner.\n}}\n" + ) +} + +/// Seed `agent`'s config repo so it builds what the archetype built: its +/// `flake.nix` declares the archetype's source as an input at the URL +/// `internal/meta` holds for it today (a pinned rev), and imports the module. +/// The job behind `SeedArchetypeConfig`. +/// +/// Safe to run again: a repo whose `flake.nix` is what this writes, whatever +/// URL it holds, is left as it is, since both files go in one commit. A +/// `flake.nix` that differs belongs to someone, and is an error. +/// +/// # Errors +/// A forge failure, a name that is not a nix identifier, `internal/meta` +/// holding no input for the source or one not pinned to a commit, or a different +/// `flake.nix` already in the repo. +pub(crate) async fn seed( + forge: &impl SeedForge, + agent: &str, + archetype: &ArchetypeRef, +) -> anyhow::Result<()> { + use anyhow::Context as _; + + let ArchetypeRef { source, name } = archetype; + for part in [source, name] { + anyhow::ensure!( + is_nix_ident(part), + "archetype name {part:?} is not a nix identifier" + ); + } + let meta = forge + .meta_flake() + .await? + .context("internal/meta has no flake.nix to read the archetype source from")?; + let url = hive_meta_flake::input_url(&meta, source).with_context(|| { + format!("internal/meta has no input {source:?}, so the archetype source has no URL to pin") + })?; + // The seeded repo has no lock, so the URL's `rev` is the only pin the input has. + anyhow::ensure!( + hive_meta_flake::pinned_rev(&meta, source).is_some(), + "internal/meta's input {source:?} is not pinned to a commit, so the converted agent would float" + ); + if let Some(have) = forge.config_file(agent, "flake.nix").await? { + let ours = hive_meta_flake::input_url(&have, source) + .is_some_and(|held| have == converted_flake_nix(archetype, held)); + anyhow::ensure!( + ours, + "agent-configs/{agent} already holds a different flake.nix; the conversion does not \ + overwrite it" + ); + tracing::debug!(%agent, "turn into custom: config already seeded"); + return Ok(()); + } + forge + .create_files( + agent, + &[ + ("agent.nix", converted_agent_nix(agent, archetype)), + ("flake.nix", converted_flake_nix(archetype, url)), + ], + ) + .await?; + tracing::info!(%agent, %url, "turn into custom: seeded the config repo"); + Ok(()) +} + +/// Delete `agent`'s archetype key, so the next render and rebuild treat it as +/// custom. The job behind `DropArchetype`; deleting an absent key succeeds. +/// +/// # Errors +/// The bucket cannot be opened or written. +pub(crate) async fn drop_archetype(client: &async_nats::Client, agent: &str) -> anyhow::Result<()> { + swarm_queue_client::agent_archetype::open_or_create(client) + .await? + .delete(agent) + .await?; + tracing::info!(%agent, "turn into custom: archetype key deleted"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use std::sync::Mutex; + + const URL: &str = + "git+http://forge/hyperhive/hyperhive.git?rev=1111111111111111111111111111111111111111"; + + fn reviewer() -> ArchetypeRef { + ArchetypeRef { + source: "hyperhive".to_owned(), + name: "reviewer".to_owned(), + } + } + + /// An in-memory forge: `internal/meta`'s flake and the files of one config + /// repo. `fail_create` makes the next `create_files` fail once, without + /// writing. + #[derive(Default)] + struct FakeForge { + meta_flake: Option, + repo: Mutex>, + fail_create: Mutex, + creates: Mutex, + } + + impl FakeForge { + fn with_inputs(inputs: &[(&str, &str)]) -> Self { + use std::fmt::Write as _; + let mut lines = String::new(); + for (name, url) in inputs { + let _ = writeln!(lines, " {name}.url = \"{url}\";"); + } + Self { + meta_flake: Some(lines), + ..Self::default() + } + } + + fn pinned() -> Self { + Self::with_inputs(&[("hyperhive", URL)]) + } + } + + impl SeedForge for FakeForge { + async fn meta_flake(&self) -> anyhow::Result> { + Ok(self.meta_flake.clone()) + } + async fn config_file(&self, _agent: &str, path: &str) -> anyhow::Result> { + Ok(self.repo.lock().unwrap().get(path).cloned()) + } + async fn create_files(&self, _agent: &str, files: &[(&str, String)]) -> anyhow::Result<()> { + if std::mem::take(&mut *self.fail_create.lock().unwrap()) { + anyhow::bail!("the forge refused the commit"); + } + *self.creates.lock().unwrap() += 1; + let mut repo = self.repo.lock().unwrap(); + for (path, content) in files { + repo.insert((*path).to_owned(), content.clone()); + } + Ok(()) + } + } + + /// The seeded flake declares the source as an input at meta's URL and + /// imports the module from it; nothing is copied into the repo. + #[tokio::test] + async fn the_seed_declares_the_source_as_an_input_and_imports_its_module() { + let forge = FakeForge::pinned(); + seed(&forge, "atlas", &reviewer()).await.expect("seeds"); + let repo = forge.repo.lock().unwrap(); + let flake = &repo["flake.nix"]; + assert!( + flake.contains(&format!(" hyperhive.url = \"{URL}\";")), + "{flake}" + ); + assert!( + flake.contains("inputs.hyperhive.hyperhiveArchetypes.reviewer.module"), + "{flake}" + ); + assert!(flake.contains("_module.args.flakeInputs"), "{flake}"); + assert_eq!( + repo.keys().map(String::as_str).collect::>(), + ["agent.nix", "flake.nix"] + ); + } + + /// A source other than hyperhive takes the URL `internal/meta` holds for + /// that name, and one meta has no input for is an error. + #[tokio::test] + async fn another_source_takes_its_url_from_meta_and_an_unknown_one_fails() { + let other = ArchetypeRef { + source: "extra".to_owned(), + name: "builder".to_owned(), + }; + let forge = FakeForge::with_inputs(&[ + ("hyperhive", URL), + ( + "extra", + "git+http://x/y.git?rev=2222222222222222222222222222222222222222", + ), + ]); + seed(&forge, "atlas", &other).await.expect("seeds"); + let flake = forge.repo.lock().unwrap()["flake.nix"].clone(); + assert!( + flake.contains(" extra.url = \"git+http://x/y.git?rev=2222"), + "{flake}" + ); + assert!( + flake.contains("inputs.extra.hyperhiveArchetypes.builder.module"), + "{flake}" + ); + + let why = format!( + "{:#}", + seed(&FakeForge::pinned(), "atlas", &other) + .await + .unwrap_err() + ); + assert!(why.contains("no input \"extra\""), "{why}"); + } + + /// A seed that failed wrote nothing, and sending it again completes; a + /// third run, after the repo is seeded, commits nothing more — even when + /// meta's pin has moved since. The presence control: the second run did + /// write. + #[tokio::test] + async fn a_seed_that_failed_is_retried_and_a_seeded_repo_is_left_alone() { + let forge = FakeForge::pinned(); + *forge.fail_create.lock().unwrap() = true; + seed(&forge, "atlas", &reviewer()) + .await + .expect_err("refused"); + assert!(forge.repo.lock().unwrap().is_empty(), "a failed seed wrote"); + + seed(&forge, "atlas", &reviewer()).await.expect("retry"); + assert_eq!(*forge.creates.lock().unwrap(), 1); + + let moved = FakeForge { + repo: Mutex::new(forge.repo.lock().unwrap().clone()), + ..FakeForge::with_inputs(&[( + "hyperhive", + "git+http://forge/h.git?rev=3333333333333333333333333333333333333333", + )]) + }; + seed(&moved, "atlas", &reviewer()).await.expect("again"); + assert_eq!( + *moved.creates.lock().unwrap(), + 0, + "a seeded repo was committed to again" + ); + } + + /// Someone else's `flake.nix` is never overwritten. + #[tokio::test] + async fn a_different_flake_nix_is_an_error() { + let forge = FakeForge::pinned(); + forge + .repo + .lock() + .unwrap() + .insert("flake.nix".to_owned(), "{ }".to_owned()); + let why = format!( + "{:#}", + seed(&forge, "atlas", &reviewer()).await.unwrap_err() + ); + assert!(why.contains("different flake.nix"), "{why}"); + assert_eq!(*forge.creates.lock().unwrap(), 0); + } + + #[tokio::test] + async fn a_missing_meta_flake_or_a_bad_name_is_an_error() { + let forge = FakeForge::default(); + seed(&forge, "atlas", &reviewer()) + .await + .expect_err("no meta flake"); + let bad = ArchetypeRef { + source: "hyperhive".to_owned(), + name: "9; evil".to_owned(), + }; + seed(&FakeForge::pinned(), "atlas", &bad) + .await + .expect_err("bad name"); + } + + fn row(name: &str, freshness: Freshness, snapshot: Option) -> HiveStatus { + HiveStatus { + name: name.to_owned(), + domain: None, + dashboard: None, + freshness, + last_seen_unix: None, + age_seconds: None, + snapshot, + } + } + + /// The rollout gate: a hive listing the capability is allowed; one that + /// lists others, lists none (an older hive's payload), has gone quiet or + /// never reported is refused, naming the hive. + #[test] + fn only_a_fresh_hive_listing_the_capability_may_convert() { + let with = serde_json::json!({"status": "ok", "warnings": [], "capabilities": ["no-config-checkout"]}); + let old = serde_json::json!({"status": "ok", "warnings": []}); + let other = serde_json::json!({"status": "ok", "warnings": [], "capabilities": ["x"]}); + let rows = |hive: Freshness, snapshot| { + vec![ + row("alpha", hive, Some(snapshot)), + row("beta", Freshness::Fresh, Some(with.clone())), + ] + }; + + assert_eq!( + offers_no_config_checkout(&rows(Freshness::Fresh, with.clone()), "alpha"), + Ok(()) + ); + for refused in [old.clone(), other] { + let why = + offers_no_config_checkout(&rows(Freshness::Fresh, refused), "alpha").unwrap_err(); + assert!( + why.contains("\"alpha\"") && why.contains("no-config-checkout"), + "{why}" + ); + } + let stale = + offers_no_config_checkout(&rows(Freshness::Stale, with.clone()), "alpha").unwrap_err(); + assert!(stale.contains("no recent status"), "{stale}"); + let never = vec![row("alpha", Freshness::NeverReported, None)]; + offers_no_config_checkout(&never, "alpha").unwrap_err(); + offers_no_config_checkout(&[], "alpha").unwrap_err(); + } + + /// An old-shaped payload, without `capabilities`, still parses as the + /// opaque snapshot the status reader serves. + #[test] + fn an_old_shaped_payload_still_parses_and_lists_nothing() { + let old: serde_json::Value = + serde_json::from_str(r#"{"status":"ok","warnings":[]}"#).unwrap(); + assert!(old["capabilities"].as_array().is_none()); + let why = offers_no_config_checkout(&[row("alpha", Freshness::Fresh, Some(old))], "alpha") + .unwrap_err(); + assert!(why.contains("alpha"), "{why}"); + } + fn placing(hive: &str, agent: &str) -> Vec<(String, swarm_wire::wanted::HiveWanted)> { + let wanted = swarm_wire::wanted::HiveWanted { + agents: BTreeMap::from([( + agent.to_owned(), + swarm_wire::wanted::AgentWanted { + state: swarm_wire::wanted::AgentState::Up, + }, + )]), + }; + vec![(hive.to_owned(), wanted)] + } + + fn reporting(hive: &str, capabilities: &[&str]) -> Vec { + let snapshot = + serde_json::json!({"status": "ok", "warnings": [], "capabilities": capabilities}); + vec![row(hive, Freshness::Fresh, Some(snapshot))] + } + + /// The refusals and the outcomes of a request, decided before anything is + /// queued: an agent no hive places and a hive that doesn't list the + /// capability are refused; an archetype agent converts, and a custom agent + /// (its key already gone) is only re-deployed. The conversion is the + /// control that the refusals' inputs get through otherwise. + #[test] + fn a_request_is_refused_for_an_unplaced_agent_or_an_old_hive() { + let placed = placing("alpha", "atlas"); + let ready = reporting("alpha", &["no-config-checkout"]); + assert_eq!( + plan("atlas", Some(reviewer()), &placed, &ready), + Ok((Some(reviewer()), "alpha".to_owned())) + ); + assert_eq!( + plan("atlas", None, &placed, &ready), + Ok((None, "alpha".to_owned())) + ); + + let unplaced = plan("atlas", Some(reviewer()), &[], &ready).unwrap_err(); + assert!(unplaced.contains("0 hives"), "{unplaced}"); + let old = plan("atlas", Some(reviewer()), &placed, &reporting("alpha", &[])).unwrap_err(); + assert!(old.contains("\"alpha\""), "{old}"); + // The retry of a custom agent waits for the capability too. + plan("atlas", None, &placed, &reporting("alpha", &[])).unwrap_err(); + } + + /// A source `internal/meta` does not pin to a commit would float in a repo + /// with no lock, so the seed refuses it and writes nothing. + #[tokio::test] + async fn a_source_that_meta_does_not_pin_is_not_seeded() { + let other = ArchetypeRef { + source: "extra".to_owned(), + name: "builder".to_owned(), + }; + let forge = FakeForge::with_inputs(&[("hyperhive", URL), ("extra", "git+http://x/y.git")]); + let why = format!("{:#}", seed(&forge, "atlas", &other).await.unwrap_err()); + assert!(why.contains("not pinned"), "{why}"); + assert!(forge.repo.lock().unwrap().is_empty()); + } +} diff --git a/swarm-controller/src/agent_status.rs b/swarm-controller/src/agent_status.rs index 4f703627..705f36ef 100644 --- a/swarm-controller/src/agent_status.rs +++ b/swarm-controller/src/agent_status.rs @@ -73,6 +73,14 @@ pub struct AgentStatusRow { /// `AppState::agent_login` — this reader sees the hive's bucket, and the /// flag comes from the agent itself. pub needs_login: Option, + /// The archetype the agent is built from; `None` for a custom agent. Always + /// `None` out of this reader, filled in by the `GET /api/agents/status` + /// handler from the `agent-archetype` bucket. + #[schema( + value_type = Option, + example = json!({"source": "hyperhive", "name": "reviewer"}) + )] + pub archetype: Option, } /// A snapshot as retained by the bucket, with the hive it was published @@ -194,6 +202,7 @@ fn render( config_pr: None, wanted: None, needs_login: None, + archetype: None, }, }) .collect(); @@ -239,6 +248,7 @@ fn row( config_pr: None, wanted: None, needs_login: None, + archetype: None, } } diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index 3092c518..ea78b4a0 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -807,22 +807,38 @@ impl Client { tracing::debug!(%repo, "swarm forge: config already seeded"); return Ok(()); } - let files = vec![ - ChangeFileOperation { - content: Some(base64_encode(&initial_agent_nix(agent))), + self.create_config_files( + repo, + &[ + ("agent.nix", initial_agent_nix(agent)), + ("flake.nix", initial_flake_nix().to_owned()), + ], + "swarm-controller: seed agent config", + ) + .await?; + tracing::info!(%repo, %agent, "swarm forge: seeded agent.nix + flake.nix"); + Ok(()) + } + + /// Commit `files` (path, content) to `repo`'s default branch in one + /// `repo_change_files` call, creating each path. No presence check: a + /// path that already exists makes the forge refuse the whole commit. + pub(crate) async fn create_config_files( + &self, + repo: &str, + files: &[(&str, String)], + message: &str, + ) -> Result<()> { + let files: Vec = files + .iter() + .map(|(path, content)| ChangeFileOperation { + content: Some(base64_encode(content)), from_path: None, operation: ChangeFileOperationOperation::Create, - path: "agent.nix".to_owned(), + path: (*path).to_owned(), sha: None, - }, - ChangeFileOperation { - content: Some(base64_encode(initial_flake_nix())), - from_path: None, - operation: ChangeFileOperationOperation::Create, - path: "flake.nix".to_owned(), - sha: None, - }, - ]; + }) + .collect(); self.api .call(|api| { api.repo_change_files( @@ -835,7 +851,7 @@ impl Client { dates: None, files: files.clone(), force_overwrite_new_branch: None, - message: Some("swarm-controller: seed agent config".to_owned()), + message: Some(message.to_owned()), new_branch: None, signoff: None, }, @@ -843,8 +859,7 @@ impl Client { .into_future() }) .await - .with_context(|| format!("seed config files in {CONFIG_ORG}/{repo}"))?; - tracing::info!(%repo, %agent, "swarm forge: seeded agent.nix + flake.nix"); + .with_context(|| format!("commit config files to {CONFIG_ORG}/{repo}"))?; Ok(()) } diff --git a/swarm-controller/src/jobq.rs b/swarm-controller/src/jobq.rs index 0d6c346d..cd21a738 100644 --- a/swarm-controller/src/jobq.rs +++ b/swarm-controller/src/jobq.rs @@ -57,6 +57,15 @@ pub(crate) enum SwarmNodeKind { /// swarm routes a deploy message to — it belongs on the node that /// sends that message, not on this one. InitAgentConfigRepo { agent: String }, + /// Seed the agent's config repo with the archetype's module, for turn + /// into custom. See `agent_custom::seed`. + SeedArchetypeConfig { + agent: String, + archetype: swarm_wire::agent_archetype::ArchetypeRef, + }, + /// Delete the agent's archetype key, which makes it custom. See + /// `agent_custom::drop_archetype`. + DropArchetype { agent: String }, /// Mint `agent`'s own client certificate for the swarm secret store, /// publish it there, grant it, and log in with it. See /// `agent_identity::mint_and_verify` — including why this node does not @@ -168,6 +177,8 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { SwarmNodeKind::AddRepoMember { .. } => "add_repo_member".to_owned(), SwarmNodeKind::GrantDocsRead { .. } => "grant_docs_read".to_owned(), SwarmNodeKind::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(), + SwarmNodeKind::SeedArchetypeConfig { .. } => "seed_archetype_config".to_owned(), + SwarmNodeKind::DropArchetype { .. } => "drop_archetype".to_owned(), SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(), SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(), SwarmNodeKind::MintAgentMatrixAccount { .. } => "mint_agent_matrix_account".to_owned(), @@ -202,6 +213,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { | SwarmNodeKind::AddRepoMember { agent } | SwarmNodeKind::GrantDocsRead { agent } | SwarmNodeKind::InitAgentConfigRepo { agent } + | SwarmNodeKind::DropArchetype { agent } | SwarmNodeKind::MintAgentIdentity { agent } | SwarmNodeKind::MintAgentForgeToken { agent } | SwarmNodeKind::MintAgentMatrixAccount { agent } @@ -211,6 +223,9 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { SwarmNodeKind::BuildMeta { agent, pr } => { serde_json::json!({ "agent": agent, "pr": pr }) } + SwarmNodeKind::SeedArchetypeConfig { agent, archetype } => { + serde_json::json!({ "agent": agent, "archetype": archetype.name }) + } SwarmNodeKind::MintHiveSenderToken { hive } | SwarmNodeKind::MintHiveForgeToken { hive } => serde_json::json!({ "hive": hive }), SwarmNodeKind::MintClassifierForgeToken @@ -404,6 +419,9 @@ async fn run_node_work( }, SwarmNodeKind::GrantDocsRead { agent } => grant_docs_read(forge, &agent).await, SwarmNodeKind::InitAgentConfigRepo { agent } => init_agent_config_repo(forge, &agent).await, + SwarmNodeKind::SeedArchetypeConfig { .. } | SwarmNodeKind::DropArchetype { .. } => { + convert_node(&kind, forge, deps.queue.as_ref()).await + } SwarmNodeKind::MintAgentIdentity { agent } => { mint_identity(deps.wanted.as_deref(), &agent).await } @@ -592,6 +610,44 @@ async fn init_agent_config_repo( } } +/// The `SeedArchetypeConfig` and `DropArchetype` arms, lifted out for the +/// same reason. Any other kind is not this function's to run. +async fn convert_node( + kind: &SwarmNodeKind, + forge: Option>, + queue: Option<&async_nats::Client>, +) -> hive_jobq::scheduler::Outcome { + use hive_jobq::scheduler::Outcome; + + let done = |result: Result<()>| match result { + Ok(()) => Outcome::Done, + Err(e) => Outcome::Failed(format!("{e:#}")), + }; + match kind { + SwarmNodeKind::SeedArchetypeConfig { agent, archetype } => { + let Some(client) = forge else { + return Outcome::Failed( + "no forge access here (SWARM_CONTROLLER_FORGE_URL unset, or its token \ + not in the swarm secret store)" + .to_owned(), + ); + }; + done( + forge_node( + "seed_archetype_config", + crate::agent_custom::seed(client.as_ref(), agent, archetype), + ) + .await, + ) + } + SwarmNodeKind::DropArchetype { agent } => match queue { + Some(client) => done(crate::agent_custom::drop_archetype(client, agent).await), + None => Outcome::Failed("no swarm queue is configured on this host".to_owned()), + }, + other => unreachable!("{other:?} is not a conversion node"), + } +} + /// The `RenewAgentQueueCredential` arm, lifted out for the same reason. async fn renew_queue_credential(agent: &str) -> hive_jobq::scheduler::Outcome { use hive_jobq::scheduler::Outcome; @@ -1421,6 +1477,65 @@ pub(crate) fn declare_agent_job( vec![create_identity.guid()] } +/// The sub-DAG turning the archetype agent `agent` into a custom one: its +/// config repo is created and seeded, and only then is its archetype key +/// deleted, so a failed step leaves the agent archetype-built. The meta +/// update and the deploy follow the delete (`after_ok`), as in +/// [`merged_deploy_job`]. Returns the repo node, the handle the response +/// reports. +/// +/// `CreateRepo`, `CreateForgeUser` and `AddRepoMember` are no-ops for what +/// already exists, so the same graph can be queued again after a failure. +/// +/// With `archetype` `None` the agent is already custom: its key was deleted and +/// a later step failed, so the graph is the meta update and the deploy alone, +/// and the handle is the update. A build that fails after the key delete leaves +/// the agent custom, which is how that failure is retried. +pub(crate) fn declare_custom_job( + b: &hive_jobq::builder::JobBuilder, + agent: &str, + hive: &str, + archetype: Option<&swarm_wire::agent_archetype::ArchetypeRef>, +) -> Vec { + let Some(archetype) = archetype else { + let update_meta = b + .node(SwarmNodeKind::UpdateMeta) + .needs(SwarmResourceKind::Meta); + let _deploy = gated_deploy(b, hive, agent, None, None, Some(update_meta.guid())); + return vec![update_meta.guid()]; + }; + let create_repo = b.node(SwarmNodeKind::CreateRepo { + agent: agent.to_owned(), + }); + let create_forge_user = b.node(SwarmNodeKind::CreateForgeUser { + agent: agent.to_owned(), + }); + let add_repo_member = b + .node(SwarmNodeKind::AddRepoMember { + agent: agent.to_owned(), + }) + .after_ok(create_repo) + .after_ok(create_forge_user); + let seed = b + .node(SwarmNodeKind::SeedArchetypeConfig { + agent: agent.to_owned(), + archetype: archetype.clone(), + }) + .after_ok(create_repo); + let drop_archetype = b + .node(SwarmNodeKind::DropArchetype { + agent: agent.to_owned(), + }) + .after_ok(seed) + .after_ok(add_repo_member); + let update_meta = b + .node(SwarmNodeKind::UpdateMeta) + .needs(SwarmResourceKind::Meta) + .after_ok(drop_archetype); + let _deploy = gated_deploy(b, hive, agent, None, None, Some(update_meta.guid())); + vec![create_repo.guid()] +} + /// Insert one job per agent: `CreateForgeUser`, then `MintAgentForgeToken` /// `after_ok` it — the same edge [`declare_agent_job`] uses. Returns the mint /// nodes' ids. @@ -2389,6 +2504,87 @@ mod tests { let _ = super::merged_deploy_job(b, "pr1ma", "atlas", "abc", 7); } + fn custom_dag(b: &TestBuilder) { + let archetype = swarm_wire::agent_archetype::ArchetypeRef { + source: "hyperhive".to_owned(), + name: "reviewer".to_owned(), + }; + let _ = super::declare_custom_job(b, "atlas", "pr1ma", Some(&archetype)); + } + + /// Turn into custom: with every node succeeding the agent is seeded, loses + /// its key, and is deployed. + #[tokio::test] + async fn a_conversion_seeds_then_drops_the_key_then_deploys() { + use hive_jobq::State; + + let done = run_dag_failing(custom_dag, None).await; + for label in [ + "create_repo", + "seed_archetype_config", + "drop_archetype", + "update_meta", + "trigger_deploy", + ] { + assert_eq!(state_of(&done, label), State::Done, "{label}"); + } + // The key is deleted only after the seed and the member grant, and + // each edge refuses a failure. The deploy edge is the control that + // the lookup finds an `after_ok` edge in this graph. + for (from, on) in [ + ("drop_archetype", "seed_archetype_config"), + ("drop_archetype", "add_repo_member"), + ("update_meta", "drop_archetype"), + ("trigger_deploy", "update_meta"), + ] { + assert!( + !edge_accepts_failure(custom_dag, from, on), + "{from} -> {on}" + ); + } + } + + /// The retry of a conversion whose build failed after the key was deleted: + /// with no archetype the graph is the meta update and the deploy, with no + /// repo, seed or key delete. The conversion is the control that those + /// nodes exist when there is one. + #[tokio::test] + async fn a_retry_of_a_custom_agent_is_only_the_update_and_the_deploy() { + use hive_jobq::State; + + fn retry_dag(b: &TestBuilder) { + let _ = super::declare_custom_job(b, "atlas", "pr1ma", None); + } + let done = run_dag_failing(retry_dag, None).await; + let labels: Vec<&str> = done.iter().map(|(l, _)| l.as_str()).collect(); + for absent in ["create_repo", "seed_archetype_config", "drop_archetype"] { + assert!(!labels.contains(&absent), "{absent} in {labels:?}"); + } + assert_eq!(state_of(&done, "update_meta"), State::Done); + assert_eq!(state_of(&done, "trigger_deploy"), State::Done); + let converting = run_dag_failing(custom_dag, None).await; + assert!(converting.iter().any(|(l, _)| l == "drop_archetype")); + // A failed build leaves the deploy skipped, so a retry has work to do. + let red = run_dag_failing(retry_dag, Some("build_meta")).await; + assert_eq!(state_of(&red, "trigger_deploy"), State::Skipped); + } + + /// A seed that fails leaves the key in place and nothing deployed; the + /// same graph, queued again with the seed succeeding, completes. + #[tokio::test] + async fn a_failed_seed_keeps_the_key_and_a_retry_completes() { + use hive_jobq::State; + + let failed = run_dag_failing(custom_dag, Some("seed_archetype_config")).await; + assert_eq!(state_of(&failed, "seed_archetype_config"), State::Failed); + for label in ["drop_archetype", "update_meta", "trigger_deploy"] { + assert_eq!(state_of(&failed, label), State::Skipped, "{label}"); + } + let retried = run_dag_failing(custom_dag, None).await; + assert_eq!(state_of(&retried, "drop_archetype"), State::Done); + assert_eq!(state_of(&retried, "trigger_deploy"), State::Done); + } + /// A deploy needs the meta update to succeed: its edge refuses a failed /// update, in both DAGs that queue one. #[test] diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index bac04742..46d1cdc9 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -23,6 +23,7 @@ use anyhow::{Context, Result}; mod agent_archetype; mod agent_control; +mod agent_custom; mod agent_entity; mod agent_icon; mod agent_identity; diff --git a/swarm-controller/src/meta_flake.rs b/swarm-controller/src/meta_flake.rs index d0232c13..0172a4e5 100644 --- a/swarm-controller/src/meta_flake.rs +++ b/swarm-controller/src/meta_flake.rs @@ -418,26 +418,29 @@ async fn render( CONFIG_ORG, &agent.name, Some(CONFIG_BRANCH), - &["flake.lock"], + &["flake.lock", "flake.nix"], ) .await? else { tracing::warn!(agent = %agent.name, "meta flake: its config repo has no commit on {CONFIG_BRANCH}; leaving the agent out"); continue; }; - let lock = head - .files - .first() - .map(|f| String::from_utf8_lossy(&f.bytes).into_owned()); + let read = |path: &str| { + head.files + .iter() + .find(|f| f.path == path) + .map(|f| String::from_utf8_lossy(&f.bytes).into_owned()) + }; + let (lock, flake) = (read("flake.lock"), read("flake.nix")); Some(hive_meta_flake::AgentPin { url: hive_meta_flake::pinned_git_url( &settings.git_url(CONFIG_ORG, &agent.name), Some(&head.commit), )?, - canonical_inputs: lock - .as_deref() - .map(hive_meta_flake::canonical_inputs_in_lock) - .unwrap_or_default(), + canonical_inputs: hive_meta_flake::canonical_inputs_declared( + lock.as_deref(), + flake.as_deref(), + ), }) }; specs.push(hive_meta_flake::AgentSpec { diff --git a/swarm-controller/src/routes/agents.rs b/swarm-controller/src/routes/agents.rs index e44c7131..4c8baf01 100644 --- a/swarm-controller/src/routes/agents.rs +++ b/swarm-controller/src/routes/agents.rs @@ -472,6 +472,21 @@ pub(crate) async fn get_agents_status( row.needs_login = login.get(&row.name); } } + // Absent on a failed read, like `wanted`: one unreadable bucket + // must not blank the roster. + if let Some(reader) = state.status.as_ref() { + match agent_archetype::of_agents(&reader.queue_client(), &rows).await { + Ok(mut archetypes) => { + for row in &mut rows { + row.archetype = archetypes.remove(&row.name); + } + } + Err(e) => tracing::warn!( + error = %format!("{e:#}"), + "reading the agent archetypes failed; rows show none" + ), + } + } Ok(Json(rows)) } Err(e) => { @@ -1800,6 +1815,7 @@ mod tests { config_pr: None, wanted: None, needs_login: None, + archetype: None, } } diff --git a/swarm-controller/src/startup.rs b/swarm-controller/src/startup.rs index 4adf557f..dc8e2627 100644 --- a/swarm-controller/src/startup.rs +++ b/swarm-controller/src/startup.rs @@ -28,12 +28,12 @@ use crate::jobq::{ use crate::routes::hives::{HiveEntry, ServiceLink}; use crate::routes::{self, AppState, agents, config_prs, forge_admin, hives, jobq_graph}; use crate::{ - agent_control, agent_icon, agent_input, agent_limits, agent_login, agent_permissions, - agent_renewal, agent_state_stream, agent_status, agents_md, alerts, archetypes, auth, - backend_env, builder, claude_login, config_pr, egress_allow, forge, forge_account, - github_account, inference, issue_report, linked_accounts, matrix_account, message_stream, - notify_flows, notify_router_api, pulls, queue_identity, review_classifier, schedules, status, - subagent_term, term_stream, wanted, webhook, + agent_control, agent_custom, agent_icon, agent_input, agent_limits, agent_login, + agent_permissions, agent_renewal, agent_state_stream, agent_status, agents_md, alerts, + archetypes, auth, backend_env, builder, claude_login, config_pr, egress_allow, forge, + forge_account, github_account, inference, issue_report, linked_accounts, matrix_account, + message_stream, notify_flows, notify_router_api, pulls, queue_identity, review_classifier, + schedules, status, subagent_term, term_stream, wanted, webhook, }; /// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`. @@ -656,6 +656,7 @@ pub(crate) fn build_app(state: AppState) -> axum::Router { .routes(routes!(agent_input::post_agent_input)) .routes(routes!(agent_input::post_agents_broadcast)) .routes(routes!(agent_control::post_agent_cancel)) + .routes(routes!(agent_custom::post_agent_custom)) .routes(routes!( claude_login::post_claude_login, claude_login::delete_claude_login diff --git a/swarm-wire/src/hive_status.rs b/swarm-wire/src/hive_status.rs index babb5e6d..8da85459 100644 --- a/swarm-wire/src/hive_status.rs +++ b/swarm-wire/src/hive_status.rs @@ -12,6 +12,12 @@ /// this. pub const TOLERANT_PERMISSIONS: &str = "tolerant-permissions"; +/// A hive lists this when it builds a custom agent from `internal/meta` alone +/// and keeps no checkout of its config: no `applied` or `proposed` repo and no +/// config mount in the container. swarm-controller turns an archetype agent +/// into a custom one only on a hive that advertises it. +pub const NO_CONFIG_CHECKOUT: &str = "no-config-checkout"; + /// The part of a hive's status snapshot this module describes. #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Capabilities {