topology: drop the parent field and the hierarchy it fed
`topology.json` was a map of `name -> parent | null`, and that value fed the whole agent hierarchy: `<parent>` / `<children>` recipient sentinels, the reparenting API (CLI verb, wire verb, dashboard endpoints, DAG node), the dashboard tree, the rebuild depth sort, and an unconditional bind-mount grant giving every agent RW on its direct children's state. Per the operator's ruling the field goes, and with it all of the above. The file survives as what remains once the value is gone: the roster of agent names, which is the set `ManageRootAgent` grants mounts over. It is now a JSON array; `read` still accepts the old map shape and keeps its keys, so a hive that upgrades across this does not blank its roster (and so no capability holder loses its mounts for the length of that window). Two sites kept their behaviour under a different recipient rather than losing it. Both addressed `<parent>`, which the broker already resolved to `operator` for a root agent, and every agent is now what that fallback called a root: - the harness's turn-failure / plugin-failure notification (`Surface::send_to_parent` -> `send_to_operator`), and - the send allow-list's always-permitted escape hatch, so an agent with a restrictive allow-list still has a way to say it is stuck. What is NOT preserved, deliberately: an agent with no capability no longer sees any other agent's dirs. `ManageRootAgent`'s own grant is unchanged -- still every agent in the roster, still state RW + config RO, still no `harness`. The dashboard's reparenting control (the M0V3 picker) is deleted with its CSS. The tree rendering that reads `ContainerView.parent` is left for the frontend owner -- it degrades to a flat list with the field gone.
This commit is contained in:
parent
392f16cbc0
commit
d94bc2188d
28 changed files with 236 additions and 1513 deletions
|
|
@ -1,35 +1,26 @@
|
|||
//! 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.
|
||||
//! Agent roster storage — the set of agent names the hive knows about.
|
||||
//! Persisted as a JSON array at `/var/lib/hyperhive/meta/topology.json`,
|
||||
//! alongside the meta `flake.nix`, so roster changes thread through the
|
||||
//! same git commit log as deploys.
|
||||
//!
|
||||
//! Broader-than-your-own-children bind-mount grants are **not** stored
|
||||
//! here: they hang off the `ManageRootAgent` capability in
|
||||
//! `capabilities.json`. See `lifecycle::set_nspawn_flags`.
|
||||
//! **There is no hierarchy here any more.** The file used to be a map of
|
||||
//! `name → parent | null` and this module owned the parent/child tree that
|
||||
//! fed `<parent>` / `<children>` routing, the reparenting API and the
|
||||
//! dashboard's tree view. All of that is gone (#4472); what the file is
|
||||
//! *for* now is the one thing that survived the removal — naming every
|
||||
//! agent, which is the set a
|
||||
//! [`hive_sh4re::permissions::Capability::ManageRootAgent`] holder gets
|
||||
//! bind-mounted ([`all_agents`]).
|
||||
//!
|
||||
//! Format, rationale, read/reconcile/inject/surface flow, and target
|
||||
//! enforcement semantics: `docs/agent-lifecycle/agent-hierarchy.md::Where the tree lives`.
|
||||
//! `<parent>` sentinel resolution (delivered by [`resolve_recipient`]):
|
||||
//! `docs/process/conventions.md::Recipient sentinels`.
|
||||
//!
|
||||
//! ## Graph representation
|
||||
//!
|
||||
//! The on-disk format stays as a flat JSON map `name → parent | null`
|
||||
//! (small, git-diffable). In-memory, cycle detection uses a [`petgraph`]
|
||||
//! directed graph where each edge runs
|
||||
//! **parent → child**. This replaces the ad-hoc bounded walks that existed
|
||||
//! before: petgraph's `is_cyclic_directed`
|
||||
//! is correct for graphs of any depth (no 32-hop ceiling) and well-tested.
|
||||
//! The graph is built on demand from the flat map; it is not cached across
|
||||
//! calls (the map is small and disk I/O dominates anyway).
|
||||
//! Broader-than-your-own bind-mount grants are **not** stored here: they
|
||||
//! hang off the `ManageRootAgent` capability in `capabilities.json`. See
|
||||
//! `lifecycle::set_nspawn_flags`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use petgraph::algo::is_cyclic_directed;
|
||||
use petgraph::graph::{DiGraph, NodeIndex};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
const TOPOLOGY_FILE: &str = "topology.json";
|
||||
|
||||
#[must_use]
|
||||
|
|
@ -37,55 +28,44 @@ pub fn topology_path() -> PathBuf {
|
|||
crate::paths::meta_root().join(TOPOLOGY_FILE)
|
||||
}
|
||||
|
||||
/// Snapshot of the topology map. Read on every `container_view::build_all`
|
||||
/// On-disk shapes [`read`] accepts. The array is what [`write`] emits; the
|
||||
/// map is the pre-#4472 `name → parent | null` format, kept readable so a
|
||||
/// hive that upgrades across this change keeps its roster instead of
|
||||
/// blanking it until the next `reconcile` pass — and a blank roster is not
|
||||
/// a cosmetic gap, it is every `ManageRootAgent` holder losing its mounts
|
||||
/// for the length of that window.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum OnDisk {
|
||||
Roster(BTreeSet<String>),
|
||||
/// The value was the parent name; only the keys carry over.
|
||||
WithParents(std::collections::BTreeMap<String, Option<String>>),
|
||||
}
|
||||
|
||||
/// Snapshot of the agent roster. 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.
|
||||
/// Returns an empty set when the file is absent or unparsable. Safe
|
||||
/// degradation for fresh installs that haven't run through
|
||||
/// `meta::sync_agents` yet.
|
||||
#[must_use]
|
||||
pub fn read() -> BTreeMap<String, Option<String>> {
|
||||
pub fn read() -> BTreeSet<String> {
|
||||
let path = topology_path();
|
||||
let Ok(raw) = std::fs::read_to_string(&path) else {
|
||||
return BTreeMap::new();
|
||||
return BTreeSet::new();
|
||||
};
|
||||
serde_json::from_str(&raw).unwrap_or_default()
|
||||
match serde_json::from_str::<OnDisk>(&raw) {
|
||||
Ok(OnDisk::Roster(names)) => names,
|
||||
Ok(OnDisk::WithParents(map)) => map.into_keys().collect(),
|
||||
Err(_) => BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 `<children>`).
|
||||
#[must_use]
|
||||
pub fn children_of(name: &str) -> Vec<String> {
|
||||
children_of_in(&read(), name)
|
||||
}
|
||||
|
||||
/// Pure form of [`children_of`] for unit tests.
|
||||
#[must_use]
|
||||
pub fn children_of_in(topo: &BTreeMap<String, Option<String>>, name: &str) -> Vec<String> {
|
||||
topo.iter()
|
||||
.filter_map(|(agent, parent)| {
|
||||
if parent.as_deref() == Some(name) {
|
||||
Some(agent.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every agent the topology knows about, in name order. This is the set
|
||||
/// Every agent the roster knows about, in name order. This is the set
|
||||
/// a [`hive_sh4re::permissions::Capability::ManageRootAgent`] holder gets
|
||||
/// bind-mounted, and it is deliberately unfiltered: that capability means
|
||||
/// "may manage any agent", so the set is all of them.
|
||||
///
|
||||
/// It replaces a `top_level_agents()` that selected `parent.is_none()`.
|
||||
/// With the hierarchy removed every agent is parentless, so the old
|
||||
/// predicate already matched everything — keeping it would have hidden an
|
||||
/// all-agents grant behind a filter that no longer filters.
|
||||
#[must_use]
|
||||
pub fn all_agents() -> Vec<String> {
|
||||
all_agents_in(&read())
|
||||
|
|
@ -93,86 +73,15 @@ pub fn all_agents() -> Vec<String> {
|
|||
|
||||
/// Pure form of [`all_agents`] for unit tests.
|
||||
#[must_use]
|
||||
pub fn all_agents_in(topo: &BTreeMap<String, Option<String>>) -> Vec<String> {
|
||||
topo.keys().cloned().collect()
|
||||
pub fn all_agents_in(topo: &BTreeSet<String>) -> Vec<String> {
|
||||
topo.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Resolve a magic recipient sentinel (currently just
|
||||
/// [`hive_sh4re::manager::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/process/conventions.md::Recipient sentinels`.
|
||||
/// Fast path: ordinary recipient names short-circuit before any
|
||||
/// disk read — only `<parent>` 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::manager::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<String, Option<String>>,
|
||||
sender: &str,
|
||||
to: &str,
|
||||
) -> String {
|
||||
if to == hive_sh4re::manager::PARENT_RECIPIENT {
|
||||
topo.get(sender)
|
||||
.cloned()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned())
|
||||
} else {
|
||||
to.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an in-memory petgraph directed graph from the topology map.
|
||||
///
|
||||
/// Edges run **parent → child** so that:
|
||||
/// - `children_of(name)` = outgoing neighbours of `name`'s node
|
||||
/// - cycle detection = `is_cyclic_directed` after a speculative edge insert
|
||||
///
|
||||
/// Returns the graph and a `BTreeMap<name → NodeIndex>` for O(log n)
|
||||
/// name-to-node lookups. Both are local to each call site — the graph is
|
||||
/// not cached. Hive topologies are small (< ~100 nodes); building on demand
|
||||
/// is dominated by the surrounding disk read.
|
||||
#[must_use]
|
||||
fn build_graph(
|
||||
topo: &BTreeMap<String, Option<String>>,
|
||||
) -> (DiGraph<String, ()>, BTreeMap<String, NodeIndex>) {
|
||||
let mut graph: DiGraph<String, ()> = DiGraph::new();
|
||||
let mut idx: BTreeMap<String, NodeIndex> = BTreeMap::new();
|
||||
|
||||
// Add one node per agent.
|
||||
for name in topo.keys() {
|
||||
let ni = graph.add_node(name.clone());
|
||||
idx.insert(name.clone(), ni);
|
||||
}
|
||||
// Add parent→child edges.
|
||||
for (name, parent_opt) in topo {
|
||||
if let Some(parent) = parent_opt
|
||||
&& let (Some(&p_idx), Some(&c_idx)) = (idx.get(parent), idx.get(name.as_str()))
|
||||
{
|
||||
graph.add_edge(p_idx, c_idx, ());
|
||||
}
|
||||
}
|
||||
(graph, idx)
|
||||
}
|
||||
|
||||
/// Persist the topology map. Sorted JSON output (`BTreeMap` is sorted by
|
||||
/// key) keeps git diffs minimal across re-writes. Best-effort —
|
||||
/// Persist the roster. Sorted JSON output (`BTreeSet` iterates in key
|
||||
/// order) 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<String, Option<String>>) -> std::io::Result<()> {
|
||||
/// should abort their op (`sync_agents`) or just log.
|
||||
pub fn write(topology: &BTreeSet<String>) -> std::io::Result<()> {
|
||||
let path = topology_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
|
|
@ -182,90 +91,15 @@ pub fn write(topology: &BTreeMap<String, Option<String>>) -> std::io::Result<()>
|
|||
std::fs::write(&path, format!("{text}\n"))
|
||||
}
|
||||
|
||||
/// Compute the default topology for a fresh install: every agent is a
|
||||
/// root (parent = null). There is no structural "manager" — agents
|
||||
/// arrange themselves via explicit parent edges, written by the operator
|
||||
/// through the dashboard / `RequestSetParent` API.
|
||||
/// 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<String, Option<String>> {
|
||||
let mut out = BTreeMap::new();
|
||||
for name in agent_names {
|
||||
out.insert(name.clone(), None);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pure validation + apply for [`crate::meta::bulk_commit_topology`]. 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-lifecycle/agent-hierarchy.md::Reparenting` has the rationale.
|
||||
pub fn apply_set_parent(
|
||||
topo: &BTreeMap<String, Option<String>>,
|
||||
child: &str,
|
||||
new_parent: Option<&str>,
|
||||
) -> Result<BTreeMap<String, Option<String>>, 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 via petgraph: build the current graph, speculatively
|
||||
// insert the proposed parent→child edge, then test for cycles with
|
||||
// `is_cyclic_directed`. This replaces the earlier ad-hoc 32-hop
|
||||
// ancestor walk — petgraph is correct for any tree depth and the
|
||||
// algorithm is well-tested.
|
||||
let (mut graph, idx) = build_graph(topo);
|
||||
if let (Some(&p_ni), Some(&c_ni)) = (idx.get(p), idx.get(child)) {
|
||||
graph.add_edge(p_ni, c_ni, ());
|
||||
if is_cyclic_directed(&graph) {
|
||||
return Err(format!(
|
||||
"cycle: {p} is in {child}'s subtree (would create a loop)"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut next = topo.clone();
|
||||
next.insert(child.to_owned(), new_parent.map(str::to_owned));
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// Reconcile `topology.json` against the current agent set. Adds an
|
||||
/// entry (default: parent = null — a new agent with no declared parent
|
||||
/// is its own 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.
|
||||
/// Reconcile `topology.json` against the current agent set. Adds any agent
|
||||
/// missing from the file; removes entries for agents no longer present.
|
||||
/// Returns true when the file changed and should be re-committed by the
|
||||
/// caller.
|
||||
///
|
||||
/// `pending` lists agents that have a provisioned proposed config repo
|
||||
/// but no container yet (provisioned, not yet spawned). They are KEPT
|
||||
/// (not dropped) so an explicit parent edge written before the first
|
||||
/// spawn survives until the first apply-commit, but they are NOT seeded with a
|
||||
/// default parent here — that happens when the container actually spawns
|
||||
/// and the name moves into `agent_names`.
|
||||
/// (not dropped) so an agent that exists on disk but has never booted is
|
||||
/// still a name the hive knows about.
|
||||
pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result<bool> {
|
||||
let (next, changed) = apply_reconcile(&read(), agent_names, pending);
|
||||
if changed {
|
||||
|
|
@ -274,32 +108,25 @@ pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result<
|
|||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Pure form of [`reconcile`] for unit tests. Adds missing live agents
|
||||
/// at their default position, drops entries for agents that are neither
|
||||
/// live nor pending-init, and reports whether anything changed.
|
||||
/// Pure form of [`reconcile`] for unit tests. Adds missing live agents,
|
||||
/// drops entries for agents that are neither live nor pending-init, and
|
||||
/// reports whether anything changed.
|
||||
#[must_use]
|
||||
pub fn apply_reconcile(
|
||||
current: &BTreeMap<String, Option<String>>,
|
||||
current: &BTreeSet<String>,
|
||||
agent_names: &[String],
|
||||
pending: &[String],
|
||||
) -> (BTreeMap<String, Option<String>>, bool) {
|
||||
) -> (BTreeSet<String>, bool) {
|
||||
let mut next = current.clone();
|
||||
let mut changed = false;
|
||||
for name in agent_names {
|
||||
if !next.contains_key(name) {
|
||||
// A new agent with no declared parent defaults to root
|
||||
// (parent = null). An agent placed under a parent carries an
|
||||
// explicit edge written before its first spawn, so it never
|
||||
// hits this default — only spawns with no declared parent do,
|
||||
// and those are roots. No agent is structurally privileged
|
||||
// here: "root-ness" is just a null parent.
|
||||
next.insert(name.clone(), None);
|
||||
if next.insert(name.clone()) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let known: std::collections::HashSet<&String> =
|
||||
agent_names.iter().chain(pending.iter()).collect();
|
||||
next.retain(|name, _| {
|
||||
next.retain(|name| {
|
||||
let keep = known.contains(name);
|
||||
if !keep {
|
||||
changed = true;
|
||||
|
|
@ -311,301 +138,91 @@ pub fn apply_reconcile(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::{BTreeSet, OnDisk, all_agents_in, apply_reconcile};
|
||||
|
||||
#[test]
|
||||
fn default_seed_makes_every_agent_root() {
|
||||
// No structural manager: every agent defaults to root (null
|
||||
// parent). Explicit edges are layered on later.
|
||||
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));
|
||||
assert_eq!(seed.get("alice"), Some(&None));
|
||||
assert_eq!(seed.get("bob"), Some(&None));
|
||||
fn roster_three() -> BTreeSet<String> {
|
||||
["alice", "bob", "carol"]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_seed_handles_empty_input() {
|
||||
let seed = default_seed(&[]);
|
||||
assert!(seed.is_empty());
|
||||
}
|
||||
|
||||
fn topo_three_level() -> BTreeMap<String, Option<String>> {
|
||||
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 apply_reconcile_adds_missing_live_agent_as_root() {
|
||||
// A live agent with no prior topology entry defaults to root
|
||||
// (null parent) — no structural manager to hang it under.
|
||||
fn apply_reconcile_adds_missing_live_agent() {
|
||||
let live = vec![
|
||||
crate::lifecycle::MANAGER_NAME.to_owned(),
|
||||
"newbie".to_owned(),
|
||||
];
|
||||
let (next, changed) = apply_reconcile(&BTreeMap::new(), &live, &[]);
|
||||
let (next, changed) = apply_reconcile(&BTreeSet::new(), &live, &[]);
|
||||
assert!(changed);
|
||||
assert_eq!(next.get("newbie"), Some(&None));
|
||||
assert_eq!(next.get(crate::lifecycle::MANAGER_NAME), Some(&None));
|
||||
assert!(next.contains("newbie"));
|
||||
assert!(next.contains(crate::lifecycle::MANAGER_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_reconcile_drops_vanished_agent() {
|
||||
let live = vec![
|
||||
crate::lifecycle::MANAGER_NAME.to_owned(),
|
||||
"alice".to_owned(),
|
||||
];
|
||||
// carol + bob are gone from the live set and not pending.
|
||||
let (next, changed) = apply_reconcile(&topo_three_level(), &live, &[]);
|
||||
let live = vec!["alice".to_owned()];
|
||||
let (next, changed) = apply_reconcile(&roster_three(), &live, &[]);
|
||||
assert!(changed);
|
||||
assert!(!next.contains_key("bob"));
|
||||
assert!(!next.contains_key("carol"));
|
||||
assert!(next.contains_key("alice"));
|
||||
assert!(!next.contains("bob"));
|
||||
assert!(!next.contains("carol"));
|
||||
assert!(next.contains("alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_reconcile_keeps_pending_init_agent_edge() {
|
||||
// `dora` was placed under alice (edge present) but has no
|
||||
// container yet, so it's absent from the live set. It must NOT
|
||||
// be dropped, and its alice-parent edge must be preserved (not
|
||||
// re-seeded under the manager).
|
||||
let mut topo = topo_three_level();
|
||||
topo.insert("dora".to_owned(), Some("alice".to_owned()));
|
||||
let live = vec![
|
||||
crate::lifecycle::MANAGER_NAME.to_owned(),
|
||||
"alice".to_owned(),
|
||||
"bob".to_owned(),
|
||||
"carol".to_owned(),
|
||||
];
|
||||
fn apply_reconcile_keeps_pending_init_agent() {
|
||||
// `dora` was provisioned but has no container yet, so it's absent
|
||||
// from the live set. It must NOT be dropped.
|
||||
let mut roster = roster_three();
|
||||
roster.insert("dora".to_owned());
|
||||
let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()];
|
||||
let pending = vec!["dora".to_owned()];
|
||||
let (next, changed) = apply_reconcile(&topo, &live, &pending);
|
||||
let (next, changed) = apply_reconcile(&roster, &live, &pending);
|
||||
assert!(!changed, "no change expected: {next:?}");
|
||||
assert_eq!(next.get("dora"), Some(&Some("alice".to_owned())));
|
||||
assert!(next.contains("dora"));
|
||||
}
|
||||
|
||||
#[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::manager::OPERATOR_RECIPIENT),
|
||||
hive_sh4re::manager::OPERATOR_RECIPIENT
|
||||
);
|
||||
fn apply_reconcile_is_a_noop_when_already_in_sync() {
|
||||
let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()];
|
||||
let (next, changed) = apply_reconcile(&roster_three(), &live, &[]);
|
||||
assert!(!changed);
|
||||
assert_eq!(next, roster_three());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_recipient_rewrites_parent_sentinel_to_parent_label() {
|
||||
let topo = topo_three_level();
|
||||
// bob's parent is alice → `<parent>` from bob goes to alice.
|
||||
assert_eq!(
|
||||
resolve_recipient_in(&topo, "bob", hive_sh4re::manager::PARENT_RECIPIENT),
|
||||
"alice"
|
||||
);
|
||||
// alice's parent is the manager — same one-hop rewrite.
|
||||
assert_eq!(
|
||||
resolve_recipient_in(&topo, "alice", hive_sh4re::manager::PARENT_RECIPIENT),
|
||||
crate::lifecycle::MANAGER_NAME
|
||||
);
|
||||
fn all_agents_in_returns_every_name_sorted() {
|
||||
assert_eq!(all_agents_in(&roster_three()), vec!["alice", "bob", "carol"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_recipient_falls_back_to_operator_for_root_agent() {
|
||||
let topo = topo_three_level();
|
||||
// Manager is structurally root (parent = None) → `<parent>`
|
||||
// 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::manager::PARENT_RECIPIENT
|
||||
),
|
||||
hive_sh4re::manager::OPERATOR_RECIPIENT
|
||||
);
|
||||
fn all_agents_in_empty_roster_returns_empty() {
|
||||
assert!(all_agents_in(&BTreeSet::new()).is_empty());
|
||||
}
|
||||
|
||||
/// The upgrade path. A hive whose `topology.json` still carries the
|
||||
/// pre-#4472 `name → parent` map must read as the same roster, parent
|
||||
/// values discarded — otherwise the first read after the upgrade hands
|
||||
/// `ManageRootAgent` holders an empty mount set.
|
||||
#[test]
|
||||
fn a_pre_4472_parent_map_reads_as_its_key_set() {
|
||||
let raw = r#"{"alice": "bob", "bob": null, "carol": "bob"}"#;
|
||||
let parsed: OnDisk = serde_json::from_str(raw).expect("legacy map parses");
|
||||
let names = match parsed {
|
||||
OnDisk::Roster(n) => n,
|
||||
OnDisk::WithParents(m) => m.into_keys().collect(),
|
||||
};
|
||||
assert_eq!(names, roster_three());
|
||||
}
|
||||
|
||||
#[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::manager::PARENT_RECIPIENT),
|
||||
hive_sh4re::manager::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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_agents_in_returns_every_name() {
|
||||
let topo = topo_three_level();
|
||||
let mut all = all_agents_in(&topo);
|
||||
all.sort();
|
||||
let mut expected = vec![crate::lifecycle::MANAGER_NAME, "alice", "bob", "carol"];
|
||||
expected.sort_unstable();
|
||||
assert_eq!(all, expected);
|
||||
}
|
||||
|
||||
/// The set behind the `ManageRootAgent` mount grant must not depend on
|
||||
/// `parent`: a capability holder manages an agent whether or not that
|
||||
/// agent sits under someone. This is the assertion the old
|
||||
/// `top_level_agents_in` could not have made.
|
||||
#[test]
|
||||
fn all_agents_in_includes_parented_agents() {
|
||||
let mut topo = BTreeMap::new();
|
||||
topo.insert("alice".to_owned(), Some("bob".to_owned()));
|
||||
topo.insert("bob".to_owned(), None);
|
||||
let mut all = all_agents_in(&topo);
|
||||
all.sort();
|
||||
assert_eq!(all, vec!["alice", "bob"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_agents_in_empty_topo_returns_empty() {
|
||||
let topo = BTreeMap::new();
|
||||
assert!(all_agents_in(&topo).is_empty());
|
||||
fn a_roster_array_round_trips() {
|
||||
let raw = serde_json::to_string(&roster_three()).expect("serialises");
|
||||
let parsed: OnDisk = serde_json::from_str(&raw).expect("array parses");
|
||||
let names = match parsed {
|
||||
OnDisk::Roster(n) => n,
|
||||
OnDisk::WithParents(m) => m.into_keys().collect(),
|
||||
};
|
||||
assert_eq!(names, roster_three());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue