One list, in nix/reserved-names.nix, handed to everything that needs it as HIVE_RESERVED_NAMES. Keeping it current becomes a config change rather than a rebuild, and hive names and agent names -- one namespace going forward -- are checked against the same file: swarm-otel.nix's hand-written reservedOwners is gone. Whitespace-separated rather than JSON, deliberately, unlike the structured env vars beside it. Every entry is an Ident ([a-z0-9-]), so whitespace cannot occur inside a name and the encoding is provably lossless; JSON would mean either a parser dependency in a crate whose purpose is to have none, or a copy of the parse in every consumer. An UNSET variable is not "nothing is reserved". Both creation sites log an error and return a warning saying the check did not run, so a misconfigured deployment says so instead of silently accepting every name. A blank value folds into unset: nix always renders a non-empty list, so present-but-empty is a rendering fault, not a declaration. Two guards whose subject moved out of their own file now assert their own case is still in it, because a guard that can be retired by an edit elsewhere is not a guard: - swarm-otel.nix asserts reserved-names.nix still contains its swarmTierName. - hive-sh4re's sentinel drift test PANICS when the variable is missing rather than skipping -- a drift test that quietly does nothing still reports green. checks.nix and devshell.nix both export it so CI and a local cargo test agree. Verified as a pair: with the variable set, 8 tests pass; with it unset, exactly the 4 drift tests fail and the unrelated ones still pass.
271 lines
11 KiB
Rust
271 lines
11 KiB
Rust
//! Config-approval request handlers: `RequestInitConfig` /
|
|
//! `RequestUpdateMetaInputs`, plus the shared submit helpers
|
|
//! (`submit_init_config` / `submit_merge_config_pr`).
|
|
//!
|
|
//! `submit_merge_config_pr` is called from the dashboard webhook handler
|
|
//! (`dashboard::webhook`) — agents no longer need an MCP tool for config
|
|
//! changes; opening a config PR on `agent-configs/<agent>` is enough to
|
|
//! trigger hive-c0re's webhook-driven queue path.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use hive_core_agent_sock::Response;
|
|
|
|
use super::require_new_child;
|
|
use crate::coordinator::Coordinator;
|
|
|
|
/// `RequestInitConfig` — queue an `InitConfig` approval for an agent. The
|
|
/// `name` must be brand-new (absent from the topology) or already in the
|
|
/// caller's subtree; the requester is recorded as the new agent's parent (the
|
|
/// root requesting a new agent → a top-level agent, matching reconcile's
|
|
/// default).
|
|
pub(super) fn handle_request_init_config(
|
|
coord: &Arc<Coordinator>,
|
|
agent: &str,
|
|
name: &str,
|
|
description: Option<String>,
|
|
) -> Response {
|
|
if let Some(err) = require_new_child(agent, name, "request_init_config for") {
|
|
return err;
|
|
}
|
|
tracing::info!(%agent, %name, "request_init_config");
|
|
// Warn, do not refuse: an agent already created under a colliding
|
|
// name must stay re-initialisable, so the refusal comes later, once
|
|
// the warning has had time to be seen.
|
|
//
|
|
// Checked HERE and not only in `swarm-controller::create_agent`:
|
|
// that daemon is opt-in and off on most hives, while this is the
|
|
// path the `request_init_config` tool takes on every hive. Guarding
|
|
// only the rarer one would have left the common flow exactly as
|
|
// unguarded as before.
|
|
//
|
|
// The blacklist itself comes from nix via `HIVE_RESERVED_NAMES`, so it
|
|
// stays a config change rather than a rebuild. An UNSET variable means
|
|
// this daemon was never told — which is not the same as "no name is
|
|
// reserved", and saying nothing there would be a check that reports
|
|
// clean because it could not run.
|
|
let raw = hive_types::reserved_names_raw();
|
|
let warnings = match raw.as_deref().map(hive_types::parse_reserved_names) {
|
|
None => {
|
|
tracing::error!(
|
|
var = hive_types::RESERVED_NAMES_ENV,
|
|
"request_init_config: reserved-name check could not run — variable not set"
|
|
);
|
|
vec![format!(
|
|
"the reserved-name check did not run: {} is unset, so {name:?} was accepted \
|
|
without being checked against the protocol literals",
|
|
hive_types::RESERVED_NAMES_ENV
|
|
)]
|
|
}
|
|
Some(reserved) if hive_types::is_reserved_name(name, &reserved) => {
|
|
tracing::warn!(%agent, %name, "request_init_config: reserved name");
|
|
vec![format!(
|
|
"agent name {name:?} is a reserved protocol name — messages from this agent will \
|
|
be indistinguishable from hyperhive's own; this will become an error"
|
|
)]
|
|
}
|
|
Some(_) => Vec::new(),
|
|
};
|
|
match submit_init_config(coord, name, Some(agent), description) {
|
|
Ok(_id) if warnings.is_empty() => Response::Ok,
|
|
Ok(_id) => Response::OkWarn { warnings },
|
|
Err(e) => Response::Err {
|
|
message: format!("{e:#}"),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// `RequestUpdateMetaInputs` — queue an `UpdateMetaInputs` approval
|
|
/// carrying the JSON-encoded input list in `commit_ref` (no git commit
|
|
/// is involved; the field is the payload the approval handler decodes).
|
|
pub(super) fn handle_request_update_meta_inputs(
|
|
coord: &Arc<Coordinator>,
|
|
requester: &str,
|
|
inputs: &[String],
|
|
description: Option<&str>,
|
|
) -> Response {
|
|
let label = if inputs.is_empty() {
|
|
"all inputs".to_string()
|
|
} else {
|
|
inputs.join(", ")
|
|
};
|
|
tracing::info!(%requester, %label, "request_update_meta_inputs");
|
|
let commit_ref = serde_json::to_string(inputs).unwrap_or_default();
|
|
let id = match coord
|
|
.approvals
|
|
.submit_kind(
|
|
requester,
|
|
hive_sh4re::approvals::ApprovalKind::UpdateMetaInputs,
|
|
&commit_ref,
|
|
description,
|
|
requester,
|
|
None,
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("{e:#}"))
|
|
{
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
return Response::Err {
|
|
message: format!("queue update_meta_inputs approval: {e:#}"),
|
|
};
|
|
}
|
|
};
|
|
tracing::info!(%id, %label, "update_meta_inputs approval queued");
|
|
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
|
|
id,
|
|
agent: requester,
|
|
approval_kind: "update_meta_inputs",
|
|
sha_short: None,
|
|
description: description.map(str::to_owned),
|
|
pr_number: None,
|
|
});
|
|
Response::Ok
|
|
}
|
|
|
|
/// Submit-time half of the PR-merge flow: fetch the PR head sha from the
|
|
/// forge, queue the approval row, and emit the `approval_added` event so the
|
|
/// dashboard shows the pending card immediately.
|
|
///
|
|
/// The PR head sha is stored as `fetched_sha` on the approval row — the
|
|
/// "reviewed sha" the deploy's `MergeVerify` node drift-gates
|
|
/// against before doing anything irreversible. This does NOT fetch the commit
|
|
/// into the applied repo at submission time (that happens inside the approve
|
|
/// handler, step 2, after the drift check). No flake pre-flight either —
|
|
/// eval-verify happens at approval time too.
|
|
pub(crate) async fn submit_merge_config_pr(
|
|
coord: &Arc<Coordinator>,
|
|
agent: &str,
|
|
pr_number: u64,
|
|
description: Option<&str>,
|
|
submitter: &str,
|
|
) -> anyhow::Result<i64> {
|
|
let applied_dir = crate::paths::applied_dir(agent);
|
|
if !applied_dir.join(".git").exists() {
|
|
anyhow::bail!(
|
|
"applied repo missing for agent '{agent}' (expected at {}) — \
|
|
merge_config_pr requires the agent to be fully provisioned; \
|
|
spawn the agent first (operator spawn) before opening config PRs",
|
|
applied_dir.display()
|
|
);
|
|
}
|
|
let repo = crate::forge::config_repo(agent);
|
|
// Verify the PR is still open before queueing an approval that would
|
|
// fail at approve time anyway (a closed/merged PR has no live head ref
|
|
// for the drift gate to compare against).
|
|
if !crate::forge::pr_is_open(&repo, pr_number)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("check PR state for {agent} PR #{pr_number}: {e}"))?
|
|
{
|
|
anyhow::bail!(
|
|
"PR #{pr_number} on {repo} is closed or already merged — \
|
|
merge_config_pr requires an open PR"
|
|
);
|
|
}
|
|
// Fetch the current PR head sha — becomes the "reviewed" sha.
|
|
// Submitted together with the approval row (atomic single INSERT) so a
|
|
// crash mid-submit cannot leave a stranded sha-less row.
|
|
let sha = crate::forge::pr_head_sha(&repo, pr_number)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("fetch PR head sha for {agent} PR #{pr_number}: {e}"))?;
|
|
// Both the webhook (`synchronize`) and the poll fallback call this on
|
|
// every PR update. If an approval for this PR is already pending, reconcile
|
|
// it against the live head sha rather than blindly queuing another:
|
|
// - same sha → the PR hasn't moved, so this is a duplicate signal — no-op.
|
|
// - drifted sha → the reviewed head is stale. Don't mutate the
|
|
// pending row in place (that races a concurrent approve); cancel it and
|
|
// fall through to queue a FRESH approval pinned to the new head.
|
|
if let Some((old_id, old_sha)) = coord.approvals.pending_merge_config_pr(agent, pr_number)? {
|
|
if old_sha.as_deref() == Some(sha.as_str()) {
|
|
return Ok(old_id);
|
|
}
|
|
let cancelled = coord
|
|
.approvals
|
|
.mark_cancelled(old_id, "config PR updated — superseded by a fresh approval")
|
|
.map_err(|e| anyhow::anyhow!("cancel superseded merge_config_pr approval: {e:#}"))?;
|
|
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
|
id: old_id,
|
|
agent,
|
|
approval_kind: "merge_config_pr",
|
|
sha_short: old_sha.map(|s| s[..s.len().min(12)].to_owned()),
|
|
status: "cancelled",
|
|
note: Some("PR head moved; superseded by a fresh approval".to_owned()),
|
|
description: cancelled.description,
|
|
});
|
|
}
|
|
let id = coord
|
|
.approvals
|
|
.submit_kind(
|
|
agent,
|
|
hive_sh4re::approvals::ApprovalKind::MergeConfigPr,
|
|
&pr_number.to_string(),
|
|
description,
|
|
submitter,
|
|
Some(&sha), // atomic: sha inserted with the row, not in a separate UPDATE
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("queue merge_config_pr approval row: {e:#}"))?;
|
|
let sha_short = sha[..sha.len().min(12)].to_owned();
|
|
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
|
|
id,
|
|
agent,
|
|
approval_kind: "merge_config_pr",
|
|
sha_short: Some(sha_short),
|
|
description: description.map(str::to_owned),
|
|
pr_number: Some(pr_number),
|
|
});
|
|
Ok(id)
|
|
}
|
|
|
|
/// Queue an `InitConfig` approval for a brand-new agent whose config repo
|
|
/// does not yet exist. Shared between the manager and agent sockets.
|
|
///
|
|
/// `parent`, when `Some`, is the agent that will own the new child once
|
|
/// the operator approves: it is stashed in the approval's `commit_ref`
|
|
/// field (unused for `InitConfig` otherwise — same pattern
|
|
/// `UpdateMetaInputs` uses to carry its inputs JSON) and consumed in
|
|
/// `run_approval_init_config` to write the `child -> parent` topology
|
|
/// edge. Callers pass the requesting agent, so the requester becomes the
|
|
/// new agent's parent (the root requesting a new agent → a top-level agent,
|
|
/// matching `topology::reconcile`'s default). `None` writes no explicit
|
|
/// edge (reconcile-default placement) — retained for that fallback.
|
|
pub(crate) fn submit_init_config(
|
|
coord: &Arc<Coordinator>,
|
|
name: &str,
|
|
parent: Option<&str>,
|
|
description: Option<String>,
|
|
) -> anyhow::Result<i64> {
|
|
let agent = hive_types::Ident::parse(name)
|
|
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
|
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(&agent);
|
|
if proposed_dir.join(".git").exists() {
|
|
anyhow::bail!(
|
|
"proposed config repo for '{name}' already exists at {} - \
|
|
nothing to init; config changes go through a forge PR on \
|
|
agent-configs/{name}",
|
|
proposed_dir.display()
|
|
);
|
|
}
|
|
let id = coord
|
|
.approvals
|
|
.submit_kind(
|
|
name,
|
|
hive_sh4re::approvals::ApprovalKind::InitConfig,
|
|
parent.unwrap_or(""),
|
|
description.as_deref(),
|
|
// `parent` is the requesting agent (becomes the new child's
|
|
// parent); it's also the submitter the approval events route
|
|
// back to. No declared parent = operator-initiated path.
|
|
parent.unwrap_or("operator"),
|
|
None, // no sha for InitConfig
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
|
|
tracing::info!(%id, %name, "init_config approval queued");
|
|
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
|
|
id,
|
|
agent: name,
|
|
approval_kind: "init_config",
|
|
sha_short: None,
|
|
description,
|
|
pr_number: None,
|
|
});
|
|
Ok(id)
|
|
}
|