From db50da570a7984d6325fba852ebf2375750bb94d Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 1 Jun 2026 22:44:21 +0200 Subject: [PATCH] refactor(#1003): nixpkgs + nixpkgs-unstable as top-level meta inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per mara's direction: both nixpkgs and nixpkgs-unstable are now top-level meta flake inputs with explicit store-path URLs. Hyperhive follows them rather than the other way around: inputs.nixpkgs.url = "path:${pkgs.path}"; inputs.nixpkgs-unstable.url = "path:${nixpkgs-unstable}"; inputs.hyperhive.url = "..."; inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"; inputs.hyperhive.inputs.nixpkgs-unstable.follows = "nixpkgs-unstable"; New NixOS host options (auto-set at build time, overridable): services.hyperhive.c0re.nixpkgsFlake default: "path:${pkgs.path}" — host's evaluated nixpkgs. services.hyperhive.c0re.nixpkgsUnstableFlake default: "path:${nixpkgs-unstable}" from hyperhive's flake.nix — the channel that carries claude-code. Operators can override to track a different unstable snapshot. Legacy fallback (both args empty) preserved for backward compat. Two new Rust tests cover the full-URL and fallback paths. --- flake.nix | 5 +++ hive-c0re/src/actions.rs | 3 ++ hive-c0re/src/auto_update.rs | 2 + hive-c0re/src/coordinator.rs | 7 ++++ hive-c0re/src/lifecycle.rs | 4 ++ hive-c0re/src/main.rs | 12 ++++++ hive-c0re/src/meta.rs | 76 +++++++++++++++++++++++------------- hive-c0re/src/migrate.rs | 1 + hive-c0re/src/server.rs | 2 + nix/modules/hive-c0re.nix | 40 ++++++++++++++++++- 10 files changed, 124 insertions(+), 28 deletions(-) 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 18568c80..9412f942 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -225,6 +225,7 @@ pub async fn run_approval_spawn( &approval.agent, &coord.hyperhive_flake, &coord.nixpkgs_flake, + &coord.nixpkgs_unstable_flake, &agent_dir, &proposed_dir, &applied_dir, @@ -545,6 +546,7 @@ 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, @@ -716,6 +718,7 @@ async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> { 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 3015e178..7c8bfdac 100644 --- a/hive-c0re/src/auto_update.rs +++ b/hive-c0re/src/auto_update.rs @@ -89,6 +89,7 @@ pub async fn rebuild_agent( name, &coord.hyperhive_flake, &coord.nixpkgs_flake, + &coord.nixpkgs_unstable_flake, &agent_dir, &applied_dir, &claude_dir, @@ -192,6 +193,7 @@ pub async fn ensure_manager(coord: &Arc) -> Result<()> { 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 3eed7564..f9d3f7d7 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -59,6 +59,11 @@ pub struct Coordinator { /// "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. @@ -220,6 +225,7 @@ impl Coordinator { 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, @@ -252,6 +258,7 @@ impl Coordinator { 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 b87f58bb..1dcd4866 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -186,6 +186,7 @@ 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, @@ -213,6 +214,7 @@ pub async fn spawn( crate::meta::sync_agents( hyperhive_flake, nixpkgs_flake, + nixpkgs_unstable_flake, dashboard_port, operator_pronouns, context_window_tokens, @@ -352,6 +354,7 @@ 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, @@ -370,6 +373,7 @@ pub async fn rebuild( 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 8bc01d94..d463ba0a 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -45,6 +45,14 @@ enum Cmd { /// `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, @@ -128,6 +136,7 @@ async fn main() -> Result<()> { Cmd::Serve { hyperhive_flake, nixpkgs_flake, + nixpkgs_unstable_flake, db, dashboard_port, operator_pronouns, @@ -136,6 +145,7 @@ async fn main() -> Result<()> { cmd_serve( hyperhive_flake, nixpkgs_flake, + nixpkgs_unstable_flake, db, dashboard_port, operator_pronouns, @@ -184,6 +194,7 @@ async fn main() -> Result<()> { async fn cmd_serve( hyperhive_flake: String, nixpkgs_flake: String, + nixpkgs_unstable_flake: String, db: std::path::PathBuf, dashboard_port: u16, operator_pronouns: String, @@ -196,6 +207,7 @@ async fn cmd_serve( &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 8b7cc8d8..a155e1b0 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -53,6 +53,7 @@ pub fn meta_dir() -> PathBuf { 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, @@ -65,6 +66,7 @@ pub async fn sync_agents( let new_flake = render_flake( hyperhive_flake, nixpkgs_flake, + nixpkgs_unstable_flake, dashboard_port, operator_pronouns, context_window_tokens, @@ -303,6 +305,7 @@ 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, @@ -311,6 +314,7 @@ fn render_flake( render_flake_with_lookup( hyperhive_flake, nixpkgs_flake, + nixpkgs_unstable_flake, dashboard_port, operator_pronouns, context_window_tokens, @@ -397,6 +401,7 @@ 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, @@ -409,31 +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` is the single channel-pin authority. `nixpkgs` is wired - // to the exact nixpkgs store path hive-c0re was evaluated with — which - // is the host's nixpkgs when the operator sets - // `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` in their host - // flake, or hyperhive's own pin otherwise. Using an explicit `path:` - // URL instead of `follows = "hyperhive/nixpkgs"` is essential here: - // meta points to hyperhive's store path, so nix would otherwise - // resolve hyperhive's own pinned lock rather than the host-substituted - // version that `follows` produced. + // `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. // - // `nixpkgs-unstable` still follows hyperhive (claude-code lives there; - // no same-channel requirement from the host side). + // 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. // - // `nixpkgs` is the single canonical name in the meta tree — every - // agent that declares it in its own `flake.nix` gets a - // `agent-.inputs.nixpkgs.follows = "nixpkgs"` directive that - // collapses all per-agent nixpkgs nodes into one. - let _ = writeln!(out, " hyperhive.url = \"{hyperhive_flake}\";"); + // 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() { - // Fallback: legacy behaviour when nixpkgs_flake not injected. + // 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"); } - out.push_str(" nixpkgs-unstable.follows = \"hyperhive/nixpkgs-unstable\";\n"); for spec in agents { let _ = writeln!( out, @@ -699,27 +708,33 @@ mod tests { 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)], ); - // Explicit nixpkgs_flake → meta uses `nixpkgs.url`, NOT follows. - // This is the path taken when hive-c0re.nix injects `pkgs.path`: - // the URL is the exact nixpkgs evaluated with the host's nixpkgs - // (which IS the host's version when `follows` is set). + // Both nixpkgs + nixpkgs-unstable are top-level inputs with + // explicit URLs; hyperhive follows them. assert!( out.contains("nixpkgs.url = \"path:/nix/store/aaaa-nixpkgs-source\""), "expected explicit nixpkgs.url:\n{out}" ); assert!( - !out.contains("nixpkgs.follows"), - "follows must not appear when nixpkgs_flake is set:\n{out}" + out.contains("nixpkgs-unstable.url = \"path:/nix/store/bbbb-nixpkgs-unstable-source\""), + "expected explicit nixpkgs-unstable.url:\n{out}" ); - // nixpkgs-unstable still follows hyperhive (claude-code lives there). assert!( - out.contains("nixpkgs-unstable.follows = \"hyperhive/nixpkgs-unstable\""), - "missing nixpkgs-unstable follows:\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}" ); } @@ -730,6 +745,7 @@ mod tests { let out = render_flake( "github:example/hyperhive", "", + "", 8000, "she/her", &std::collections::HashMap::new(), @@ -739,6 +755,10 @@ mod tests { 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}" @@ -759,6 +779,7 @@ 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(), @@ -793,6 +814,7 @@ 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(), diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index 2a026c50..df4fc050 100644 --- a/hive-c0re/src/migrate.rs +++ b/hive-c0re/src/migrate.rs @@ -79,6 +79,7 @@ pub async fn run(coord: &Arc) -> Result<()> { 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 43087a58..0987ef48 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -88,6 +88,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { name, &coord.hyperhive_flake, &coord.nixpkgs_flake, + &coord.nixpkgs_unstable_flake, &agent_dir, &proposed_dir, &applied_dir, @@ -153,6 +154,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { 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 90cd8e6e..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} --nixpkgs-flake path:${pkgs.path} --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";