Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-meta-flake/src/lib.rs

675 lines
28 KiB
Rust

//! The meta flake's text: one `nixosConfigurations.<agent>` per agent, built
//! from hyperhive's `agent-base` and the agent's config input or archetype.
//!
//! Pure: the output is a function of the arguments only. swarm-controller
//! renders through [`render_flake`]; hive-c0re reads the pins of the result
//! ([`pinned_rev`], [`builds_agent_at`]).
use anyhow::{Context, Result};
use swarm_wire::agent_archetype::ArchetypeRef;
/// One agent of the meta flake.
#[derive(Debug, Clone)]
pub struct AgentSpec {
pub name: String,
pub port: u16,
/// The archetype the agent is built from, `None`
/// for a custom agent built from its own config repo.
pub archetype: Option<ArchetypeRef>,
/// The pin of a custom agent's config input. `None` until the caller
/// resolves it; an archetype agent has no config input and keeps `None`.
pub pin: Option<AgentPin>,
}
/// What the rendered flake needs to know about one agent's config input, all
/// of it read from the agent's config repo at one commit. The rendered text
/// is a function of this and nothing on the host.
#[derive(Debug, Clone)]
pub struct AgentPin {
/// `git+<forge>/agent-configs/<n>.git?rev=<rev>`, `<rev>` being the head of
/// `main` on the forge when the flake was rendered.
pub url: String,
/// The `CANONICAL_INPUTS` the agent's `flake.lock` declares at that commit.
pub canonical_inputs: Vec<&'static str>,
}
/// `git+<forge_git_url>?rev=<rev>`. Errors on a missing or empty rev rather
/// than rendering an unpinned URL.
pub fn pinned_git_url(forge_git_url: &str, rev: Option<&str>) -> Result<String> {
let rev = rev.filter(|r| !r.is_empty()).context(
"hyperhive rev unknown: the host was built from a dirty tree \
(`self.rev` unset) and `services.hyperhive.c0re.hyperhiveRev` is not set",
)?;
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
/// `follows = "<name>"` 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"];
/// Env vars the meta flake sets in every agent container, from the
/// `(name, value)` pairs [`render_flake`] is given. A caller passes only the
/// names it has a non-empty value for, so absent options render no no-op
/// `FOO = ""` lines.
pub const FORWARDED_VARS: &[&str] = &[
// Where the swarm secret store listens. The hive's own `BAO_ADDR` value,
// forwarded under a separate name so the container never inherits the
// hive's store environment wholesale — the address is the only part of it
// an agent may have, the certificate beside it being the hive's own.
"HIVE_AGENT_BAO_ADDR",
// The mount path of the store's services `pki` engine, whose root an agent
// fetches to trust the swarm. Forwarded with the address, under the same gate.
"HIVE_AGENT_BAO_SERVICES_PKI_MOUNT",
// Where an agent's `swarm-logs` queries, computed by the host: it is a
// gateway address, and a container cannot derive it from anything it holds.
"HIVE_AGENT_LOGS_QUERY_URL",
// The metrics store's origin, for `swarm-metrics`, for the same reason.
"HIVE_AGENT_METRICS_URL",
"HIVE_FORGE_URL",
"HIVE_FORGE_PUBLIC_URL",
"HIVE_MATRIX_URL",
"HYPERHIVE_SWARM_NAME",
];
/// Map of forwarded env var -> the agent option carrying the same value.
///
/// Both exist because they're consumed at different times: the option is baked
/// into scripts at build time (forge-avatar-sync bakes `FORGE_URL` from it), the env
/// var is read at runtime. Setting only one leaves the other on its default,
/// which is how a hive ends up with two disagreeing answers for one value.
///
/// The swarm display name is here for that reason: `otel.nix` reads the option
/// at eval time for the `swarm=` resource label, and with the env var alone
/// that label evaluates to `unknown` while the process env holds the right
/// name. Half-wiring this map is a value that evaluates fine and is silently
/// wrong.
pub const FORWARDED_VAR_OPTIONS: &[(&str, &str)] = &[
// Read at build time to decide whether the in-container identity check is
// generated at all — see `nix/agent-modules/bao.nix`. Both halves wired
// for this map's own reason: the unit bakes the address into its own
// environment, so an agent whose option says no store and whose env says
// otherwise would have a check that is simply not there.
("HIVE_AGENT_BAO_ADDR", "services.hyperhive.agent.bao.addr"),
// Read at build time to bake the root-fetch URL into
// `nix/agent-modules/hive-ca.nix`'s unit.
(
"HIVE_AGENT_BAO_SERVICES_PKI_MOUNT",
"services.hyperhive.agent.bao.servicesPkiMount",
),
// Read at build time to decide whether `swarm-logs` is installed at all —
// see `nix/agent-modules/logs.nix`. Both halves wired for this map's own
// reason: an agent whose option says no log store and whose env says
// otherwise has a binary that is not on PATH and coordinates that claim
// it is.
(
"HIVE_AGENT_LOGS_QUERY_URL",
"services.hyperhive.agent.logs.queryUrl",
),
// The same for `swarm-metrics`: see `nix/agent-modules/metrics.nix`.
(
"HIVE_AGENT_METRICS_URL",
"services.hyperhive.agent.metrics.url",
),
("HIVE_FORGE_URL", "services.hyperhive.agent.forge.url"),
("HIVE_MATRIX_URL", "services.hyperhive.agent.matrix.url"),
("HYPERHIVE_SWARM_NAME", "services.hyperhive.agent.swarmName"),
];
/// Render the forwarded-var option assignments for one agent's module block.
///
/// Split out of `render_flake` so it can be tested without touching process
/// env: the render-level tests have to `set_var`, which forces them to
/// serialise on the crate's `test_env` lock (a `#[cfg(test)]` module, so this
/// deliberately isn't an intra-doc link). A pure function over the
/// already-collected pairs has no such hazard.
///
/// A var that isn't present emits nothing rather than a guess. For an optional
/// service that is the whole point — the agent option defaults to `null`,
/// meaning "not configured", and the units that would use it aren't generated.
/// For a service the hive cannot run without, silence would instead produce a
/// fleet of agents quietly missing an integration, so the caller checks
/// those before rendering.
pub fn push_forwarded_var_options(out: &mut String, vars: &[(&'static str, String)]) {
use std::fmt::Write as _;
for (var, val) in vars {
let Some((_, option)) = FORWARDED_VAR_OPTIONS.iter().find(|(name, _)| name == var) else {
continue;
};
let escaped = val.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out, " {option} = \"{escaped}\";");
}
}
/// 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.
#[must_use]
pub fn canonical_inputs_in_lock(raw: &str) -> Vec<&'static str> {
let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) else {
return Vec::new();
};
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
return Vec::new();
};
let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else {
return Vec::new();
};
let Some(root_inputs) = nodes
.get(root_name)
.and_then(|n| n.get("inputs"))
.and_then(|v| v.as_object())
else {
return Vec::new();
};
CANONICAL_INPUTS
.iter()
.copied()
.filter(|canon| root_inputs.contains_key(*canon))
.collect()
}
/// Render the meta flake. `vars` are the [`FORWARDED_VARS`] pairs with a
/// value; `github_disabled` turns the agents' GitHub integration off. Errors
/// on a custom agent without an [`AgentPin`].
pub fn render_flake(
hyperhive_url: &str,
agents: &[AgentSpec],
vars: &[(&'static str, String)],
github_disabled: bool,
) -> Result<String> {
let mut out = String::new();
push_inputs(&mut out, hyperhive_url, agents)?;
push_mk_agent_open(&mut out);
push_agent_service_options(&mut out, vars, github_disabled);
push_agent_environment(&mut out, vars);
push_agent_entries(&mut out, agents);
Ok(out)
}
/// The flake header and `inputs` block: `hyperhive`, `nixpkgs`, and one input
/// per config-repo agent.
fn push_inputs(out: &mut String, hyperhive_url: &str, agents: &[AgentSpec]) -> Result<()> {
use std::fmt::Write as _;
out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n");
// `hyperhive` is pinned by forge URL and rev, so the flake resolves on
// any machine that can reach the forge. `nixpkgs` follows hyperhive's
// own pin, which cascades to every agent because
// `agent-<n>.inputs.nixpkgs.follows = "nixpkgs"` resolves to this node.
let _ = writeln!(out, " hyperhive.url = \"{hyperhive_url}\";");
out.push_str(" nixpkgs.follows = \"hyperhive/nixpkgs\";\n");
// Each agent's config input is its forge repo at the pinned commit, so
// the flake text depends only on (agent, rev) and resolves anywhere that
// can reach the forge.
for spec in agents {
// An archetype agent has no config repo, so no input: `mkAgent`
// takes its module from the archetype.
if spec.archetype.is_some() {
continue;
}
let pin = spec.pin.as_ref().with_context(|| {
format!(
"agent {}: no pinned rev, refusing to render an unpinned config input",
spec.name
)
})?;
let _ = writeln!(
out,
" agent-{name}.url = \"{url}\";",
name = spec.name,
url = pin.url,
);
// For each canonical input the agent's `flake.lock` declares at the
// pinned commit, emit
// `inputs.agent-<name>.inputs.<canon>.follows = "<canon>"`.
// Collapses otherwise-separate-but-identical nixpkgs nodes
// (root + every agent's own nixpkgs) into one. Skipped
// silently for agents that don't declare the input — emitting
// follows on a non-existent input would error at
// `nix flake lock` time.
for canon in &pin.canonical_inputs {
let _ = writeln!(
out,
" agent-{}.inputs.{canon}.follows = \"{canon}\";",
spec.name,
);
}
}
Ok(())
}
/// Closes `inputs`, opens `outputs` and the `mkAgent` function up to the
/// start of the per-agent module.
fn push_mk_agent_open(out: &mut String) {
out.push_str(
" };\n outputs =\n { self, hyperhive, ... }@inputs:\n let\n mkAgent = { name, port, archetype ? null }:\n",
);
out.push_str(
r#" let
base = hyperhive.nixosConfigurations.agent-base;
# Every flake an archetype can come from, by its source name.
archetypeSources = { inherit hyperhive; };
agentModule =
if archetype == null then
inputs."agent-${name}".nixosModules.default
else
(archetypeSources.${archetype.source}
or (throw "agent ${name}: archetype source ${archetype.source} is not an input of this hive's meta flake")
).hyperhiveArchetypes.${archetype.name}.module
or (throw "agent ${name}: this hive's ${archetype.source} input has no archetype ${archetype.name}; update that input");
service = "hive-agent";
in
base.extendModules {
modules = [
agentModule
{
"#,
);
}
/// Service URL options and the GitHub override inside the per-agent module.
fn push_agent_service_options(
out: &mut String,
vars: &[(&'static str, String)],
github_disabled: bool,
) {
// Agent-facing service URLs (`services.hyperhive.agent.forge.url`, `services.hyperhive.agent.matrix.url`):
// emit the host's real values as build-time agent config, the same
// host-state -> agent-module shape as the CA block above.
//
// These options exist already, but until now *nothing set them*, so every
// agent silently fell back to their `localhost:<port>` defaults while the
// true value reached the container only as an env var (below). Two sources
// of truth that disagree, with the winner decided by which code path
// happens to read which one --- the option default is baked into scripts
// at build time, the env var is read at runtime.
//
// Rendering them here makes the option the single source, which is the
// precondition for removing those defaults: a loopback address is only
// ever correct when the callee shares the caller's netns, and the forge
// and homeserver are moving to swarm level, possibly on other hosts.
//
// Absent vars emit nothing rather than a guess, and the agent option
// treats "unset" as "this service is not configured" rather than
// substituting a loopback address — an absent integration instead of a
// misdirected one.
//
// That is the right default for an optional service and the wrong one for
// the forge, which a running hive always has, so the caller rejects a
// missing `HIVE_FORGE_URL` before it renders. The check is not here because
// rendering is a pure string operation the tests exercise directly.
push_forwarded_var_options(out, vars);
// GitHub integration is on by default in every agent
// (`services.hyperhive.agent.github.enable`); the host turns it off swarm-wide via
// `services.hyperhive.swarm.github.enable = false`, passed here as
// `github_disabled`. Only the OFF
// override is propagated — the enabled default needs no per-agent line.
if github_disabled {
out.push_str(" services.hyperhive.agent.github.enable = false;\n");
}
}
/// The rest of the per-agent module: user, hostname, git identity, and the
/// container-wide and harness-service environments.
fn push_agent_environment(out: &mut String, vars: &[(&'static str, String)]) {
use std::fmt::Write as _;
out.push_str(
r#" # The harness service inside the container runs as a
# non-root unix user named after the agent (`damocles`,
# `iris`, `root`, …). UID auto-assigned by NixOS; the
# per-agent override here is what makes
# `services.hyperhive.agent.user.name` match the agent's identity
# instead of the harness default of `"agent"`.
services.hyperhive.agent.user.name = name;
# Without this an agent container is called `nixos` and ships
# every log line under that: nixpkgs sets the hostname from the
# attr name in the `containers.<name>` submodule, and an
# agent's system is its own `nixosSystem` off this flake, which
# never evaluates that option. The machine name, not the
# logical one — it is what `container.name` on our metrics and
# nspawn itself already call this container, so logs join to
# both without a prefix transform.
networking.hostName = "h-${name}";
programs.git.config.user = {
name = name;
email = "${name}@hyperhive.local";
};
# Container-wide env: every service + co-process daemon can
# resolve the agent's durable state dir without hard-coding it.
# `environment.variables` only writes /etc/environment (login
# shells); `systemd.globalEnvironment` is the analogue for
# systemd units so forge-avatar-sync /
# hive-matrix-daemon etc. can read `$HYPERHIVE_STATE_DIR`
# without each service having to redeclare it.
environment.variables = {
HIVE_LABEL = name;
HYPERHIVE_STATE_DIR = "/agents/${name}/state";
HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness";
};
systemd.globalEnvironment = {
HIVE_LABEL = name;
HYPERHIVE_STATE_DIR = "/agents/${name}/state";
HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness";
"#,
);
// Forwarded vars (HIVE_FORGE_URL etc.) also go into globalEnvironment,
// not just the harness service env below, so EVERY service + shell in
// the container inherits them — crucially the bash-task runner (where
// `hive-forge` + `git` actually run), plus the matrix daemon, forge-avatar-sync
// and interactive shells. Scoped to the harness service alone they were
// invisible to bash tasks: harmless in shared netns (the localhost
// default works) but broken under isolation, where the in-cluster
// `forge.<domain>` URL is the only reachable path.
for (var, val) in vars {
let escaped = val.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out, " {var} = \"{escaped}\";");
}
out.push_str(
r" };
systemd.services.${service}.environment = {
HIVE_PORT = toString port;
HIVE_LABEL = name;
",
);
// Forwarded env vars, as the caller passed them. We copy whatever's set into
// each sub-agent's harness service env so the in-container surfaces
// (`identity.rs`, `forge_notify`) see a consistent view across the
// whole hive. Absent host-side env (option not set) → skip emission
// → in-container accessors fall back to None / defaults gracefully.
//
// - HIVE_FORGE_URL: agents poll this for Forgejo notifications.
// - HYPERHIVE_SWARM_NAME: human display name for the swarm.
for (var, val) in vars {
let escaped = val.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out, " {var} = \"{escaped}\";");
}
out.push_str(
r#" HYPERHIVE_STATE_DIR = "/agents/${name}/state";
HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness";
};
}
# Tells the harness the agent has no config repo of its own.
(if archetype == null then { } else {
systemd.services.${service}.environment.HIVE_ARCHETYPE = archetype.name;
})
];
};
in
{
nixosConfigurations = {
"#,
);
}
/// One `mkAgent` line per agent, closing `outputs`.
fn push_agent_entries(out: &mut String, agents: &[AgentSpec]) {
for spec in agents {
out.push_str(&agent_entry(&spec.name, spec.port, spec.archetype.as_ref()));
out.push('\n');
}
out.push_str(" };\n };\n}\n");
}
/// One agent's `mkAgent` line in `nixosConfigurations`, without its newline.
fn agent_entry(name: &str, port: u16, archetype: Option<&ArchetypeRef>) -> String {
// `source` and `name` are identifiers (`archetypes::store_read`), so
// they need no escaping.
let archetype = archetype.map_or(String::new(), |a| {
format!(
" archetype = {{ source = \"{}\"; name = \"{}\"; }};",
a.source, a.name
)
});
format!(" {name} = mkAgent {{ name = \"{name}\"; port = {port};{archetype} }};")
}
/// Web UI port range, shared by every agent including the manager.
/// Deterministic from the agent's name (FNV-1a hash mod range size), so the
/// dashboard can compute the same port without asking hive-c0re.
const WEB_PORT_BASE: u16 = 8100;
const WEB_PORT_RANGE: u16 = 900;
/// FNV-1a hash of a string — used by `agent_web_port`.
fn fnv1a(s: &str) -> u32 {
let mut hash: u32 = 2_166_136_261;
for b in s.bytes() {
hash ^= u32::from(b);
hash = hash.wrapping_mul(16_777_619);
}
hash
}
/// Per-agent web UI port — `WEB_PORT_BASE + FNV-1a(name) %
/// WEB_PORT_RANGE` for every agent including the manager. The port
/// allocation rule reads the same for every name; collisions are
/// possible (birthday paradox at ~30 agents) and the operator
/// resolves them by renaming an agent (different hash → different
/// port). Stable across hosts, restarts, and dashboard renders —
/// no state-file dance.
#[must_use]
pub fn agent_web_port(name: &str) -> u16 {
// Modulo of a u32 by a u16's value is guaranteed < u16::MAX, so try_from never fails.
WEB_PORT_BASE + u16::try_from(fnv1a(name) % u32::from(WEB_PORT_RANGE)).unwrap_or(0)
}
/// Whether `rev` is a full git commit id: 40 lowercase hex digits.
#[must_use]
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 commit `input` is pinned to in a flake [`render_flake`] rendered:
/// the `<rev>` of its `<input>.url = "git+<url>?rev=<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 (_, rev) = url.split_once("?rev=")?;
let rev = rev.split('&').next()?;
is_rev(rev).then_some(rev)
}
/// Whether a flake [`render_flake`] rendered builds agent `name` the way the
/// caller wants it built: from `archetype` when that is set, otherwise from
/// its own config repo at the commit `config_head`. A flake that lists the
/// agent under another archetype, or as custom where `archetype` is set (or
/// the reverse), does not; nor does a custom agent with no `config_head`.
#[must_use]
pub fn builds_agent_at(
flake_nix: &str,
name: &str,
archetype: Option<&ArchetypeRef>,
config_head: Option<&str>,
) -> bool {
let entry = format!("\n{}\n", agent_entry(name, agent_web_port(name), archetype));
if !flake_nix.contains(&entry) {
return false;
}
archetype.is_some()
|| config_head
.is_some_and(|head| pinned_rev(flake_nix, &format!("agent-{name}")) == Some(head))
}
#[cfg(test)]
mod tests {
use super::*;
const HYPERHIVE: &str = "1111111111111111111111111111111111111111";
const ARGUS: &str = "2222222222222222222222222222222222222222";
fn spec(name: &str, archetype: Option<ArchetypeRef>, pin: Option<AgentPin>) -> AgentSpec {
AgentSpec {
name: name.to_owned(),
port: agent_web_port(name),
archetype,
pin,
}
}
fn rendered() -> String {
let url = |repo: &str, rev: &str| {
pinned_git_url(&format!("https://f.t/{repo}.git"), Some(rev)).unwrap()
};
render_flake(
&url("hyperhive/hyperhive", HYPERHIVE),
&[
spec(
"argus",
None,
Some(AgentPin {
url: url("agent-configs/argus", ARGUS),
canonical_inputs: vec![],
}),
),
spec(
"rev",
Some(ArchetypeRef {
source: "hyperhive".to_owned(),
name: "reviewer".to_owned(),
}),
None,
),
],
&[],
false,
)
.unwrap()
}
#[test]
fn a_rendered_flake_names_the_commit_each_input_is_pinned_to() {
let flake = rendered();
assert_eq!(pinned_rev(&flake, "hyperhive"), Some(HYPERHIVE));
assert_eq!(pinned_rev(&flake, "agent-argus"), Some(ARGUS));
assert_eq!(
pinned_rev(&flake, "agent-rev"),
None,
"an archetype has no input"
);
assert_eq!(pinned_rev(&flake, "agent-ghost"), None);
}
#[test]
fn a_pin_that_is_not_a_commit_id_is_no_pin() {
let flake = " hyperhive.url = \"git+https://f.t/h.git?rev=main\";\n";
assert_eq!(pinned_rev(flake, "hyperhive"), None);
assert_eq!(
pinned_rev(
" hyperhive.url = \"git+https://f.t/h.git\";\n",
"hyperhive"
),
None
);
}
fn reviewer() -> ArchetypeRef {
ArchetypeRef {
source: "hyperhive".to_owned(),
name: "reviewer".to_owned(),
}
}
#[test]
fn a_custom_agent_is_built_only_at_the_config_head_the_flake_pins() {
let flake = rendered();
assert!(builds_agent_at(&flake, "argus", None, Some(ARGUS)));
assert!(
!builds_agent_at(&flake, "argus", None, Some(HYPERHIVE)),
"a stale pin"
);
assert!(
!builds_agent_at(&flake, "argus", None, None),
"a custom agent with no config commit"
);
assert!(
!builds_agent_at(&flake, "ghost", None, Some(ARGUS)),
"an agent the flake lacks"
);
}
#[test]
fn an_archetype_agent_is_built_only_from_the_archetype_the_flake_lists() {
let flake = rendered();
assert!(builds_agent_at(&flake, "rev", Some(&reviewer()), None));
let other = ArchetypeRef {
name: "planner".to_owned(),
..reviewer()
};
assert!(
!builds_agent_at(&flake, "rev", Some(&other), None),
"another archetype of the same source"
);
assert!(
!builds_agent_at(&flake, "rev", None, Some(ARGUS)),
"listed as an archetype agent, wanted as a custom one"
);
assert!(
!builds_agent_at(&flake, "argus", Some(&reviewer()), None),
"listed as a custom agent, wanted as an archetype one"
);
assert!(!builds_agent_at(&flake, "ghost", Some(&reviewer()), None));
}
/// The text of `mkAgent`'s `let` binding `name`: from `name =` to the
/// next binding at the same indent (`;` ends it at line end).
fn binding<'a>(flake: &'a str, name: &str) -> &'a str {
let from = flake
.find(&format!("\n {name} =\n"))
.or_else(|| flake.find(&format!("\n {name} = ")))
.unwrap_or_else(|| panic!("no `{name}` binding in mkAgent"));
let rest = &flake[from + 1..];
let end = rest
.find("\n service =")
.unwrap_or_else(|| panic!("`{name}` is the last binding"));
&rest[..end]
}
#[test]
fn an_archetype_agent_s_module_is_the_archetype_source_s_module() {
let flake = rendered();
let module = binding(&flake, "agentModule");
assert!(
module.contains("archetypeSources.${archetype.source}"),
"the archetype branch picks its source by name: {module}"
);
assert!(
module.contains(".hyperhiveArchetypes.${archetype.name}.module"),
"the archetype branch imports the archetype's module: {module}"
);
// The source it picks from must offer the hyperhive input.
assert!(
binding(&flake, "archetypeSources").contains("inherit hyperhive;"),
"the `hyperhive` archetype source"
);
// The module reaches the agent's system.
let modules = &flake[flake.find("modules = [").expect("a module list")..];
assert!(
modules
.trim_start_matches("modules = [")
.trim_start()
.starts_with("agentModule"),
"agentModule heads the extendModules list"
);
// The entry names the source and archetype the import reads.
assert!(
flake.contains("rev = mkAgent { name = \"rev\"; port = ")
&& flake
.contains(" archetype = { source = \"hyperhive\"; name = \"reviewer\"; }; };"),
"the archetype agent's entry"
);
}
#[test]
fn an_agent_whose_name_ends_another_s_is_not_that_agent() {
let flake = rendered();
assert!(!builds_agent_at(&flake, "gus", None, Some(ARGUS)));
assert!(!builds_agent_at(&flake, "v", Some(&reviewer()), None));
}
}