From b5acd60cf5bb2770ad7e4b45e0c1959c150c925b Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 4 Jul 2026 11:26:13 +0200 Subject: [PATCH] feat(#2170): split docs/ into its own meta-flake input --- flake.nix | 35 ++++++++++++++----- hive-c0re/src/coordinator.rs | 14 ++++++++ hive-c0re/src/meta.rs | 63 ++++++++++++++++++++++++++++++++++ nix/modules/hive-c0re.nix | 15 ++++++++ nix/templates/harness-base.nix | 39 ++++++++++++++++----- 5 files changed, 148 insertions(+), 18 deletions(-) diff --git a/flake.nix b/flake.nix index 79578b81..5213729a 100644 --- a/flake.nix +++ b/flake.nix @@ -37,15 +37,17 @@ # each real source is a named, explicitly-filtered derivation used # as `src`, rather than an inline filter at the use site. # - # Dropped: scripts/, root-level *.md (README/CLAUDE/TODO/…). - # Kept (build needs them): .nix, .rs, Cargo.*, branding/, docs/, - # frontend/, prompts/, flake.lock. docs/ MUST stay in: the - # reference-docs derivation (nix/reference-docs.nix, shipped into - # every agent container via claude --add-dir) builds from ../docs, - # so a flake source without it can't evaluate — container rebuilds - # from the meta flake die with "path …/docs does not exist". The - # rebuild-avoidance for doc edits is gone by design: containers now - # genuinely depend on the docs tree. + # Dropped: scripts/, docs/, root-level *.md (README/CLAUDE/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 → `hyperhive.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 old `nix/reference-docs.nix` 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 = ./.; @@ -56,8 +58,20 @@ 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 `hyperhiveFlake`); the + # harness resolves `$HIVE_DOCS_DIR` from it via `hyperhive.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; + }; treefmt-config = { projectRootFile = "flake.nix"; programs = { @@ -285,6 +299,9 @@ hyperhiveFrontend = system: self.packages.${system}.frontend; hyperhiveAssets = system: self.packages.${system}.assets; hyperhiveFlake = "${hyperhiveFlakeSource}"; + # Narrow docs/ source, threaded as its own meta-flake input so + # doc edits don't re-hash the whole flake source. + hyperhiveDocs = "${hyperhiveDocsSource}"; # 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. diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index b2e50f84..03ee7676 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -59,6 +59,11 @@ 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, + /// 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 `hyperhive.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 @@ -197,6 +202,11 @@ pub struct Coordinator { #[serde(default)] pub struct HiveEnv { pub hyperhive_flake: String, + /// Store-path URL of the narrow `docs/` source, wired into the meta + /// flake as `inputs.hyperhive-docs.url` and threaded to each agent as + /// `hyperhive.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, pub dashboard_port: u16, pub operator_pronouns: String, @@ -211,6 +221,7 @@ impl Default for HiveEnv { fn default() -> Self { Self { hyperhive_flake: "/etc/hyperhive".to_string(), + hyperhive_docs_flake: String::new(), nixpkgs_flake: String::new(), dashboard_port: 7000, operator_pronouns: "she/her".to_string(), @@ -425,6 +436,7 @@ impl Coordinator { ) -> Result { let HiveEnv { hyperhive_flake, + hyperhive_docs_flake, nixpkgs_flake, dashboard_port, operator_pronouns, @@ -467,6 +479,7 @@ impl Coordinator { build_logs, audit_log, hyperhive_flake, + hyperhive_docs_flake, nixpkgs_flake, dashboard_port, operator_pronouns, @@ -496,6 +509,7 @@ impl Coordinator { pub fn hive_env(&self) -> HiveEnv { HiveEnv { hyperhive_flake: self.hyperhive_flake.clone(), + hyperhive_docs_flake: self.hyperhive_docs_flake.clone(), nixpkgs_flake: self.nixpkgs_flake.clone(), dashboard_port: self.dashboard_port, operator_pronouns: self.operator_pronouns.clone(), diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index ea79005b..c5764faa 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -56,6 +56,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { let new_flake = render_flake( &hive.hyperhive_flake, + &hive.hyperhive_docs_flake, &hive.nixpkgs_flake, hive.dashboard_port, &hive.operator_pronouns, @@ -561,6 +562,7 @@ pub async fn bulk_commit_topology( fn render_flake( hyperhive_flake: &str, + docs_flake: &str, nixpkgs_flake: &str, dashboard_port: u16, operator_pronouns: &str, @@ -569,6 +571,7 @@ fn render_flake( ) -> String { render_flake_with_lookup( hyperhive_flake, + docs_flake, nixpkgs_flake, dashboard_port, operator_pronouns, @@ -844,6 +847,7 @@ fn agent_canonical_inputs(name: &str) -> Vec<&'static str> { )] fn render_flake_with_lookup( hyperhive_flake: &str, + docs_flake: &str, nixpkgs_flake: &str, dashboard_port: u16, operator_pronouns: &str, @@ -882,6 +886,17 @@ where 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 `hyperhive.docs.source`. + // Empty = hive-c0re not built with the option wired up (legacy); + // agents then keep the harness-base default (`pkgs.hyperhive-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"); + } for spec in agents { let _ = writeln!( out, @@ -930,6 +945,14 @@ where { "#, ); + // Point the in-container docs dir (`$HIVE_DOCS_DIR`) at the narrow + // `hyperhive-docs` input instead of the harness-base default + // (`pkgs.hyperhive-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(" hyperhive.docs.source = inputs.\"hyperhive-docs\".outPath;\n"); + } // CA trust: embed every hive-trusted CA so each agent validates them at // build time. The list is the hive's own self-signed CA (when active) // plus every peer-hive root CA (`swarm.peers..caCert`) — a peer CA is @@ -1274,6 +1297,7 @@ mod tests { 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", 8000, "she/her", @@ -1294,6 +1318,39 @@ mod tests { !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 pkgs.hyperhive-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("hyperhive.docs.source = inputs.\"hyperhive-docs\".outPath;"), + "expected per-agent docs source wired to the input:\n{out}" + ); + } + + #[test] + 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-base default (pkgs.hyperhive-docs). + let out = render_flake( + "github:example/hyperhive", + "", + "path:/nix/store/aaaa-nixpkgs-source", + 8000, + "she/her", + &std::collections::HashMap::new(), + &[sample_spec("alice", false, 9001)], + ); + assert!( + !out.contains("hyperhive-docs"), + "no docs input/source when docs_flake is empty:\n{out}" + ); } #[test] @@ -1302,6 +1359,7 @@ mod tests { // for any code path that can't inject pkgs.path). let out = render_flake( "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", "", 8000, "she/her", @@ -1330,6 +1388,7 @@ mod tests { }; let out = render_flake_with_lookup( "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", 8000, "she/her", @@ -1361,6 +1420,7 @@ mod tests { fn render_flake_skips_canonical_follows_when_lookup_returns_empty() { let out = render_flake_with_lookup( "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", 8000, "she/her", @@ -1394,6 +1454,7 @@ mod tests { } let out = render_flake( "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", 8000, "she/her", @@ -1441,6 +1502,7 @@ mod tests { let render = || { render_flake( "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", 8000, "she/her", @@ -1522,6 +1584,7 @@ mod tests { let render = || { render_flake( "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", 8000, "she/her", diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index cc27dad6..b8d3acb5 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -3,6 +3,7 @@ hyperhiveFrontend, hyperhiveAssets, hyperhiveFlake, + hyperhiveDocs, agentBaseToplevel, managerToplevel, }: @@ -39,6 +40,7 @@ let serveConfig = pkgs.writeText "hive-c0re-serve.json" ( builtins.toJSON { hyperhive_flake = cfg.hyperhiveFlake; + hyperhive_docs_flake = cfg.hyperhiveDocs; nixpkgs_flake = cfg.nixpkgsFlake; dashboard_port = cfg.dashboardPort; operator_pronouns = cfg.operatorPronouns; @@ -558,6 +560,19 @@ in only override if you want agents tracking a different ref. ''; }; + hyperhiveDocs = lib.mkOption { + type = lib.types.str; + default = hyperhiveDocs; + defaultText = lib.literalMD "the docs/ tree's own store path"; + description = '' + 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 `hyperhive.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. + ''; + }; nixpkgsFlake = lib.mkOption { type = lib.types.str; default = "path:${pkgs.path}"; diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 7b98750e..db68a949 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -100,19 +100,21 @@ let iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } '' rsvg-convert -f png -w 512 -h 512 ${config.hyperhive.icon} -o $out ''; - # hyperhive.docs.enable: the reference-docs tree (`pkgs.hyperhive-docs`, - # the repo `docs/` verbatim) with a generic `CLAUDE.md` pointer dropped - # at the root. The harness passes this dir to claude via `--add-dir`; - # paired with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` claude - # loads `/CLAUDE.md` ADDITIVELY (alongside, never replacing, the - # agent's own `~/.claude/CLAUDE.md`). The pointer prose lives in + # hyperhive.docs.enable: the reference-docs tree (from + # `config.hyperhive.docs.source` — the narrow `hyperhive-docs` meta-flake + # input in a hive deployment, or `pkgs.hyperhive-docs` for standalone + # builds) with a generic `CLAUDE.md` pointer dropped at the root. The + # harness passes this dir to claude via `--add-dir`; paired with + # `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` claude loads + # `/CLAUDE.md` ADDITIVELY (alongside, never replacing, the agent's + # own `~/.claude/CLAUDE.md`). The pointer prose lives in # `hive-ag3nt/prompts/docs-pointer.md` (prompts belong in prompts/, not - # nix/) and is shipped via `hyperhive-assets`. `hyperhive-docs` itself + # nix/) and is shipped via `hyperhive-assets`. The docs source itself # stays pure (no CLAUDE.md) so the website can reuse the same tree. # Lazy: only forced when `hyperhive.docs.enable` references it below. agentDocs = pkgs.runCommand "hyperhive-agent-docs" { } '' mkdir -p $out - cp -r ${pkgs.hyperhive-docs}/. $out/ + cp -r ${config.hyperhive.docs.source}/. $out/ chmod -R u+w $out cp ${pkgs.hyperhive-assets}/share/hyperhive/prompts/docs-pointer.md $out/CLAUDE.md ''; @@ -269,6 +271,22 @@ in elsewhere; any agent can flip it from its `agent.nix`. ''; + options.hyperhive.docs.source = lib.mkOption { + type = lib.types.path; + default = pkgs.hyperhive-docs; + defaultText = lib.literalMD "`pkgs.hyperhive-docs` (built from `../docs`)"; + description = '' + Store path of the reference-docs tree exposed at `$HIVE_DOCS_DIR` + when `hyperhive.docs.enable` is set. Defaults to the overlay's + `pkgs.hyperhive-docs` (the `nix/reference-docs.nix` build from + `../docs`) 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. + ''; + }; + options.hyperhive.allowedBashPatterns = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; @@ -1408,7 +1426,10 @@ in # `--add-dir` so the docs are readable; with # `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` claude also loads # the pointer additively, telling the agent the docs exist without - # clobbering its own memory. See hive-ag3nt::turn. + # clobbering its own memory. See hive-ag3nt::turn. The docs tree + # itself comes from `hyperhive.docs.source` (the narrow + # `hyperhive-docs` meta-flake input, or `pkgs.hyperhive-docs` for + # standalone builds) via `agentDocs`. HIVE_DOCS_DIR = "${agentDocs}"; CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD = "1"; }