diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index edffa4f..8d869d1 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -239,6 +239,68 @@ fn render_flake( context_window_tokens: &std::collections::HashMap, agents: &[AgentSpec], ) -> String { + render_flake_with_lookup( + hyperhive_flake, + dashboard_port, + operator_pronouns, + context_window_tokens, + agents, + agent_canonical_inputs, + ) +} + +/// Canonical inputs meta knows how to dedup. An agent that declares one +/// of these as a top-level input in its own `flake.nix` will get a +/// `follows = ""` line emitted in meta — collapsing the +/// otherwise-separate-but-identical `nixpkgs_N` nodes into a single +/// meta-level reference (#355). +const CANONICAL_INPUTS: &[&str] = &["nixpkgs", "nixpkgs-unstable"]; + +/// Read an agent's applied `flake.lock` and return the subset of +/// `CANONICAL_INPUTS` it declares as direct (root-level) inputs. +/// Returns an empty vec when the lock is missing or unparsable — +/// safe degradation, the worst case is no dedup for that agent. +fn agent_canonical_inputs(name: &str) -> Vec<&'static str> { + let path = std::path::PathBuf::from(format!("{APPLIED_ROOT}/{name}/flake.lock")); + let Ok(raw) = std::fs::read_to_string(&path) else { + return Vec::new(); + }; + let Ok(json) = serde_json::from_str::(&raw) else { + return Vec::new(); + }; + let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else { + return Vec::new(); + }; + let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else { + return Vec::new(); + }; + let Some(root_inputs) = nodes + .get(root_name) + .and_then(|n| n.get("inputs")) + .and_then(|v| v.as_object()) + else { + return Vec::new(); + }; + CANONICAL_INPUTS + .iter() + .copied() + .filter(|canon| root_inputs.contains_key(*canon)) + .collect() +} + +/// Inner render helper accepting a lookup fn so tests can stub the +/// agent flake-lock introspection. +fn render_flake_with_lookup( + hyperhive_flake: &str, + dashboard_port: u16, + operator_pronouns: &str, + context_window_tokens: &std::collections::HashMap, + agents: &[AgentSpec], + lookup: F, +) -> String +where + F: Fn(&str) -> Vec<&'static str>, +{ use std::fmt::Write as _; let mut out = String::new(); out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n"); @@ -263,6 +325,21 @@ fn render_flake( " agent-{}.url = \"git+file://{APPLIED_ROOT}/{}\";", spec.name, spec.name, ); + // For each canonical input the agent declares in its own + // `flake.nix` (detected by reading its applied `flake.lock`), + // emit `inputs.agent-.inputs..follows = ""`. + // Collapses three otherwise-separate-but-identical nixpkgs + // nodes (root + agent-bitburner's + agent-dmatrix's) into one + // (closes #355). Skipped silently for agents that don't + // declare the input — emitting follows on a non-existent + // input would error at `nix flake lock` time. + for canon in lookup(&spec.name) { + let _ = writeln!( + out, + " agent-{}.inputs.{canon}.follows = \"{canon}\";", + spec.name, + ); + } } out.push_str(" };\n outputs =\n { self, hyperhive, ... }@inputs:\n let\n"); // Free-text operator string — escape backslash + double-quote so a @@ -387,6 +464,68 @@ mod tests { assert!(out.contains("hyperhive.inputs.nixpkgs.follows = \"nixpkgs\"")); assert!(out.contains("hyperhive.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\"")); } + + #[test] + fn render_flake_emits_follows_for_agents_declaring_nixpkgs() { + // Stub lookup: pretend `bitburner` declares `nixpkgs` at its + // root, while `argus` has no canonical inputs at all. + let lookup = |name: &str| -> Vec<&'static str> { + match name { + "bitburner" => vec!["nixpkgs"], + "dmatrix" => vec!["nixpkgs", "nixpkgs-unstable"], + _ => vec![], + } + }; + let out = render_flake_with_lookup( + "github:example/hyperhive", + 8000, + "she/her", + &std::collections::HashMap::new(), + &[ + sample_spec("argus", false, 9001), + sample_spec("bitburner", false, 9002), + sample_spec("dmatrix", false, 9003), + ], + lookup, + ); + // bitburner declares nixpkgs → follows emitted. + assert!( + out.contains("agent-bitburner.inputs.nixpkgs.follows = \"nixpkgs\""), + "missing bitburner nixpkgs follows:\n{out}" + ); + // dmatrix declares both → both follows emitted. + assert!(out.contains("agent-dmatrix.inputs.nixpkgs.follows = \"nixpkgs\"")); + assert!( + out.contains("agent-dmatrix.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\"") + ); + // argus declares neither → no follows emitted for it. Asserting + // ABSENCE is the important bit: emitting a follows on a + // non-existent input errors at `nix flake lock` time. + assert!( + !out.contains("agent-argus.inputs.nixpkgs"), + "argus shouldn't have nixpkgs follows:\n{out}" + ); + } + + #[test] + fn render_flake_skips_canonical_follows_when_lookup_returns_empty() { + let out = render_flake_with_lookup( + "github:example/hyperhive", + 8000, + "she/her", + &std::collections::HashMap::new(), + &[sample_spec("alice", false, 9001)], + |_| Vec::new(), + ); + // No agent-side follows when the lookup reports nothing + // declared — protects agents whose flake.lock can't be read + // (missing / unparsable) from being broken by a follows on a + // non-existent input. + assert!( + !out.contains("agent-alice.inputs."), + "alice shouldn't have any inputs follows:\n{out}" + ); + } } async fn git_is_clean(dir: &Path) -> Result {