diff --git a/README.md b/README.md index 1af16814..3e420c60 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,10 @@ covers spreading a swarm across hosts. { inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; - hyperhive.url = "git+https://forge.darkest.space/hyperhive/hyperhive"; + hyperhive = { + url = "git+https://forge.darkest.space/hyperhive/hyperhive"; + inputs.nixpkgs.follows = "nixpkgs"; # see "Overriding nixpkgs" + }; }; outputs = { nixpkgs, hyperhive, ... }: { @@ -157,6 +160,14 @@ identity, creating your SSO account. Walk through **[`docs/getting-started/setup.md`](docs/getting-started/setup.md)** once, top to bottom. +### Overriding nixpkgs + +hyperhive pins its own `nixpkgs` so it builds standalone in CI. The +`follows` line above builds it against your host's `nixpkgs` instead: one +evaluation, no version drift. It works as long as your channel is close to +the `nixos-26.05` hyperhive develops against — drop it if a much older or +newer channel breaks in ways hyperhive's CI doesn't catch. + ## Operating it - **swarm UI** — the day-to-day surface, on the swarm apex behind SSO diff --git a/docs/swarm/builder.md b/docs/swarm/builder.md index 864afcfe..3fb34f63 100644 --- a/docs/swarm/builder.md +++ b/docs/swarm/builder.md @@ -28,8 +28,12 @@ Where it runs and how it's enabled: [`services.md`](services.md#builder). 3. The builder runs `nix build` of `nix/builder/check-flake`'s `check` output with every input overridden: - `hyperhive` and `nixpkgs`: the sources the builder's own host was - deployed with (`c0re.hyperhiveFlake`, `c0re.nixpkgsFlake`). A hive's meta - flake pins `hyperhive` by forge URL and rev instead. + deployed with, the same two a hive on that host renders into its meta + flake (`c0re.hyperhiveFlake`, `c0re.nixpkgsFlake`). + - `hyperhive-docs`: the `docs/` source a hive on that host renders into its + meta flake (`c0re.hyperhiveDocs`). The check sets + `services.hyperhive.agent.docs.source` from it, as the meta flake does, + since the `hyperhive` source has no `docs/`. - `agent`: `git+https:///agent-configs/?ref=refs/pull//head&rev=`. A build has one hour. 4. The reply is the system closure's store path, or the last lines `nix` diff --git a/docs/turn-loop/config.md b/docs/turn-loop/config.md index 7b10dfde..62815699 100644 --- a/docs/turn-loop/config.md +++ b/docs/turn-loop/config.md @@ -20,8 +20,9 @@ markdown is readable during every turn. Off by default for every agent, ruth included. An agent opts in by adding the line above to its `agent.nix`. -The docs come from the pinned `hyperhive` input (the `reference-docs` package -built from its `docs/` tree), so they match the hyperhive rev the host runs. +The `docs/` source is a narrow flake input (`hyperhive-docs`) tracked +separately from the main `hyperhive` flake so editing docs re-locks only +that input — not every agent's container gets rebuilt on a doc-only change. ## Agent icon diff --git a/flake.nix b/flake.nix index 8a970b08..667b22c5 100644 --- a/flake.nix +++ b/flake.nix @@ -14,7 +14,7 @@ }; # Thin entry point — the real logic lives under nix/: - # nix/sources.nix filtered source views (the builder's hyperhive input) + # nix/sources.nix filtered source views (meta-flake + docs inputs) # nix/rust.nix shared crane wiring (cleanSrc, cargoArtifacts) # nix/packages/ every package output # nix/checks.nix flake checks @@ -129,7 +129,9 @@ assets = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.assets; xdgIcons = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.xdg-icons; hyperhiveFlake = lib.mkDefault "${sources.hyperhiveFlakeSource}"; - hyperhiveRev = lib.mkDefault (self.rev or null); + # Narrow docs/ source, threaded as its own meta-flake input + # so doc edits don't re-hash the whole flake source. + hyperhiveDocs = lib.mkDefault "${sources.hyperhiveDocsSource}"; # The agent-base toplevel every container builds from — wired into # `system.extraDependencies` when # `services.hyperhive.c0re.preBuildAgentTemplates` is on so diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index cc9c0bb1..5351c473 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -36,13 +36,22 @@ pub struct Coordinator { /// `get_full` for the per-card chip + side-panel viewer. See /// `build_logs.rs` for retention. pub build_logs: Arc, - /// Store path of the hyperhive flake source (no fragment). Used to - /// identify the running revision (`current_flake_rev`); the meta flake - /// pins `hyperhive` by forge URL and [`Self::hyperhive_rev`] instead. + /// URL of the hyperhive flake (no fragment). Inlined into per-agent + /// `flake.nix` files as `inputs.hyperhive.url`. pub hyperhive_flake: String, - /// Git rev of the hyperhive checkout this host runs. Pins the meta - /// flake's `hyperhive` input; `None` makes the meta render fail. - pub hyperhive_rev: Option, + /// URL of the narrow `docs/` source (no fragment). Inlined into the + /// meta `flake.nix` as `inputs.hyperhive-docs.url` and threaded to + /// each agent as `services.hyperhive.agent.docs.source`. Its own store path + /// so doc edits don't re-hash `hyperhive_flake`. + pub hyperhive_docs_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 of the `claude-code` build every agent runs, written /// into each per-agent flake as `services.hyperhive.agent.claudeCodePath`. Set by /// the NixOS module option @@ -189,8 +198,12 @@ pub struct Coordinator { #[serde(default)] pub struct HiveEnv { pub hyperhive_flake: String, - /// Git rev of the hyperhive checkout this host runs; see [`Coordinator::hyperhive_rev`]. - pub hyperhive_rev: Option, + /// Store-path URL of the narrow `docs/` source, wired into the meta + /// flake as `inputs.hyperhive-docs.url` and threaded to each agent as + /// `services.hyperhive.agent.docs.source`. Separate from `hyperhive_flake` + /// so a doc edit only re-locks this input, not the whole source. + pub hyperhive_docs_flake: String, + pub nixpkgs_flake: String, /// Store path of the `claude-code` agents run, or `None` for "each /// agent keeps the one out of its own nixpkgs". Travels into the /// container as `services.hyperhive.agent.claudeCodePath` — a plain string, kept @@ -214,7 +227,8 @@ impl Default for HiveEnv { fn default() -> Self { Self { hyperhive_flake: "/etc/hyperhive".to_string(), - hyperhive_rev: None, + hyperhive_docs_flake: String::new(), + nixpkgs_flake: String::new(), claude_code_path: None, dashboard_port: 7000, agent_cpu_quota: "200%".to_string(), @@ -390,7 +404,8 @@ impl Coordinator { ) -> Result { let HiveEnv { hyperhive_flake, - hyperhive_rev, + hyperhive_docs_flake, + nixpkgs_flake, claude_code_path, dashboard_port, agent_cpu_quota, @@ -428,7 +443,8 @@ impl Coordinator { broker: Arc::new(broker), build_logs, hyperhive_flake, - hyperhive_rev, + hyperhive_docs_flake, + nixpkgs_flake, claude_code_path, dashboard_port, agent_cpu_quota, @@ -458,7 +474,8 @@ impl Coordinator { pub fn hive_env(&self) -> HiveEnv { HiveEnv { hyperhive_flake: self.hyperhive_flake.clone(), - hyperhive_rev: self.hyperhive_rev.clone(), + hyperhive_docs_flake: self.hyperhive_docs_flake.clone(), + nixpkgs_flake: self.nixpkgs_flake.clone(), claude_code_path: self.claude_code_path.clone(), dashboard_port: self.dashboard_port, agent_cpu_quota: self.agent_cpu_quota.clone(), diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 4e4db6c9..ddf6878d 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -78,10 +78,15 @@ enum Cmd { /// Path to the sqlite message store. #[arg(long, default_value = crate::paths::BROKER_DB)] db: PathBuf, - /// Override: store path of the hyperhive flake source, used to - /// identify the running revision. + /// Override: URL of the hyperhive flake. Inlined into each + /// per-agent `flake.nix` as the `hyperhive` input. #[arg(long)] hyperhive_flake: Option, + /// Override: store-path URL of the nixpkgs to wire into the meta + /// flake as `inputs.nixpkgs.url`. Empty = legacy + /// `follows = "hyperhive/nixpkgs"` fallback. + #[arg(long)] + nixpkgs_flake: Option, /// Override: dashboard HTTP port. #[arg(long)] dashboard_port: Option, @@ -132,6 +137,7 @@ async fn main() -> Result<()> { config, db, hyperhive_flake, + nixpkgs_flake, dashboard_port, agent_cpu_quota, agent_memory_max, @@ -155,6 +161,9 @@ async fn main() -> Result<()> { if let Some(v) = hyperhive_flake { sc.env.hyperhive_flake = v; } + if let Some(v) = nixpkgs_flake { + sc.env.nixpkgs_flake = v; + } if let Some(v) = dashboard_port { sc.env.dashboard_port = v; } diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index efbb7913..56392479 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -74,7 +74,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { let dir = crate::paths::meta_root(); std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; - let new_flake = render_hive_flake(hive, agents)?; + let new_flake = render_hive_flake(hive, agents); let flake_path = dir.join("flake.nix"); let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default(); let initial = !dir.join(".git").exists(); @@ -327,37 +327,13 @@ pub async fn remove_permission_files() -> Result<()> { Ok(()) } -/// Pinned forge URL of the hyperhive flake: `git+/hyperhive/hyperhive.git?rev=`. -/// Errors when the host has no rev, rather than rendering an unpinned URL. -fn hyperhive_input_url(hive: &HiveEnv) -> Result { - pinned_git_url( - &crate::forge::forge_git_url("hyperhive/hyperhive"), - hive.hyperhive_rev.as_deref(), - ) -} - -/// The pure half of [`hyperhive_input_url`], testable without `HIVE_FORGE_URL`. -fn pinned_git_url(forge_git_url: &str, rev: Option<&str>) -> Result { - let rev = rev.filter(|r| !r.is_empty()).context( - "hyperhive rev unknown: the host was built from a dirty tree \ - (`self.rev` unset) and `services.hyperhive.c0re.hyperhiveRev` is not set", - )?; - Ok(format!("git+{forge_git_url}?rev={rev}")) -} - /// [`render_flake`] with its inputs taken from `hive`, as [`sync_agents`] /// renders it. -fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> Result { - Ok(render_hive_flake_at( - hive, - &hyperhive_input_url(hive)?, - agents, - )) -} - -fn render_hive_flake_at(hive: &HiveEnv, hyperhive_url: &str, agents: &[AgentSpec]) -> String { +fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> String { render_flake( - hyperhive_url, + &hive.hyperhive_flake, + &hive.hyperhive_docs_flake, + &hive.nixpkgs_flake, hive.claude_code_path.as_deref(), hive.dashboard_port, agents, @@ -365,13 +341,17 @@ fn render_hive_flake_at(hive: &HiveEnv, hyperhive_url: &str, agents: &[AgentSpec } fn render_flake( - hyperhive_url: &str, + hyperhive_flake: &str, + docs_flake: &str, + nixpkgs_flake: &str, claude_code_path: Option<&str>, dashboard_port: u16, agents: &[AgentSpec], ) -> String { render_flake_with_lookup( - hyperhive_url, + hyperhive_flake, + docs_flake, + nixpkgs_flake, claude_code_path, dashboard_port, agents, @@ -752,7 +732,9 @@ fn agent_canonical_inputs(name: &str) -> Vec<&'static str> { contiguous fmt block, splitting it would just hide the shape" )] fn render_flake_with_lookup( - hyperhive_url: &str, + hyperhive_flake: &str, + docs_flake: &str, + nixpkgs_flake: &str, claude_code_path: Option<&str>, dashboard_port: u16, agents: &[AgentSpec], @@ -764,12 +746,42 @@ where use std::fmt::Write as _; let mut out = String::new(); out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n"); - // `hyperhive` is pinned by forge URL and rev, so the flake resolves on - // any machine that can reach the forge. `nixpkgs` follows hyperhive's - // own pin, which cascades to every agent because - // `agent-.inputs.nixpkgs.follows = "nixpkgs"` resolves to this node. - let _ = writeln!(out, " hyperhive.url = \"{hyperhive_url}\";"); - out.push_str(" nixpkgs.follows = \"hyperhive/nixpkgs\";\n"); + // `nixpkgs` is a top-level meta input with an explicit store-path URL. + // `hyperhive` then follows it via `hyperhive.inputs.nixpkgs.follows`. + // This cascades through to every agent because + // `agent-.inputs.nixpkgs.follows = "nixpkgs"` resolves to the same + // top-level node. + // + // Why an explicit `path:` URL 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` directly at nix-module evaluation time is the + // only reliable way to honour the host's channel choice. + // + // Fallback (flake arg 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"); + } else { + let _ = writeln!(out, " nixpkgs.url = \"{nixpkgs_flake}\";"); + let _ = writeln!(out, " hyperhive.url = \"{hyperhive_flake}\";"); + out.push_str(" hyperhive.inputs.nixpkgs.follows = \"nixpkgs\";\n"); + } + // Narrow `docs/` source as its own input so a doc edit only + // re-locks THIS input instead of re-hashing the whole `hyperhive` + // source. Threaded to each agent below as `services.hyperhive.agent.docs.source`. + // Empty = hive-c0re not built with the option wired up (legacy); + // agents then keep the harness default (`services.hyperhive.agent.packages.reference-docs`). + if !docs_flake.is_empty() { + // `flake = false`: the docs/ tree is a plain source (no flake.nix), + // so nix must treat it as raw source, not evaluate it as a flake. + let _ = writeln!(out, " hyperhive-docs.url = \"{docs_flake}\";"); + out.push_str(" hyperhive-docs.flake = false;\n"); + } // Each agent's config input is its LOCAL applied mirror // (`git+file://`), so the meta flake resolves entirely from // on-disk state and boot never depends on the forge being reachable. @@ -830,6 +842,14 @@ where { "#, ); + // Point the in-container docs dir (`$HIVE_DOCS_DIR`) at the narrow + // `hyperhive-docs` input instead of the harness default + // (`services.hyperhive.agent.packages.reference-docs`, built from the now-docs-stripped source). + // `inputs."hyperhive-docs"` is reachable via the outputs `@inputs` + // capture. Emitted only when the input exists (docs_flake non-empty). + if !docs_flake.is_empty() { + out.push_str(" services.hyperhive.agent.docs.source = inputs.\"hyperhive-docs\".outPath;\n"); + } // The `claude-code` agents run, as a bare store path rather than a // flake input: containers share the host's `/nix/store`, so the // binary is already there with its closure and has nothing to @@ -1263,55 +1283,71 @@ mod tests { } #[test] - fn pinned_git_url_carries_the_rev_and_refuses_without_one() { - assert_eq!( - pinned_git_url( - "https://forge.example/hyperhive/hyperhive.git", - Some("abc123") - ) - .unwrap(), - "git+https://forge.example/hyperhive/hyperhive.git?rev=abc123" + fn render_flake_uses_explicit_nixpkgs_url_when_provided() { + let out = render_flake( + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", + None, + 8000, + &[sample_spec("alice", 9001)], + ); + // nixpkgs is a top-level input with an explicit URL; hyperhive + // follows it. + assert!( + out.contains("nixpkgs.url = \"path:/nix/store/aaaa-nixpkgs-source\""), + "expected explicit nixpkgs.url:\n{out}" + ); + assert!( + out.contains("hyperhive.inputs.nixpkgs.follows = \"nixpkgs\""), + "expected hyperhive.inputs.nixpkgs.follows:\n{out}" + ); + assert!( + !out.contains("nixpkgs.follows = \"hyperhive"), + "old-style follows must not appear when flake args are set:\n{out}" + ); + // the narrow docs source is its own non-flake input, and each + // agent's docs dir resolves from it rather than services.hyperhive.agent.packages.reference-docs. + assert!( + out.contains("hyperhive-docs.url = \"path:/nix/store/bbbb-hyperhive-docs-source\""), + "expected hyperhive-docs input url:\n{out}" + ); + assert!( + out.contains("hyperhive-docs.flake = false;"), + "docs source is not a flake, must be flake = false:\n{out}" + ); + assert!( + out.contains( + "services.hyperhive.agent.docs.source = inputs.\"hyperhive-docs\".outPath;" + ), + "expected per-agent docs source wired to the input:\n{out}" ); - for missing in [None, Some("")] { - let err = pinned_git_url("https://forge.example/x.git", missing).unwrap_err(); - assert!(err.to_string().contains("rev unknown"), "{err}"); - } } #[test] - fn render_flake_pins_hyperhive_by_url_and_follows_its_nixpkgs() { + fn render_flake_omits_docs_input_when_docs_flake_empty() { + // Legacy / not-wired-up: empty docs_flake emits no docs input and + // leaves each agent on the harness default (services.hyperhive.agent.packages.reference-docs). let out = render_flake( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "", + "path:/nix/store/aaaa-nixpkgs-source", None, 8000, &[sample_spec("alice", 9001)], ); assert!( - out.contains( - "hyperhive.url = \"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd\";" - ), - "expected hyperhive pinned by forge URL:\n{out}" - ); - assert!( - out.contains("nixpkgs.follows = \"hyperhive/nixpkgs\";"), - "expected nixpkgs to follow hyperhive:\n{out}" - ); - assert!( - !out.contains("nixpkgs.url") && !out.contains("hyperhive.inputs.nixpkgs"), - "no separate nixpkgs input:\n{out}" - ); - // docs come from the pinned hyperhive input: no separate docs input, - // no docs.source override. - assert!( - !out.contains("hyperhive-docs") && !out.contains("docs.source"), - "no docs input or override:\n{out}" + !out.contains("hyperhive-docs"), + "no docs input/source when docs_flake is empty:\n{out}" ); } #[test] fn render_flake_names_each_agent_container_after_the_agent() { let out = render_flake( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "", + "path:/nix/store/aaaa-nixpkgs-source", None, 8000, &[sample_spec("alice", 9001)], @@ -1347,7 +1383,9 @@ mod tests { // input would re-copy it as a reference-less `-source` and strip // the closure the binary actually needs. let out = render_flake( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", Some("/nix/store/cccc-claude-code-2.1.220"), 8000, &[sample_spec("alice", 9001)], @@ -1371,7 +1409,9 @@ mod tests { // in `environment.systemPackages`, which is what makes the // unpinned case self-contained). let out = render_flake( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", None, 8000, &[sample_spec("alice", 9001)], @@ -1382,6 +1422,28 @@ mod tests { ); } + #[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", + "path:/nix/store/bbbb-hyperhive-docs-source", + "", + None, + 8000, + &[sample_spec("alice", 9001)], + ); + assert!( + out.contains("nixpkgs.follows = \"hyperhive/nixpkgs\""), + "expected fallback 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() { // Stub lookup: pretend `bitburner` + `dmatrix` declare `nixpkgs` @@ -1393,7 +1455,9 @@ mod tests { } }; let out = render_flake_with_lookup( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", None, 8000, &[ @@ -1422,7 +1486,9 @@ mod tests { #[test] fn render_flake_builds_every_agent_from_agent_base() { let out = render_flake( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", None, 8000, &[sample_spec("alice", 9001), sample_spec("ruth", 9002)], @@ -1447,7 +1513,9 @@ mod tests { #[test] fn render_flake_skips_canonical_follows_when_lookup_returns_empty() { let out = render_flake_with_lookup( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", None, 8000, &[sample_spec("alice", 9001)], @@ -1475,7 +1543,9 @@ mod tests { ..sample_spec("droid", 9002) }; let out = render_flake_with_lookup( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", None, 8000, &[sample_spec("alice", 9001), android], @@ -1526,7 +1596,9 @@ mod tests { std::env::set_var("HIVE_FORGE_URL", "http://forge.example.test"); } let out = render_flake( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", None, 8000, &[sample_spec("alice", 9001)], @@ -1567,7 +1639,9 @@ mod tests { std::env::set_var(VARS[1], domain); } render_flake( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", None, 8000, &[sample_spec("alice", 9001)], @@ -1762,7 +1836,9 @@ mod tests { std::env::set_var("HIVE_MATRIX_URL", "http://matrix.example.test"); } let out = render_flake( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", None, 8000, &[sample_spec("alice", 9001)], @@ -1794,7 +1870,9 @@ mod tests { // forge being reachable. The forge `agent-configs/` repos stay the // review surface, but they are not the flake's build input. let out = render_flake( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", None, 8000, &[sample_spec("alice", 9001)], @@ -1808,8 +1886,8 @@ mod tests { "expected the agent input to point at the local applied mirror ({want}):\n{out}" ); assert!( - !out.contains("agent-alice.url = \"git+http"), - "no forge git+http URL must remain for the agent input:\n{out}" + !out.contains("git+http"), + "no forge git+http URL must remain in the rendered flake:\n{out}" ); } @@ -1834,7 +1912,9 @@ mod tests { let render = || { render_flake( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", None, 8000, &[sample_spec("alice", 9001)], @@ -1922,7 +2002,9 @@ mod tests { // SAFETY: serialised by the guard above; restored before returning. let render = || { render_flake( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", None, 8000, &[sample_spec("alice", 9001)], @@ -1992,7 +2074,9 @@ mod tests { // SAFETY: serialised by the guard above; restored before returning. let render = || { render_flake( - "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", None, 8000, &[sample_spec("alice", 9001)], @@ -2028,16 +2112,8 @@ mod tests { ..HiveEnv::default() }; let agents = [sample_spec("alice", 9001)]; - let small = render_hive_flake_at( - &hive("4G"), - "git+https://forge.example/h.git?rev=abc", - &agents, - ); - let large = render_hive_flake_at( - &hive("16G"), - "git+https://forge.example/h.git?rev=abc", - &agents, - ); + let small = render_hive_flake(&hive("4G"), &agents); + let large = render_hive_flake(&hive("16G"), &agents); assert_eq!( small, large, "the rendered flake must not depend on the memory cap" diff --git a/nix/agent-modules/docs.nix b/nix/agent-modules/docs.nix index c8a08e2a..3f51d64c 100644 --- a/nix/agent-modules/docs.nix +++ b/nix/agent-modules/docs.nix @@ -9,7 +9,7 @@ { options.services.hyperhive.agent.docs.enable = lib.mkEnableOption '' make the hyperhive reference docs (the repo `docs/` tree, shipped - read-only as the `reference-docs` package) available + read-only as the standalone `hyperhive-docs` derivation) available in-container. When enabled the harness exposes the docs dir to claude via `claude --add-dir`, so the markdown is readable at `$HIVE_DOCS_DIR/`, and appends a single pointer sentence to the agent's @@ -26,7 +26,11 @@ Store path of the reference-docs tree exposed at `$HIVE_DOCS_DIR` when `services.hyperhive.agent.docs.enable` is set. Defaults to the flake's `reference-docs` package (the `nix/packages/reference-docs.nix` - build), which the meta flake's pinned `hyperhive` input provides. + build) so a standalone container build from a full checkout + works unchanged. The generated meta flake overrides this with the + narrow `hyperhive-docs` flake input so a doc edit only + re-locks that input instead of rebuilding the container from a + re-hashed `hyperhive` source. ''; }; @@ -36,7 +40,9 @@ # `--add-dir` so the docs are readable, and appends a single # pointer sentence to the system prompt # (hive-agent::prompt::render) telling the agent the docs exist. - # Source is `services.hyperhive.agent.docs.source` (default: the `reference-docs` package). See hive-agent::turn. + # Source is `services.hyperhive.agent.docs.source` (the narrow `hyperhive-docs` + # meta-flake input, or `pkgs.hyperhive-docs` for standalone + # builds). See hive-agent::turn. HIVE_DOCS_DIR = "${config.services.hyperhive.agent.docs.source}"; }; }; diff --git a/nix/builder/check-flake/flake.nix b/nix/builder/check-flake/flake.nix index dbbcd7b4..f39eab56 100644 --- a/nix/builder/check-flake/flake.nix +++ b/nix/builder/check-flake/flake.nix @@ -1,6 +1,8 @@ # The swarm builder's build check: one agent config on hyperhive's agent base, # composed the way a hive's meta flake composes it, minus the values a hive -# adds (its CA, claude-code path and ports). +# adds (its CA, claude-code path and ports). The docs source is set the way the +# meta flake sets it: `hyperhive`'s source has no `docs/`, so the default +# `docs.source` fails to evaluate. # # Every input is replaced at build time with `--override-input` # (swarm-builder/src/nix_build.rs), so the URLs below are never fetched and @@ -12,19 +14,25 @@ nixpkgs.url = "flake:nixpkgs"; hyperhive.url = "flake:hyperhive"; hyperhive.inputs.nixpkgs.follows = "nixpkgs"; + hyperhive-docs.url = "flake:hyperhive-docs"; + hyperhive-docs.flake = false; agent.url = "flake:agent"; }; outputs = { hyperhive, + hyperhive-docs, agent, ... }: { packages.x86_64-linux.check = (hyperhive.nixosConfigurations.agent-base.extendModules { - modules = [ agent.nixosModules.default ]; + modules = [ + agent.nixosModules.default + { services.hyperhive.agent.docs.source = hyperhive-docs.outPath; } + ]; }).config.system.build.toplevel; }; } diff --git a/nix/host-modules/hive-c0re/default.nix b/nix/host-modules/hive-c0re/default.nix index 24452938..24e20cd4 100644 --- a/nix/host-modules/hive-c0re/default.nix +++ b/nix/host-modules/hive-c0re/default.nix @@ -96,7 +96,8 @@ let # c0re binary itself changes. serveConfigJson = builtins.toJSON { hyperhive_flake = cfg.hyperhiveFlake; - hyperhive_rev = cfg.hyperhiveRev; + hyperhive_docs_flake = cfg.hyperhiveDocs; + nixpkgs_flake = cfg.nixpkgsFlake; # The `claude-code` every agent runs, or null for "each agent keeps # the one out of its own nixpkgs". `builtins.toJSON` serialises a # derivation as its out path (and null as null), so the package goes diff --git a/nix/host-modules/hive-c0re/options.nix b/nix/host-modules/hive-c0re/options.nix index ebabd39e..6abf7a29 100644 --- a/nix/host-modules/hive-c0re/options.nix +++ b/nix/host-modules/hive-c0re/options.nix @@ -16,10 +16,6 @@ (lib.mkRemovedOptionModule [ "services" "hyperhive" "c0re" "managerToplevel" ] '' Every agent, ruth included, builds from `agentBaseToplevel`. Remove this definition. '') - (lib.mkRemovedOptionModule [ "services" "hyperhive" "c0re" "hyperhiveDocs" ] '' - Agents take their reference docs from the pinned `hyperhive` input, which - carries `docs/`. Remove this definition. - '') ]; options.services.hyperhive.c0re = { @@ -74,24 +70,24 @@ type = lib.types.str; defaultText = lib.literalMD "the hyperhive flake's own filtered source store path"; description = '' - Store path of the hyperhive flake source (no fragment). hive-c0re - reads it to tell which revision is running, and the swarm builder - builds on it. The meta flake does not use it: that pins - `hyperhive` by forge URL and `hyperhiveRev`. Wired by - `nixosModules.default` to this flake's own filtered source. + 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. Wired by `nixosModules.default` to this + flake's own filtered source — only override if you want agents + tracking a different ref. ''; }; - hyperhiveRev = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - defaultText = lib.literalMD "the hyperhive flake's own `self.rev`"; + hyperhiveDocs = lib.mkOption { + type = lib.types.str; + defaultText = lib.literalMD "the docs/ tree's own store path"; description = '' - Git rev of the hyperhive checkout this host runs. hive-c0re pins - the generated meta flake's `hyperhive` input to - `git+/hyperhive/hyperhive.git?rev=`, and `nixpkgs` - follows that input. Wired by `nixosModules.default` from - `self.rev`, which a dirty tree does not have; with `null`, - hive-c0re refuses to render the meta flake. + URL of the narrow `docs/` source (no fragment). Inlined into the + generated meta `flake.nix` at `inputs.hyperhive-docs.url` and + threaded to each agent as `services.hyperhive.agent.docs.source`, from which the + harness resolves `$HIVE_DOCS_DIR`. Its own store path — separate + from `hyperhiveFlake` — so a doc edit only re-locks this input + instead of rebuilding every agent container. ''; }; agentBaseToplevel = lib.mkOption { @@ -109,12 +105,20 @@ default = "path:${pkgs.path}"; defaultText = lib.literalMD "`\"path:\${pkgs.path}\"`"; description = '' - Store-path URL of the nixpkgs the swarm builder's check flake - builds on. The generated meta flake does not use it: its `nixpkgs` - follows `hyperhive/nixpkgs`. + 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 nixpkgs the host NixOS - module was evaluated with. + 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. ''; }; preBuildAgentTemplates = lib.mkOption { @@ -336,7 +340,7 @@ description = '' The `claude-code` build every agent on this hive runs, or `null` (the default) to leave each agent on the `claude-code` from its own - nixpkgs — i.e. the nixpkgs pinned by hyperhive's own `flake.lock`. + nixpkgs — i.e. whatever `c0re.nixpkgsFlake` resolves to. This is the one binary the whole hive is built around, and it moves fast enough that a release channel routinely trails diff --git a/nix/host-modules/swarm-builder.nix b/nix/host-modules/swarm-builder.nix index efc6224f..1cfabbc8 100644 --- a/nix/host-modules/swarm-builder.nix +++ b/nix/host-modules/swarm-builder.nix @@ -89,9 +89,11 @@ let # The `nix` the host daemon runs, so client and daemon speak one protocol. nix = config.nix.package; - # Strings, not paths: both are already store paths. + # Strings, not paths: all three are already store paths, and the flake + # references the daemon passes must be the ones a hive's meta flake names. hyperhiveFlake = hyperhiveCfg.c0re.hyperhiveFlake; nixpkgsFlake = hyperhiveCfg.c0re.nixpkgsFlake; + docsFlake = hyperhiveCfg.c0re.hyperhiveDocs; privateNetwork = true; in @@ -252,6 +254,7 @@ in SWARM_BUILDER_CHECK_FLAKE = "${../builder/check-flake}"; SWARM_BUILDER_HYPERHIVE_FLAKE = hyperhiveFlake; SWARM_BUILDER_NIXPKGS_FLAKE = nixpkgsFlake; + SWARM_BUILDER_HYPERHIVE_DOCS_FLAKE = docsFlake; SWARM_BUILDER_FORGE_URL = "https://${forgeCfg.domain}"; SWARM_BUILDER_CONFIG_ORG = "agent-configs"; } diff --git a/nix/module-eval/builder.nix b/nix/module-eval/builder.nix index b987a6db..7b7f9ef1 100644 --- a/nix/module-eval/builder.nix +++ b/nix/module-eval/builder.nix @@ -51,7 +51,8 @@ let # The check flake on the inputs the builder's unit names, called as # `nix build` calls it. `hyperhive` is entered through the filtered source's - # own `flake.nix`. + # own `flake.nix`, so its agent base sees the tree without `docs/` that a + # real build gets; `self`'s own agent base would find `docs/` here. builderCheck = agentModule: let @@ -68,6 +69,7 @@ let in ((import "${env.SWARM_BUILDER_CHECK_FLAKE}/flake.nix").outputs { inherit hyperhive; + hyperhive-docs.outPath = env.SWARM_BUILDER_HYPERHIVE_DOCS_FLAKE; agent.nixosModules.default = agentModule; }).packages.x86_64-linux.check; @@ -133,11 +135,12 @@ let && lib.elem "swarm-bao-pki.service" c.after; } { - # The check builds on the sources this host was deployed with, so a green check means - # the same hyperhive and nixpkgs base. - name = "the builder builds on the hyperhive and nixpkgs this host was deployed with"; + # The check builds on the sources a hive on this host would render into + # its meta flake, so a green check means the same base. + name = "the builder builds on the hyperhive, docs and nixpkgs this host's hives use"; ok = env.SWARM_BUILDER_HYPERHIVE_FLAKE == withBuilder.services.hyperhive.c0re.hyperhiveFlake + && env.SWARM_BUILDER_HYPERHIVE_DOCS_FLAKE == withBuilder.services.hyperhive.c0re.hyperhiveDocs && env.SWARM_BUILDER_NIXPKGS_FLAKE == withBuilder.services.hyperhive.c0re.nixpkgsFlake && lib.hasSuffix "-check-flake" env.SWARM_BUILDER_CHECK_FLAKE && env.SWARM_BUILDER_CONFIG_ORG == "agent-configs" diff --git a/nix/sources.nix b/nix/sources.nix index 41654c3e..79992135 100644 --- a/nix/sources.nix +++ b/nix/sources.nix @@ -4,10 +4,23 @@ # (crane's cargo-source filter — see ./rust.nix). { lib }: { - # Filtered view of this repo the swarm builder builds on as its `hyperhive` - # input. Drops files no nix or cargo derivation reads — shell helper - # scripts and root-level markdown. `docs/` is kept: the agent base's - # `reference-docs` package builds from it. + # Filtered view of this repo handed to hive-c0re as the `hyperhive` + # meta-flake input. Drops files no nix or cargo derivation reads — + # shell helper scripts and root-level markdown — so editing them + # does NOT change the store path and therefore does NOT force a + # rebuild of every agent container. + # + # Dropped: scripts/, docs/, root-level *.md (README/AGENTS/TODO/…). + # Kept (build needs them): .nix, .rs, Cargo.*, branding/, + # frontend/, prompts/, flake.lock. docs/ is deliberately dropped: + # it is shipped to agent containers as its OWN narrow meta-flake + # input (`hyperhiveDocsSource`, below), threaded through hive-c0re + # → the meta flake → `services.hyperhive.agent.docs.source`. Keeping docs/ out of + # THIS source means a doc edit only re-hashes the docs input (a + # cheap re-link), not the whole flake source (which would rebuild + # every agent container). The `reference-docs` build from `../docs` + # stays only for standalone `nix build .#reference-docs` from a + # full checkout — the meta path never evaluates it. hyperhiveFlakeSource = lib.cleanSourceWith { name = "hyperhive-flake-source"; src = ../.; @@ -18,6 +31,19 @@ rel = lib.removePrefix (toString ../. + "/") (toString path); in !(lib.hasPrefix "scripts/" rel || rel == "scripts") + && !(lib.hasPrefix "docs/" rel || rel == "docs") && !(type == "regular" && !lib.hasInfix "/" rel && lib.hasSuffix ".md" rel); }; + + # The repo `docs/` tree as a standalone narrow source. Its store + # path moves ONLY on doc edits, decoupled from `hyperhiveFlakeSource`. + # hive-c0re threads this to the meta flake as the `hyperhive-docs` + # input (same pattern as the `hyperhive` input); the harness resolves + # `$HIVE_DOCS_DIR` from it via `services.hyperhive.agent.docs.source`. Evaluated + # here on the host where docs/ exists — it cannot be derived from + # inside the docs-stripped `hyperhiveFlakeSource`. + hyperhiveDocsSource = lib.cleanSourceWith { + name = "hyperhive-docs-source"; + src = ../docs; + }; } diff --git a/swarm-builder/README.md b/swarm-builder/README.md index ae3b640b..5de45514 100644 --- a/swarm-builder/README.md +++ b/swarm-builder/README.md @@ -12,8 +12,9 @@ the answer into a `swarm-builder` commit status on the PR's head. ## What a build is -- `nix build` of `nix/builder/check-flake`'s `check` output, with its three +- `nix build` of `nix/builder/check-flake`'s `check` output, with its four inputs overridden: `hyperhive` and `nixpkgs` to this host's own sources, + `hyperhive-docs` to the `docs/` source its hives use (`c0re.hyperhiveDocs`), and `agent` to `git+https:///agent-configs/?ref=refs/pull//head&rev=`. - The evaluation runs here, pure; the builds run in the host daemon's sandbox as an untrusted client, so a config can neither set daemon options nor read diff --git a/swarm-builder/src/main.rs b/swarm-builder/src/main.rs index c68a3864..b0b7501f 100644 --- a/swarm-builder/src/main.rs +++ b/swarm-builder/src/main.rs @@ -68,6 +68,7 @@ async fn main() -> Result<()> { check_flake: var("CHECK_FLAKE")?, hyperhive: var("HYPERHIVE_FLAKE")?, nixpkgs: var("NIXPKGS_FLAKE")?, + hyperhive_docs: var("HYPERHIVE_DOCS_FLAKE")?, forge_url: forge_url()?, config_org: var("CONFIG_ORG")?, deadline: BUILD_DEADLINE, diff --git a/swarm-builder/src/nix_build.rs b/swarm-builder/src/nix_build.rs index 414211fe..b6937378 100644 --- a/swarm-builder/src/nix_build.rs +++ b/swarm-builder/src/nix_build.rs @@ -1,5 +1,5 @@ -//! One build: `nix build` of the check flake with its three inputs pointed at -//! this host's hyperhive and nixpkgs and at the agent's config +//! One build: `nix build` of the check flake with its four inputs pointed at +//! this host's hyperhive, hyperhive docs and nixpkgs and at the agent's config //! commit. //! //! The config repo is private, so `git` fetches it with the builder's forge @@ -45,6 +45,8 @@ pub struct Builder { pub hyperhive: String, /// The nixpkgs source this host was deployed from. pub nixpkgs: String, + /// The hyperhive `docs/` source this host was deployed from. + pub hyperhive_docs: String, /// `https://`, with no trailing slash and no path. pub forge_url: String, /// The org the config repos are in. @@ -100,6 +102,9 @@ impl Builder { "nixpkgs".to_owned(), self.nixpkgs.clone(), "--override-input".to_owned(), + "hyperhive-docs".to_owned(), + self.hyperhive_docs.clone(), + "--override-input".to_owned(), "agent".to_owned(), self.agent_url(job), ]) @@ -303,6 +308,7 @@ mod tests { check_flake: "/nix/store/c-check".to_owned(), hyperhive: "/nix/store/h-source".to_owned(), nixpkgs: "/nix/store/n-source".to_owned(), + hyperhive_docs: "/nix/store/d-docs-source".to_owned(), forge_url: "https://forge.example".to_owned(), config_org: "agent-configs".to_owned(), nix: PathBuf::from("nix"), @@ -327,7 +333,14 @@ mod tests { .filter(|w| w[0] == "--override-input") .map(|w| w[1].as_str()) .collect(); - assert_eq!(overridden, ["hyperhive", "nixpkgs", "agent"]); + assert_eq!( + overridden, + ["hyperhive", "nixpkgs", "hyperhive-docs", "agent"] + ); + assert!( + args.windows(2) + .any(|w| w[0] == "hyperhive-docs" && w[1] == "/nix/store/d-docs-source") + ); assert!(args.iter().any(|a| a == "/nix/store/c-check#check")); assert!(args.iter().any(|a| a == "--no-write-lock-file")); }