diff --git a/CLAUDE.md b/CLAUDE.md index 02030052..1f2f80af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,62 +140,11 @@ 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 + 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 4 — `hm1nd` manager binary with privileged tool surface +- 🔜 Phase 5 — git-commit approval flow (`state-repo` + per-agent config flakes) - 🔜 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 5f7a0295..ce2032e6 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -14,32 +14,22 @@ 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, - /// URL of the hyperhive flake (no fragment). Inlined into per-agent - /// `flake.nix` files as `inputs.hyperhive.url`. - pub hyperhive_flake: String, + pub agent_flake: String, agents: Mutex>, } impl Coordinator { - pub fn open(db_path: &Path, hyperhive_flake: String) -> Result { + pub fn open(db_path: &Path, agent_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), - hyperhive_flake, + agent_flake, agents: Mutex::new(HashMap::new()), }) } @@ -79,15 +69,4 @@ 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 fd3555f5..7399ff74 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -1,4 +1,4 @@ -//! `nixos-container` lifecycle + per-agent config flake generation. +//! Thin async wrappers over `nixos-container`. use std::path::Path; @@ -16,9 +16,6 @@ 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}") } @@ -36,19 +33,10 @@ fn validate(name: &str) -> Result<()> { Ok(()) } -pub async fn spawn( - name: &str, - hyperhive_flake: &str, - agent_dir: &Path, - proposed_dir: &Path, - applied_dir: &Path, -) -> Result<()> { +pub async fn spawn(name: &str, agent_flake: &str, agent_dir: &Path) -> Result<()> { validate(name)?; - setup_proposed(proposed_dir, name).await?; - setup_applied(applied_dir, name, hyperhive_flake).await?; let container = container_name(name); - let flake_ref = format!("{}#default", applied_dir.display()); - run(&["create", &container, "--flake", &flake_ref]).await?; + run(&["create", &container, "--flake", agent_flake]).await?; set_nspawn_flags(&container, agent_dir)?; run(&["start", &container]).await } @@ -59,18 +47,11 @@ pub async fn kill(name: &str) -> Result<()> { run(&["stop", &container]).await } -pub async fn rebuild( - name: &str, - hyperhive_flake: &str, - agent_dir: &Path, - applied_dir: &Path, -) -> Result<()> { +pub async fn rebuild(name: &str, agent_flake: &str, agent_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", &flake_ref]).await?; + run(&["update", &container, "--flake", agent_flake]).await?; // Restart so any nspawn-level changes (bind mounts, networking, etc.) apply. run(&["stop", &container]).await?; run(&["start", &container]).await @@ -97,157 +78,6 @@ 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 fd8329e1..ce5aa10e 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -31,10 +31,9 @@ struct Cli { enum Cmd { /// Run the coordinator daemon. Serve { - /// 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, + /// Flake reference for the agent base template. + #[arg(long, default_value = "/etc/hyperhive#agent-base")] + agent_flake: String, /// Path to the sqlite message store. #[arg(long, default_value = "/var/lib/hyperhive/broker.sqlite")] db: PathBuf, @@ -66,11 +65,8 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.cmd { - Cmd::Serve { - hyperhive_flake, - db, - } => { - let coord = Arc::new(Coordinator::open(&db, hyperhive_flake)?); + Cmd::Serve { agent_flake, db } => { + let coord = Arc::new(Coordinator::open(&db, agent_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 408bc4a5..086c9ba3 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -95,17 +95,7 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse tracing::info!(%name, "manager: spawn"); let result: Result<()> = async { let agent_dir = coord.register_agent(name)?; - 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 - { + if let Err(e) = lifecycle::spawn(name, &coord.agent_flake, &agent_dir).await { coord.unregister_agent(name); return Err(e); } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 6c1acb7a..8ed0c15c 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(), hyperhive_flake = %coord.hyperhive_flake, "hive-c0re admin listening"); + tracing::info!(socket = %socket.display(), agent_flake = %coord.agent_flake, "hive-c0re admin listening"); loop { let (stream, _) = listener.accept().await.context("accept connection")?; @@ -61,17 +61,7 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse { HostRequest::Spawn { name } => { tracing::info!(%name, "spawn"); let agent_dir = coord.register_agent(name)?; - 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 - { + if let Err(e) = lifecycle::spawn(name, &coord.agent_flake, &agent_dir).await { // Roll back socket registration if container creation failed. coord.unregister_agent(name); return Err(e); @@ -87,31 +77,18 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse { HostRequest::Rebuild { name } => { tracing::info!(%name, "rebuild"); let agent_dir = coord.register_agent(name)?; - let applied_dir = Coordinator::agent_applied_dir(name); - lifecycle::rebuild(name, &coord.hyperhive_flake, &agent_dir, &applied_dir).await?; + lifecycle::rebuild(name, &coord.agent_flake, &agent_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: applying + rebuilding"); + tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval applied: rebuilding agent"); let agent_dir = coord.register_agent(&approval.agent)?; - 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 { + if let Err(e) = + lifecycle::rebuild(&approval.agent, &coord.agent_flake, &agent_dir).await + { 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 96b8f34c..86ba5909 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -16,15 +16,10 @@ in defaultText = lib.literalExpression "pkgs.hyperhive"; description = "Package that provides /bin/hive-c0re."; }; - hyperhiveFlake = lib.mkOption { + agentFlake = lib.mkOption { type = lib.types.str; - 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. - ''; + default = "/etc/hyperhive#agent-base"; + description = "Flake reference passed to `nixos-container create --flake` when spawning sub-agents."; }; }; @@ -36,7 +31,7 @@ in wantedBy = [ "multi-user.target" ]; path = [ "/run/current-system/sw" ]; serviceConfig = { - ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake}"; + ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --agent-flake ${cfg.agentFlake}"; Restart = "on-failure"; RestartSec = 2; RuntimeDirectory = "hyperhive"; diff --git a/nix/templates/manager.nix b/nix/templates/manager.nix index 3bf9e87c..b1912955 100644 --- a/nix/templates/manager.nix +++ b/nix/templates/manager.nix @@ -11,14 +11,6 @@ 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" ];