//! Agent topology storage — single source of truth for parent/child //! relations in the hive. Persisted as a flat JSON map of `name → //! parent name | null` at `/var/lib/hyperhive/meta/topology.json`, //! alongside the meta `flake.nix`, so topology changes thread through //! the same git commit log as deploys. //! //! Format, rationale, read/reconcile/inject/surface flow, and target //! enforcement semantics: `docs/agent-hierarchy.md::Current state`. //! `` sentinel resolution (delivered by [`resolve_recipient`]): //! `docs/conventions.md::Recipient sentinels`. 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() } /// Return the direct children of `name` — agents whose `topology.json` /// entry has `name` as their parent. Reads the map once and scans all /// entries; cheap enough for the fan-out path (one disk read per send /// to ``). #[must_use] pub fn children_of(name: &str) -> Vec { children_of_in(&read(), name) } /// Pure form of [`children_of`] for unit tests. #[must_use] pub fn children_of_in( topo: &BTreeMap>, name: &str, ) -> Vec { topo.iter() .filter_map(|(agent, parent)| { if parent.as_deref() == Some(name) { Some(agent.clone()) } else { None } }) .collect() } /// Resolve a magic recipient sentinel (currently just /// [`hive_sh4re::PARENT_RECIPIENT`]) to a real broker recipient at /// send time. Returns an owned `String` so callers can plug it /// straight into [`crate::broker::Broker::send`] without /// borrow-juggling around the temporary lookup. /// /// Rules + rationale: `docs/conventions.md::Recipient sentinels`. /// Fast path: ordinary recipient names short-circuit before any /// disk read — only `` triggers `read()` on `topology.json`. #[must_use] pub fn resolve_recipient(sender: &str, to: &str) -> String { // Early exit: only sentinel recipients need topology lookup. This // keeps the cost of a normal `send` at one string comparison. if to != hive_sh4re::PARENT_RECIPIENT { return to.to_owned(); } resolve_recipient_in(&read(), sender, to) } /// Pure form of [`resolve_recipient`] taking the topology map /// explicitly. Split out so unit tests can exercise the sentinel /// rules without writing a `topology.json` to disk. #[must_use] pub fn resolve_recipient_in( topo: &BTreeMap>, sender: &str, to: &str, ) -> String { if to == hive_sh4re::PARENT_RECIPIENT { topo.get(sender) .cloned() .flatten() .unwrap_or_else(|| hive_sh4re::OPERATOR_RECIPIENT.to_owned()) } else { to.to_owned() } } /// True when `candidate` is `ancestor` or any descendant of /// `ancestor` per the current topology. Walks parents from /// `candidate` upward; the walk terminates at root or on a cycle /// (cycle defence: bounded to 32 hops, more than any plausible /// hive depth). Used by the cancel-authorization check in /// `manager_server::handle_cancel_schedule` to enforce /// "managers can cancel anything their subtree owns." #[must_use] pub fn is_descendant_of(candidate: &str, ancestor: &str) -> bool { if candidate == ancestor { return true; } let topo = read(); let mut cur = candidate.to_owned(); for _ in 0..32 { let Some(parent) = topo.get(&cur).cloned().flatten() else { return false; }; if parent == ancestor { return true; } cur = parent; } false } /// 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 (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] #[allow( dead_code, reason = "kept for the dashboard / RequestSetParent write API; \ `sync_agents` does its own seeding today" )] 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 } /// Pure validation + apply for [`set_parent`]. Splits off so tests /// can exercise the rules (cycle / unknown) on an in-memory /// `BTreeMap` without touching the on-disk `topology.json`. Returns /// either the post-move map (caller writes it back) or a /// user-readable error string. /// /// The manager is reparentable like any other agent — its special /// powers come from the privileged MCP socket, not its tree /// position. The cycle walk below covers "moving X under its own /// descendant" for the manager as much as any other agent. /// `docs/agent-hierarchy.md::Current state` has the rationale. pub fn apply_set_parent( topo: &BTreeMap>, child: &str, new_parent: Option<&str>, ) -> Result>, String> { if !topo.contains_key(child) { return Err(format!("unknown agent: {child}")); } if let Some(p) = new_parent { if !topo.contains_key(p) { return Err(format!("unknown parent: {p}")); } if p == child { return Err("an agent cannot be its own parent".to_owned()); } // Cycle check: walk `p`'s ancestors in the EXISTING map. If // we hit `child`, then making `child`'s parent = `p` would // close the loop (child → … → p → child). let mut cur = p.to_owned(); for _ in 0..32 { if cur == child { return Err(format!( "cycle: {p} is in {child}'s subtree (would create a loop)" )); } let Some(next) = topo.get(&cur).cloned().flatten() else { break; }; cur = next; } } let mut next = topo.clone(); next.insert(child.to_owned(), new_parent.map(str::to_owned)); Ok(next) } /// Operator-driven parent move. Set `child`'s parent to `new_parent` /// (or `None` to promote to root). See [`apply_set_parent`] for the /// validation rules. The operator-set parent sticks across /// `reconcile()` calls (which preserves existing entries). /// /// No bind-mount / container churn today — the hierarchy is /// currently logical-only. Once sub-manager bind mounts land, the /// caller adds an umount-old / mount-new / restart-cascade step on /// top. pub fn set_parent(child: &str, new_parent: Option<&str>) -> Result<(), String> { let current = read(); // Idempotent no-op fast path: skip the disk write when nothing // changes. apply_set_parent still runs to surface validation // errors (e.g. unknown child) so the caller gets a real signal. let next = apply_set_parent(¤t, child, new_parent)?; if next == current { return Ok(()); } write(&next).map_err(|e| format!("write topology.json: {e}")) } /// 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()); } fn topo_three_level() -> BTreeMap> { let mut m = BTreeMap::new(); m.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); m.insert( "alice".to_owned(), Some(crate::lifecycle::MANAGER_NAME.to_owned()), ); m.insert("bob".to_owned(), Some("alice".to_owned())); m.insert("carol".to_owned(), Some("alice".to_owned())); m } #[test] fn apply_set_parent_promotes_to_root() { let next = apply_set_parent(&topo_three_level(), "alice", None).unwrap(); assert_eq!(next.get("alice"), Some(&None)); } #[test] fn apply_set_parent_reparents_under_sibling_subtree() { // bob and carol both under alice; move carol under bob. let next = apply_set_parent(&topo_three_level(), "carol", Some("bob")).unwrap(); assert_eq!(next.get("carol"), Some(&Some("bob".to_owned()))); } #[test] fn apply_set_parent_allows_manager_move() { // The manager is reparentable like any other agent (its // privileges live on the MCP socket, not its tree position). // Build a topo with an unrelated root-level agent `peer` so // moving the manager under it doesn't trip the cycle walk // (every non-manager agent in topo_three_level descends from // the manager, so that fixture can't exercise a legal // manager move). let mut topo = BTreeMap::new(); topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); topo.insert("peer".to_owned(), None); let next = apply_set_parent(&topo, crate::lifecycle::MANAGER_NAME, Some("peer")) .expect("manager move should succeed"); assert_eq!( next.get(crate::lifecycle::MANAGER_NAME), Some(&Some("peer".to_owned())) ); } #[test] fn apply_set_parent_refuses_manager_under_own_descendant() { // Moving the manager under `bob` (who already lives under // `alice` who lives under the manager) would close the loop. // The general cycle walk catches this; no separate manager // guard needed. let err = apply_set_parent(&topo_three_level(), crate::lifecycle::MANAGER_NAME, Some("bob")) .unwrap_err(); assert!(err.contains("cycle"), "err = {err}"); } #[test] fn apply_set_parent_refuses_unknown_child() { let err = apply_set_parent(&topo_three_level(), "nobody", Some("alice")).unwrap_err(); assert!(err.contains("unknown agent"), "err = {err}"); } #[test] fn apply_set_parent_refuses_unknown_parent() { let err = apply_set_parent(&topo_three_level(), "bob", Some("nobody")).unwrap_err(); assert!(err.contains("unknown parent"), "err = {err}"); } #[test] fn apply_set_parent_refuses_self() { let err = apply_set_parent(&topo_three_level(), "alice", Some("alice")).unwrap_err(); assert!(err.contains("own parent"), "err = {err}"); } #[test] fn apply_set_parent_refuses_cycle() { // bob's parent is alice; trying to make alice's parent = // bob would close the loop alice → bob → alice. let err = apply_set_parent(&topo_three_level(), "alice", Some("bob")).unwrap_err(); assert!(err.contains("cycle"), "err = {err}"); } #[test] fn apply_set_parent_refuses_deep_cycle() { // Three-deep chain: manager → alice → bob → carol. Moving // alice under carol would create the loop alice → carol → bob → alice. let mut topo = BTreeMap::new(); topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); topo.insert( "alice".to_owned(), Some(crate::lifecycle::MANAGER_NAME.to_owned()), ); topo.insert("bob".to_owned(), Some("alice".to_owned())); topo.insert("carol".to_owned(), Some("bob".to_owned())); let err = apply_set_parent(&topo, "alice", Some("carol")).unwrap_err(); assert!(err.contains("cycle"), "err = {err}"); } #[test] fn apply_set_parent_is_idempotent_noop() { // bob is already under alice — same value returned. let next = apply_set_parent(&topo_three_level(), "bob", Some("alice")).unwrap(); assert_eq!(next, topo_three_level()); } #[test] fn resolve_recipient_passes_through_ordinary_names() { let topo = topo_three_level(); // Real labels, broadcast, and the operator literal all // shortcut through unchanged — no resolution magic. assert_eq!(resolve_recipient_in(&topo, "bob", "alice"), "alice"); assert_eq!(resolve_recipient_in(&topo, "bob", "*"), "*"); assert_eq!( resolve_recipient_in(&topo, "bob", hive_sh4re::OPERATOR_RECIPIENT), hive_sh4re::OPERATOR_RECIPIENT ); } #[test] fn resolve_recipient_rewrites_parent_sentinel_to_parent_label() { let topo = topo_three_level(); // bob's parent is alice → `` from bob goes to alice. assert_eq!( resolve_recipient_in(&topo, "bob", hive_sh4re::PARENT_RECIPIENT), "alice" ); // alice's parent is the manager — same one-hop rewrite. assert_eq!( resolve_recipient_in(&topo, "alice", hive_sh4re::PARENT_RECIPIENT), crate::lifecycle::MANAGER_NAME ); } #[test] fn resolve_recipient_falls_back_to_operator_for_root_agent() { let topo = topo_three_level(); // Manager is structurally root (parent = None) → `` // resolves to the operator (the "no parent → tell mara" // fallback documented in conventions.md). assert_eq!( resolve_recipient_in( &topo, crate::lifecycle::MANAGER_NAME, hive_sh4re::PARENT_RECIPIENT ), hive_sh4re::OPERATOR_RECIPIENT ); } #[test] fn resolve_recipient_falls_back_to_operator_for_unknown_sender() { // Sender absent from topology entirely — defensive fallback // covers the race window where an agent's spawn has registered // its socket but the meta-flake `sync_agents` hasn't yet added // its row. let topo = topo_three_level(); assert_eq!( resolve_recipient_in(&topo, "nobody", hive_sh4re::PARENT_RECIPIENT), hive_sh4re::OPERATOR_RECIPIENT ); } #[test] fn children_of_in_returns_direct_descendants() { let topo = topo_three_level(); // alice's children: bob, carol. let mut children = children_of_in(&topo, "alice"); children.sort(); assert_eq!(children, vec!["bob", "carol"]); } #[test] fn children_of_in_manager_returns_root_level_agents() { let topo = topo_three_level(); // Only alice's parent is manager; bob+carol are under alice. let children = children_of_in(&topo, crate::lifecycle::MANAGER_NAME); assert_eq!(children, vec!["alice"]); } #[test] fn children_of_in_leaf_returns_empty() { let topo = topo_three_level(); // bob and carol have no children. assert!(children_of_in(&topo, "bob").is_empty()); assert!(children_of_in(&topo, "carol").is_empty()); } #[test] fn children_of_in_unknown_sender_returns_empty() { let topo = topo_three_level(); assert!(children_of_in(&topo, "nobody").is_empty()); } }