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
|
|
@ -1384,27 +1384,6 @@ body.dashboard-shell.has-selection {
|
|||
padding-bottom: 4.5em;
|
||||
}
|
||||
|
||||
.move-picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3em;
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
.move-picker-select {
|
||||
font-family: inherit;
|
||||
font-size: 0.75em;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--purple);
|
||||
border-radius: 2px;
|
||||
padding: 0.1em 0.3em;
|
||||
max-width: 16em;
|
||||
}
|
||||
.move-picker-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ST4TS moved to its own page (`/stats.html`): the
|
||||
window selector / summary chips / bars live in stats.css, and the
|
||||
shared `.hive-stats-table` moved to common.css (the SYST3M ›
|
||||
|
|
|
|||
|
|
@ -953,153 +953,6 @@ export function renderSelectionBar(containers) {
|
|||
`PURGE ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? containers, config history, claude creds, and notes are all WIPED. no undo.`,
|
||||
});
|
||||
|
||||
// Move agent(s) in the topology tree — selecting an option in the
|
||||
// M0V3 dropdown immediately confirms + executes the move. "(no parent)"
|
||||
// promotes to root (empty new_parent on the backend). Cycle-safe:
|
||||
// dropdown filters out self and descendants on the client side; the
|
||||
// backend rechecks via `topology::set_parent`.
|
||||
//
|
||||
// Backend: POST /api/topology/set-parent (dashboard.rs),
|
||||
// form-encoded `child=<name>&new_parent=<target-or-empty>`. Re-emits
|
||||
// container snapshots on success so the tree repaints without a
|
||||
// separate refresh.
|
||||
addMoveActions(actions, selected, containers);
|
||||
}
|
||||
|
||||
// Render the M0V3 picker in the selection bar. Selecting any real option
|
||||
// (including "(no parent)") immediately fires a confirm + POST — no
|
||||
// separate button. Backend `topology::set_parent` refuses invalid moves
|
||||
// and the refusal surfaces in the alert roll-up.
|
||||
function addMoveActions(parent, selected, containers) {
|
||||
const candidates = validReparentCandidates(selected, containers);
|
||||
const wrap = el("span", { class: "move-picker" });
|
||||
const selectTitle =
|
||||
selected.length === 1
|
||||
? `change ${selected[0].name}'s parent`
|
||||
: `change ${selected.length} agents' parent`;
|
||||
const sel = el("select", { class: "move-picker-select", title: selectTitle });
|
||||
sel.append(el("option", { value: "" }, "⇢ M0V3 →"));
|
||||
// "(no parent)" -> empty new_parent on the backend (promotes to root).
|
||||
sel.append(el("option", { value: "__root__" }, "(no parent)"));
|
||||
for (const name of candidates) {
|
||||
sel.append(el("option", { value: name }, name));
|
||||
}
|
||||
sel.addEventListener("change", async () => {
|
||||
if (sel.selectedIndex === 0) return;
|
||||
const newParent = sel.value === "__root__" ? "" : sel.value;
|
||||
const newParentLabel = sel.value === "__root__" ? "(no parent)" : sel.value;
|
||||
const names = selected.map((c) => c.name);
|
||||
const promptMsg =
|
||||
names.length === 1
|
||||
? `move ${names[0]} → ${newParentLabel}?`
|
||||
: `move ${names.length} agents (${names.join(", ")}) → ${newParentLabel}?`;
|
||||
if (!(await themedConfirm({ message: promptMsg, danger: true }))) {
|
||||
sel.selectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
sel.disabled = true;
|
||||
const failures = [];
|
||||
if (names.length === 1) {
|
||||
// Single agent — use the form-encoded endpoint (backwards compat).
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
child: names[0],
|
||||
new_parent: newParent,
|
||||
});
|
||||
const resp = await fetch("/api/topology/set-parent", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
redirect: "manual",
|
||||
});
|
||||
const ok =
|
||||
resp.ok ||
|
||||
resp.type === "opaqueredirect" ||
|
||||
(resp.status >= 200 && resp.status < 400);
|
||||
if (!ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
failures.push(
|
||||
`${names[0]}: http ${resp.status}${text ? " — " + text.slice(0, 200) : ""}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
failures.push(`${names[0]}: ${err}`);
|
||||
}
|
||||
} else {
|
||||
// Multiple agents — use the bulk endpoint so all moves land in
|
||||
// a single git commit instead of one per agent.
|
||||
try {
|
||||
const payload = names.map((n) => ({
|
||||
child: n,
|
||||
new_parent: newParent || null,
|
||||
}));
|
||||
const resp = await fetch("/api/topology/set-parent-bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
redirect: "manual",
|
||||
});
|
||||
const ok =
|
||||
resp.ok ||
|
||||
resp.type === "opaqueredirect" ||
|
||||
(resp.status >= 200 && resp.status < 400);
|
||||
if (!ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
failures.push(
|
||||
`bulk: http ${resp.status}${text ? " — " + text.slice(0, 200) : ""}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
failures.push(`bulk: ${err}`);
|
||||
}
|
||||
}
|
||||
sel.disabled = false;
|
||||
sel.selectedIndex = 0;
|
||||
if (failures.length) {
|
||||
themedToast(
|
||||
`M0V3 completed with ${failures.length} failure${failures.length === 1 ? "" : "s"}:\n\n` +
|
||||
failures.join("\n"),
|
||||
{ type: "error", duration: 0 },
|
||||
);
|
||||
}
|
||||
});
|
||||
wrap.append(sel);
|
||||
parent.append(wrap);
|
||||
}
|
||||
|
||||
// Filter the dashboard's container list to those that are valid
|
||||
// re-parent targets for the `selected` agents: anyone who isn't IN
|
||||
// the selection itself, isn't a descendant of any selected agent
|
||||
// (cycle prevention across the whole batch). The backend re-checks
|
||||
// per-agent via `topology::set_parent`; this client-side filter is
|
||||
// purely UX so the operator can't pick an obviously-invalid option.
|
||||
function validReparentCandidates(selected, containers) {
|
||||
// Build child map once.
|
||||
const childrenOf = new Map();
|
||||
for (const c of containers) {
|
||||
const p = c.parent || null;
|
||||
if (!childrenOf.has(p)) childrenOf.set(p, []);
|
||||
childrenOf.get(p).push(c.name);
|
||||
}
|
||||
// Union descendant set across every selected agent (each agent's
|
||||
// descendants AND itself).
|
||||
const blocked = new Set();
|
||||
for (const t of selected) {
|
||||
const queue = [t.name];
|
||||
blocked.add(t.name);
|
||||
while (queue.length) {
|
||||
const n = queue.shift();
|
||||
for (const child of childrenOf.get(n) || []) {
|
||||
if (blocked.has(child)) continue;
|
||||
blocked.add(child);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return containers
|
||||
.filter((c) => !blocked.has(c.name))
|
||||
.map((c) => c.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
|
||||
|
|
@ -1147,23 +1000,15 @@ function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
|
|||
// (rebuild_queue dedups but other endpoints don't); the loop is
|
||||
// short — bulk selections are typically a handful of agents.
|
||||
//
|
||||
// Two URL shapes:
|
||||
// - `opts.action` is a path prefix and the agent name gets
|
||||
// appended (lifecycle endpoints: /start/<name>, /rebuild/<name>).
|
||||
// `opts.body` is a static object applied to every POST.
|
||||
// - `opts.perAgentBodyFor(name)` is set: `opts.action` is the
|
||||
// full URL (no name appended) and the per-agent body comes
|
||||
// from the callback. Used by /api/topology/set-parent, where
|
||||
// the agent name is a body field rather than a URL component.
|
||||
// `opts.action` is a path prefix and the agent name gets appended
|
||||
// (lifecycle endpoints: /start/<name>, /rebuild/<name>). `opts.body`
|
||||
// is a static object applied to every POST.
|
||||
for (const name of names) {
|
||||
const body = opts.perAgentBodyFor
|
||||
? new URLSearchParams(opts.perAgentBodyFor(name))
|
||||
: new URLSearchParams(opts.body || {});
|
||||
const url = opts.perAgentBodyFor
|
||||
? opts.action
|
||||
: opts.action +
|
||||
encodeURIComponent(name) +
|
||||
(graceful ? "?graceful=true" : "");
|
||||
const body = new URLSearchParams(opts.body || {});
|
||||
const url =
|
||||
opts.action +
|
||||
encodeURIComponent(name) +
|
||||
(graceful ? "?graceful=true" : "");
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -12,20 +12,23 @@
|
|||
const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json";
|
||||
|
||||
/// Enforce the per-agent send allow-list. Returns `Ok` when the
|
||||
/// recipient is permitted (no list configured, `<parent>` sentinel
|
||||
/// always allowed, or `to` is in the list); returns `Err(refusal)`
|
||||
/// with a claude-readable string when blocked — the harness surfaces
|
||||
/// the refusal as the tool result so claude knows the message didn't
|
||||
/// land and can react (e.g. route via `<parent>` instead).
|
||||
/// recipient is permitted (no list configured, the operator, or `to` is
|
||||
/// in the list); returns `Err(refusal)` with a claude-readable string
|
||||
/// when blocked — the harness surfaces the refusal as the tool result so
|
||||
/// claude knows the message didn't land and can react (e.g. route to the
|
||||
/// operator instead).
|
||||
pub fn check_send_allowed(to: &str) -> Result<(), String> {
|
||||
if to == hive_sh4re::manager::PARENT_RECIPIENT {
|
||||
// Always allow `<parent>` — the allow-list constrains peer
|
||||
// chatter, not the structural reporting line; the operator
|
||||
// can rewire who the parent IS via `set_parent` without
|
||||
// having to remember to update the per-agent allow-list.
|
||||
// The broker resolves the sentinel to the real parent label
|
||||
// on the host side per topology.json (falls back to `operator`
|
||||
// for root agents).
|
||||
if to == hive_sh4re::manager::OPERATOR_RECIPIENT {
|
||||
// Always allow the operator — the allow-list constrains peer
|
||||
// chatter, not the reporting line out, and an agent with no way
|
||||
// to say "I am stuck" is an agent that fails silently.
|
||||
//
|
||||
// This bypass used to be spelled `<parent>`, which the broker
|
||||
// resolved per `topology.json` and which fell back to `operator`
|
||||
// for a root agent. #4472 removed the parent field, so every
|
||||
// agent is what that fallback called a root — the exemption is
|
||||
// now written as the name it always resolved to. Same reachable
|
||||
// set, one fewer indirection.
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else {
|
||||
|
|
@ -50,9 +53,9 @@ pub fn check_send_allowed(to: &str) -> Result<(), String> {
|
|||
}
|
||||
Err(format!(
|
||||
"send refused: recipient '{to}' not in services.hyperhive.agent.allowedRecipients \
|
||||
(configured in agent.nix). Allowed: {allow:?}. Your structural \
|
||||
parent is always reachable — route through `send(to: \"{}\", …)` \
|
||||
if you need to reach someone outside the allow-list.",
|
||||
hive_sh4re::manager::PARENT_RECIPIENT
|
||||
(configured in agent.nix). Allowed: {allow:?}. The operator is always \
|
||||
reachable — route through `send(to: \"{}\", …)` if you need to reach \
|
||||
someone outside the allow-list.",
|
||||
hive_sh4re::manager::OPERATOR_RECIPIENT
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ async fn main() -> Result<()> {
|
|||
|
||||
/// Surface a `SYSTEM_SENDER` message in the live event bus + tracing
|
||||
/// log. Both agents and the manager receive `ContainerCrash`,
|
||||
/// reparent notifications, and friends; the parse and log path is
|
||||
/// notifications, and friends; the parse and log path is
|
||||
/// identical. Quiet no-op when `from` isn't
|
||||
/// `SYSTEM_SENDER`.
|
||||
fn log_system_event(bus: &Bus, from: &str, body: &str) {
|
||||
|
|
@ -122,8 +122,8 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) {
|
|||
});
|
||||
}
|
||||
|
||||
/// Body string for the turn-failure notification we route to
|
||||
/// `<parent>` on `TurnError::Failed`. Reads the hive-qualified
|
||||
/// Body string for the turn-failure notification we route to the
|
||||
/// operator on `TurnError::Failed`. Reads the hive-qualified
|
||||
/// identity so the receiver sees `agent@hive` rather than relying on
|
||||
/// the caller threading a `label` through every turn-handling layer.
|
||||
/// Falls back to `<unknown>` when `HIVE_LABEL` is missing so a
|
||||
|
|
@ -278,10 +278,15 @@ trait Surface {
|
|||
/// fallback. Same shape as `graceful_stop_complete`.
|
||||
fn pause_acknowledged(socket: &Path) -> impl Future<Output = ()>;
|
||||
|
||||
/// Send a message addressed to `<parent>` (broker resolves the
|
||||
/// sentinel via `topology::parent_of` at delivery time; root
|
||||
/// agents/manager fall through to operator).
|
||||
fn send_to_parent(socket: &Path, body: String) -> impl Future<Output = ()>;
|
||||
/// Send a message addressed to the operator. The reporting line out
|
||||
/// of a container: this is where a turn failure or a plugin-install
|
||||
/// failure surfaces when nothing inside the harness can act on it.
|
||||
///
|
||||
/// Was `<parent>` before #4472, a sentinel the broker resolved per
|
||||
/// `topology.json` and which already fell through to `operator` for a
|
||||
/// root agent. With the parent field gone every agent takes that
|
||||
/// branch, so the recipient is written out rather than resolved.
|
||||
fn send_to_operator(socket: &Path, body: String) -> impl Future<Output = ()>;
|
||||
|
||||
/// Long-poll the broker for the next message. Wraps the
|
||||
/// `Messages`/empty/error trichotomy in `RecvOutcome` so the
|
||||
|
|
@ -367,11 +372,11 @@ impl Surface for AgentSurface {
|
|||
(threads, reminders)
|
||||
}
|
||||
|
||||
async fn send_to_parent(socket: &Path, body: String) {
|
||||
async fn send_to_operator(socket: &Path, body: String) {
|
||||
let res = hive_sock_client::request::<_, Response>(
|
||||
socket,
|
||||
&Request::Send {
|
||||
to: hive_sh4re::manager::PARENT_RECIPIENT.into(),
|
||||
to: hive_sh4re::manager::OPERATOR_RECIPIENT.into(),
|
||||
body,
|
||||
in_reply_to: None,
|
||||
},
|
||||
|
|
@ -379,7 +384,7 @@ impl Surface for AgentSurface {
|
|||
)
|
||||
.await;
|
||||
if let Err(e) = res {
|
||||
tracing::warn!(error = ?e, "failed to notify parent of turn failure");
|
||||
tracing::warn!(error = ?e, "failed to notify the operator of turn failure");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -519,11 +524,9 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
|||
}
|
||||
let files = turn::TurnFiles::prepare(socket, &label).await?;
|
||||
// Plugin install failures come back as a Vec<String> — route each
|
||||
// through `<parent>` via the `send_to_parent` failure-notify path.
|
||||
// The broker resolves `<parent>` per `topology::parent_of`;
|
||||
// root agents fall through to operator.
|
||||
// through the `send_to_operator` failure-notify path.
|
||||
for failure in plugins::install_configured().await {
|
||||
S::send_to_parent(socket, failure).await;
|
||||
S::send_to_operator(socket, failure).await;
|
||||
}
|
||||
// The forge notification poller used to be spawned here. It is its own
|
||||
// process now (`hive-forge-notify`, its own systemd unit) so a harness
|
||||
|
|
@ -1004,7 +1007,7 @@ async fn handle_turn<S: Surface>(
|
|||
/// The non-happy-path half of `handle_turn`: react to each `TurnError`
|
||||
/// variant the turn could have failed with (park-and-retry on rate-limit/
|
||||
/// stall/auth, requeue-for-a-fresh-turn on prompt-too-long/session-not-
|
||||
/// found, notify the parent on a hard failure). Split out purely to keep
|
||||
/// found, notify the operator on a hard failure). Split out purely to keep
|
||||
/// `handle_turn` itself under clippy's line-count lint — no behavior
|
||||
/// change from when this lived inline.
|
||||
async fn handle_turn_error_recovery<S: Surface>(
|
||||
|
|
@ -1063,6 +1066,6 @@ async fn handle_turn_error_recovery<S: Surface>(
|
|||
S::requeue_inflight(socket).await;
|
||||
}
|
||||
if let Err(turn::TurnError::Failed(e)) = outcome {
|
||||
S::send_to_parent(socket, format_turn_failure(e)).await;
|
||||
S::send_to_operator(socket, format_turn_failure(e)).await;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,9 +98,8 @@ async fn update_marketplaces() {
|
|||
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`.
|
||||
/// Returns a list of human-readable failure messages so the caller can
|
||||
/// route them through their own per-role surface (turn-failure-style
|
||||
/// notification, see `Surface::send_to_parent`). Wire-agnostic: the
|
||||
/// caller picks the recipient via the same `<parent>` sentinel that
|
||||
/// failure-notify uses everywhere else.
|
||||
/// notification, see `Surface::send_to_operator`). Wire-agnostic: the
|
||||
/// caller picks the recipient, the same way failure-notify does.
|
||||
pub async fn install_configured() -> Vec<String> {
|
||||
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
|
||||
return Vec::new();
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ pub enum TurnError {
|
|||
/// rate-limit path — NOT a crash.
|
||||
ApiStall,
|
||||
/// A hard failure with no recovery — the serve loop escalates it to the
|
||||
/// parent (`send_to_parent`).
|
||||
/// operator (`send_to_operator`).
|
||||
Failed(anyhow::Error),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,14 +49,6 @@ pub struct ContainerView {
|
|||
/// for this agent's input.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deployed_sha: Option<String>,
|
||||
/// Name of this agent's parent in the agent hierarchy. `None`
|
||||
/// marks the agent as root-level; the dashboard renders it without
|
||||
/// indentation. Sourced from `meta/topology.json` (single source of
|
||||
/// truth, hive-c0re-owned) — NOT from per-agent agent.nix, because
|
||||
/// an agent shouldn't be able to unilaterally declare its own place
|
||||
/// in the tree. See `docs/agent-lifecycle/agent-hierarchy.md::Where the tree lives`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<String>,
|
||||
/// The Claude model the agent's harness is currently using, read from
|
||||
/// `state/hyperhive-harness.json["active_model"]`. `None` when the
|
||||
/// agent has never started a turn or the field is absent. Only
|
||||
|
|
@ -121,7 +113,6 @@ impl From<ContainerView> for hive_sh4re::container::AgentStatusRow {
|
|||
needs_login: v.needs_login,
|
||||
deployed_sha: v.deployed_sha,
|
||||
pending_reminders: 0,
|
||||
parent: v.parent,
|
||||
paused: v.paused,
|
||||
active_model: v.active_model,
|
||||
status_text: v.status_text,
|
||||
|
|
@ -164,13 +155,8 @@ fn agent_url_for_domain(domain: &str, name: &str) -> String {
|
|||
pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> Vec<ContainerView> {
|
||||
let raw = lifecycle::list().await.unwrap_or_default();
|
||||
let locked = read_meta_locked_revs();
|
||||
// Pull the topology map once and look up each agent's parent below.
|
||||
// Empty / absent topology.json → every agent root-level (safe
|
||||
// degradation for fresh installs that haven't run sync_agents yet).
|
||||
let topology = crate::topology::read();
|
||||
// Same once-per-scan treatment as the topology map: the override file
|
||||
// is read here and resolved per agent below, rather than re-read for
|
||||
// every container on every SSE scan.
|
||||
// Read once per scan rather than re-read for every container on every
|
||||
// SSE scan.
|
||||
let limits = crate::resource_limits::read();
|
||||
let mut out = Vec::new();
|
||||
for c in &raw {
|
||||
|
|
@ -189,7 +175,6 @@ pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> Vec<ContainerView>
|
|||
let needs_update =
|
||||
crate::auto_update::agent_config_pending(logical.as_str(), deployed_full).await;
|
||||
let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned());
|
||||
let parent = topology.get(logical.as_str()).cloned().flatten();
|
||||
// One `systemctl` call for both facts: `unit_state` is the same
|
||||
// shell-out `is_running` makes, minus `--quiet`. Asking twice would
|
||||
// double the per-agent subprocess count on every SSE scan.
|
||||
|
|
@ -252,7 +237,6 @@ pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> Vec<ContainerView>
|
|||
needs_update,
|
||||
needs_login,
|
||||
deployed_sha,
|
||||
parent,
|
||||
active_model,
|
||||
status_text,
|
||||
status_set_at,
|
||||
|
|
@ -547,7 +531,6 @@ mod tests {
|
|||
needs_update: true,
|
||||
needs_login: false,
|
||||
deployed_sha: Some("abc123def456".to_owned()),
|
||||
parent: Some("bob".to_owned()),
|
||||
active_model: Some("claude-opus".to_owned()),
|
||||
status_text: Some("shipping".to_owned()),
|
||||
status_set_at: Some(Utc.timestamp_opt(1_700_000_000, 0).unwrap()),
|
||||
|
|
@ -563,7 +546,6 @@ mod tests {
|
|||
assert!(row.needs_update);
|
||||
assert!(!row.needs_login);
|
||||
assert_eq!(row.deployed_sha.as_deref(), Some("abc123def456"));
|
||||
assert_eq!(row.parent.as_deref(), Some("bob"));
|
||||
assert!(row.paused);
|
||||
assert_eq!(row.active_model.as_deref(), Some("claude-opus"));
|
||||
assert_eq!(row.status_text.as_deref(), Some("shipping"));
|
||||
|
|
|
|||
|
|
@ -870,81 +870,6 @@ impl Coordinator {
|
|||
}
|
||||
}
|
||||
|
||||
/// Apply topology reparent(s) + fan the resulting notifications out to
|
||||
/// the affected agents. Applies all moves under a single `META_LOCK`
|
||||
/// acquisition (one git commit — a single-move call is just a
|
||||
/// one-element slice) then, for each move that actually changed
|
||||
/// topology, drops a one-line system message into the inbox of:
|
||||
///
|
||||
/// 1. The **old parent** (if any) — `"{child} moved out of your
|
||||
/// subtree to {new_parent_or_root}"`.
|
||||
/// 2. The **new parent** (if any) — `"{child} just moved into
|
||||
/// your subtree (was previously under
|
||||
/// {old_parent_or_root})"`.
|
||||
/// 3. The **moved agent** — `"your parent changed from
|
||||
/// {old_parent_or_root} to {new_parent_or_root}"`.
|
||||
///
|
||||
/// `_or_root` resolves to the literal string `"<root>"` when the
|
||||
/// slot is `None`, keeping the wording consistent with the
|
||||
/// `<parent>` sentinel's "root → operator" routing (see
|
||||
/// `docs/process/conventions.md::Recipient sentinels`). The
|
||||
/// notifications fire as ordinary broker messages with
|
||||
/// `from = hive_sh4re::manager::SYSTEM_SENDER` so the dashboard renders
|
||||
/// them under the existing system-source styling.
|
||||
///
|
||||
/// First validation failure aborts the whole batch with no disk writes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates any error returned by [`crate::meta::bulk_commit_topology`]
|
||||
/// (validation failure or topology-file write error).
|
||||
pub async fn reparent_bulk_with_notify(
|
||||
self: &Arc<Self>,
|
||||
moves: &[(&str, Option<&str>)],
|
||||
) -> std::result::Result<(), String> {
|
||||
if moves.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// bulk_commit_topology applies all set_parent calls under one lock
|
||||
// and returns (child, old_parent) for every move that changed.
|
||||
let changed = crate::meta::bulk_commit_topology(moves).await?;
|
||||
|
||||
// Send per-agent notifications for each changed move.
|
||||
for (child, old_parent) in &changed {
|
||||
// Find the new parent from the moves slice.
|
||||
let new_parent = moves
|
||||
.iter()
|
||||
.find(|(c, _)| *c == child)
|
||||
.and_then(|(_, np)| *np);
|
||||
let old_label = old_parent.as_deref().unwrap_or("<root>");
|
||||
let new_label = new_parent.unwrap_or("<root>");
|
||||
if let Some(op) = old_parent.as_deref() {
|
||||
let _ = self.broker.send(&hive_sh4re::inbox::Message {
|
||||
from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::SYSTEM_SENDER),
|
||||
to: op.to_owned(),
|
||||
body: format!("{child} moved out of your subtree to {new_label}"),
|
||||
in_reply_to: None,
|
||||
});
|
||||
}
|
||||
if let Some(np) = new_parent {
|
||||
let _ = self.broker.send(&hive_sh4re::inbox::Message {
|
||||
from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::SYSTEM_SENDER),
|
||||
to: np.to_owned(),
|
||||
body: format!(
|
||||
"{child} just moved into your subtree (was previously under {old_label})"
|
||||
),
|
||||
in_reply_to: None,
|
||||
});
|
||||
}
|
||||
let _ = self
|
||||
.broker
|
||||
.send_coalescing_reparent(child, old_label, new_label);
|
||||
}
|
||||
|
||||
self.rescan_containers_and_emit().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read-only snapshot of the last cached container view. Used by
|
||||
/// `/api/state` to cold-load page-open clients without re-running
|
||||
/// `nixos-container list` themselves; the
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ use crate::lifecycle;
|
|||
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
||||
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
||||
(name = "tombstones", description = "purge of retained state for destroyed agents"),
|
||||
(name = "topology", description = "operator-driven agent reparenting"),
|
||||
(name = "webhook", description = "forgejo webhook receivers"),
|
||||
)
|
||||
)]
|
||||
|
|
@ -72,7 +71,6 @@ mod schedules;
|
|||
mod state_files;
|
||||
mod state_snapshot;
|
||||
mod tombstones;
|
||||
mod topology;
|
||||
mod webhook;
|
||||
|
||||
// Run after lock bumps by the job queue (`job_queue/exec.rs`); the view
|
||||
|
|
@ -161,8 +159,6 @@ pub async fn serve(
|
|||
.routes(routes!(build_logs::get_build_logs_agent))
|
||||
.routes(routes!(build_logs::get_build_log_full))
|
||||
.routes(routes!(build_logs::get_build_log_raw))
|
||||
.routes(routes!(topology::post_set_parent))
|
||||
.routes(routes!(topology::post_set_parent_bulk))
|
||||
.routes(routes!(permissions::get_tool_groups))
|
||||
.routes(routes!(permissions::post_tool_groups))
|
||||
.routes(routes!(permissions::get_capabilities))
|
||||
|
|
@ -402,8 +398,6 @@ mod router_build_probe {
|
|||
.routes(routes!(build_logs::get_build_logs_agent))
|
||||
.routes(routes!(build_logs::get_build_log_full))
|
||||
.routes(routes!(build_logs::get_build_log_raw))
|
||||
.routes(routes!(topology::post_set_parent))
|
||||
.routes(routes!(topology::post_set_parent_bulk))
|
||||
.routes(routes!(permissions::get_tool_groups))
|
||||
.routes(routes!(permissions::post_tool_groups))
|
||||
.routes(routes!(permissions::get_capabilities))
|
||||
|
|
|
|||
|
|
@ -805,7 +805,6 @@ mod tests {
|
|||
needs_update: false,
|
||||
needs_login: false,
|
||||
deployed_sha: None,
|
||||
parent: None,
|
||||
active_model: None,
|
||||
status_text: None,
|
||||
status_set_at: None,
|
||||
|
|
|
|||
|
|
@ -1,165 +0,0 @@
|
|||
//! Topology (set-parent) endpoints for the dashboard.
|
||||
//!
|
||||
//! Operator-driven agent reparenting — single (`/api/topology/set-parent`,
|
||||
//! form-encoded) and bulk (`/api/topology/set-parent-bulk`, JSON array →
|
||||
//! one git commit). Both submit a `NodeKind::Reparent` DAG to the job
|
||||
//! queue (fire-and-forget, like every other queue-backed op — the
|
||||
//! dashboard tree repaints off the queue's own snapshot/rescan once the
|
||||
//! commit lands, same as a rebuild or restart). The executor delegates to
|
||||
//! `Coordinator::reparent_bulk_with_notify`, which wraps
|
||||
//! `crate::meta::bulk_commit_topology` with the move-notification messages
|
||||
//! and the `ContainerView` rescan.
|
||||
|
||||
use axum::{
|
||||
extract::{Form, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use problem_details::ProblemDetails;
|
||||
|
||||
use super::{AppState, error_problem};
|
||||
|
||||
/// `POST /api/topology/set-parent` body. `child` is required.
|
||||
/// `new_parent` may be:
|
||||
/// - absent or empty / whitespace-only → promote to root,
|
||||
/// - non-empty → new parent's logical name.
|
||||
///
|
||||
/// (The CLI surface gates "no parent specified" behind an explicit
|
||||
/// `--root` flag for safety; the HTTP surface is permissive
|
||||
/// because the dashboard form encodes "no value" as the empty
|
||||
/// string for the optional radio-group input.)
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(super) struct SetParentForm {
|
||||
child: String,
|
||||
new_parent: Option<String>,
|
||||
}
|
||||
|
||||
/// One entry in a `POST /api/topology/set-parent-bulk` JSON array.
|
||||
/// `new_parent`: absent/null/empty-string all mean "promote to root".
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(super) struct SetParentBulkEntry {
|
||||
child: String,
|
||||
#[serde(default)]
|
||||
new_parent: Option<String>,
|
||||
}
|
||||
|
||||
/// Operator-driven parent move.
|
||||
///
|
||||
/// Form fields: `child` (required, agent name), `new_parent`
|
||||
/// (optional — empty / absent string ⇒ promote to root). Refuses
|
||||
/// cycles and unknown agents (surfaced async on the job view — this
|
||||
/// handler only validates the identifiers, not the move itself). The
|
||||
/// manager is reparentable like any other agent — its privileges come
|
||||
/// from the privileged MCP socket, not its tree position. Submitting
|
||||
/// re-emits the queue snapshot immediately so the dashboard shows the
|
||||
/// queued move without a refresh; the tree itself repaints once the
|
||||
/// commit lands.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/topology/set-parent",
|
||||
responses(
|
||||
(status = 200, description = "reparent queued", body = String),
|
||||
(status = 400, description = "missing/invalid child or new_parent identifier"),
|
||||
),
|
||||
tag = "topology"
|
||||
)]
|
||||
pub(super) async fn post_set_parent(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<SetParentForm>,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
let child = form.child.trim().to_owned();
|
||||
if child.is_empty() {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail("set-parent: `child` required"));
|
||||
}
|
||||
let child = hive_types::Ident::parse(&child)
|
||||
.map_err(|e| error_problem(&format!("set-parent: `child` {e}")))?;
|
||||
// Empty / whitespace-only `new_parent` ⇒ promote to root. Web
|
||||
// forms submit the empty string for a "no value" radio button,
|
||||
// so this is the ergonomic encoding.
|
||||
let new_parent = form
|
||||
.new_parent
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(hive_types::Ident::parse)
|
||||
.transpose()
|
||||
.map_err(|e| error_problem(&format!("set-parent: `new_parent` {e}")))?;
|
||||
tracing::info!(
|
||||
child = %child,
|
||||
new_parent = ?new_parent,
|
||||
"operator: set-parent via dashboard"
|
||||
);
|
||||
state
|
||||
.coord
|
||||
.job_queue
|
||||
.insert_job(|b| {
|
||||
crate::job_queue::templates::reparent(b, vec![(child, new_parent)]);
|
||||
Vec::new()
|
||||
})
|
||||
.expect("template-declared shapes are acyclic");
|
||||
state.coord.emit_rebuild_queue_snapshot();
|
||||
Ok((StatusCode::OK, "ok").into_response())
|
||||
}
|
||||
|
||||
/// Move multiple agents in a
|
||||
/// single request, producing **one** git commit.
|
||||
///
|
||||
/// JSON body: `[{"child":"name", "new_parent":"target-or-null"}, ...]`.
|
||||
/// Empty array is a no-op (200 OK). First identifier that fails to
|
||||
/// parse aborts the whole batch before anything is submitted — a
|
||||
/// partially-invalid bulk move never reaches the queue.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/topology/set-parent-bulk",
|
||||
responses(
|
||||
(status = 200, description = "reparents queued", body = String),
|
||||
(status = 400, description = "an invalid child identifier in the batch"),
|
||||
),
|
||||
tag = "topology"
|
||||
)]
|
||||
pub(super) async fn post_set_parent_bulk(
|
||||
State(state): State<AppState>,
|
||||
axum::Json(body): axum::Json<Vec<SetParentBulkEntry>>,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
if body.is_empty() {
|
||||
return Ok((StatusCode::OK, "ok").into_response());
|
||||
}
|
||||
// Collect into `Result<_, String>` first, not `ProblemDetails` directly —
|
||||
// clippy::result_large_err flags a ~232-byte `Err` variant threaded
|
||||
// through this closure's `?`. `String` is small enough to satisfy the
|
||||
// lint; the single `map_err` below promotes it to a `ProblemDetails`
|
||||
// once, after the fallible collect.
|
||||
let moves = body
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let child = hive_types::Ident::parse(e.child.trim())
|
||||
.map_err(|err| format!("set-parent-bulk: `{}` {err}", e.child))?;
|
||||
let new_parent = e
|
||||
.new_parent
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(hive_types::Ident::parse)
|
||||
.transpose()
|
||||
.map_err(|err| format!("set-parent-bulk: `{}` {err}", e.child))?;
|
||||
Ok((child, new_parent))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
.map_err(|e| error_problem(&e))?;
|
||||
let names: Vec<&str> = body.iter().map(|e| e.child.as_str()).collect();
|
||||
tracing::info!(agents = ?names, "operator: set-parent-bulk via dashboard");
|
||||
state
|
||||
.coord
|
||||
.job_queue
|
||||
.insert_job(|b| {
|
||||
crate::job_queue::templates::reparent(b, moves);
|
||||
Vec::new()
|
||||
})
|
||||
.expect("template-declared shapes are acyclic");
|
||||
state.coord.emit_rebuild_queue_snapshot();
|
||||
Ok((StatusCode::OK, "ok").into_response())
|
||||
}
|
||||
|
|
@ -60,7 +60,7 @@ pub(super) async fn run_node(
|
|||
kind: &NodeKind,
|
||||
) -> (super::JobBuilder, Result<()>) {
|
||||
// The agent this node targets rides the payload — empty for the agentless
|
||||
// kinds (`MetaLock`, `Reparent`), which never read it.
|
||||
// kinds (`MetaLock`), which never read it.
|
||||
let agent = kind.agent();
|
||||
// Every arm is `Result<()>`; the three that grow work declare into `builder`
|
||||
// *synchronously*, after their own awaits have finished. Borrowing `&builder`
|
||||
|
|
@ -120,7 +120,6 @@ pub(super) async fn run_node(
|
|||
// takes it directly instead of re-matching the kind behind a `bail!`
|
||||
// that could never fire.
|
||||
NodeKind::WritePermFile { payload, .. } => run_write_perm_file(coord, agent, payload).await,
|
||||
NodeKind::Reparent { moves } => run_reparent(coord, moves).await,
|
||||
NodeKind::MergeVerify { approval_id, .. } => {
|
||||
run_merge_verify(coord, *approval_id, id).await
|
||||
}
|
||||
|
|
@ -815,33 +814,6 @@ async fn run_write_perm_file(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply the node's `(child, new_parent)` moves as one `META_LOCK`-fused
|
||||
/// commit (`Coordinator::reparent_bulk_with_notify`, which already handles
|
||||
/// both the single- and bulk-move case, sends the per-agent move
|
||||
/// notifications, and rescans + diff-emits the container tree). Runs under
|
||||
/// the deploy window (it declares `Resource::MetaWindow`), same reasoning as
|
||||
/// `run_write_perm_file`: a topology commit landing inside another node's
|
||||
/// staged deploy window would sweep the staged lock into its commit.
|
||||
async fn run_reparent(
|
||||
coord: &Arc<Coordinator>,
|
||||
moves: &[(hive_types::Ident, Option<hive_types::Ident>)],
|
||||
) -> Result<()> {
|
||||
let refs: Vec<(&str, Option<&str>)> = moves
|
||||
.iter()
|
||||
.map(|(child, parent)| {
|
||||
(
|
||||
child.as_str(),
|
||||
parent.as_ref().map(hive_types::Ident::as_str),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
coord
|
||||
.reparent_bulk_with_notify(&refs)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deploy phase 1 — drift gate, fetch, eval-verify. Mutates nothing, so a
|
||||
/// failure here cancel-cascades the rest of the subtree with the forge and the
|
||||
/// applied repo exactly as they were.
|
||||
|
|
@ -890,7 +862,9 @@ async fn run_deploy_tail(
|
|||
/// flake affects — the fan-out set for `MetaUpdate` DAGs. Empty
|
||||
/// `inputs` or any input under `hyperhive` → every container;
|
||||
/// otherwise just the agents named by `agent-<name>` inputs.
|
||||
/// Topology-sorted so parents rebuild before their children.
|
||||
/// Sorted by name — #4472 removed the parent field the old depth sort
|
||||
/// keyed on, and with every agent a root that sort already reduced to
|
||||
/// this.
|
||||
///
|
||||
/// `inputs` is the caller-supplied flake-input-name list (the dashboard's
|
||||
/// `POST /api/meta-update` form field, operator-supplied but not
|
||||
|
|
@ -918,8 +892,7 @@ pub async fn meta_update_cascade_agents(inputs: &[String]) -> Vec<String> {
|
|||
} else {
|
||||
touched_agents
|
||||
};
|
||||
let topo = crate::topology::read();
|
||||
crate::auto_update::topology_sort(&mut names, &topo);
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,12 +102,6 @@ pub enum NodeKind {
|
|||
WriteDropin { agent: String },
|
||||
/// Commit `tool-groups.json` / `capabilities.json` per its `payload`.
|
||||
WritePermFile { agent: String, payload: PermPayload },
|
||||
/// Topology move(s) — `set-parent` (len 1) or `set-parent-bulk` (len N) —
|
||||
/// as a single queue node. `moves` is typed `(Ident, Option<Ident>)`
|
||||
/// pairs, applied in order under one `META_LOCK` acquisition.
|
||||
Reparent {
|
||||
moves: Vec<(hive_types::Ident, Option<hive_types::Ident>)>,
|
||||
},
|
||||
/// Group root of the approval-deploy (`MergeConfigPr`) subtree — the
|
||||
/// **brace** that owns the deploy window. See _Braces_ and _Approvals_.
|
||||
DeployWindow { agent: String, approval_id: i64 },
|
||||
|
|
@ -203,7 +197,6 @@ impl hive_jobq_wire::WireNode for NodeKind {
|
|||
impl NodeKind {
|
||||
/// The agent this node targets, or `""` for agentless kinds
|
||||
/// ([`NodeKind::MetaLock`] on the `hyperhive` pseudo-agent,
|
||||
/// [`NodeKind::Reparent`] which can span multiple agents, and
|
||||
/// [`NodeKind::ResolveApproval`] which acts on an approval row).
|
||||
#[must_use]
|
||||
pub fn agent(&self) -> &str {
|
||||
|
|
@ -236,7 +229,6 @@ impl NodeKind {
|
|||
| NodeKind::EmitRebuilt { agent, .. }
|
||||
| NodeKind::SetWanted { agent, .. } => agent,
|
||||
NodeKind::MetaLock { .. }
|
||||
| NodeKind::Reparent { .. }
|
||||
| NodeKind::ResolveApproval { .. }
|
||||
| NodeKind::ForgeSweep
|
||||
| NodeKind::MatrixSweep
|
||||
|
|
|
|||
|
|
@ -597,27 +597,6 @@ pub fn meta_update(builder: &JobBuilder, inputs: Vec<String>, approval_id: Optio
|
|||
}
|
||||
}
|
||||
|
||||
/// Topology move(s) as a single-node DAG. `moves` is `(child, new_parent)`
|
||||
/// pairs — len 1 for `set-parent`, len N for `set-parent-bulk`, applied
|
||||
/// uniformly by the one [`NodeKind::Reparent`] node (which holds the global
|
||||
/// meta window for its duration, same precedent as [`NodeKind::WritePermFile`]).
|
||||
/// No rebuild subgraph: `topology.json` is read live by every consumer
|
||||
/// (dashboard tree, `<parent>`/`<children>` sentinel routing, permission
|
||||
/// checks), so a parent move needs no container rebuild to take effect.
|
||||
/// No transient pill either — the node is agentless (no lease to hang one
|
||||
/// off of) and near-instant. No tail node: the write is the whole effect.
|
||||
///
|
||||
/// Returns the single node's guid so a caller can wait on it.
|
||||
pub fn reparent(
|
||||
builder: &JobBuilder,
|
||||
moves: Vec<(hive_types::Ident, Option<hive_types::Ident>)>,
|
||||
) -> hive_jobq::NodeGuid {
|
||||
builder
|
||||
.node(NodeKind::Reparent { moves })
|
||||
.needs(Resource::MetaWindow)
|
||||
.guid()
|
||||
}
|
||||
|
||||
// The boot is assembled inline in `workers/auto_update.rs::submit_boot_tree`
|
||||
// as ONE `Boot` DAG (a sweep `MetaLock` root that grows rebuild subgraphs
|
||||
// in-DAG, plus a `Reconcile` root per drifted agent) — no anchor node and no
|
||||
|
|
|
|||
|
|
@ -49,10 +49,6 @@ fn insert_named(
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn ident(s: &str) -> hive_types::Ident {
|
||||
hive_types::Ident::parse(s).expect("valid test ident")
|
||||
}
|
||||
|
||||
fn rebuild(builder: &JobBuilder, agent: &str) -> Vec<hive_jobq::NodeGuid> {
|
||||
templates::rebuild(builder, agent, true)
|
||||
}
|
||||
|
|
@ -186,14 +182,6 @@ fn node_of(q: &JobQueue, kind: &str) -> hive_jobq::NodeId {
|
|||
found.pop().expect("checked above")
|
||||
}
|
||||
|
||||
/// The payload of the one node of `kind`, for assertions about what a node
|
||||
/// *carries* rather than how it is wired.
|
||||
fn payload_of(q: &JobQueue, kind: &str) -> NodeKind {
|
||||
let id = node_of(q, kind);
|
||||
let sched = q.sched().lock().expect("job_queue mutex poisoned");
|
||||
sched.graph().node(id).expect("node exists").payload.clone()
|
||||
}
|
||||
|
||||
/// Kinds of every node still `Pending` — the nodes that could yet run.
|
||||
/// Stronger than asking the scheduler what is *ready right now*: a node
|
||||
/// blocked on a dep is not ready but is very much still alive.
|
||||
|
|
@ -1785,48 +1773,3 @@ fn perm_change_shape_prefixes_rebuild_chain() {
|
|||
"the perm write prefixes an otherwise ordinary rebuild chain"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_shape_is_a_lone_agentless_meta_window_node() {
|
||||
// Single-move `set-parent` shape: one node, no rebuild subgraph (no
|
||||
// container rebuild needed for a parent move), agentless like
|
||||
// `MetaLock`, and it must declare the meta window — a topology commit
|
||||
// must not land inside another node's staged deploy window.
|
||||
let q = JobQueue::new(1);
|
||||
insert(&q, |builder| {
|
||||
templates::reparent(builder, vec![(ident("alice"), Some(ident("bob")))]);
|
||||
});
|
||||
assert_eq!(
|
||||
declared_shape(&q),
|
||||
vec![row("reparent", None, &[])],
|
||||
"one node, no rebuild subgraph"
|
||||
);
|
||||
let node = node_of(&q, "reparent");
|
||||
assert_eq!(
|
||||
declared_resources(&q, node),
|
||||
vec![Resource::MetaWindow],
|
||||
"a topology commit must declare the same MetaWindow as WritePermFile, \
|
||||
and nothing else — no lease (agentless), no build slot (no nix work)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_bulk_shape_carries_every_move_on_one_node() {
|
||||
// `set-parent-bulk`: still ONE node (one git commit, `moves.len() > 1`),
|
||||
// not one node per move — bulk atomicity across every move in the
|
||||
// request is the reason a single node was chosen in the first place.
|
||||
let moves = vec![(ident("alice"), Some(ident("bob"))), (ident("carol"), None)];
|
||||
let q = JobQueue::new(1);
|
||||
insert(&q, |builder| {
|
||||
templates::reparent(builder, moves.clone());
|
||||
});
|
||||
assert_eq!(
|
||||
declared_shape(&q),
|
||||
vec![row("reparent", None, &[])],
|
||||
"one node for the whole request, not one per move"
|
||||
);
|
||||
let NodeKind::Reparent { moves: got } = payload_of(&q, "reparent") else {
|
||||
panic!("expected a Reparent node");
|
||||
};
|
||||
assert_eq!(got, moves, "every move rides the single node");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,8 @@ pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied";
|
|||
/// clone is where a config change is staged, so it can hold a proposal
|
||||
/// that is still under review or was rejected outright — mounting it shows
|
||||
/// an agent a config which does not govern it. Both mounts (an agent's own
|
||||
/// and a parent's view of a child's) go through here so they cannot drift.
|
||||
/// and a `ManageRootAgent` holder's view of another agent's) go through
|
||||
/// here so they cannot drift.
|
||||
///
|
||||
/// Never empty under a live container: `provision_container` runs
|
||||
/// `setup_applied` before `create_only` makes the container at all.
|
||||
|
|
@ -86,26 +87,30 @@ fn config_bind_source(name: &str) -> PathBuf {
|
|||
}
|
||||
|
||||
/// Append bind flags for `child`'s state and config dirs into `binds`.
|
||||
/// See docs/agent-lifecycle/persistence.md ("Parent access to child state") for what a
|
||||
/// parent may touch and why. Creates missing host-side directories so
|
||||
/// nspawn doesn't refuse to start; missing dirs are non-fatal.
|
||||
/// See docs/agent-lifecycle/persistence.md ("Cross-agent access to state")
|
||||
/// for what the holder may touch and why. Creates missing host-side
|
||||
/// directories so nspawn doesn't refuse to start; missing dirs are
|
||||
/// non-fatal.
|
||||
///
|
||||
/// The only caller left is the `ManageRootAgent` grant — #4472 removed the
|
||||
/// parent/child tree that used to hand every agent its own children here.
|
||||
///
|
||||
/// **Three dirs, three different answers** — the uniformity of the
|
||||
/// original loop is what hid that:
|
||||
///
|
||||
/// - `state` is **read-write**: a parent reads and writes a child's notes
|
||||
/// to recover it, which is the one case that needs to work while the
|
||||
/// child is down.
|
||||
/// - `config` is **read-only**, and read-only for the *parent* is the
|
||||
/// point: a config change is a PR against the child's repo on the
|
||||
/// - `state` is **read-write**: the holder reads and writes another
|
||||
/// agent's notes to recover it, which is the one case that needs to
|
||||
/// work while that agent is down.
|
||||
/// - `config` is **read-only**, and read-only for the *holder* is the
|
||||
/// point: a config change is a PR against the other agent's repo on the
|
||||
/// forge, reviewed and merged, never an edit in place. A writable mount
|
||||
/// here is a second path to the same file that skips the review — the
|
||||
/// boundary would then be a convention rather than a permission.
|
||||
/// - `harness` is **absent entirely**. It holds the child's own runtime
|
||||
/// - `harness` is **absent entirely**. It holds that agent's own runtime
|
||||
/// material — `bash-tasks/`, turn-stats and event sqlite dbs — none of
|
||||
/// which a parent has a stated reason to read, let alone write.
|
||||
/// which the holder has a stated reason to read, let alone write.
|
||||
/// hive-c0re still reads it directly on the host (`stats::hive_stats`),
|
||||
/// which needs no bind mount into the parent.
|
||||
/// which needs no bind mount into anyone else's container.
|
||||
///
|
||||
/// ⚠️ The config-repo seeding hive-c0re does at spawn is **not** affected by
|
||||
/// the `config` flag and must not be read as a reason to widen it: that runs
|
||||
|
|
@ -316,21 +321,12 @@ async fn set_nspawn_flags(
|
|||
read_only: true,
|
||||
});
|
||||
|
||||
// Topology-driven child mounts: every direct child of this agent gets
|
||||
// its state dir RW and its config dir RO. See `bind_child_agent_dirs`
|
||||
// for why each is what it is.
|
||||
let direct_children = crate::topology::children_of(agent_name);
|
||||
for child in &direct_children {
|
||||
bind_child_agent_dirs(child, &mut binds);
|
||||
}
|
||||
|
||||
// `ManageRootAgent` capability: additionally mount *every* agent in
|
||||
// the hive as a virtual child. Enables recovery — the holder can
|
||||
// update another agent's config even when that agent is down. Also
|
||||
// grants RO access to /applied and /meta.
|
||||
// `ManageRootAgent` capability: mount *every* agent in the hive as a
|
||||
// virtual child. Enables recovery — the holder can update another
|
||||
// agent's config even when that agent is down. Also grants RO access
|
||||
// to /applied and /meta.
|
||||
//
|
||||
// ⚠️ "every agent" reads as a widening next to the `children_of`
|
||||
// mounts above, so: it is the definition of this capability, not an
|
||||
// ⚠️ "every agent" is the definition of this capability, not an
|
||||
// accident of how the set is computed. The grant used to hang off a
|
||||
// `can_manage_top_level_agents` role and cover `top_level_agents()`
|
||||
// — i.e. `parent.is_none()` — which was "everything outside the
|
||||
|
|
@ -338,13 +334,18 @@ async fn set_nspawn_flags(
|
|||
// parentless, so that set *was* every agent anyway; the capability
|
||||
// now says so out loud instead of deriving it from a field that no
|
||||
// longer discriminates.
|
||||
//
|
||||
// This is the *only* cross-agent mount left. #4472 dropped the
|
||||
// topology parent field, and with it the unconditional grant every
|
||||
// agent used to get over its own direct children — so an agent with
|
||||
// no capability now sees its own dirs and nothing else.
|
||||
if crate::capabilities::has_cap(agent_name, Capability::ManageRootAgent) {
|
||||
// Skipping self is a no-op, not a narrowing: `agent_notes_dir` is
|
||||
// `agent_state_dir/state` and `config_bind_source` is shared, so
|
||||
// binding the holder as its own virtual child reproduced the two
|
||||
// own-dir mounts pushed above, byte for byte.
|
||||
for other in crate::topology::all_agents() {
|
||||
if other != agent_name && !direct_children.contains(&other) {
|
||||
if other != agent_name {
|
||||
bind_child_agent_dirs(&other, &mut binds);
|
||||
}
|
||||
}
|
||||
|
|
@ -416,10 +417,11 @@ mod tests {
|
|||
binds
|
||||
}
|
||||
|
||||
/// The boundary, asserted as a whole rather than per-dir: a parent
|
||||
/// sees a child's `state` and `config`, and nothing else.
|
||||
/// The boundary, asserted as a whole rather than per-dir: a
|
||||
/// `ManageRootAgent` holder sees another agent's `state` and
|
||||
/// `config`, and nothing else.
|
||||
#[test]
|
||||
fn parent_sees_only_child_state_and_config() {
|
||||
fn holder_sees_only_other_agents_state_and_config() {
|
||||
let paths: Vec<String> = child_binds()
|
||||
.into_iter()
|
||||
.map(|b| b.container_path)
|
||||
|
|
@ -448,21 +450,21 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// The regression this exists for. `harness` holds the child's own
|
||||
/// runtime material and was only ever mounted because one loop
|
||||
/// The regression this exists for. `harness` holds the other agent's
|
||||
/// own runtime material and was only ever mounted because one loop
|
||||
/// treated all three dirs alike — re-adding it to that loop is a
|
||||
/// one-word change that nothing else would catch.
|
||||
#[test]
|
||||
fn parent_never_sees_a_child_harness_dir() {
|
||||
fn a_holder_never_sees_another_agents_harness_dir() {
|
||||
for bind in child_binds() {
|
||||
assert!(
|
||||
!bind.container_path.contains("harness"),
|
||||
"child harness must not be bound into a parent: {}",
|
||||
"another agent's harness must not be bound in: {}",
|
||||
bind.container_path
|
||||
);
|
||||
assert!(
|
||||
!bind.host_path.contains("harness"),
|
||||
"child harness must not be bound into a parent: {}",
|
||||
"another agent's harness must not be bound in: {}",
|
||||
bind.host_path
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,11 +143,8 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
|||
let ca_touched = materialise_ca_files(&dir, &ca_files)?;
|
||||
|
||||
// Reconcile topology.json against the live agent set — adds
|
||||
// entries for newly-spawned agents (default: manager as parent,
|
||||
// manager itself as root) and drops removed agents. Operator
|
||||
// overrides via the write API are preserved because reconcile
|
||||
// only fills in missing entries. Idempotent; when nothing changed
|
||||
// the file isn't touched.
|
||||
// newly-spawned agents and drops removed ones. Idempotent; when
|
||||
// nothing changed the file isn't touched.
|
||||
let agent_names: Vec<String> = agents.iter().map(|a| a.name.clone()).collect();
|
||||
let pending: Vec<String> = crate::coordinator::Coordinator::pending_init_names()
|
||||
.into_iter()
|
||||
|
|
@ -573,85 +570,6 @@ pub async fn commit_perms(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies every `(child, new_parent)` move under a single `META_LOCK`
|
||||
/// acquisition and creates **one** git commit for all of them — a
|
||||
/// single-move call is just a one-element slice, so there's no separate
|
||||
/// non-batch entry point. Moves are applied in the order given; the first
|
||||
/// validation error short-circuits the whole batch. True atomic write: all
|
||||
/// moves are pre-validated against a cumulative in-memory state with
|
||||
/// [`crate::topology::apply_set_parent`] before anything touches disk, then
|
||||
/// [`crate::topology::write`] is called exactly once. If any move fails
|
||||
/// validation the topology file is never modified.
|
||||
///
|
||||
/// The multi-move commit message uses `moves[0].1` as the destination label.
|
||||
/// This is intentional: the dashboard bulk-move UI always sends a single
|
||||
/// destination for all selected agents, so the message is always accurate in
|
||||
/// practice.
|
||||
///
|
||||
/// Returns a `Vec` of `(child, old_parent)` pairs for every move that
|
||||
/// actually changed the topology (idempotent same-parent moves are skipped),
|
||||
/// so the caller can send targeted notifications.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a `String` error if any move fails validation (cycle, unknown
|
||||
/// agent, etc.) or if the topology file cannot be written. Git-commit failure
|
||||
/// is logged as a warning and does not propagate — `sync_agents` will recover.
|
||||
pub async fn bulk_commit_topology(
|
||||
moves: &[(&str, Option<&str>)],
|
||||
) -> std::result::Result<Vec<(String, Option<String>)>, String> {
|
||||
if moves.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let _guard = META_LOCK.lock().await;
|
||||
// Snapshot parents before any writes so we can compute the diff.
|
||||
let topo_before = crate::topology::read();
|
||||
// Validate all moves against a cumulative in-memory state -- no disk
|
||||
// writes yet; first error aborts with the topology file untouched.
|
||||
let mut next = topo_before.clone();
|
||||
for (child, new_parent) in moves {
|
||||
next = crate::topology::apply_set_parent(&next, child, *new_parent)?;
|
||||
}
|
||||
// Only flush to disk if something actually changed.
|
||||
if next != topo_before {
|
||||
crate::topology::write(&next).map_err(|e| format!("{e:#}"))?;
|
||||
}
|
||||
// Commit the whole batch as one git operation.
|
||||
let dir = crate::paths::meta_root();
|
||||
let commit_msg = if moves.len() == 1 {
|
||||
let (child, new_parent) = moves[0];
|
||||
format!("topology: {} → {}", child, new_parent.unwrap_or("<root>"))
|
||||
} else {
|
||||
let names: Vec<&str> = moves.iter().map(|(c, _)| *c).collect();
|
||||
let dest = moves[0].1.unwrap_or("<root>");
|
||||
format!(
|
||||
"topology: move {} agents → {} ({})",
|
||||
moves.len(),
|
||||
dest,
|
||||
names.join(", ")
|
||||
)
|
||||
};
|
||||
let stage = async {
|
||||
git(&dir, &["add", "topology.json"]).await?;
|
||||
if paths_dirty(&dir, &["topology.json"]).await? {
|
||||
git_commit_paths(&dir, &commit_msg, &["topology.json"]).await?;
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
};
|
||||
if let Err(e) = stage.await {
|
||||
tracing::warn!(error = ?e, "bulk_commit_topology: topology written but git commit failed (sync_agents will recover)");
|
||||
}
|
||||
// Return (child, old_parent) for each move that changed state.
|
||||
let changed = moves
|
||||
.iter()
|
||||
.filter_map(|(child, new_parent)| {
|
||||
let old = topo_before.get(*child).cloned().flatten();
|
||||
(old.as_deref() != *new_parent).then_some((child.to_string(), old))
|
||||
})
|
||||
.collect();
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::too_many_arguments,
|
||||
reason = "many genuine flake inputs (source flakes, port, pronouns, tokens, \
|
||||
|
|
@ -1163,7 +1081,7 @@ where
|
|||
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null, toolGroups ? null, capabilities ? null, memoryMaxBytes ? null }}:"
|
||||
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, toolGroups ? null, capabilities ? null, memoryMaxBytes ? null }}:"
|
||||
);
|
||||
out.push_str(
|
||||
r#" let
|
||||
|
|
@ -1172,7 +1090,6 @@ where
|
|||
else hyperhive.nixosConfigurations.agent-base;
|
||||
input = inputs."agent-${name}";
|
||||
service = "hive-agent";
|
||||
parentEnv = if parent == null then {} else { HIVE_PARENT = parent; };
|
||||
toolGroupsEnv = if toolGroups == null then {} else { HIVE_TOOL_GROUPS = toolGroups; };
|
||||
capabilitiesEnv = if capabilities == null then {} else { HIVE_CAPABILITIES = capabilities; };
|
||||
in
|
||||
|
|
@ -1353,7 +1270,7 @@ where
|
|||
}
|
||||
out.push_str(
|
||||
r" };
|
||||
systemd.services.${service}.environment = parentEnv // toolGroupsEnv // capabilitiesEnv // {
|
||||
systemd.services.${service}.environment = toolGroupsEnv // capabilitiesEnv // {
|
||||
HIVE_PORT = toString port;
|
||||
HIVE_LABEL = name;
|
||||
HIVE_DASHBOARD_PORT = toString dashboardPort;
|
||||
|
|
@ -1399,19 +1316,10 @@ where
|
|||
nixosConfigurations = {
|
||||
"#,
|
||||
);
|
||||
// Pull the topology map once and look up each agent's parent. An
|
||||
// empty / absent topology.json yields `parent = null` for everyone
|
||||
// (every container at root). `meta::sync_agents` seeds the file
|
||||
// on first run with manager as root + everyone else under manager.
|
||||
let topology = crate::topology::read();
|
||||
let tool_groups_map = crate::tool_groups::read();
|
||||
let capabilities_map = crate::capabilities::read();
|
||||
let resource_limits_map = crate::resource_limits::read();
|
||||
for spec in agents {
|
||||
let parent_attr = topology
|
||||
.get(&spec.name)
|
||||
.and_then(|p| p.as_ref())
|
||||
.map_or_else(|| "null".to_owned(), |p| format!("\"{p}\""));
|
||||
// Emit `toolGroups = "group1,group2"` when the operator has
|
||||
// explicitly configured groups for this agent. Absent entry = null
|
||||
// = harness falls back to AGENT_DEFAULT (no env var emitted,
|
||||
|
|
@ -1451,12 +1359,11 @@ where
|
|||
.map_or_else(|| "null".to_owned(), |b| b.to_string());
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; toolGroups = {}; capabilities = {}; memoryMaxBytes = {}; }};",
|
||||
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; capabilities = {}; memoryMaxBytes = {}; }};",
|
||||
spec.name,
|
||||
spec.name,
|
||||
if spec.is_manager { "true" } else { "false" },
|
||||
spec.port,
|
||||
parent_attr,
|
||||
tool_groups_attr,
|
||||
capabilities_attr,
|
||||
memory_max_attr,
|
||||
|
|
|
|||
|
|
@ -207,26 +207,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
actions::deny(&coord, *id, None)?;
|
||||
HostResponse::success()
|
||||
}
|
||||
HostRequest::SetParent { child, new_parent } => {
|
||||
tracing::info!(%child, ?new_parent, "set_parent");
|
||||
// Fire-and-forget, like every other queue-backed op:
|
||||
// submit returns a DAG id immediately, the caller polls
|
||||
// `QueueDag` (`hivectl`'s wait/progress loop) for the
|
||||
// outcome instead of blocking here on the commit.
|
||||
let inserted = coord.job_queue.insert_job(|b| {
|
||||
vec![crate::job_queue::templates::reparent(
|
||||
b,
|
||||
vec![(child.clone(), new_parent.clone())],
|
||||
)]
|
||||
});
|
||||
match inserted {
|
||||
Ok(ids) => {
|
||||
coord.emit_rebuild_queue_snapshot();
|
||||
HostResponse::queued(ids.into_iter().map(hive_jobq::NodeId::get).collect())
|
||||
}
|
||||
Err(e) => HostResponse::error(format!("queue reparent: {e}")),
|
||||
}
|
||||
}
|
||||
HostRequest::SetResourceLimits {
|
||||
name,
|
||||
cpu_quota,
|
||||
|
|
|
|||
|
|
@ -712,36 +712,9 @@ fn check_can_cancel_approval(canceller: &str) -> Result<(), String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Fan out one message to each recipient in `targets`. Skips the sender
|
||||
/// itself. Returns a list of `"<agent>: <error>"` strings for any delivery
|
||||
/// failures (empty = all good).
|
||||
pub(crate) fn fan_out_send(
|
||||
coord: &Arc<Coordinator>,
|
||||
from: &str,
|
||||
body: &str,
|
||||
in_reply_to: Option<i64>,
|
||||
targets: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
for target in targets {
|
||||
if target == from {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = coord.broker.send(&Message {
|
||||
from: hive_sh4re::manager::trusted_sender(from),
|
||||
to: target.clone(),
|
||||
body: body.to_owned(),
|
||||
in_reply_to,
|
||||
}) {
|
||||
errors.push(format!("{target}: {e}"));
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
/// Common Send handler shared between dispatch arms. Applies the
|
||||
/// 4 KiB body cap, then routes broadcast (`to == "*"`) / children fan-out
|
||||
/// (`to == "<children>"`) / unicast through their respective broker calls.
|
||||
/// 4 KiB body cap, then routes broadcast (`to == "*"`) / unicast through
|
||||
/// their respective broker calls.
|
||||
/// `pub(crate)` so `dispatch_shared` can use it across both socket paths.
|
||||
pub(crate) fn handle_send(
|
||||
coord: &Arc<Coordinator>,
|
||||
|
|
@ -763,50 +736,30 @@ pub(crate) fn handle_send(
|
|||
}
|
||||
};
|
||||
}
|
||||
// `<children>`: fan out to every direct descendant of the sender per
|
||||
// topology.json. Bypasses the allow-list check — structural fan-out
|
||||
// targets are never user-listed peers. No-op (returns Ok) for leaf
|
||||
// agents that have no children.
|
||||
if to == hive_sh4re::manager::CHILDREN_RECIPIENT {
|
||||
let children = crate::topology::children_of(agent);
|
||||
let errors = fan_out_send(coord, agent, body, in_reply_to, &children);
|
||||
return if errors.is_empty() {
|
||||
Response::Ok
|
||||
} else {
|
||||
Response::Err {
|
||||
message: format!("children fan-out failed for agents: {}", errors.join(", ")),
|
||||
}
|
||||
};
|
||||
}
|
||||
// Resolve magic-recipient sentinels (`<parent>`) against topology.json;
|
||||
// no-op for ordinary names. Lets agents address structural roles without
|
||||
// learning the label — runtime reparenting propagates for free. See
|
||||
// `docs/process/conventions.md::Recipient sentinels`.
|
||||
let resolved = crate::topology::resolve_recipient(agent, to);
|
||||
// Validate that the resolved recipient is a known local agent or the
|
||||
// Validate that the recipient is a known local agent or the
|
||||
// special "operator" recipient. Without this check a typo in `to`
|
||||
// silently queues a message nobody will ever read.
|
||||
//
|
||||
// Cross-hive messaging (`name@hive` qualified names) is not routed
|
||||
// through the broker — use the Matrix MCP tools for that instead.
|
||||
if resolved.contains('@') {
|
||||
if to.contains('@') {
|
||||
return Response::Err {
|
||||
message: format!(
|
||||
"send failed: cross-hive recipient `{resolved}` is not supported \
|
||||
"send failed: cross-hive recipient `{to}` is not supported \
|
||||
via the broker — use Matrix MCP tools for cross-hive messaging"
|
||||
),
|
||||
};
|
||||
}
|
||||
if resolved != hive_sh4re::manager::OPERATOR_RECIPIENT {
|
||||
if to != hive_sh4re::manager::OPERATOR_RECIPIENT {
|
||||
// A name that doesn't parse as an Ident can't be a local agent, so
|
||||
// it collapses into the same "unknown recipient" error as a valid
|
||||
// name with no state dir.
|
||||
let exists = hive_types::Ident::parse(&resolved)
|
||||
let exists = hive_types::Ident::parse(to)
|
||||
.is_ok_and(|id| crate::paths::agent_state_dir(&id).exists());
|
||||
if !exists {
|
||||
return Response::Err {
|
||||
message: format!(
|
||||
"send failed: unknown recipient `{resolved}` \
|
||||
"send failed: unknown recipient `{to}` \
|
||||
(no agent with that name exists on this hive)"
|
||||
),
|
||||
};
|
||||
|
|
@ -814,7 +767,7 @@ pub(crate) fn handle_send(
|
|||
}
|
||||
match coord.broker.send(&Message {
|
||||
from: hive_sh4re::manager::trusted_sender(agent),
|
||||
to: resolved,
|
||||
to: to.to_owned(),
|
||||
body: body.to_owned(),
|
||||
in_reply_to,
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -252,7 +252,6 @@ mod tests {
|
|||
needs_update: false,
|
||||
needs_login,
|
||||
deployed_sha: None,
|
||||
parent: None,
|
||||
active_model: None,
|
||||
status_text: None,
|
||||
status_set_at: None,
|
||||
|
|
|
|||
|
|
@ -355,83 +355,6 @@ impl Broker {
|
|||
Ok(u64::try_from(n.max(0)).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Send a "your parent changed from X to Y" notification to `child`,
|
||||
/// coalescing with any existing undelivered one so that multiple moves
|
||||
/// while the agent is offline collapse into a single message spanning
|
||||
/// the full arc (e.g. A→B then B→C becomes "your parent changed from A to C").
|
||||
///
|
||||
/// If an undelivered system reparent notification for `child` already
|
||||
/// exists, its body is updated in-place preserving the original "from"
|
||||
/// label. If none exists, a fresh message is inserted with `old_label`
|
||||
/// as the source.
|
||||
pub fn send_coalescing_reparent(
|
||||
&self,
|
||||
child: &str,
|
||||
old_label: &str,
|
||||
new_label: &str,
|
||||
) -> Result<()> {
|
||||
const PREFIX: &str = "your parent changed from ";
|
||||
const SEPARATOR: &str = " to ";
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let now = Utc::now().timestamp();
|
||||
let existing: Option<(i64, String)> = conn
|
||||
.query_row(
|
||||
"SELECT id, body FROM messages
|
||||
WHERE recipient = ?1
|
||||
AND sender = ?2
|
||||
AND body LIKE ?3
|
||||
AND delivered_at IS NULL
|
||||
AND acked_at IS NULL
|
||||
LIMIT 1",
|
||||
params![
|
||||
child,
|
||||
hive_sh4re::manager::SYSTEM_SENDER,
|
||||
format!("{PREFIX}%")
|
||||
],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some((row_id, old_body)) = existing {
|
||||
// Preserve the original "from" label from the earlier notification.
|
||||
let original_from = old_body
|
||||
.strip_prefix(PREFIX)
|
||||
.and_then(|rest| rest.split(SEPARATOR).next())
|
||||
.unwrap_or(old_label);
|
||||
let new_body = format!("{PREFIX}{original_from}{SEPARATOR}{new_label}");
|
||||
conn.execute(
|
||||
"UPDATE messages SET body = ?1, sent_at = ?2 WHERE id = ?3",
|
||||
params![new_body, now, row_id],
|
||||
)?;
|
||||
drop(conn);
|
||||
let _ = self.events.send(MessageEvent::Sent {
|
||||
id: row_id,
|
||||
from: hive_sh4re::manager::SYSTEM_SENDER.to_owned(),
|
||||
to: child.to_owned(),
|
||||
body: new_body,
|
||||
at: now,
|
||||
in_reply_to: None,
|
||||
});
|
||||
} else {
|
||||
let body = format!("{PREFIX}{old_label}{SEPARATOR}{new_label}");
|
||||
conn.execute(
|
||||
"INSERT INTO messages (sender, recipient, body, sent_at, in_reply_to)
|
||||
VALUES (?1, ?2, ?3, ?4, NULL)",
|
||||
params![hive_sh4re::manager::SYSTEM_SENDER, child, body, now],
|
||||
)?;
|
||||
let row_id = conn.last_insert_rowid();
|
||||
drop(conn);
|
||||
let _ = self.events.send(MessageEvent::Sent {
|
||||
id: row_id,
|
||||
from: hive_sh4re::manager::SYSTEM_SENDER.to_owned(),
|
||||
to: child.to_owned(),
|
||||
body,
|
||||
at: now,
|
||||
in_reply_to: None,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Long-poll variant of `recv_batch`: returns immediately if any
|
||||
/// row is pending (popping up to `max`); otherwise waits up to
|
||||
/// `timeout` for the broker to emit a `Sent { to: recipient }`
|
||||
|
|
|
|||
|
|
@ -250,44 +250,6 @@ fn seed_manager_capabilities() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Sort `names` in-place so parents precede their children in the topology.
|
||||
/// Uses BFS from root agents (depth 0). Agents absent from `topo` sort last,
|
||||
/// alphabetically within their tier. Stable within each depth tier.
|
||||
pub fn topology_sort(
|
||||
names: &mut [String],
|
||||
topo: &std::collections::BTreeMap<String, Option<String>>,
|
||||
) {
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
// Build depth map using owned clones so the borrow on `names` is released
|
||||
// before the sort_by mutable borrow.
|
||||
let name_set: Vec<String> = names.to_vec();
|
||||
let mut depth: HashMap<String, usize> = HashMap::new();
|
||||
let mut queue: VecDeque<String> = VecDeque::new();
|
||||
// Seed roots: entries with no parent, or names not present in topo at all.
|
||||
for name in &name_set {
|
||||
if topo.get(name).is_none_or(Option::is_none) {
|
||||
depth.insert(name.clone(), 0);
|
||||
queue.push_back(name.clone());
|
||||
}
|
||||
}
|
||||
// BFS to assign depths to children.
|
||||
while let Some(parent) = queue.pop_front() {
|
||||
let d = depth[&parent] + 1;
|
||||
for name in &name_set {
|
||||
let is_child = topo.get(name).and_then(|p| p.as_deref()) == Some(parent.as_str());
|
||||
if is_child && !depth.contains_key(name) {
|
||||
depth.insert(name.clone(), d);
|
||||
queue.push_back(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort_by(|a, b| {
|
||||
let da = depth.get(a).copied().unwrap_or(usize::MAX);
|
||||
let db = depth.get(b).copied().unwrap_or(usize::MAX);
|
||||
da.cmp(&db).then(a.cmp(b))
|
||||
});
|
||||
}
|
||||
|
||||
/// Boot reconcile (see the module doc): classify every agent by rev
|
||||
/// freshness + persisted `wanted` intent, submit one `Boot` DAG
|
||||
/// (hyperhive lock bump growing an in-DAG rebuild subgraph per stale
|
||||
|
|
@ -305,16 +267,16 @@ pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
|
|||
|
||||
let current_rev = current_flake_rev(&coord.hyperhive_flake);
|
||||
|
||||
// Resolve container names to logical agent names, then sort by
|
||||
// topology depth so parents are always rebuilt before their
|
||||
// children. Root agents (depth 0) go first; agents absent from
|
||||
// the topology file sort last (stable, alphabetical within tier).
|
||||
// Resolve container names to logical agent names, then sort. #4472
|
||||
// removed the parent field this used to depth-sort by; with no
|
||||
// hierarchy left to respect, alphabetical is the whole order — and it
|
||||
// is exactly what the depth sort already produced once every agent
|
||||
// was a root.
|
||||
let mut logical_names: Vec<String> = containers
|
||||
.iter()
|
||||
.filter_map(|c| c.strip_prefix(AGENT_PREFIX).map(str::to_owned))
|
||||
.collect();
|
||||
let topo = crate::topology::read();
|
||||
topology_sort(&mut logical_names, &topo);
|
||||
logical_names.sort();
|
||||
|
||||
// Classify. `get_or_seed` doubles as the one-time migration: an
|
||||
// agent without an `agent_power` row is seeded from its observed
|
||||
|
|
@ -374,8 +336,8 @@ pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
|
|||
// Rebuild running agents first. All fanout entries are wanted=Up;
|
||||
// among them, warm the live/serving agents onto the fresh config before the
|
||||
// stopped-but-wanted-up ones so the scarce build slots hit uptime-critical
|
||||
// agents first. Stable sort keeps topology order (parents before children)
|
||||
// within each running/stopped group. The `drifted` reconciles aren't sorted
|
||||
// agents first. Stable sort keeps the alphabetical order within each
|
||||
// running/stopped group. The `drifted` reconciles aren't sorted
|
||||
// — they hold no build slot and run concurrently, so their order is moot.
|
||||
fanout.sort_by_key(|(_, running)| !running);
|
||||
let fanout: Vec<String> = fanout.into_iter().map(|(name, _)| name).collect();
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ pub enum HostRequest {
|
|||
/// credentials into the existing state tree.
|
||||
AgentExists { name: Ident },
|
||||
/// List managed agents with their full status + technical state
|
||||
/// (running / needs-login / needs-update / deployed sha / parent /
|
||||
/// (running / needs-login / needs-update / deployed sha /
|
||||
/// pending reminders) — the `hivectl list-agents` roster view.
|
||||
/// Reuses the dashboard's per-agent `ContainerView` aggregation.
|
||||
AgentStatus,
|
||||
|
|
@ -247,14 +247,6 @@ pub enum HostRequest {
|
|||
Approve { id: i64 },
|
||||
/// Deny a pending request by id.
|
||||
Deny { id: i64 },
|
||||
/// Move an agent in the topology tree. `new_parent = None`
|
||||
/// promotes the agent to root, `Some(name)` sets a new parent.
|
||||
/// Validation rules + bind-mount caveat documented in
|
||||
/// `docs/agent-lifecycle/agent-hierarchy.md::Reparenting`.
|
||||
SetParent {
|
||||
child: Ident,
|
||||
new_parent: Option<Ident>,
|
||||
},
|
||||
/// Declare an agent's CPU/memory overrides for the per-container
|
||||
/// systemd drop-in, persisted to `meta/resource-limits.json`.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -53,9 +53,6 @@ pub struct AgentStatusRow {
|
|||
/// paused while stopped, and pause survives a restart.
|
||||
#[serde(default)]
|
||||
pub paused: bool,
|
||||
/// Parent in the topology tree. `None` marks a root-level agent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<String>,
|
||||
/// The Claude model the agent's harness is currently using. Mirrors
|
||||
/// `container_view::ContainerView::active_model`: `None` when the
|
||||
/// agent has never started a turn, the field is absent from its
|
||||
|
|
|
|||
|
|
@ -17,23 +17,6 @@ pub const MANAGER_AGENT: &str = "ruth";
|
|||
/// dashboard's inbox view — they are never `recv`'d by an agent harness.
|
||||
pub const OPERATOR_RECIPIENT: &str = "operator";
|
||||
|
||||
/// Reserved magic recipient — `send(to: "<parent>", ...)` is rewritten
|
||||
/// by hive-c0re at delivery time to whoever `topology::parent_of(sender)`
|
||||
/// returns, or to [`OPERATOR_RECIPIENT`] when the sender is a root agent
|
||||
/// (no parent). Lets agents address their parent without hardcoding the
|
||||
/// label, so runtime reparenting requires no agent-side restart. The
|
||||
/// angle brackets are not valid in agent names (validators reject
|
||||
/// `<`/`>`), so this name can never collide with a real recipient.
|
||||
pub const PARENT_RECIPIENT: &str = "<parent>";
|
||||
|
||||
/// Reserved magic recipient — `send(to: "<children>", ...)` fans out to
|
||||
/// every agent whose direct parent (per `topology.json`) is the sender.
|
||||
/// Lets a sub-manager nudge its subtree without enumerating labels at
|
||||
/// call-time; topology changes propagate for free. The angle brackets
|
||||
/// are structurally safe — agent name validation rejects `<`/`>`.
|
||||
/// Delivers to an empty set (no-op) for leaf agents that have no children.
|
||||
pub const CHILDREN_RECIPIENT: &str = "<children>";
|
||||
|
||||
/// Sender hive-c0re uses for events it pushes into the manager's inbox.
|
||||
/// Manager harness recognises this and parses the body as a `HelperEvent`.
|
||||
pub const SYSTEM_SENDER: &str = "system";
|
||||
|
|
@ -113,9 +96,7 @@ pub struct SchedulePromptPayload {
|
|||
|
||||
#[cfg(test)]
|
||||
mod reserved_name_tests {
|
||||
use super::{
|
||||
CHILDREN_RECIPIENT, MANAGER_AGENT, OPERATOR_RECIPIENT, PARENT_RECIPIENT, SYSTEM_SENDER,
|
||||
};
|
||||
use super::{MANAGER_AGENT, OPERATOR_RECIPIENT, SYSTEM_SENDER};
|
||||
use hive_types::{Ident, RESERVED_NAMES_ENV, is_reserved_name};
|
||||
|
||||
/// The blacklist as nix rendered it for this test run.
|
||||
|
|
@ -182,24 +163,6 @@ mod reserved_name_tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The other half, and the reason the test above is not vacuous: these
|
||||
/// sentinels are *unreachable* as agent names because the charset
|
||||
/// rejects them, so they are correctly absent from the list. If a
|
||||
/// charset change ever made one parseable, it would become a real
|
||||
/// collision and this test is what notices.
|
||||
#[test]
|
||||
fn bracketed_recipients_cannot_be_agent_names() {
|
||||
let owned = reserved();
|
||||
let reserved: Vec<&str> = owned.iter().map(String::as_str).collect();
|
||||
for sentinel in [PARENT_RECIPIENT, CHILDREN_RECIPIENT] {
|
||||
assert!(
|
||||
Ident::parse(sentinel).is_err(),
|
||||
"{sentinel:?} parses as an ident now — it is reachable as an agent name and must be reserved"
|
||||
);
|
||||
assert!(!is_reserved_name(sentinel, &reserved));
|
||||
}
|
||||
}
|
||||
|
||||
/// `ruth` is a real agent, not a protocol literal, so it is not
|
||||
/// reserved: a second agent wanting the name is a *taken* name, which
|
||||
/// the roster check answers. Recorded as a test so the distinction is
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! `hivectl agent <name>` — everything scoped to ONE managed agent:
|
||||
//! container lifecycle over the host admin socket (restart/pause/resume/
|
||||
//! spawn/kill/destroy/rebuild/set-parent/set-limits/choom/watch), plus the
|
||||
//! spawn/kill/destroy/rebuild/set-limits/choom/watch), plus the
|
||||
//! `quota` and `subvol` groups, whose handlers live in their own modules.
|
||||
//! `agents_list` (`hivectl list-agents`) is the one genuinely hive-wide
|
||||
//! read that lives in this module too since it shares the same daemon
|
||||
|
|
@ -172,15 +172,14 @@ pub(crate) async fn agents_list(socket: &Path, json: bool) -> Result<()> {
|
|||
}
|
||||
s
|
||||
};
|
||||
let headers = ["NAME", "STATUS", "REV", "PARENT", "REMIND"];
|
||||
let table: Vec<[String; 5]> = rows
|
||||
let headers = ["NAME", "STATUS", "REV", "REMIND"];
|
||||
let table: Vec<[String; 4]> = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
[
|
||||
r.name.clone(),
|
||||
status_of(r),
|
||||
r.deployed_sha.clone().unwrap_or_else(|| "-".to_owned()),
|
||||
r.parent.clone().unwrap_or_else(|| "-".to_owned()),
|
||||
if r.pending_reminders > 0 {
|
||||
r.pending_reminders.to_string()
|
||||
} else {
|
||||
|
|
@ -291,18 +290,6 @@ pub(crate) async fn run_agent(socket: &Path, name: &str, cmd: AgentCmd) -> Resul
|
|||
let name = crate::util::parse_ident(name)?;
|
||||
render(crate::client::request(socket, HostRequest::Rebuild { name }).await?)
|
||||
}
|
||||
AgentCmd::SetParent { parent, root } => {
|
||||
let child = crate::util::parse_ident(name)?;
|
||||
let new_parent = if root {
|
||||
None
|
||||
} else {
|
||||
parent.map(|p| crate::util::parse_ident(&p)).transpose()?
|
||||
};
|
||||
render(
|
||||
crate::client::request(socket, HostRequest::SetParent { child, new_parent })
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
AgentCmd::SetLimits {
|
||||
cpu_quota,
|
||||
memory_max,
|
||||
|
|
|
|||
|
|
@ -507,16 +507,6 @@ pub enum AgentCmd {
|
|||
},
|
||||
/// Apply pending config to this managed container.
|
||||
Rebuild,
|
||||
/// Move this agent in the topology tree — under a new parent, or to
|
||||
/// root.
|
||||
SetParent {
|
||||
/// New parent agent name. Mutually exclusive with `--root`.
|
||||
#[arg(long, conflicts_with = "root", required_unless_present = "root")]
|
||||
parent: Option<String>,
|
||||
/// Promote this agent to root (no parent).
|
||||
#[arg(long)]
|
||||
root: bool,
|
||||
},
|
||||
/// Declare this agent's CPU/memory limits, overriding the hive-wide
|
||||
/// defaults.
|
||||
///
|
||||
|
|
|
|||
Loading…
Reference in a new issue