hive-c0re: let request_init_config spawn a brand-new sub-agent under its requester
This commit is contained in:
parent
63a79d76ba
commit
b3d002e4a7
8 changed files with 254 additions and 23 deletions
|
|
@ -272,42 +272,108 @@ pub fn set_parent(child: &str, new_parent: Option<&str>) -> Result<(), String> {
|
|||
write(&next).map_err(|e| format!("write topology.json: {e}"))
|
||||
}
|
||||
|
||||
/// Declare a brand-new agent's parent edge before the agent exists in
|
||||
/// the container set. Unlike [`set_parent`] (which reparents an entry
|
||||
/// that must already be present), this inserts a fresh `child -> parent`
|
||||
/// row. Used by the `InitConfig` approval to place a just-scaffolded
|
||||
/// sub-agent under its requesting parent, so the edge is in place
|
||||
/// before the first apply-commit spawns the container (and before
|
||||
/// [`reconcile`] would otherwise default it to the manager).
|
||||
///
|
||||
/// Idempotent when the edge already exists. Refuses to clobber an entry
|
||||
/// whose parent differs (one agent can't steal another's child) and
|
||||
/// validates that `parent` is itself a known agent.
|
||||
pub fn add_child(child: &str, parent: &str) -> Result<(), String> {
|
||||
let current = read();
|
||||
match apply_add_child(¤t, child, parent)? {
|
||||
Some(next) => write(&next).map_err(|e| format!("write topology.json: {e}")),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure form of [`add_child`] for unit tests. Returns the post-insert
|
||||
/// map (caller writes it back), `None` for an idempotent no-op (edge
|
||||
/// already present), or an error string (unknown parent / name owned by
|
||||
/// a different parent).
|
||||
pub fn apply_add_child(
|
||||
topo: &BTreeMap<String, Option<String>>,
|
||||
child: &str,
|
||||
parent: &str,
|
||||
) -> Result<Option<BTreeMap<String, Option<String>>>, String> {
|
||||
if !topo.contains_key(parent) {
|
||||
return Err(format!("unknown parent: {parent}"));
|
||||
}
|
||||
match topo.get(child) {
|
||||
Some(Some(p)) if p == parent => return Ok(None),
|
||||
Some(existing) => {
|
||||
return Err(format!(
|
||||
"agent {child} already exists in topology (parent: {existing:?}); \
|
||||
refusing to reparent via add_child"
|
||||
));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let mut next = topo.clone();
|
||||
next.insert(child.to_owned(), Some(parent.to_owned()));
|
||||
Ok(Some(next))
|
||||
}
|
||||
|
||||
/// 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();
|
||||
///
|
||||
/// `pending` lists agents that have an operator-approved proposed config
|
||||
/// repo but no container yet (init'd, not yet spawned). They are KEPT
|
||||
/// (not dropped) so the parent edge written at `InitConfig` approval
|
||||
/// survives until the first apply-commit, but they are NOT seeded with a
|
||||
/// default parent here and NOT added to roles — that happens when the
|
||||
/// container actually spawns and the name moves into `agent_names`.
|
||||
pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result<bool> {
|
||||
let (next, changed) = apply_reconcile(&read(), agent_names, pending);
|
||||
if changed {
|
||||
write(&next)?;
|
||||
}
|
||||
// Keep roles in sync with the live agent set (pending agents get
|
||||
// roles when they spawn).
|
||||
reconcile_roles(agent_names)?;
|
||||
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.
|
||||
#[must_use]
|
||||
pub fn apply_reconcile(
|
||||
current: &BTreeMap<String, Option<String>>,
|
||||
agent_names: &[String],
|
||||
pending: &[String],
|
||||
) -> (BTreeMap<String, Option<String>>, bool) {
|
||||
let mut next = current.clone();
|
||||
let mut changed = false;
|
||||
// Add missing agents at their default position.
|
||||
for name in agent_names {
|
||||
if !current.contains_key(name) {
|
||||
if !next.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);
|
||||
next.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 known: std::collections::HashSet<&String> =
|
||||
agent_names.iter().chain(pending.iter()).collect();
|
||||
next.retain(|name, _| {
|
||||
let keep = known.contains(name);
|
||||
if !keep {
|
||||
changed = true;
|
||||
}
|
||||
keep
|
||||
});
|
||||
if changed {
|
||||
write(¤t)?;
|
||||
}
|
||||
// Keep roles in sync with the agent set.
|
||||
reconcile_roles(agent_names)?;
|
||||
Ok(changed)
|
||||
(next, changed)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -562,6 +628,90 @@ mod tests {
|
|||
assert_eq!(next, topo_three_level());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_add_child_inserts_new_edge_under_parent() {
|
||||
// alice spawns a brand-new child `dora`: the edge lands
|
||||
// with alice as parent without disturbing the rest of the tree.
|
||||
let next = apply_add_child(&topo_three_level(), "dora", "alice")
|
||||
.unwrap()
|
||||
.expect("brand-new edge should produce a map");
|
||||
assert_eq!(next.get("dora"), Some(&Some("alice".to_owned())));
|
||||
// existing entries untouched.
|
||||
assert_eq!(next.get("bob"), Some(&Some("alice".to_owned())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_add_child_is_idempotent_when_edge_exists() {
|
||||
// bob already under alice — re-init returns a no-op (None).
|
||||
assert!(
|
||||
apply_add_child(&topo_three_level(), "bob", "alice")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_add_child_refuses_unknown_parent() {
|
||||
let err = apply_add_child(&topo_three_level(), "dora", "nobody").unwrap_err();
|
||||
assert!(err.contains("unknown parent"), "err = {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_add_child_refuses_name_owned_by_other_parent() {
|
||||
// bob lives under alice; the manager can't claim it via add_child.
|
||||
let err = apply_add_child(&topo_three_level(), "bob", crate::lifecycle::MANAGER_NAME)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("already exists"), "err = {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_reconcile_adds_missing_live_agent_under_manager() {
|
||||
let live = vec![
|
||||
crate::lifecycle::MANAGER_NAME.to_owned(),
|
||||
"newbie".to_owned(),
|
||||
];
|
||||
let (next, changed) = apply_reconcile(&BTreeMap::new(), &live, &[]);
|
||||
assert!(changed);
|
||||
assert_eq!(
|
||||
next.get("newbie"),
|
||||
Some(&Some(crate::lifecycle::MANAGER_NAME.to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[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, &[]);
|
||||
assert!(changed);
|
||||
assert!(!next.contains_key("bob"));
|
||||
assert!(!next.contains_key("carol"));
|
||||
assert!(next.contains_key("alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_reconcile_keeps_pending_init_agent_edge() {
|
||||
// `dora` was init'd 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(),
|
||||
];
|
||||
let pending = vec!["dora".to_owned()];
|
||||
let (next, changed) = apply_reconcile(&topo, &live, &pending);
|
||||
assert!(!changed, "no change expected: {next:?}");
|
||||
assert_eq!(next.get("dora"), Some(&Some("alice".to_owned())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_recipient_passes_through_ordinary_names() {
|
||||
let topo = topo_three_level();
|
||||
|
|
|
|||
Loading…
Reference in a new issue