The naersk → crane swap in the parent commit flips clippy from silently passing to actually failing on `-D warnings` (naersk's `mode = "clippy"` mangled the `--` separator so the deny never took effect). This commit clears the surfaced lints so the workspace builds clean under the new enforcement — every fix is mechanical and preserves behaviour. Tests still pass (160 across the workspace). Auto-fixes via `cargo clippy --fix`: - `doc_markdown` (19 sites): bare identifiers in doc comments wrapped in backticks - `format_in_format_args`, `explicit_into_iter_loop`, `redundant_closure_for_method_calls`, `useless_conversion`, and a few more — mechanical rewrites of the kind cargo can apply safely. Hand-fixed: - `match_same_arms` (forge_notify::is_atx_heading): two arms returning `true` collapsed into a single `matches!` pattern. - `cast_sign_loss` + `format_push_string` (mcp.rs status formatter): guarded `i64 → u64` through `u64::try_from(…).unwrap_or(0)` (status timestamps are always positive in practice; clamp the skew edge to 0) and swapped `out.push_str(&format!(…))` for `write!` into the buffer with an infallible-writer `let _ =`. - `doc_lazy_continuation` in turn.rs + manager_server.rs + sh4re/lib.rs: doc paragraphs that the markdown parser was treating as list-item continuations got either a separating blank line or a `/`-for-`+` word swap so the parser stops seeing a list. - `unused_async` (manager_server::handle_request_schedule_prompt): function has no `.await`; dropped the `async` and its `.await` call site. - `needless_pass_by_value` (scheduled_prompts::submit): take `&NewSchedule` instead of moving the struct in; updated two prod callers and eight test sites to pass references. - `type_complexity` (approvals::mark_cancelled): hoisted the 7-tuple SELECT row shape into a `type CancelLookupRow = (…);` alias. Allow-with-reason for intentional patterns: - `option_option` (6 sites across dashboard / scheduled_prompts / manager_server): `Option<Option<T>>` carries three-state PATCH semantics (missing key = leave alone, `Some(None)` = clear, `Some(Some(v))` = set). Collapsing to `Option<T>` loses the "clear" state. - `dead_code` (rebuild_queue::QueueKind::Destroy / QueueSource::CrashRecover; topology::parent_of / default_seed): wire-shape variants + API surfaces kept for the upcoming features (#361 follow-ups, future `Destroy` queue routing, crash-recovery path). Allowed at the variant / function level with the rationale in `reason = "…"`. - `too_many_lines` on three specific call-sites: a 117-line exhaustive-variant test (dashboard_events::kind_tag_matches_…), the meta-flake string template renderer (meta::render_flake_with_lookup), and the notification poll loop (forge_notify::poll_once) — splitting any of them would just hide the contiguous shape they exist to keep visible. `nix flake check` formatting target is still broken on main itself (pre-existing nixfmt drift across ~28 files unrelated to this PR); left alone here so the scope stays "crane port + lints the port exposed" and the operator's review doesn't have to triage drive-by nixfmt churn.
354 lines
13 KiB
Rust
354 lines
13 KiB
Rust
//! 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<String, Option<String>> {
|
|
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]
|
|
#[allow(
|
|
dead_code,
|
|
reason = "convenience API; callers go through `read()` today, kept for the \
|
|
dashboard/manager-server surfaces landing in #361 follow-ups"
|
|
)]
|
|
pub fn parent_of(name: &str) -> Option<String> {
|
|
read().get(name).cloned().flatten()
|
|
}
|
|
|
|
/// 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<String, Option<String>>) -> 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]
|
|
#[allow(
|
|
dead_code,
|
|
reason = "kept for the dashboard / RequestSetParent write API landing in \
|
|
#361 follow-ups; `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 {
|
|
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 / manager-protect) 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.
|
|
pub fn apply_set_parent(
|
|
topo: &BTreeMap<String, Option<String>>,
|
|
child: &str,
|
|
new_parent: Option<&str>,
|
|
) -> Result<BTreeMap<String, Option<String>>, String> {
|
|
if child == crate::lifecycle::MANAGER_NAME {
|
|
return Err("cannot reparent the manager — it is structurally root".to_owned());
|
|
}
|
|
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 (#486 / #487). 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 (see #486 comment 5042). Once
|
|
/// sub-manager bind mounts land alongside #361, 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<bool> {
|
|
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<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_refuses_manager_move() {
|
|
let err = apply_set_parent(&topo_three_level(), crate::lifecycle::MANAGER_NAME, None)
|
|
.unwrap_err();
|
|
assert!(err.contains("manager"), "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());
|
|
}
|
|
}
|