diff --git a/CLAUDE.md b/CLAUDE.md index 1f2f80af..02030052 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,11 +140,62 @@ in. hot-reload works under the patch stack (validated empirically on muede-lpt2) - ✅ Phase 2 — per-agent sockets, in-memory broker, agent harness round-trips messages - ✅ Phase 3 — sqlite broker (durable across restart) + claude-or-echo turn loop -- 🔜 Phase 4 — `hm1nd` manager binary with privileged tool surface -- 🔜 Phase 5 — git-commit approval flow (`state-repo` + per-agent config flakes) +- ✅ Phase 4 — `hm1nd` manager binary + manager socket + declarative `containers.hm1nd` +- ✅ Phase 5 — git-commit approval flow: + - 5a — sqlite approval queue (`request_apply_commit` / `pending` / `approve` / `deny`) + - 5b — per-agent config flakes (proposed + applied repos) + - 5c — split: manager edits `proposed`, hive-c0re writes-only `applied`; the + container builds from `applied`. Approve = read `agent.nix` at the + approved commit from `proposed`, copy into `applied`, commit + rebuild. + Manager cannot move `main` on its own. - 🔜 Phase 6 — per-agent web UI + dashboard MVP - 🔜 Phase 7 — dashboard commit-view + polish +## Approval flow (Phase 5) + +End-to-end: manager edits per-agent config repo → commits → submits commit sha +for approval → user approves on host CLI → `hive-c0re` advances `main` + rebuilds. + +``` +# Inside the hm1nd container (manager has /agents bind-mounted RW): +cd /agents/alice/config +$EDITOR agent.nix # add `environment.systemPackages = [ pkgs.htop ];` +git commit -am "add htop" +SHA=$(git rev-parse HEAD) +hive-m1nd request-apply-commit alice $SHA +exit + +# On the host: +sudo hive-c0re pending # shows the queued approval with id N +sudo hive-c0re approve N # validates, advances main, rebuilds h-alice +sudo nixos-container run h-alice -- which htop # /run/current-system/sw/bin/htop +``` + +Per-agent layout — two separate git repos: + +``` +/var/lib/hyperhive/agents//config/ # proposed — manager edits, hive-c0re reads only +├── .git/ +└── agent.nix # the only file the manager can change + # (initial commit by hive-c0re on first spawn, + # never touched by hive-c0re again) + +/var/lib/hyperhive/applied// # applied — hive-c0re-only; container builds here +├── .git/ +├── flake.nix # hive-c0re-managed; references hyperhive_flake +└── agent.nix # overwritten by approve from the proposed commit +``` + +The container's `--flake` ref is `#default`. The flake's +`nixosConfigurations.default` extends `hyperhive.nixosConfigurations.agent-base` +with `./agent.nix` plus an inline module setting `environment.etc."gitconfig".text` +with the agent's name as the git committer identity. + +On approve: `git show :agent.nix` from `proposed/`, write the bytes +into `applied//agent.nix`, commit there as `hive-c0re`, then +`nixos-container update`. The manager can only propose; only hive-c0re advances +`applied`'s `main`. + See PLAN.md for the full design and the deferred-out-of-scope list. ## Inspirations diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index ce2032e6..5f7a0295 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -14,22 +14,32 @@ use crate::broker::Broker; const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents"; const MANAGER_RUNTIME_ROOT: &str = "/run/hyperhive/manager"; +/// Manager-editable per-agent config repos. Bind-mounted RW into the manager +/// container as `/agents//`. Hive-c0re only writes to these on first +/// spawn (initial commit); after that it's manager-only. +const AGENT_STATE_ROOT: &str = "/var/lib/hyperhive/agents"; +/// Hive-c0re-only authoritative per-agent config repos. Containers build from +/// these. Manager has no filesystem access; the only way to update is via +/// `request_apply_commit` + user approval. +const APPLIED_STATE_ROOT: &str = "/var/lib/hyperhive/applied"; pub struct Coordinator { pub broker: Arc, pub approvals: Arc, - pub agent_flake: String, + /// URL of the hyperhive flake (no fragment). Inlined into per-agent + /// `flake.nix` files as `inputs.hyperhive.url`. + pub hyperhive_flake: String, agents: Mutex>, } impl Coordinator { - pub fn open(db_path: &Path, agent_flake: String) -> Result { + pub fn open(db_path: &Path, hyperhive_flake: String) -> Result { let broker = Broker::open(db_path).context("open broker")?; let approvals = Approvals::open(db_path).context("open approvals")?; Ok(Self { broker: Arc::new(broker), approvals: Arc::new(approvals), - agent_flake, + hyperhive_flake, agents: Mutex::new(HashMap::new()), }) } @@ -69,4 +79,15 @@ impl Coordinator { pub fn manager_socket_path() -> PathBuf { Self::manager_dir().join("mcp.sock") } + + /// Manager-editable proposed config repo. Bind-mounted into the manager + /// container as `/agents//config/`. + pub fn agent_proposed_dir(name: &str) -> PathBuf { + PathBuf::from(format!("{AGENT_STATE_ROOT}/{name}/config")) + } + + /// Authoritative applied config repo. Hive-c0re-only. + pub fn agent_applied_dir(name: &str) -> PathBuf { + PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}")) + } } diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 7399ff74..fd3555f5 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -1,4 +1,4 @@ -//! Thin async wrappers over `nixos-container`. +//! `nixos-container` lifecycle + per-agent config flake generation. use std::path::Path; @@ -16,6 +16,9 @@ pub const MANAGER_NAME: &str = "hm1nd"; /// Mount point of the per-agent runtime directory inside the container. pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive"; +const GIT_NAME: &str = "hive-c0re"; +const GIT_EMAIL: &str = "hive-c0re@hyperhive"; + pub fn container_name(name: &str) -> String { format!("{AGENT_PREFIX}{name}") } @@ -33,10 +36,19 @@ fn validate(name: &str) -> Result<()> { Ok(()) } -pub async fn spawn(name: &str, agent_flake: &str, agent_dir: &Path) -> Result<()> { +pub async fn spawn( + name: &str, + hyperhive_flake: &str, + agent_dir: &Path, + proposed_dir: &Path, + applied_dir: &Path, +) -> Result<()> { validate(name)?; + setup_proposed(proposed_dir, name).await?; + setup_applied(applied_dir, name, hyperhive_flake).await?; let container = container_name(name); - run(&["create", &container, "--flake", agent_flake]).await?; + 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 } @@ -47,11 +59,18 @@ pub async fn kill(name: &str) -> Result<()> { run(&["stop", &container]).await } -pub async fn rebuild(name: &str, agent_flake: &str, agent_dir: &Path) -> Result<()> { +pub async fn rebuild( + name: &str, + hyperhive_flake: &str, + agent_dir: &Path, + applied_dir: &Path, +) -> Result<()> { validate(name)?; + setup_applied(applied_dir, name, hyperhive_flake).await?; let container = container_name(name); + let flake_ref = format!("{}#default", applied_dir.display()); set_nspawn_flags(&container, agent_dir)?; - run(&["update", &container, "--flake", agent_flake]).await?; + run(&["update", &container, "--flake", &flake_ref]).await?; // Restart so any nspawn-level changes (bind mounts, networking, etc.) apply. run(&["stop", &container]).await?; run(&["start", &container]).await @@ -78,6 +97,157 @@ pub async fn list() -> Result> { .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. +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_body = format!( + r#"{{ + description = "hyperhive sub-agent {name}"; + inputs.hyperhive.url = "{hyperhive_flake}"; + outputs = + {{ hyperhive, ... }}: + {{ + nixosConfigurations.default = hyperhive.nixosConfigurations.agent-base.extendModules {{ + modules = [ + ./agent.nix + {{ + environment.etc."gitconfig".text = '' + [user] + name = {name} + email = {name}@hyperhive + [init] + defaultBranch = main + ''; + }} + ]; + }}; + }}; +}} +"#, + ); + std::fs::write(applied_dir.join("flake.nix"), flake_body) + .with_context(|| format!("write {}/flake.nix", applied_dir.display()))?; + + let agent_path = applied_dir.join("agent.nix"); + if !agent_path.exists() { + std::fs::write(&agent_path, initial_agent_nix(name)) + .with_context(|| format!("write {}", agent_path.display()))?; + } + + if !applied_dir.join(".git").exists() { + git(applied_dir, &["init", "--initial-branch=main"]).await?; + } + git(applied_dir, &["add", "-A"]).await?; + let clean = git_status(applied_dir, &["diff", "--cached", "--quiet"]).await?; + if !clean { + git_commit(applied_dir, "hive-c0re sync").await?; + } + Ok(()) +} + +/// 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 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?; + } + 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) + .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(()) +} + +/// Returns true if the command exits 0. +async fn git_status(dir: &Path, args: &[&str]) -> Result { + let st = Command::new("git") + .current_dir(dir) + .args(args) + .status() + .await + .with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?; + Ok(st.success()) +} + /// Idempotently rewrite the `EXTRA_NSPAWN_FLAGS` line in /// `/etc/nixos-containers/.conf`. The start script expands this /// variable unquoted into the `systemd-nspawn` command. diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index ce5aa10e..fd8329e1 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -31,9 +31,10 @@ struct Cli { enum Cmd { /// Run the coordinator daemon. Serve { - /// Flake reference for the agent base template. - #[arg(long, default_value = "/etc/hyperhive#agent-base")] - agent_flake: String, + /// URL of the hyperhive flake. Inlined into each per-agent + /// `flake.nix` as the `hyperhive` input. + #[arg(long, default_value = "/etc/hyperhive")] + hyperhive_flake: String, /// Path to the sqlite message store. #[arg(long, default_value = "/var/lib/hyperhive/broker.sqlite")] db: PathBuf, @@ -65,8 +66,11 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.cmd { - Cmd::Serve { agent_flake, db } => { - let coord = Arc::new(Coordinator::open(&db, agent_flake)?); + Cmd::Serve { + hyperhive_flake, + db, + } => { + let coord = Arc::new(Coordinator::open(&db, hyperhive_flake)?); manager_server::start(coord.clone())?; server::serve(&cli.socket, coord).await } diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 086c9ba3..408bc4a5 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -95,7 +95,17 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse tracing::info!(%name, "manager: spawn"); let result: Result<()> = async { let agent_dir = coord.register_agent(name)?; - if let Err(e) = lifecycle::spawn(name, &coord.agent_flake, &agent_dir).await { + let proposed_dir = Coordinator::agent_proposed_dir(name); + let applied_dir = Coordinator::agent_applied_dir(name); + if let Err(e) = lifecycle::spawn( + name, + &coord.hyperhive_flake, + &agent_dir, + &proposed_dir, + &applied_dir, + ) + .await + { coord.unregister_agent(name); return Err(e); } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 8ed0c15c..6c1acb7a 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -20,7 +20,7 @@ pub async fn serve(socket: &Path, coord: Arc) -> Result<()> { let listener = UnixListener::bind(socket) .with_context(|| format!("bind admin socket {}", socket.display()))?; - tracing::info!(socket = %socket.display(), agent_flake = %coord.agent_flake, "hive-c0re admin listening"); + tracing::info!(socket = %socket.display(), hyperhive_flake = %coord.hyperhive_flake, "hive-c0re admin listening"); loop { let (stream, _) = listener.accept().await.context("accept connection")?; @@ -61,7 +61,17 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse { HostRequest::Spawn { name } => { tracing::info!(%name, "spawn"); let agent_dir = coord.register_agent(name)?; - if let Err(e) = lifecycle::spawn(name, &coord.agent_flake, &agent_dir).await { + let proposed_dir = Coordinator::agent_proposed_dir(name); + let applied_dir = Coordinator::agent_applied_dir(name); + if let Err(e) = lifecycle::spawn( + name, + &coord.hyperhive_flake, + &agent_dir, + &proposed_dir, + &applied_dir, + ) + .await + { // Roll back socket registration if container creation failed. coord.unregister_agent(name); return Err(e); @@ -77,18 +87,31 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse { HostRequest::Rebuild { name } => { tracing::info!(%name, "rebuild"); let agent_dir = coord.register_agent(name)?; - lifecycle::rebuild(name, &coord.agent_flake, &agent_dir).await?; + let applied_dir = Coordinator::agent_applied_dir(name); + lifecycle::rebuild(name, &coord.hyperhive_flake, &agent_dir, &applied_dir).await?; HostResponse::success() } HostRequest::List => HostResponse::list(lifecycle::list().await?), HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?), HostRequest::Approve { id } => { let approval = coord.approvals.mark_approved(*id)?; - tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval applied: rebuilding agent"); + tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval: applying + rebuilding"); let agent_dir = coord.register_agent(&approval.agent)?; - if let Err(e) = - lifecycle::rebuild(&approval.agent, &coord.agent_flake, &agent_dir).await - { + let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent); + let applied_dir = Coordinator::agent_applied_dir(&approval.agent); + let result: anyhow::Result<()> = async { + lifecycle::apply_commit(&applied_dir, &proposed_dir, &approval.commit_ref) + .await?; + lifecycle::rebuild( + &approval.agent, + &coord.hyperhive_flake, + &agent_dir, + &applied_dir, + ) + .await + } + .await; + if let Err(e) = result { let note = format!("{e:#}"); let _ = coord.approvals.mark_failed(approval.id, ¬e); return Err(e); diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 86ba5909..96b8f34c 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -16,10 +16,15 @@ in defaultText = lib.literalExpression "pkgs.hyperhive"; description = "Package that provides /bin/hive-c0re."; }; - agentFlake = lib.mkOption { + hyperhiveFlake = lib.mkOption { type = lib.types.str; - default = "/etc/hyperhive#agent-base"; - description = "Flake reference passed to `nixos-container create --flake` when spawning sub-agents."; + default = "/etc/hyperhive"; + description = '' + URL of the hyperhive flake (no fragment). Inlined into each + per-agent `flake.nix` at `inputs.hyperhive.url`. The per-agent + flake then pulls `hyperhive.nixosConfigurations.agent-base` to + build the container. + ''; }; }; @@ -31,7 +36,7 @@ in wantedBy = [ "multi-user.target" ]; path = [ "/run/current-system/sw" ]; serviceConfig = { - ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --agent-flake ${cfg.agentFlake}"; + ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake}"; Restart = "on-failure"; RestartSec = 2; RuntimeDirectory = "hyperhive"; diff --git a/nix/templates/manager.nix b/nix/templates/manager.nix index b1912955..3bf9e87c 100644 --- a/nix/templates/manager.nix +++ b/nix/templates/manager.nix @@ -11,6 +11,14 @@ coreutils-full ]; + environment.etc."gitconfig".text = '' + [user] + name = hm1nd + email = hm1nd@hyperhive + [init] + defaultBranch = main + ''; + systemd.services.hive-m1nd = { description = "hive-m1nd manager harness"; wantedBy = [ "multi-user.target" ];