diff --git a/flake.nix b/flake.nix index 6d746ab6..d82e9169 100644 --- a/flake.nix +++ b/flake.nix @@ -236,6 +236,11 @@ hyperhiveFrontend = system: self.packages.${system}.frontend; hyperhiveAssets = system: self.packages.${system}.assets; hyperhiveFlake = "${self}"; + # Store path of the nixpkgs-unstable input this flake was evaluated + # with — the channel that carries claude-code. Passed as the default + # for `services.hyperhive.c0re.nixpkgsUnstableFlake` so operators can + # override it without touching this file. + hyperhiveNixpkgsUnstable = "path:${nixpkgs-unstable}"; # Per-container toplevels — wired into `system.extraDependencies` # when `services.hyperhive.c0re.preBuildAgentTemplates` is on so the # host system closure pre-fetches the heavy build inputs (#97). diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 1905ac0b..9412f942 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -224,6 +224,8 @@ pub async fn run_approval_spawn( let result = lifecycle::spawn( &approval.agent, &coord.hyperhive_flake, + &coord.nixpkgs_flake, + &coord.nixpkgs_unstable_flake, &agent_dir, &proposed_dir, &applied_dir, @@ -543,6 +545,8 @@ async fn run_apply_commit( }; if let Err(e) = crate::meta::sync_agents( &coord.hyperhive_flake, + &coord.nixpkgs_flake, + &coord.nixpkgs_unstable_flake, coord.dashboard_port, &coord.operator_pronouns, &coord.context_window_tokens, @@ -713,6 +717,8 @@ async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> { let agents = lifecycle::agents_for_meta_listing().await?; crate::meta::sync_agents( &coord.hyperhive_flake, + &coord.nixpkgs_flake, + &coord.nixpkgs_unstable_flake, coord.dashboard_port, &coord.operator_pronouns, &coord.context_window_tokens, diff --git a/hive-c0re/src/auto_update.rs b/hive-c0re/src/auto_update.rs index 856b1caf..7c8bfdac 100644 --- a/hive-c0re/src/auto_update.rs +++ b/hive-c0re/src/auto_update.rs @@ -88,6 +88,8 @@ pub async fn rebuild_agent( let result = lifecycle::rebuild( name, &coord.hyperhive_flake, + &coord.nixpkgs_flake, + &coord.nixpkgs_unstable_flake, &agent_dir, &applied_dir, &claude_dir, @@ -190,6 +192,8 @@ pub async fn ensure_manager(coord: &Arc) -> Result<()> { lifecycle::spawn( MANAGER_NAME, &coord.hyperhive_flake, + &coord.nixpkgs_flake, + &coord.nixpkgs_unstable_flake, &runtime, &proposed, &applied, diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 7bea60bc..f9d3f7d7 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -51,6 +51,19 @@ pub struct Coordinator { /// URL of the hyperhive flake (no fragment). Inlined into per-agent /// `flake.nix` files as `inputs.hyperhive.url`. pub hyperhive_flake: String, + /// Store-path URL of the nixpkgs to wire into the meta flake as + /// `inputs.nixpkgs.url`. Populated by `--nixpkgs-flake` (set by the + /// NixOS module to `"path:${pkgs.path}"` so the meta flake always + /// tracks the same nixpkgs the host evaluated with — which is the + /// host's nixpkgs when `inputs.hyperhive.inputs.nixpkgs.follows = + /// "nixpkgs"` is set in the host flake). Empty string = legacy + /// `follows = "hyperhive/nixpkgs"` behaviour. + pub nixpkgs_flake: String, + /// Store-path URL for `nixpkgs-unstable` to wire as a top-level meta + /// flake input. Hyperhive's `inputs.nixpkgs-unstable` then follows it. + /// Set via `--nixpkgs-unstable-flake` from `hive-c0re.nix`. Empty string + /// falls back to the legacy `follows = "hyperhive/nixpkgs-unstable"`. + pub nixpkgs_unstable_flake: String, /// TCP port the host's hive-c0re dashboard listens on. Inlined into /// each per-agent flake so the agent's web UI can build the right /// rebuild-button URL pointing back at the dashboard. @@ -211,6 +224,8 @@ impl Coordinator { pub fn open( db_path: &Path, hyperhive_flake: String, + nixpkgs_flake: String, + nixpkgs_unstable_flake: String, dashboard_port: u16, operator_pronouns: String, context_window_tokens: std::collections::HashMap, @@ -242,6 +257,8 @@ impl Coordinator { scheduled_prompts: Arc::new(scheduled_prompts), build_logs, hyperhive_flake, + nixpkgs_flake, + nixpkgs_unstable_flake, dashboard_port, operator_pronouns, context_window_tokens, diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 516ae0db..1dcd4866 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -185,6 +185,8 @@ async fn port_collision(self_name: &str) -> Option { pub async fn spawn( name: &str, hyperhive_flake: &str, + nixpkgs_flake: &str, + nixpkgs_unstable_flake: &str, agent_dir: &Path, proposed_dir: &Path, applied_dir: &Path, @@ -211,6 +213,8 @@ pub async fn spawn( let agents = agents_after_spawn(name).await?; crate::meta::sync_agents( hyperhive_flake, + nixpkgs_flake, + nixpkgs_unstable_flake, dashboard_port, operator_pronouns, context_window_tokens, @@ -349,6 +353,8 @@ pub async fn destroy(name: &str) -> Result<()> { pub async fn rebuild( name: &str, hyperhive_flake: &str, + nixpkgs_flake: &str, + nixpkgs_unstable_flake: &str, agent_dir: &Path, applied_dir: &Path, claude_dir: &Path, @@ -366,6 +372,8 @@ pub async fn rebuild( let agents = agents_for_meta(None).await?; crate::meta::sync_agents( hyperhive_flake, + nixpkgs_flake, + nixpkgs_unstable_flake, dashboard_port, operator_pronouns, context_window_tokens, diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 6d149abf..d463ba0a 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -36,6 +36,23 @@ enum Cmd { /// `flake.nix` as the `hyperhive` input. #[arg(long, default_value = "/etc/hyperhive")] hyperhive_flake: String, + /// Store-path URL of the nixpkgs to wire into the meta flake as + /// `inputs.nixpkgs.url`. Set by the NixOS module to + /// `"path:${pkgs.path}"` so the meta flake tracks exactly the + /// nixpkgs the host was evaluated with (the host's own nixpkgs + /// when `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` is + /// set, otherwise hyperhive's pin). Empty = legacy + /// `follows = "hyperhive/nixpkgs"` fallback. + #[arg(long, default_value = "")] + nixpkgs_flake: String, + /// Store-path URL of the nixpkgs-unstable to wire into the meta + /// flake as `inputs.nixpkgs-unstable.url`. Hyperhive's + /// `inputs.nixpkgs-unstable` then follows this top-level input. + /// Set by the NixOS module; defaults to the hyperhive flake's own + /// nixpkgs-unstable store path. Empty = legacy + /// `follows = "hyperhive/nixpkgs-unstable"` fallback. + #[arg(long, default_value = "")] + nixpkgs_unstable_flake: String, /// Path to the sqlite message store. #[arg(long, default_value = "/var/lib/hyperhive/broker.sqlite")] db: PathBuf, @@ -118,6 +135,8 @@ async fn main() -> Result<()> { match cli.cmd { Cmd::Serve { hyperhive_flake, + nixpkgs_flake, + nixpkgs_unstable_flake, db, dashboard_port, operator_pronouns, @@ -125,6 +144,8 @@ async fn main() -> Result<()> { } => { cmd_serve( hyperhive_flake, + nixpkgs_flake, + nixpkgs_unstable_flake, db, dashboard_port, operator_pronouns, @@ -172,6 +193,8 @@ async fn main() -> Result<()> { /// dashboard), then serve the admin socket until a signal arrives. async fn cmd_serve( hyperhive_flake: String, + nixpkgs_flake: String, + nixpkgs_unstable_flake: String, db: std::path::PathBuf, dashboard_port: u16, operator_pronouns: String, @@ -183,6 +206,8 @@ async fn cmd_serve( let coord = Arc::new(Coordinator::open( &db, hyperhive_flake, + nixpkgs_flake, + nixpkgs_unstable_flake, dashboard_port, operator_pronouns, cwt, diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 8bd21f32..a155e1b0 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -52,6 +52,8 @@ pub fn meta_dir() -> PathBuf { #[allow(dead_code, clippy::implicit_hasher)] // first caller lands in a later commit pub async fn sync_agents( hyperhive_flake: &str, + nixpkgs_flake: &str, + nixpkgs_unstable_flake: &str, dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, @@ -63,6 +65,8 @@ pub async fn sync_agents( let new_flake = render_flake( hyperhive_flake, + nixpkgs_flake, + nixpkgs_unstable_flake, dashboard_port, operator_pronouns, context_window_tokens, @@ -300,6 +304,8 @@ pub async fn lock_update_hyperhive() -> Result<()> { fn render_flake( hyperhive_flake: &str, + nixpkgs_flake: &str, + nixpkgs_unstable_flake: &str, dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, @@ -307,6 +313,8 @@ fn render_flake( ) -> String { render_flake_with_lookup( hyperhive_flake, + nixpkgs_flake, + nixpkgs_unstable_flake, dashboard_port, operator_pronouns, context_window_tokens, @@ -392,6 +400,8 @@ fn agent_canonical_inputs(name: &str) -> Vec<&'static str> { )] fn render_flake_with_lookup( hyperhive_flake: &str, + nixpkgs_flake: &str, + nixpkgs_unstable_flake: &str, dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, @@ -404,28 +414,35 @@ where 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..inputs.nixpkgs.follows = "nixpkgs"` directive - // resolves transitively to hyperhive's pin. One channel decision - // in the whole tree, no second source to drift. + // `nixpkgs` + `nixpkgs-unstable` are top-level meta inputs with explicit + // store-path URLs. `hyperhive` then follows them via + // `hyperhive.inputs.*.follows`. This cascades through to every agent + // because `agent-.inputs.nixpkgs.follows = "nixpkgs"` resolves to + // the same top-level node. // - // All nixpkgs follow the one hyperhive was deployed with — - // anything else would drift. + // Why explicit `path:` URLs instead of + // `nixpkgs.follows = "hyperhive/nixpkgs"`: + // meta points to hyperhive's *store path* as its flake input, so nix + // reads hyperhive's own pinned lock when evaluating that input — the + // host-level `follows` the operator set never propagates. Injecting the + // evaluated `pkgs.path` / nixpkgs-unstable path directly at nix-module + // evaluation time is the only reliable way to honour the host's channel + // choice. // - // 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. - // - // `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"); + // Fallback (both flake args empty): legacy `follows` wiring — used when + // hive-c0re is not built with this option wired up. + if nixpkgs_flake.is_empty() { + // Legacy path: meta defers to hyperhive's own lock. + 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"); + } else { + let _ = writeln!(out, " nixpkgs.url = \"{nixpkgs_flake}\";"); + let _ = writeln!(out, " nixpkgs-unstable.url = \"{nixpkgs_unstable_flake}\";"); + let _ = writeln!(out, " hyperhive.url = \"{hyperhive_flake}\";"); + out.push_str(" hyperhive.inputs.nixpkgs.follows = \"nixpkgs\";\n"); + out.push_str(" hyperhive.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\";\n"); + } for spec in agents { let _ = writeln!( out, @@ -687,40 +704,66 @@ mod tests { } #[test] - fn render_flake_aliases_nixpkgs_to_hyperhive() { + fn render_flake_uses_explicit_nixpkgs_url_when_provided() { let out = render_flake( "github:example/hyperhive", + "path:/nix/store/aaaa-nixpkgs-source", + "path:/nix/store/bbbb-nixpkgs-unstable-source", 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. All nixpkgs follow the one - // hyperhive was deployed with. + // Both nixpkgs + nixpkgs-unstable are top-level inputs with + // explicit URLs; hyperhive follows them. assert!( - out.contains("nixpkgs.follows = \"hyperhive/nixpkgs\""), - "missing nixpkgs follows alias:\n{out}" + out.contains("nixpkgs.url = \"path:/nix/store/aaaa-nixpkgs-source\""), + "expected explicit nixpkgs.url:\n{out}" ); assert!( - out.contains("nixpkgs-unstable.follows = \"hyperhive/nixpkgs-unstable\""), - "missing nixpkgs-unstable follows alias:\n{out}" + out.contains("nixpkgs-unstable.url = \"path:/nix/store/bbbb-nixpkgs-unstable-source\""), + "expected explicit nixpkgs-unstable.url:\n{out}" ); - // And conversely: no literal channel ref baked into meta. If - // this fails, someone reintroduced a hardcoded ref — would - // drift away from hyperhive's pin. assert!( - !out.contains("nixpkgs.url ="), - "no literal `nixpkgs.url` should be emitted (hyperhive owns the pin):\n{out}" + out.contains("hyperhive.inputs.nixpkgs.follows = \"nixpkgs\""), + "expected hyperhive.inputs.nixpkgs.follows:\n{out}" + ); + assert!( + out.contains("hyperhive.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\""), + "expected hyperhive.inputs.nixpkgs-unstable.follows:\n{out}" + ); + assert!( + !out.contains("nixpkgs.follows = \"hyperhive"), + "old-style follows must not appear when flake args are set:\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_falls_back_to_follows_when_nixpkgs_flake_empty() { + // Empty nixpkgs_flake → legacy follows behaviour (backward compat + // for any code path that can't inject pkgs.path). + let out = render_flake( + "github:example/hyperhive", + "", + "", + 8000, + "she/her", + &std::collections::HashMap::new(), + &[sample_spec("alice", false, 9001)], + ); + assert!( + out.contains("nixpkgs.follows = \"hyperhive/nixpkgs\""), + "expected fallback follows:\n{out}" + ); + assert!( + out.contains("nixpkgs-unstable.follows = \"hyperhive/nixpkgs-unstable\""), + "expected fallback unstable follows:\n{out}" + ); + assert!( + !out.contains("nixpkgs.url ="), + "no explicit url should be emitted in fallback mode:\n{out}" + ); + } #[test] fn render_flake_emits_follows_for_agents_declaring_nixpkgs() { @@ -735,6 +778,8 @@ mod tests { }; let out = render_flake_with_lookup( "github:example/hyperhive", + "path:/nix/store/aaaa-nixpkgs-source", + "path:/nix/store/bbbb-nixpkgs-unstable-source", 8000, "she/her", &std::collections::HashMap::new(), @@ -768,6 +813,8 @@ mod tests { fn render_flake_skips_canonical_follows_when_lookup_returns_empty() { let out = render_flake_with_lookup( "github:example/hyperhive", + "path:/nix/store/aaaa-nixpkgs-source", + "path:/nix/store/bbbb-nixpkgs-unstable-source", 8000, "she/her", &std::collections::HashMap::new(), diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index 8b37643d..df4fc050 100644 --- a/hive-c0re/src/migrate.rs +++ b/hive-c0re/src/migrate.rs @@ -78,6 +78,8 @@ pub async fn run(coord: &Arc) -> Result<()> { .unwrap_or_default(); if let Err(e) = meta::sync_agents( &coord.hyperhive_flake, + &coord.nixpkgs_flake, + &coord.nixpkgs_unstable_flake, coord.dashboard_port, &coord.operator_pronouns, &coord.context_window_tokens, diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 62d7410f..0987ef48 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -87,6 +87,8 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { match lifecycle::spawn( name, &coord.hyperhive_flake, + &coord.nixpkgs_flake, + &coord.nixpkgs_unstable_flake, &agent_dir, &proposed_dir, &applied_dir, @@ -151,6 +153,8 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { let result = lifecycle::rebuild( name, &coord.hyperhive_flake, + &coord.nixpkgs_flake, + &coord.nixpkgs_unstable_flake, &agent_dir, &applied_dir, &claude_dir, diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 38a7f158..8b31026d 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -3,6 +3,7 @@ hyperhiveFrontend, hyperhiveAssets, hyperhiveFlake, + hyperhiveNixpkgsUnstable, agentBaseToplevel, managerToplevel, }: @@ -187,6 +188,43 @@ in only override if you want agents tracking a different ref. ''; }; + nixpkgsFlake = lib.mkOption { + type = lib.types.str; + default = "path:${pkgs.path}"; + defaultText = lib.literalMD "`\"path:\${pkgs.path}\"`"; + description = '' + Store-path URL for the `nixpkgs` input in the generated meta + flake. The meta flake declares this as a top-level input and + wires `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` so + every agent container evaluates with this exact nixpkgs. + + Defaults to `"path:''${pkgs.path}"` — the store path of the + nixpkgs the host NixOS module was evaluated with. When the + operator sets `inputs.hyperhive.inputs.nixpkgs.follows = + "nixpkgs"` in their host flake, `pkgs.path` resolves to the + host's own nixpkgs, so agents transparently track the same + channel as the host. + + Override to pin agents to a specific nixpkgs version regardless + of the host's channel. + ''; + }; + nixpkgsUnstableFlake = lib.mkOption { + type = lib.types.str; + default = hyperhiveNixpkgsUnstable; + defaultText = lib.literalMD "hyperhive's own `nixpkgs-unstable` store path"; + description = '' + Store-path URL for the `nixpkgs-unstable` input in the generated + meta flake. The meta flake declares this as a top-level input and + wires `inputs.hyperhive.inputs.nixpkgs-unstable.follows = + "nixpkgs-unstable"` so agents use this exact unstable nixpkgs. + + Defaults to the store path of the `nixpkgs-unstable` input + hyperhive's own `flake.nix` was evaluated with (the channel that + carries `claude-code`). Override when you want to track a newer + unstable snapshot or a custom `claude-code` package. + ''; + }; dashboardPort = lib.mkOption { type = lib.types.port; default = 7000; @@ -373,7 +411,7 @@ in ); }; serviceConfig = { - ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns} --context-window-tokens ${lib.escapeShellArg (builtins.toJSON cfg.contextWindowTokens)}"; + ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --nixpkgs-flake ${cfg.nixpkgsFlake} --nixpkgs-unstable-flake ${cfg.nixpkgsUnstableFlake} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns} --context-window-tokens ${lib.escapeShellArg (builtins.toJSON cfg.contextWindowTokens)}"; Restart = "on-failure"; RestartSec = 2; RuntimeDirectory = "hyperhive";