An agent's name was checked for shape and never for meaning: `Ident::parse` is 1-63 chars of [a-z0-9-] and there was no reserved-name list anywhere in the tree. So an agent could be called `operator`, `forge` or `todo` -- names the message layer already produces as a sender -- and a wake from that component became indistinguishable, at the broker, from a message sent by the agent. Adds `RESERVED_NAMES` + `is_reserved_name` to `hive-types`, the zero-dep leaf both `hive-c0re` and `swarm-controller` already depend on, so neither grows a dependency to use it. Every entry is a value some component actually produces as a message `from`/`to`, taken from `hive-sh4re`'s own sentinel constants rather than guessed: operator, system, reminder, forge, scheduled, todo, compact, graceful-stop. Two sentinels are deliberately absent -- `<parent>` and `<children>` are unreachable as agent names because the charset rejects them, and `ruth` is a real agent, so wanting that name is a name being *taken*, which the roster answers. Deliberately not enforced inside `Ident::parse`: parsing runs on every read of an already-created name, so rejecting there would make existing agents unreadable rather than un-creatable -- and it would be a refusal, which is a stronger action than the warning this is used for today. `create_agent` now warns on both halves -- a reserved name, and a name that is also a hive in the roster -- and does not refuse. The warnings ride on `CreateAgentResponse` rather than only the daemon's log, because the person who can still fix the name in one keystroke is holding the response, not reading the journal. `skip_serializing_if` keeps the no-warning JSON byte-identical to before, so this is a non-breaking first step toward refusing later. `hive-sh4re` gains a drift test tying its sentinel constants to the list: two crates that cannot import each other's intent now fail loudly if a sentinel is added without being reserved. Mutation-verified -- forcing the predicate false, forcing it true, and dropping a single entry each turn a different test red.
182 lines
7.8 KiB
Rust
182 lines
7.8 KiB
Rust
//! Manager socket — `/run/hyperhive/manager/mcp.sock` on the host,
|
|
//! bind-mounted into the manager container at `/run/hive/mcp.sock`.
|
|
//! Reserved recipient names/senders, the out-of-band `HelperEvent`
|
|
//! payload hive-c0re pushes into the manager's inbox, and the
|
|
//! schedule-prompt submission payload.
|
|
|
|
use hive_types::Ident;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::approvals::ApprovalStatus;
|
|
|
|
/// Logical name the broker uses for the manager.
|
|
pub const MANAGER_AGENT: &str = "ruth";
|
|
|
|
/// Logical name the broker uses for the human operator. Messages with
|
|
/// `to = OPERATOR_RECIPIENT` accumulate in sqlite and surface on the
|
|
/// 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";
|
|
|
|
/// Parse `s` as a [`Ident`] for use as `Message.from`, falling back to
|
|
/// [`SYSTEM_SENDER`] on the (should-be-unreachable) case that `s` isn't
|
|
/// ident-shaped. `Message.from` is always either a fixed sentinel literal
|
|
/// (`SYSTEM_SENDER`, `OPERATOR_RECIPIENT`, `"scheduled"`, …) or an
|
|
/// already-registered agent's own name reaching this point through
|
|
/// hive-c0re's internal dispatch — never arbitrary external input — so
|
|
/// this is a defensive fallback for a programming-bug case, not a
|
|
/// validation gate.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Never, unless [`SYSTEM_SENDER`] itself stops being ident-shaped (which
|
|
/// would also be a programming bug, caught by `hive-types`' own tests).
|
|
#[must_use]
|
|
pub fn trusted_sender(s: &str) -> Ident {
|
|
Ident::parse(s)
|
|
.unwrap_or_else(|_| Ident::parse(SYSTEM_SENDER).expect("SYSTEM_SENDER is a valid Ident"))
|
|
}
|
|
|
|
/// Out-of-band events the host-side daemon pushes to the manager's inbox.
|
|
/// Serialised as JSON in `Message::body` (sender = `SYSTEM_SENDER`).
|
|
/// Per-variant triggers + the optional `sha`/`tag` semantics live in
|
|
/// `docs/approvals.md::Helper events to the manager`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "event", rename_all = "snake_case")]
|
|
pub enum HelperEvent {
|
|
/// An approval transitioned to a terminal state.
|
|
ApprovalResolved {
|
|
id: i64,
|
|
agent: String,
|
|
commit_ref: String,
|
|
status: ApprovalStatus,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
note: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
sha: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
tag: Option<String>,
|
|
},
|
|
/// A sub-agent's recorded flake rev is stale relative to hyperhive.
|
|
NeedsUpdate { agent: String },
|
|
/// Container exited without an operator-initiated stop (crash).
|
|
ContainerCrash {
|
|
agent: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
note: Option<String>,
|
|
},
|
|
/// A question queued via `Ask` was answered. `id` matches the
|
|
/// originating `QuestionQueued.id`; `answerer` is `"operator"` /
|
|
/// a peer agent name / `"ttl-watchdog"` on expiry.
|
|
QuestionAnswered {
|
|
id: i64,
|
|
question: String,
|
|
answer: String,
|
|
answerer: String,
|
|
},
|
|
/// A peer (or the manager) asked this agent a question. Recipient
|
|
/// replies via `Answer { id, answer }`; the answer routes back to
|
|
/// the asker as `QuestionAnswered`.
|
|
QuestionAsked {
|
|
id: i64,
|
|
asker: String,
|
|
question: String,
|
|
#[serde(default)]
|
|
options: Vec<String>,
|
|
#[serde(default)]
|
|
multi: bool,
|
|
},
|
|
}
|
|
|
|
/// Submission payload for `RequestSchedulePrompt`. Lives outside the
|
|
/// enum so it can also serialize into the approval row's `commit_ref`
|
|
/// (the dispatcher re-parses it on approve and inserts the schedule).
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct SchedulePromptPayload {
|
|
/// Names of recipient agents. Operator + `root` allowed.
|
|
pub targets: Vec<String>,
|
|
/// Message body delivered to each target's inbox at fire time.
|
|
/// Same size budget as `Send.body` — soft cap at the broker level.
|
|
pub body: String,
|
|
/// Absolute unix timestamp (seconds) for the FIRST fire. For
|
|
/// recurring schedules the worker then re-arms in
|
|
/// `interval_seconds` steps.
|
|
pub first_fire_at_unix: i64,
|
|
/// `None` = one-shot. `Some(n > 0)` = recurring every `n` seconds.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub interval_seconds: Option<u64>,
|
|
/// Optional description shown on the dashboard approval card AND
|
|
/// stored on the resulting schedule row for the operator's
|
|
/// "what is this?" reference later.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod reserved_name_tests {
|
|
use super::{
|
|
CHILDREN_RECIPIENT, MANAGER_AGENT, OPERATOR_RECIPIENT, PARENT_RECIPIENT, SYSTEM_SENDER,
|
|
};
|
|
use hive_types::{Ident, is_reserved_name};
|
|
|
|
/// The sentinels declared here and the reserved-name list in
|
|
/// `hive-types` are two spellings of one fact, in crates that cannot
|
|
/// import each other's intent. This pins them together: adding a
|
|
/// sentinel without reserving it now fails here rather than years
|
|
/// later, when an agent takes the name.
|
|
#[test]
|
|
fn ident_shaped_sentinels_are_reserved() {
|
|
for sentinel in [OPERATOR_RECIPIENT, SYSTEM_SENDER] {
|
|
assert!(
|
|
is_reserved_name(sentinel),
|
|
"{sentinel:?} is a sentinel an agent could be named — it must be in RESERVED_NAMES"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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() {
|
|
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));
|
|
}
|
|
}
|
|
|
|
/// `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
|
|
/// enforced rather than remembered.
|
|
#[test]
|
|
fn manager_name_is_taken_not_reserved() {
|
|
assert!(Ident::parse(MANAGER_AGENT).is_ok());
|
|
assert!(!is_reserved_name(MANAGER_AGENT));
|
|
}
|
|
}
|