Phase 5c: separate proposed (manager) and applied (hive-c0re) repos; per-agent gitconfig

This commit is contained in:
müde 2026-05-14 23:20:32 +02:00
parent f6d681c2e2
commit 2fd80dbd68
4 changed files with 147 additions and 64 deletions

View file

@ -40,12 +40,14 @@ pub async fn spawn(
name: &str,
hyperhive_flake: &str,
agent_dir: &Path,
config_dir: &Path,
proposed_dir: &Path,
applied_dir: &Path,
) -> Result<()> {
validate(name)?;
setup_config(config_dir, name, hyperhive_flake).await?;
setup_proposed(proposed_dir, name).await?;
setup_applied(applied_dir, name, hyperhive_flake).await?;
let container = container_name(name);
let flake_ref = format!("{}#default", config_dir.display());
let flake_ref = format!("{}#default", applied_dir.display());
run(&["create", &container, "--flake", &flake_ref]).await?;
set_nspawn_flags(&container, agent_dir)?;
run(&["start", &container]).await
@ -61,12 +63,12 @@ pub async fn rebuild(
name: &str,
hyperhive_flake: &str,
agent_dir: &Path,
config_dir: &Path,
applied_dir: &Path,
) -> Result<()> {
validate(name)?;
setup_config(config_dir, name, hyperhive_flake).await?;
setup_applied(applied_dir, name, hyperhive_flake).await?;
let container = container_name(name);
let flake_ref = format!("{}#default", config_dir.display());
let flake_ref = format!("{}#default", applied_dir.display());
set_nspawn_flags(&container, agent_dir)?;
run(&["update", &container, "--flake", &flake_ref]).await?;
// Restart so any nspawn-level changes (bind mounts, networking, etc.) apply.
@ -95,15 +97,34 @@ pub async fn list() -> Result<Vec<String>> {
.collect())
}
/// Ensure `config_dir` exists as a git repo containing a per-agent flake. The
/// `flake.nix` is rewritten every call (so a new hyperhive store path
/// propagates on rebuild); `agent.nix` is written only the first time
/// (manager-editable thereafter).
pub async fn setup_config(config_dir: &Path, name: &str, hyperhive_flake: &str) -> Result<()> {
std::fs::create_dir_all(config_dir)
.with_context(|| format!("create {}", config_dir.display()))?;
/// 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.
pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> {
if proposed_dir.join(".git").exists() {
return Ok(());
}
std::fs::create_dir_all(proposed_dir)
.with_context(|| format!("create {}", proposed_dir.display()))?;
let agent_path = proposed_dir.join("agent.nix");
if !agent_path.exists() {
std::fs::write(&agent_path, initial_agent_nix(name))
.with_context(|| format!("write {}", agent_path.display()))?;
}
git(proposed_dir, &["init", "--initial-branch=main"]).await?;
git(proposed_dir, &["add", "agent.nix"]).await?;
git_commit(proposed_dir, "hive-c0re init").await?;
Ok(())
}
/// Maintain the authoritative applied repo. Rewrites `flake.nix` every call
/// (so a new hyperhive flake URL propagates on rebuild); seeds `agent.nix`
/// only on first call. `apply_commit` overwrites `agent.nix` later.
pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str) -> Result<()> {
std::fs::create_dir_all(applied_dir)
.with_context(|| format!("create {}", applied_dir.display()))?;
let flake_path = config_dir.join("flake.nix");
let flake_body = format!(
r#"{{
description = "hyperhive sub-agent {name}";
@ -112,64 +133,96 @@ pub async fn setup_config(config_dir: &Path, name: &str, hyperhive_flake: &str)
{{ hyperhive, ... }}:
{{
nixosConfigurations.default = hyperhive.nixosConfigurations.agent-base.extendModules {{
modules = [ ./agent.nix ];
modules = [
./agent.nix
{{
environment.etc."gitconfig".text = ''
[user]
name = {name}
email = {name}@hyperhive
[init]
defaultBranch = main
'';
}}
];
}};
}};
}}
"#,
);
std::fs::write(&flake_path, flake_body)
.with_context(|| format!("write {}", flake_path.display()))?;
std::fs::write(applied_dir.join("flake.nix"), flake_body)
.with_context(|| format!("write {}/flake.nix", applied_dir.display()))?;
let agent_path = config_dir.join("agent.nix");
let agent_path = applied_dir.join("agent.nix");
if !agent_path.exists() {
let initial = 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",
);
std::fs::write(&agent_path, initial)
std::fs::write(&agent_path, initial_agent_nix(name))
.with_context(|| format!("write {}", agent_path.display()))?;
}
if !config_dir.join(".git").exists() {
git(config_dir, &["init", "--initial-branch=main"]).await?;
if !applied_dir.join(".git").exists() {
git(applied_dir, &["init", "--initial-branch=main"]).await?;
}
git(config_dir, &["add", "-A"]).await?;
let clean = git_status(config_dir, &["diff", "--cached", "--quiet"]).await?;
git(applied_dir, &["add", "-A"]).await?;
let clean = git_status(applied_dir, &["diff", "--cached", "--quiet"]).await?;
if !clean {
git(
config_dir,
&[
"-c",
&format!("user.name={GIT_NAME}"),
"-c",
&format!("user.email={GIT_EMAIL}"),
"commit",
"-m",
"hive-c0re sync",
],
)
.await?;
git_commit(applied_dir, "hive-c0re sync").await?;
}
Ok(())
}
/// Verify `commit_ref` exists in the config repo, advance `main` to it, and
/// reset the working tree. Caller is responsible for the subsequent rebuild.
pub async fn apply_commit(config_dir: &Path, commit_ref: &str) -> Result<()> {
let st = Command::new("git")
.current_dir(config_dir)
.args(["cat-file", "-e", commit_ref])
.status()
/// Apply a manager-proposed commit: read `agent.nix` at `commit_ref` from the
/// proposed repo, write it into the applied repo, commit. Hive-c0re alone
/// advances `applied`'s `main`; the manager only sees `proposed/`.
pub async fn apply_commit(
applied_dir: &Path,
proposed_dir: &Path,
commit_ref: &str,
) -> Result<()> {
let out = Command::new("git")
.current_dir(proposed_dir)
.args(["show", &format!("{commit_ref}:agent.nix")])
.output()
.await
.with_context(|| format!("git cat-file in {}", config_dir.display()))?;
if !st.success() {
bail!("commit {commit_ref} not found in {}", config_dir.display());
.with_context(|| format!("git show in {}", proposed_dir.display()))?;
if !out.status.success() {
bail!(
"agent.nix at commit {commit_ref} not found in {}: {}",
proposed_dir.display(),
String::from_utf8_lossy(&out.stderr).trim()
);
}
std::fs::write(applied_dir.join("agent.nix"), &out.stdout)
.with_context(|| format!("write {}/agent.nix", applied_dir.display()))?;
git(applied_dir, &["add", "agent.nix"]).await?;
let clean = git_status(applied_dir, &["diff", "--cached", "--quiet"]).await?;
if !clean {
git_commit(applied_dir, &format!("apply {commit_ref}")).await?;
}
git(config_dir, &["update-ref", "refs/heads/main", commit_ref]).await?;
git(config_dir, &["reset", "--hard", commit_ref]).await?;
Ok(())
}
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",
)
}
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
}
async fn git(dir: &Path, args: &[&str]) -> Result<()> {
let out = Command::new("git")
.current_dir(dir)