hive-c0re/topology.rs: trim prose to docs pointers (#715 batch 3)

This commit is contained in:
damocles 2026-05-31 17:11:11 +02:00 committed by mara
commit 4f9697220e

View file

@ -1,31 +1,13 @@
//! Agent topology storage — single source of truth for parent/child //! Agent topology storage — single source of truth for parent/child
//! relations in the hive. Lives in the hive-c0re-owned meta repo at //! relations in the hive. Persisted as a flat JSON map of `name →
//! `/var/lib/hyperhive/meta/topology.json`, alongside `flake.nix`, so //! parent name | null` at `/var/lib/hyperhive/meta/topology.json`,
//! topology changes thread through the same git commit log as deploys. //! alongside the meta `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 //! Format, rationale, read/reconcile/inject/surface flow, and target
//! parent without that parent's consent, and an operator-driven //! enforcement semantics: `docs/agent-hierarchy.md::Current state`.
//! re-parenting shouldn't require touching the moved agent's own //! `<parent>` sentinel resolution (delivered by [`resolve_recipient`]):
//! config. Topology IS a system-level concern; meta is where //! `docs/conventions.md::Recipient sentinels`.
//! 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::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
@ -64,25 +46,13 @@ pub fn parent_of(name: &str) -> Option<String> {
/// Resolve a magic recipient sentinel (currently just /// Resolve a magic recipient sentinel (currently just
/// [`hive_sh4re::PARENT_RECIPIENT`]) to a real broker recipient at /// [`hive_sh4re::PARENT_RECIPIENT`]) to a real broker recipient at
/// send time. Lets agents address structural roles /// send time. Returns an owned `String` so callers can plug it
/// (`send(to: "<parent>", ...)`) without learning their parent's /// straight into [`crate::broker::Broker::send`] without
/// label — runtime reparenting (#486) propagates for free.
///
/// Resolution rules:
/// - `<parent>` → `topology.json`'s parent for `sender` if some,
/// else [`hive_sh4re::OPERATOR_RECIPIENT`] (the "no parent → tell
/// mara" fallback from #692 / #8592).
/// - Anything else: returned unchanged.
///
/// Returns an owned `String` for the rewritten recipient so callers
/// can plug it straight into [`crate::broker::Broker::send`] without
/// borrow-juggling around the temporary lookup. /// borrow-juggling around the temporary lookup.
/// ///
/// Fast path: ordinary recipient names (the overwhelming majority of /// Rules + rationale: `docs/conventions.md::Recipient sentinels`.
/// sends) short-circuit before touching the disk — only `<parent>` /// Fast path: ordinary recipient names short-circuit before any
/// triggers the `read()` of `topology.json`. Sentinel-free traffic /// disk read — only `<parent>` triggers `read()` on `topology.json`.
/// pays a single string compare; the disk read is amortised across
/// every `<parent>` send (per argus on #703#issuecomment-8750).
#[must_use] #[must_use]
pub fn resolve_recipient(sender: &str, to: &str) -> String { pub fn resolve_recipient(sender: &str, to: &str) -> String {
// Early exit: only sentinel recipients need topology lookup. This // Early exit: only sentinel recipients need topology lookup. This
@ -156,15 +126,15 @@ pub fn write(topology: &BTreeMap<String, Option<String>>) -> std::io::Result<()>
/// agent has the manager as parent; manager itself is root. Used by /// agent has the manager as parent; manager itself is root. Used by
/// `meta::sync_agents` on first call to seed `topology.json`. /// `meta::sync_agents` on first call to seed `topology.json`.
/// ///
/// As soon as an explicit write lands (#361 follow-up: dashboard / /// As soon as an explicit write lands (dashboard / `RequestSetParent`
/// `RequestSetParent` API), this seeding stops touching pre-existing /// API), this seeding stops touching pre-existing entries —
/// entries — `sync_agents` only adds rows for newly-spawned agents /// `sync_agents` only adds rows for newly-spawned agents against
/// against whatever the operator has configured. /// whatever the operator has configured.
#[must_use] #[must_use]
#[allow( #[allow(
dead_code, dead_code,
reason = "kept for the dashboard / RequestSetParent write API landing in \ reason = "kept for the dashboard / RequestSetParent write API; \
#361 follow-ups; `sync_agents` does its own seeding today" `sync_agents` does its own seeding today"
)] )]
pub fn default_seed(agent_names: &[String]) -> BTreeMap<String, Option<String>> { pub fn default_seed(agent_names: &[String]) -> BTreeMap<String, Option<String>> {
let mut out = BTreeMap::new(); let mut out = BTreeMap::new();
@ -187,13 +157,11 @@ pub fn default_seed(agent_names: &[String]) -> BTreeMap<String, Option<String>>
/// either the post-move map (caller writes it back) or a /// either the post-move map (caller writes it back) or a
/// user-readable error string. /// user-readable error string.
/// ///
/// Pre-#743 this also refused to reparent the manager /// The manager is reparentable like any other agent — its special
/// ("cannot reparent the manager — it is structurally root") — /// powers come from the privileged MCP socket, not its tree
/// argus-paranoia from #361 that we dropped per mara's /// position. The cycle walk below covers "moving X under its own
/// `#9512` / `#9557`: the manager's special powers come from its /// descendant" for the manager as much as any other agent.
/// privileged MCP socket, not its tree position. The cycle walk /// `docs/agent-hierarchy.md::Current state` has the rationale.
/// below covers "moving X under its own descendant" for the
/// manager as much as any other agent.
pub fn apply_set_parent( pub fn apply_set_parent(
topo: &BTreeMap<String, Option<String>>, topo: &BTreeMap<String, Option<String>>,
child: &str, child: &str,
@ -230,16 +198,15 @@ pub fn apply_set_parent(
Ok(next) Ok(next)
} }
/// Operator-driven parent move (#486 / #487). Set `child`'s parent /// Operator-driven parent move. Set `child`'s parent to `new_parent`
/// to `new_parent` (or `None` to promote to root). See /// (or `None` to promote to root). See [`apply_set_parent`] for the
/// [`apply_set_parent`] for the validation rules. The operator-set /// validation rules. The operator-set parent sticks across
/// parent sticks across `reconcile()` calls (which preserves /// `reconcile()` calls (which preserves existing entries).
/// existing entries).
/// ///
/// No bind-mount / container churn today — the hierarchy is /// No bind-mount / container churn today — the hierarchy is
/// currently logical-only (see #486 comment 5042). Once /// currently logical-only. Once sub-manager bind mounts land, the
/// sub-manager bind mounts land alongside #361, the caller adds /// caller adds an umount-old / mount-new / restart-cascade step on
/// an umount-old / mount-new / restart-cascade step on top. /// top.
pub fn set_parent(child: &str, new_parent: Option<&str>) -> Result<(), String> { pub fn set_parent(child: &str, new_parent: Option<&str>) -> Result<(), String> {
let current = read(); let current = read();
// Idempotent no-op fast path: skip the disk write when nothing // Idempotent no-op fast path: skip the disk write when nothing
@ -348,18 +315,18 @@ mod tests {
#[test] #[test]
fn apply_set_parent_allows_manager_move() { fn apply_set_parent_allows_manager_move() {
// Post-#743: the manager is reparentable like any other agent // The manager is reparentable like any other agent (its
// (its privileges live on the MCP socket, not its tree // privileges live on the MCP socket, not its tree position).
// position). Build a topo with an unrelated root-level agent // Build a topo with an unrelated root-level agent `peer` so
// `peer` so moving the manager under it doesn't trip the // moving the manager under it doesn't trip the cycle walk
// cycle walk (every non-manager agent in topo_three_level // (every non-manager agent in topo_three_level descends from
// descends from the manager, so that fixture can't exercise // the manager, so that fixture can't exercise a legal
// a legal manager move). // manager move).
let mut topo = BTreeMap::new(); let mut topo = BTreeMap::new();
topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None);
topo.insert("peer".to_owned(), None); topo.insert("peer".to_owned(), None);
let next = apply_set_parent(&topo, crate::lifecycle::MANAGER_NAME, Some("peer")) let next = apply_set_parent(&topo, crate::lifecycle::MANAGER_NAME, Some("peer"))
.expect("manager move should succeed post-#743"); .expect("manager move should succeed");
assert_eq!( assert_eq!(
next.get(crate::lifecycle::MANAGER_NAME), next.get(crate::lifecycle::MANAGER_NAME),
Some(&Some("peer".to_owned())) Some(&Some("peer".to_owned()))
@ -458,8 +425,8 @@ mod tests {
fn resolve_recipient_falls_back_to_operator_for_root_agent() { fn resolve_recipient_falls_back_to_operator_for_root_agent() {
let topo = topo_three_level(); let topo = topo_three_level();
// Manager is structurally root (parent = None) → `<parent>` // Manager is structurally root (parent = None) → `<parent>`
// resolves to the operator (matching the "no parent → tell mara" // resolves to the operator (the "no parent → tell mara"
// rule from #692#issuecomment-8592). // fallback documented in conventions.md).
assert_eq!( assert_eq!(
resolve_recipient_in( resolve_recipient_in(
&topo, &topo,