//! Agent topology storage — single source of truth for parent/child //! relations in the hive. Lives in the hive-c0re-owned meta repo at //! `/var/lib/hyperhive/meta/topology.json`, alongside `flake.nix`, so //! topology changes thread through the same git commit log as deploys. //! //! Why meta, not per-agent: an agent shouldn't be able to claim a //! parent without that parent's consent, and an operator-driven //! re-parenting shouldn't require touching the moved agent's own //! config. Topology IS a system-level concern; meta is where //! system-level facts live. //! //! Format — flat JSON map keyed by agent name, values are the parent //! agent's name or `null` for root: //! //! ```json //! { //! "manager": null, //! "alice": "manager", //! "bob": "alice" //! } //! ``` //! //! Agents present in `nixos-container list` but absent from the file //! default to root-level (`parent = None`). This file is operator/ //! manager-managed via approval-gated writes (write API lands in a //! follow-up PR on the #361 milestone); for the bootstrap commit //! `meta::sync_agents` seeds it with the existing implicit topology //! (manager as root, all current sub-agents as direct children). use std::collections::BTreeMap; use std::path::PathBuf; const TOPOLOGY_FILE: &str = "topology.json"; #[must_use] pub fn topology_path() -> PathBuf { crate::meta::meta_dir().join(TOPOLOGY_FILE) } /// Snapshot of the topology map. Read on every `container_view::build_all` /// and every `render_flake` call. The file is small (one line per agent), /// so we re-read rather than caching — keeps the source of truth on disk. /// /// Returns an empty map when the file is absent or unparsable; callers /// treat that as "no recorded parents", which falls back to every agent /// being root-level. Safe degradation for fresh installs that haven't /// run through `meta::sync_agents` yet. #[must_use] pub fn read() -> BTreeMap> { let path = topology_path(); let Ok(raw) = std::fs::read_to_string(&path) else { return BTreeMap::new(); }; serde_json::from_str(&raw).unwrap_or_default() } /// Look up one agent's parent. Returns `None` when the agent is root /// or absent from the file. Cheap convenience over `read()` for /// callers that want a single entry. #[must_use] pub fn parent_of(name: &str) -> Option { read().get(name).cloned().flatten() } /// Persist the topology map. Sorted JSON output (BTreeMap is sorted by /// key) keeps git diffs minimal across re-writes. Best-effort — /// returns an `io::Error` so callers can decide whether a failure /// should abort their op (sync_agents, RequestSetParent) or just log. pub fn write(topology: &BTreeMap>) -> std::io::Result<()> { let path = topology_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let text = serde_json::to_string_pretty(topology) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; std::fs::write(&path, format!("{text}\n")) } /// Compute the default topology for a fresh install: every non-manager /// agent has the manager as parent; manager itself is root. Used by /// `meta::sync_agents` on first call to seed `topology.json`. /// /// As soon as an explicit write lands (#361 follow-up: dashboard / /// `RequestSetParent` API), this seeding stops touching pre-existing /// entries — `sync_agents` only adds rows for newly-spawned agents /// against whatever the operator has configured. #[must_use] pub fn default_seed(agent_names: &[String]) -> BTreeMap> { let mut out = BTreeMap::new(); for name in agent_names { if name == crate::lifecycle::MANAGER_NAME { out.insert(name.clone(), None); } else { out.insert(name.clone(), Some(crate::lifecycle::MANAGER_NAME.to_owned())); } } out } /// Reconcile `topology.json` against the current agent set. Adds an /// entry (default: parent = manager, manager itself = root) for any /// agent missing from the file; removes entries for agents no longer /// present. Existing entries are preserved as-is — operator/manager /// choices stick across regenerations. Returns true when the file /// changed and should be re-committed by the caller. pub fn reconcile(agent_names: &[String]) -> std::io::Result { let mut current = read(); let mut changed = false; // Add missing agents at their default position. for name in agent_names { if !current.contains_key(name) { let parent = if name == crate::lifecycle::MANAGER_NAME { None } else { Some(crate::lifecycle::MANAGER_NAME.to_owned()) }; current.insert(name.clone(), parent); changed = true; } } // Drop entries for agents that no longer exist. let known: std::collections::HashSet<_> = agent_names.iter().collect(); current.retain(|name, _| { let keep = known.contains(name); if !keep { changed = true; } keep }); if changed { write(¤t)?; } Ok(changed) } #[cfg(test)] mod tests { use super::*; #[test] fn default_seed_makes_manager_root_others_children() { let agents = vec![ "alice".to_owned(), crate::lifecycle::MANAGER_NAME.to_owned(), "bob".to_owned(), ]; let seed = default_seed(&agents); assert_eq!( seed.get(crate::lifecycle::MANAGER_NAME), Some(&None), "manager should be root" ); assert_eq!( seed.get("alice"), Some(&Some(crate::lifecycle::MANAGER_NAME.to_owned())) ); assert_eq!( seed.get("bob"), Some(&Some(crate::lifecycle::MANAGER_NAME.to_owned())) ); } #[test] fn default_seed_handles_empty_input() { let seed = default_seed(&[]); assert!(seed.is_empty()); } }