hyperhive/hive-c0re/src/meta.rs
damocles c7a8cec2b5 hive-c0re: agent_sockets.json writer (#784 phase 2, step 2a)
sibling to agent_ports.rs (#748 / #15). Writes
/var/lib/hyperhive/agent-sockets.json mapping agent name → unix
socket path under /run/hive-agent/<name>.sock — the source of truth
for the gateway's eventual unix-socket upstreams once it flips off
TCP per #784's transition plan.

shape mirrors agent_ports.rs:
- BTreeMap for deterministic JSON output
- atomic <path>.tmp + rename for partial-write safety
- idempotent (skip rewrite when content unchanged)
- MANAGER_NAME filter (gateway routes manager via dashboard upstream,
  not /agent/<n>/)

socket_path_for(name) is pure-deterministic so a name alone resolves
to the same path the c0re writes + the harness binds via
HIVE_WEB_SOCKET (post-PR #800).

hooked into meta::sync_agents alongside the existing
agent_ports::write call (same best-effort + non-fatal shape).

next: step 2b (lifecycle bind-mount /run/hive-agent/ into agent +
gateway containers, with per-agent isolation per mara's #800 nudge
'agents can only access their own sockets'). then atlas's step 3
(gateway proxy_pass http://unix:...). then step 4 drops TCP.
2026-05-31 15:38:04 +02:00

737 lines
29 KiB
Rust

//! Single hive-c0re-owned flake at `/var/lib/hyperhive/meta/` that
//! consumes every agent's applied repo as a flake input and exports one
//! `nixosConfiguration` per agent. Containers run against
//! `--flake /var/lib/hyperhive/meta#<name>`; lifecycle ops here drive the
//! lock file so meta's git log is the system-wide deploy audit trail.
//!
//! Flow:
//! - `sync_agents` (idempotent) — render `flake.nix` for the current
//! agent set, init the repo on first call, relock if the rendered
//! contents changed, commit. Used by spawn / destroy / startup
//! migration.
//! - `prepare_deploy` + `finalize_deploy` / `abort_deploy` — two-phase
//! for the `request_apply_commit` path so a failed
//! `nixos-container update` leaves no orphan commit in meta. Prepare
//! writes the new lock without committing; finalize commits with the
//! deploy message; abort `git restore`s the lock back.
//! - `lock_update_hyperhive` — one-shot for the auto-update path.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use tokio::process::Command;
use tokio::sync::Mutex;
use crate::lifecycle;
const META_ROOT: &str = "/var/lib/hyperhive/meta";
const APPLIED_ROOT: &str = "/var/lib/hyperhive/applied";
const GIT_NAME: &str = "c0re";
const GIT_EMAIL: &str = "c0re@hyperhive";
/// Single-writer lock around every meta-repo operation. Git isn't
/// safe to drive from concurrent processes against the same `.git/`
/// — two simultaneous `git add` / `commit` invocations race on
/// `.git/index.lock`; if either dies before releasing, the lock
/// sticks and the next operation hits "another git process seems to
/// be running" until somebody `rm`s it manually. Holding this mutex
/// across each public function's git+nix calls makes parallel
/// rebuilds (`auto_update` + dashboard-triggered + apply-commit)
/// take turns instead of colliding.
static META_LOCK: Mutex<()> = Mutex::const_new(());
/// Where the manager sees this directory inside its container (RO bind).
#[allow(dead_code)] // wired up by set_nspawn_flags in a follow-up commit
pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta";
#[derive(Debug, Clone)]
pub struct AgentSpec {
pub name: String,
pub is_manager: bool,
pub port: u16,
}
#[must_use]
pub fn meta_dir() -> PathBuf {
PathBuf::from(META_ROOT)
}
/// Idempotently reconcile the meta repo with the current agent set.
/// First call inits the git repo, runs `nix flake lock`, and lands a
/// seed commit. Subsequent calls only touch `flake.nix` when the
/// rendered contents differ from disk; an unchanged `flake.nix` is a
/// no-op.
#[allow(dead_code)] // first caller lands in a later commit
pub async fn sync_agents(
hyperhive_flake: &str,
dashboard_port: u16,
operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>,
agents: &[AgentSpec],
) -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = meta_dir();
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let new_flake = render_flake(
hyperhive_flake,
dashboard_port,
operator_pronouns,
context_window_tokens,
agents,
);
let flake_path = dir.join("flake.nix");
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
let initial = !dir.join(".git").exists();
if !initial && on_disk == new_flake {
return Ok(());
}
std::fs::write(&flake_path, &new_flake)
.with_context(|| format!("write {}", flake_path.display()))?;
// Reconcile topology.json against the live agent set — adds
// entries for newly-spawned agents (default: manager as parent,
// manager itself as root) and drops removed agents. Operator
// overrides via the write API (#361 follow-up) are preserved
// because reconcile only fills in missing entries. Idempotent;
// when nothing changed the file isn't touched.
let agent_names: Vec<String> = agents.iter().map(|a| a.name.clone()).collect();
let topology_changed = crate::topology::reconcile(&agent_names)
.with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?;
// Refresh /var/lib/hyperhive/agent-ports.json so the hive-gateway
// nginx sees the new agent set (#15 / #740). The file is the
// single source of truth for which agents the gateway proxies to,
// since the gateway container lives in system config and can't be
// rebuilt from meta-flake events. Atomic write (tmp + rename) so
// a partial write never trips the gateway's read.
if let Err(e) = crate::agent_ports::write(&agent_names) {
// Best-effort: a failed write doesn't block the meta-flake
// regen + container ops that follow. The gateway falls back
// to whatever map is currently on disk (or an empty map on
// first boot, meaning no per-agent routing yet).
tracing::warn!(error = ?e, "agent_ports::write failed (non-fatal)");
}
// Refresh /var/lib/hyperhive/agent-sockets.json — sibling to the
// ports map, drives the gateway's unix-socket upstreams once
// agents opt in to `HIVE_WEB_SOCKET` (PR #800 / #784 phase 1).
// Coexists with the TCP-port map during the transition: the
// gateway picks the socket upstream when one exists, falls back
// to the TCP port otherwise. Same best-effort + non-fatal shape.
if let Err(e) = crate::agent_sockets::write(&agent_names) {
tracing::warn!(error = ?e, "agent_sockets::write failed (non-fatal)");
}
if initial {
git(&dir, &["init", "--initial-branch=main"]).await?;
}
// Stage flake.nix *before* running nix flake lock. When meta is
// a git repo, nix treats it as a `git+file://` self-reference;
// its dirty-tree fetcher includes index entries (tracked +
// staged) but skips untracked files, so without the stage step
// an untracked flake.nix surfaces as "source tree does not
// contain '/flake.nix'". Lock then commit once with both
// flake.nix and flake.lock — single commit per change.
git(&dir, &["add", "flake.nix"]).await?;
// Stage topology.json on every sync (regenerated by reconcile
// above when the agent set changed). git add is a no-op when the
// file content is unchanged.
if crate::topology::topology_path().exists() {
git(&dir, &["add", "topology.json"]).await?;
}
nix(&dir, &["flake", "lock"]).await?;
if std::path::Path::new(&dir).join("flake.lock").exists() {
git(&dir, &["add", "flake.lock"]).await?;
}
let msg = if initial {
format!("seed meta from {} agent(s)", agents.len())
} else if topology_changed {
"regenerate meta flake + topology".to_owned()
} else {
"regenerate meta flake".to_owned()
};
git_commit(&dir, &msg).await?;
Ok(())
}
/// Phase 1 of an apply-commit deploy. Updates the locked rev of
/// `agent-<name>` to whatever `applied/<name>/main` currently points
/// at and **stages** the lock so `nixos-container update --flake
/// meta#<n>` (which reads via `git+file://`) sees the new rev via
/// the index. Doesn't commit — `finalize_deploy` commits on build
/// success, `abort_deploy` drops the staged change on failure so
/// meta history only carries successful deploys.
#[allow(dead_code)] // wired up by actions::run_apply_commit in a later commit
pub async fn prepare_deploy(name: &str) -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = meta_dir();
let input = format!("agent-{name}");
nix(&dir, &["flake", "update", &input]).await?;
// Stage the new lock — git+file://'s dirty-tree fetcher reads
// index entries, so the upcoming nixos-container update sees the
// bumped rev without a commit yet.
git(&dir, &["add", "flake.lock"]).await
}
/// Phase 2-success. Commit the staged lock with the deployed tag +
/// sha as the message. No-op when the rev was already at the right
/// place (nothing staged → nothing to commit).
#[allow(dead_code)]
pub async fn finalize_deploy(name: &str, sha: &str, tag: &str) -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = meta_dir();
if !has_staged_changes(&dir).await? {
return Ok(());
}
let short = &sha[..sha.len().min(12)];
git_commit(&dir, &format!("deploy {name} {tag} {short}")).await
}
/// Phase 2-failure. Unstage + restore the lock so meta returns to
/// the previously-committed shas. The failed proposal is still
/// captured in `applied/<n>`'s annotated `failed/<id>` tag.
#[allow(dead_code)]
pub async fn abort_deploy() -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = meta_dir();
git(&dir, &["restore", "--staged", "flake.lock"]).await?;
git(&dir, &["restore", "flake.lock"]).await
}
async fn has_staged_changes(dir: &Path) -> Result<bool> {
let st = lifecycle::git_command()
.current_dir(dir)
.args(["diff", "--cached", "--quiet"])
.status()
.await
.with_context(|| format!("git diff --cached in {}", dir.display()))?;
// exit 1 = differences present, 0 = no diff, other = error
match st.code() {
Some(0) => Ok(false),
Some(1) => Ok(true),
_ => bail!("git diff --cached exited unexpectedly"),
}
}
/// One-shot used by the manual-rebuild path: relock just one
/// agent's input and commit the lock change if any. Single-phase
/// (no separate finalize) because rebuild has no failure-revert
/// semantics — it always wants the latest main.
#[allow(dead_code)] // wired up by lifecycle::rebuild in this commit
pub async fn lock_update_for_rebuild(name: &str) -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = meta_dir();
let input = format!("agent-{name}");
nix(&dir, &["flake", "update", &input]).await?;
if git_is_clean(&dir).await? {
return Ok(());
}
git(&dir, &["add", "flake.lock"]).await?;
git_commit(&dir, &format!("rebuild {name}: lock update")).await
}
/// Update one or more named inputs in the meta flake and commit
/// the resulting lock change with a single combined message.
/// Used by the dashboard's "update meta inputs" form so the
/// operator can bulk-bump `hyperhive` + selected agents in one
/// shot. Each input name is passed verbatim to
/// Run `nix flake update [inputs...]` on the meta flake and commit the
/// resulting lock changes. When `inputs` is empty, updates ALL inputs
/// (bare `nix flake update`). The caller is responsible for picking
/// real input keys (e.g. via `inputs_view()` snapshotted from the lock
/// file) when targeting specific inputs.
pub async fn lock_update(inputs: &[String]) -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = meta_dir();
let mut args: Vec<&str> = vec!["flake", "update"];
for i in inputs {
args.push(i.as_str());
}
nix(&dir, &args).await?;
if git_is_clean(&dir).await? {
return Ok(());
}
git(&dir, &["add", "flake.lock"]).await?;
let msg = if inputs.is_empty() {
"lock update: all inputs".to_string()
} else if inputs.len() == 1 {
format!("lock update: {}", inputs[0])
} else {
format!("lock update: {}", inputs.join(", "))
};
git_commit(&dir, &msg).await
}
/// One-shot used by the auto-update path: pin the latest hyperhive
/// rev, commit if the lock changed. Cheaper than `sync_agents`
/// because the per-agent inputs aren't touched.
#[allow(dead_code)]
pub async fn lock_update_hyperhive() -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = meta_dir();
nix(&dir, &["flake", "update", "hyperhive"]).await?;
if git_is_clean(&dir).await? {
return Ok(());
}
git(&dir, &["add", "flake.lock"]).await?;
git_commit(&dir, "bump hyperhive").await
}
fn render_flake(
hyperhive_flake: &str,
dashboard_port: u16,
operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>,
agents: &[AgentSpec],
) -> String {
render_flake_with_lookup(
hyperhive_flake,
dashboard_port,
operator_pronouns,
context_window_tokens,
agents,
agent_canonical_inputs,
)
}
/// 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 (#355).
const CANONICAL_INPUTS: &[&str] = &["nixpkgs", "nixpkgs-unstable"];
/// Env vars hive-c0re forwards from its own systemd unit env into every
/// sub-agent's harness service env. Each entry is `(env_var_name,
/// host_value)`. Empty / unset vars are filtered out so absent options
/// don't render no-op `FOO = ""` lines into the meta flake.
///
/// Returns owned `String` values so the result is `'static`-friendly +
/// trivial to stub from tests (which build their own slice instead of
/// touching process-wide env).
const FORWARDED_VARS: &[&str] = &[
"HIVE_FORGE_URL",
"HYPERHIVE_HIVE_DOMAIN",
"HYPERHIVE_HIVE_NAME",
"HYPERHIVE_SWARM_NAME",
];
fn forwarded_env_vars() -> Vec<(&'static str, String)> {
FORWARDED_VARS
.iter()
.filter_map(|&name| {
std::env::var(name)
.ok()
.filter(|v| !v.is_empty())
.map(|v| (name, v))
})
.collect()
}
/// Read an agent's applied `flake.lock` and return the subset of
/// `CANONICAL_INPUTS` it declares as direct (root-level) inputs.
/// Returns an empty vec when the lock is missing or unparsable —
/// safe degradation, the worst case is no dedup for that agent.
fn agent_canonical_inputs(name: &str) -> Vec<&'static str> {
let path = std::path::PathBuf::from(format!("{APPLIED_ROOT}/{name}/flake.lock"));
let Ok(raw) = std::fs::read_to_string(&path) else {
return Vec::new();
};
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()
}
/// Inner render helper accepting a lookup fn so tests can stub the
/// agent flake-lock introspection.
#[allow(
clippy::too_many_lines,
reason = "templated string-builder for the meta flake — the length is one \
contiguous fmt block, splitting it would just hide the shape"
)]
fn render_flake_with_lookup<F>(
hyperhive_flake: &str,
dashboard_port: u16,
operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>,
agents: &[AgentSpec],
lookup: F,
) -> String
where
F: Fn(&str) -> Vec<&'static str>,
{
use std::fmt::Write as _;
let mut out = String::new();
out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n");
// hyperhive's own flake.nix is the single channel-pin authority.
// meta declares `nixpkgs` + `nixpkgs-unstable` as aliases for
// hyperhive's sub-inputs via `follows`, so every agent-level
// `inputs.<X>.inputs.nixpkgs.follows = "nixpkgs"` directive
// resolves transitively to hyperhive's pin. One channel decision
// in the whole tree, no second source to drift.
//
// Per mara via triage on #619: "all nixpkgs follow the one
// hyperhive was deployed with — if not, that's what we should
// fix." This is the fix (flip from the concrete-url shape
// #619 originally shipped with).
//
// Operators who want to slide the whole swarm onto a different
// channel do it at the host level via
// `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"`, which
// makes hyperhive's nixpkgs = the host's nixpkgs and cascades
// through to every agent.
//
// closes #317 invariant still satisfied — `nixpkgs` is still a
// single canonical name in the meta tree, it just resolves
// through hyperhive instead of being its own root input.
let _ = writeln!(out, " hyperhive.url = \"{hyperhive_flake}\";");
out.push_str(" nixpkgs.follows = \"hyperhive/nixpkgs\";\n");
out.push_str(" nixpkgs-unstable.follows = \"hyperhive/nixpkgs-unstable\";\n");
for spec in agents {
let _ = writeln!(
out,
" agent-{}.url = \"git+file://{APPLIED_ROOT}/{}\";",
spec.name, spec.name,
);
// For each canonical input the agent declares in its own
// `flake.nix` (detected by reading its applied `flake.lock`),
// emit `inputs.agent-<name>.inputs.<canon>.follows = "<canon>"`.
// Collapses three otherwise-separate-but-identical nixpkgs
// nodes (root + agent-bitburner's + agent-dmatrix's) into one
// (closes #355). 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 lookup(&spec.name) {
let _ = writeln!(
out,
" agent-{}.inputs.{canon}.follows = \"{canon}\";",
spec.name,
);
}
}
out.push_str(" };\n outputs =\n { self, hyperhive, ... }@inputs:\n let\n");
// Free-text operator string — escape backslash + double-quote so a
// pronouns value like `he/him \ "rare"` round-trips into a valid
// nix string literal without breaking the flake.
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(
out,
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null }}:"
);
out.push_str(
r#" let
base = if isManager
then hyperhive.nixosConfigurations.manager
else hyperhive.nixosConfigurations.agent-base;
input = inputs."agent-${name}";
service = if isManager then "hive-m1nd" else "hive-ag3nt";
parentEnv = if parent == null then {} else { HIVE_PARENT = parent; };
in
base.extendModules {
modules = [
input.nixosModules.default
{
# Drop root (#658): the harness service inside the
# container runs as a non-root unix user named after
# the agent (`damocles`, `iris`, `hm1nd`, …). UID
# auto-assigned by NixOS per mara on #658; the per-
# agent override here is what makes `hyperhive.user.name`
# match the agent's identity instead of the harness-
# base default of `"agent"`.
hyperhive.user.name = name;
programs.git.config.user = {
name = name;
email = "${name}@hyperhive";
};
# 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 tea-login / forge-avatar-sync /
# matrix-avatar-sync etc. can read `$HYPERHIVE_STATE_DIR`
# without each service having to redeclare it (#604).
environment.variables = {
HIVE_LABEL = name;
HYPERHIVE_STATE_DIR = "/agents/${name}/state";
};
systemd.globalEnvironment = {
HIVE_LABEL = name;
HYPERHIVE_STATE_DIR = "/agents/${name}/state";
};
systemd.services.${service}.environment = parentEnv // {
HIVE_PORT = toString port;
HIVE_LABEL = name;
HIVE_DASHBOARD_PORT = toString dashboardPort;
HIVE_OPERATOR_PRONOUNS = operatorPronouns;"#,
);
// Per-model context-window env vars declared in the host-level
// `services.hive-c0re.contextWindowTokens` option. Use a sorted
// iterator for deterministic flake output (no spurious git diffs).
let mut sorted_tokens: Vec<(&String, &u64)> = context_window_tokens.iter().collect();
sorted_tokens.sort_by_key(|(k, _)| k.as_str());
for (key, val) in &sorted_tokens {
let upper_key = key.to_ascii_uppercase();
let _ = writeln!(
out,
" HIVE_CONTEXT_WINDOW_TOKENS_{upper_key} = \"{val}\";"
);
}
// Forwarded env vars — picked up from hive-c0re's own systemd unit
// env (`services.hyperhive.*` options flow through nix/modules/
// hive-c0re.nix into the host process). 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_HIVE_DOMAIN: machine-readable hive DNS (#589).
// - HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME: human display
// names for hive + swarm (#701).
for (var, val) in forwarded_env_vars() {
let escaped = val.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out, " {var} = \"{escaped}\";");
}
out.push_str(
r#" HYPERHIVE_STATE_DIR = "/agents/${name}/state";
};
}
];
};
in
{
nixosConfigurations = {
"#,
);
// Pull the topology map once and look up each agent's parent. An
// empty / absent topology.json yields `parent = null` for everyone
// — equivalent to the pre-#361 status quo (every container at root).
// `meta::sync_agents` seeds the file on first run with manager as
// root + everyone else under manager.
let topology = crate::topology::read();
for spec in agents {
let parent_attr = topology
.get(&spec.name)
.and_then(|p| p.as_ref())
.map_or_else(|| "null".to_owned(), |p| format!("\"{p}\""));
let _ = writeln!(
out,
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; }};",
spec.name,
spec.name,
if spec.is_manager { "true" } else { "false" },
spec.port,
parent_attr,
);
}
out.push_str(" };\n };\n}\n");
out
}
async fn git_is_clean(dir: &Path) -> Result<bool> {
let out = lifecycle::git_command()
.current_dir(dir)
.args(["status", "--porcelain"])
.output()
.await
.with_context(|| format!("git status in {}", dir.display()))?;
Ok(out.stdout.iter().all(u8::is_ascii_whitespace))
}
async fn git(dir: &Path, args: &[&str]) -> Result<()> {
let out = lifecycle::git_command()
.current_dir(dir)
.args(args)
.output()
.await
.with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?;
if !out.status.success() {
bail!(
"git {} failed ({}): {}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
async fn git_commit(dir: &Path, message: &str) -> Result<()> {
git(
dir,
&[
"-c",
&format!("user.name={GIT_NAME}"),
"-c",
&format!("user.email={GIT_EMAIL}"),
"commit",
"-m",
message,
],
)
.await?;
// Best-effort mirror to the bundled forge. No-op when the forge
// isn't seeded (no core token on disk); push failures log a warn
// but don't bubble up — a missing mirror shouldn't fail an
// otherwise successful deploy.
if let Err(e) = crate::forge::push_meta(dir).await {
tracing::warn!(error = ?e, "forge: meta push after commit failed (non-fatal)");
}
Ok(())
}
async fn nix(dir: &Path, args: &[&str]) -> Result<()> {
// `--extra-experimental-features` belt-and-suspenders for hosts
// that haven't set this in nix.conf. The hyperhive module's
// deploy guide assumes flakes are already enabled, but the cost
// of being defensive is one extra argv each call.
let mut all = vec!["--extra-experimental-features", "nix-command flakes"];
all.extend(args);
let out = Command::new("nix")
.current_dir(dir)
.args(&all)
.output()
.await
.with_context(|| format!("nix {} in {}", args.join(" "), dir.display()))?;
if !out.status.success() {
bail!(
"nix {} failed ({}): {}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_spec(name: &str, is_manager: bool, port: u16) -> AgentSpec {
AgentSpec {
name: name.to_owned(),
is_manager,
port,
}
}
#[test]
fn render_flake_aliases_nixpkgs_to_hyperhive() {
let out = render_flake(
"github:example/hyperhive",
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
);
// Meta's `nixpkgs` + `nixpkgs-unstable` are aliases for
// hyperhive's sub-inputs. Single channel-pin authority:
// hyperhive's own flake.nix. Per mara via triage on #619:
// "all nixpkgs follow the one hyperhive was deployed with."
assert!(
out.contains("nixpkgs.follows = \"hyperhive/nixpkgs\""),
"missing nixpkgs follows alias:\n{out}"
);
assert!(
out.contains("nixpkgs-unstable.follows = \"hyperhive/nixpkgs-unstable\""),
"missing nixpkgs-unstable follows alias:\n{out}"
);
// And conversely: no literal channel ref baked into meta. If
// this fails, someone reintroduced a hardcoded ref — see
// #619 follow-up rationale for why that drifts.
assert!(
!out.contains("nixpkgs.url ="),
"no literal `nixpkgs.url` should be emitted (hyperhive owns the pin):\n{out}"
);
}
// `render_flake_collapses_hyperhive_nixpkgs_via_follows` dropped:
// with meta's `nixpkgs.follows = "hyperhive/nixpkgs"`, there's no
// separate meta-level nixpkgs to collapse hyperhive's into. The
// redirect goes the other way now (the alias test above covers
// the new invariant).
#[test]
fn render_flake_emits_follows_for_agents_declaring_nixpkgs() {
// Stub lookup: pretend `bitburner` declares `nixpkgs` at its
// root, while `argus` has no canonical inputs at all.
let lookup = |name: &str| -> Vec<&'static str> {
match name {
"bitburner" => vec!["nixpkgs"],
"dmatrix" => vec!["nixpkgs", "nixpkgs-unstable"],
_ => vec![],
}
};
let out = render_flake_with_lookup(
"github:example/hyperhive",
8000,
"she/her",
&std::collections::HashMap::new(),
&[
sample_spec("argus", false, 9001),
sample_spec("bitburner", false, 9002),
sample_spec("dmatrix", false, 9003),
],
lookup,
);
// bitburner declares nixpkgs → follows emitted.
assert!(
out.contains("agent-bitburner.inputs.nixpkgs.follows = \"nixpkgs\""),
"missing bitburner nixpkgs follows:\n{out}"
);
// dmatrix declares both → both follows emitted.
assert!(out.contains("agent-dmatrix.inputs.nixpkgs.follows = \"nixpkgs\""));
assert!(
out.contains("agent-dmatrix.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\"")
);
// argus declares neither → no follows emitted for it. Asserting
// ABSENCE is the important bit: emitting a follows on a
// non-existent input errors at `nix flake lock` time.
assert!(
!out.contains("agent-argus.inputs.nixpkgs"),
"argus shouldn't have nixpkgs follows:\n{out}"
);
}
#[test]
fn render_flake_skips_canonical_follows_when_lookup_returns_empty() {
let out = render_flake_with_lookup(
"github:example/hyperhive",
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
|_| Vec::new(),
);
// No agent-side follows when the lookup reports nothing
// declared — protects agents whose flake.lock can't be read
// (missing / unparsable) from being broken by a follows on a
// non-existent input.
assert!(
!out.contains("agent-alice.inputs."),
"alice shouldn't have any inputs follows:\n{out}"
);
}
}