Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-sh4re/src/manager.rs
atlas efbfec6d01 config PRs: remove the hive's config-PR webhook, poll and core merge
An operator's merge on the forge deploys a config PR through
swarm-controller's DeployRequest{rev}. The hive-side path that queued a
MergeConfigPr approval and merged the PR as `core` goes:

- the `/webhook/config-pr` receiver, its HMAC secret, the WebhookRegister
  boot node and the org-hook registration; the hive vhost's `/webhook/`
  location
- the 5-minute config-PR poll
- ApprovalKind::MergeConfigPr, its dashboard card, and the deploy DAG it
  drove (DeployWindow, MergeVerify, DeployApply, FinalizeDeploy,
  DeployTail), with verify_commit, the two-phase meta deploy, rollback
  refs, the PR-failure comment and forge/pr_merge.rs
- `fetched_sha`, `sha_short`/`pr_number` on approval events, and
  `sha`/`tag` on HelperEvent::ApprovalResolved: only the merge path set
  them

`config_repo`, `merged_pr_for_commit` and `post_pr_comment` move to
forge/pr_comment.rs for the merged-rev deploy's refusal comment.
Approvals v5 drops stored `merge_config_pr` rows; a test reopens a v4
database holding them.

Closes #4850
2026-10-02 23:13:03 +02:00

173 lines
7.5 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";
/// 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/agent-lifecycle/approvals.md::Helper events to the submitting agent`.
#[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>,
},
/// 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>,
},
}
/// 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::{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.
///
/// **Panics when the variable is absent, deliberately.** The list lives
/// in `nix/reserved-names.nix` now, so this test can no longer read it
/// from the Rust tree; `nix/checks.nix` and `nix/devshell.nix` both
/// export it. Skipping instead would turn "nobody wired the variable"
/// into a green run — the same shape as a checker that reports clean
/// because it crashed, and the whole point of a drift test is that it
/// is the thing that notices.
fn reserved() -> Vec<String> {
let raw = hive_types::reserved_names_raw().unwrap_or_else(|| {
panic!(
"{RESERVED_NAMES_ENV} is unset or blank, so the drift test cannot run. \
nix/checks.nix and nix/devshell.nix are supposed to export it from \
nix/reserved-names.nix — fix the plumbing rather than this test."
)
});
hive_types::parse_reserved_names(&raw)
.into_iter()
.map(str::to_owned)
.collect()
}
/// The sentinels declared here and the blacklist nix owns are two
/// spellings of one fact, in places 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() {
let owned = reserved();
let reserved: Vec<&str> = owned.iter().map(String::as_str).collect();
for sentinel in [OPERATOR_RECIPIENT, SYSTEM_SENDER] {
assert!(
is_reserved_name(sentinel, &reserved),
"{sentinel:?} is a sentinel an agent could be named — it must be in \
nix/reserved-names.nix"
);
}
}
/// Every entry nix hands us must be a name an agent could actually have
/// been given. One that `Ident::parse` rejects is dead weight — nothing
/// could ever have been created with it, so listing it implies a guard
/// doing nothing. `graceful-stop` is what makes this worth asserting: it
/// is hyphenated, and a charset tightening would silently retire it.
///
/// This assertion used to live beside the list in `hive-types`; it moved
/// here because here is where the real list is readable.
#[test]
fn every_reserved_name_is_a_valid_ident() {
let owned = reserved();
assert!(
!owned.is_empty(),
"the blacklist rendered empty — an empty list makes every assertion below vacuous"
);
for name in &owned {
assert!(
Ident::parse(name).is_ok(),
"{name:?} is reserved but not a parseable ident — one of the two is wrong"
);
}
}
/// `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() {
let owned = reserved();
let reserved: Vec<&str> = owned.iter().map(String::as_str).collect();
assert!(Ident::parse(MANAGER_AGENT).is_ok());
assert!(!is_reserved_name(MANAGER_AGENT, &reserved));
}
}