lifecycle: module-only agent flake.nix, tracked in proposed

setup_proposed now seeds both agent.nix (a regular NixOS module
function) and flake.nix (boilerplate exporting nixosModules.default
= import ./agent.nix) into the manager-editable proposed repo,
committed together. setup_applied's hyperhive_flake + dashboard
port wrapper generation is deleted entirely — the meta flake at
/var/lib/hyperhive/meta/ now owns the wrapper module. setup_
applied just fetches proposed's main on first spawn and tags
deployed/0; subsequent rebuilds touch nothing in applied that
the manager didn't author. spawn + rebuild keep their old param
list with the now-unused hyperhive_flake + dashboard_port
underscored — call sites get cleaned up after the meta module
lands and consumes them.
This commit is contained in:
müde 2026-05-16 00:10:06 +02:00
parent a1cfb60fd0
commit 5b5a93e0c6

View file

@ -80,9 +80,12 @@ pub fn is_manager(name: &str) -> bool {
name == MANAGER_NAME
}
/// The nixosConfiguration in the hyperhive flake the agent's `flake.nix`
/// extends. Manager → `manager`; everyone else → `agent-base`.
/// The nixosConfiguration in the hyperhive flake the agent's
/// wrapper extends. Manager → `manager`; everyone else →
/// `agent-base`. Used by the meta-flake generator to know which
/// base to extend per agent.
#[must_use]
#[allow(dead_code)] // wired up by the meta module in a follow-up commit
pub fn flake_base(name: &str) -> &'static str {
if is_manager(name) {
"manager"
@ -136,13 +139,16 @@ async fn port_collision(self_name: &str) -> Option<String> {
#[allow(clippy::too_many_arguments)]
pub async fn spawn(
name: &str,
hyperhive_flake: &str,
// hyperhive_flake + dashboard_port are unused now that the meta
// flake owns the wrapper; left here as the caller surface settles
// — meta-module landing will remove them in a follow-up.
_hyperhive_flake: &str,
agent_dir: &Path,
proposed_dir: &Path,
applied_dir: &Path,
claude_dir: &Path,
notes_dir: &Path,
dashboard_port: u16,
_dashboard_port: u16,
) -> Result<()> {
validate(name)?;
if let Some(other) = port_collision(name).await {
@ -152,14 +158,7 @@ pub async fn spawn(
);
}
setup_proposed(proposed_dir, name).await?;
setup_applied(
applied_dir,
Some(proposed_dir),
name,
hyperhive_flake,
dashboard_port,
)
.await?;
setup_applied(applied_dir, Some(proposed_dir), name).await?;
ensure_claude_dir(claude_dir)?;
ensure_state_dir(notes_dir)?;
let container = container_name(name);
@ -223,12 +222,14 @@ pub async fn destroy(name: &str) -> Result<()> {
pub async fn rebuild(
name: &str,
hyperhive_flake: &str,
// hyperhive_flake + dashboard_port unused after the meta-flake
// overhaul; kept on the signature until callers are reworked.
_hyperhive_flake: &str,
agent_dir: &Path,
applied_dir: &Path,
claude_dir: &Path,
notes_dir: &Path,
dashboard_port: u16,
_dashboard_port: u16,
) -> Result<()> {
validate(name)?;
if let Some(other) = port_collision(name).await {
@ -237,7 +238,7 @@ pub async fn rebuild(
agent_web_port(name)
);
}
setup_applied(applied_dir, None, name, hyperhive_flake, dashboard_port).await?;
setup_applied(applied_dir, None, name).await?;
ensure_claude_dir(claude_dir)?;
ensure_state_dir(notes_dir)?;
let container = container_name(name);
@ -272,10 +273,17 @@ pub async fn list() -> Result<Vec<String>> {
.collect())
}
/// Initialize the manager-editable proposed repo. Contains only `agent.nix`
/// (the file the manager edits). Touched by hive-c0re only on first spawn —
/// never again — so the manager can't be surprised by hive-c0re commits or
/// working-tree resets.
/// Initialize the manager-editable proposed repo. Seeds two tracked
/// files: `agent.nix` (the module the manager edits) and `flake.nix`
/// (the boilerplate that lets the meta flake import this repo as an
/// input — meta locks at a specific sha and reads
/// `nixosModules.default`, so `flake.nix` must be in the commit). The
/// manager shouldn't edit `flake.nix` (the prompt says so) but it's
/// visible so they can introspect.
///
/// Touched by hive-c0re only on first spawn — never again — so the
/// manager can't be surprised by hive-c0re commits or working-tree
/// resets.
pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> {
if proposed_dir.join(".git").exists() {
return Ok(());
@ -287,36 +295,37 @@ pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> {
std::fs::write(&agent_path, initial_agent_nix(name))
.with_context(|| format!("write {}", agent_path.display()))?;
}
let flake_path = proposed_dir.join("flake.nix");
if !flake_path.exists() {
std::fs::write(&flake_path, initial_flake_nix())
.with_context(|| format!("write {}", flake_path.display()))?;
}
git(proposed_dir, &["init", "--initial-branch=main"]).await?;
git(proposed_dir, &["add", "agent.nix"]).await?;
git(proposed_dir, &["add", "agent.nix", "flake.nix"]).await?;
git_commit(proposed_dir, "hive-c0re init").await?;
Ok(())
}
/// Set up the applied repo. Two responsibilities:
/// - First-spawn only: init the repo, pull proposed's initial commit
/// in via `git fetch`, tag it `deployed/0`. This is the *only* time
/// hive-c0re reads from `proposed` for an agent — subsequent
/// proposals are fetched at `request_apply_commit` time and tagged
/// `proposal/<id>` (see `actions::approve` for the tag state
/// machine).
/// - Every call: regenerate the untracked `flake.nix` so flake-url /
/// dashboard-port changes pick up on rebuild without churning the
/// git log.
/// Set up the applied repo. First-spawn only: init the repo, pull
/// proposed's initial commit in via `git fetch`, tag it `deployed/0`.
/// This is the *only* time hive-c0re reads from `proposed` for an
/// agent — subsequent proposals are fetched at `request_apply_commit`
/// time and tagged `proposal/<id>` (see `actions::approve` for the
/// tag state machine).
///
/// `proposed_dir` is `None` on rebuild paths that just want the flake
/// refreshed.
/// `proposed_dir` is `None` on rebuild paths where the repo already
/// exists — we just verify it's the right shape and bail otherwise.
/// Unlike the pre-overhaul code path, `flake.nix` is no longer
/// regenerated at the host level: it's tracked in proposed (seeded by
/// `setup_proposed`) and rides along on every fetch.
pub async fn setup_applied(
applied_dir: &Path,
proposed_dir: Option<&Path>,
name: &str,
hyperhive_flake: &str,
dashboard_port: u16,
) -> Result<()> {
std::fs::create_dir_all(applied_dir)
.with_context(|| format!("create {}", applied_dir.display()))?;
// 1. First-spawn git init from proposed (or pre-overhaul detection).
if !applied_dir.join(".git").exists() {
let Some(proposed) = proposed_dir else {
bail!(
@ -339,60 +348,18 @@ pub async fn setup_applied(
.await
.is_err()
{
// Pre-overhaul applied repo — agent.nix is tracked directly,
// commits authored by hive-c0re, no deployed/* tag scheme.
// No in-place migration; fail loudly so the operator purges.
// Pre-overhaul applied repo — no deployed/* tag scheme,
// flake.nix may be untracked, agent.nix possibly authored by
// hive-c0re directly. The startup auto-migration fixes this
// in place; if it didn't run (or got skipped), surface a
// clear error.
bail!(
"applied repo at {} predates the tag-driven config flow. \
Run `hive-c0re destroy --purge {name}` and re-spawn.",
"applied repo at {} predates the meta-flake layout. \
Restart hive-c0re to let the auto-migration run, or \
destroy --purge {name} and re-spawn.",
applied_dir.display()
);
}
// 2. (Re)write the untracked wrapper flake. Tracked files in the
// working tree (agent.nix and anything the manager committed) are
// untouched.
let port = agent_web_port(name);
let base = flake_base(name);
let service = if is_manager(name) {
"hive-m1nd"
} else {
"hive-ag3nt"
};
let description = if is_manager(name) {
format!("hyperhive manager {name}")
} else {
format!("hyperhive sub-agent {name}")
};
let flake_body = format!(
r#"{{
description = "{description}";
inputs.hyperhive.url = "{hyperhive_flake}";
outputs =
{{ hyperhive, ... }}:
{{
nixosConfigurations.default = hyperhive.nixosConfigurations.{base}.extendModules {{
modules = [
./agent.nix
{{
programs.git.config.user = {{
name = "{name}";
email = "{name}@hyperhive";
}};
systemd.services.{service}.environment = {{
HIVE_PORT = "{port}";
HIVE_LABEL = "{name}";
HIVE_DASHBOARD_PORT = "{dashboard_port}";
}};
}}
];
}};
}};
}}
"#,
);
std::fs::write(applied_dir.join("flake.nix"), flake_body)
.with_context(|| format!("write {}/flake.nix", applied_dir.display()))?;
Ok(())
}
@ -423,10 +390,18 @@ fn ensure_state_dir(notes_dir: &Path) -> Result<()> {
fn initial_agent_nix(name: &str) -> String {
format!(
"{{ ... }}:\n{{\n # Per-agent overrides for {name}. The manager edits this\n # file (and commits) to customise the agent's NixOS config.\n}}\n",
"{{ config, pkgs, lib, ... }}:\n{{\n # Per-agent overrides for {name}. This is a regular NixOS module\n # — add packages, services, modules, imports as needed.\n #\n # imports = [ ./extra-module.nix ];\n # environment.systemPackages = with pkgs; [ ];\n}}\n",
)
}
/// Module-only flake exposed by every agent's repo. Consumed by the
/// hive-c0re-owned meta flake at `/var/lib/hyperhive/meta/` as a flake
/// input. Identity injection (HIVE_PORT / HIVE_LABEL / dashboard port /
/// git committer) lives in the meta flake's wrapper, not here.
fn initial_flake_nix() -> &'static str {
"{\n description = \"hyperhive agent\";\n inputs = { };\n outputs = { self }: {\n nixosModules.default = import ./agent.nix;\n };\n}\n"
}
async fn git_commit(dir: &Path, message: &str) -> Result<()> {
git(
dir,