hyperhive/hive-c0re/src/socket_server/config_approvals.rs

343 lines
13 KiB
Rust

//! Config-approval request handlers: `RequestInitConfig` /
//! `RequestApplyCommit` / `RequestUpdateMetaInputs`, plus the shared
//! submit helpers (`submit_init_config` / `submit_apply_commit`) and the
//! commit-sha shape check (`validate_commit_ref`).
use std::sync::Arc;
use anyhow::{Context, Result};
use hive_sh4re::AgentResponse;
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>,
) -> AgentResponse {
if let Some(err) = require_new_child(agent, name, "request_init_config for") {
return err;
}
tracing::info!(%agent, %name, "request_init_config");
match submit_init_config(coord, name, Some(agent), description) {
Ok(_id) => AgentResponse::Ok,
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
}
}
/// `RequestApplyCommit` — queue an apply-commit approval for an agent. The
/// target must be in the caller's subtree (the root covers every agent).
pub(super) async fn handle_request_apply_commit(
coord: &Arc<Coordinator>,
agent: &str,
target_agent: &str,
commit_ref: &str,
description: Option<&str>,
) -> AgentResponse {
if let Some(err) = require_new_child(agent, target_agent, "request_apply_commit for") {
return err;
}
tracing::info!(%agent, %target_agent, %commit_ref, "request_apply_commit");
match submit_apply_commit(coord, target_agent, commit_ref, description, agent).await {
Ok((id, sha)) => {
tracing::info!(%id, %target_agent, %sha, "apply_commit approval queued");
AgentResponse::Ok
}
Err(e) => AgentResponse::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>,
) -> AgentResponse {
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::ApprovalKind::UpdateMetaInputs,
&commit_ref,
description,
requester,
)
.map_err(|e| anyhow::anyhow!("{e:#}"))
{
Ok(id) => id,
Err(e) => {
return AgentResponse::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,
diff: None,
description: description.map(str::to_owned),
pr_number: None,
});
AgentResponse::Ok
}
/// `request_apply_commit` takes a commit SHA only — not a branch or
/// tag name. A branch is mutable; pinning the proposal to a concrete
/// sha keeps "what the manager asked to deploy" unambiguous and means
/// the `proposal/<id>` tag is a faithful record of the request.
/// Accepts a 7..=40 char hex string (short or full sha); the exact
/// commit is resolved + existence-checked against the proposed repo
/// later in `lifecycle::git_fetch_to_tag`.
pub(crate) fn validate_commit_ref(commit_ref: &str) -> Result<()> {
let n = commit_ref.len();
let hex = commit_ref.chars().all(|c| c.is_ascii_hexdigit());
if !(7..=40).contains(&n) || !hex {
anyhow::bail!(
"commit_ref '{commit_ref}' is not a commit sha — request_apply_commit \
takes a 7-40 char hex sha, not a branch or tag name"
);
}
Ok(())
}
/// 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 proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(name);
if proposed_dir.join(".git").exists() {
anyhow::bail!(
"proposed config repo for '{name}' already exists at {} - \
use request_apply_commit to update an existing agent's config",
proposed_dir.display()
);
}
let id = coord
.approvals
.submit_kind(
name,
hive_sh4re::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"),
)
.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,
diff: None,
description,
pr_number: None,
});
Ok(id)
}
/// Submit-time half of the apply flow: queue the approval row, then
/// fetch the manager's commit from the proposed repo into applied and
/// pin it as `refs/tags/proposal/<id>`. From this point on the manager
/// repo is irrelevant for this approval — even if the manager amends
/// or force-pushes, the canonical sha hive-c0re will eventually
/// approve/deny lives in applied's object DB.
///
/// If anything fails after the row is inserted (sha missing in
/// proposed, fs error, git plumbing crash) we mark the row failed and
/// surface the error to the manager. We don't try to roll the row
/// back — the failure is part of the audit trail.
pub(crate) async fn submit_apply_commit(
coord: &Arc<Coordinator>,
agent: &str,
commit_ref: &str,
description: Option<&str>,
submitter: &str,
) -> anyhow::Result<(i64, String)> {
validate_commit_ref(commit_ref)?;
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(agent);
let applied_dir = crate::paths::applied_dir(agent);
if !proposed_dir.exists() {
anyhow::bail!(
"proposed repo missing for agent '{agent}' (expected at {})",
proposed_dir.display()
);
}
if !applied_dir.join(".git").exists() {
// First deploy: seed the applied repo from proposed so we can plant
// the proposal/<id> tag below. setup_applied seeds at the root
// (template) commit of proposed, not at main, so deployed/0 is the
// template baseline. This makes the diff mara sees on approval
// show the manager's actual changes rather than an empty diff.
crate::lifecycle::setup_applied(&applied_dir, Some(&proposed_dir), agent)
.await
.context("seed applied repo for first spawn")?;
}
let id = coord
.approvals
.submit_kind(
agent,
hive_sh4re::ApprovalKind::ApplyCommit,
commit_ref,
description,
submitter,
)
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
let tag = format!("proposal/{id}");
let sha =
match crate::lifecycle::git_fetch_to_tag(&applied_dir, &proposed_dir, commit_ref, &tag)
.await
{
Ok(s) => s,
Err(e) => {
// Surface the failure on the approval row so the
// dashboard reflects it instead of leaving a phantom
// pending entry. The note doubles as the operator-visible
// explanation of why the approval can't be approved.
let note = format!("{e:#}");
let _ = coord.approvals.mark_failed(id, &note);
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id,
agent,
approval_kind: "apply_commit",
sha_short: None,
status: "failed",
note: Some(note),
description: description.map(str::to_owned),
});
return Err(anyhow::anyhow!("git_fetch_to_tag: {e:#}"));
}
};
coord
.approvals
.set_fetched_sha(id, &sha)
.map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?;
// Pre-flight gates: both reject the apply before approval if
// the agent's flake state would inflate meta's lock with duplicates
// or lie about what nix will fetch. Both checks independently read
// `<tag>:flake.lock` via git — they don't share state. Order matters
// only for early-exit + messaging: sync first means a stale lock
// bails with the actionable "run `nix flake lock`" hint rather than
// a dedup pass on a lock nix would never produce.
//
// Runs after `set_fetched_sha` so the failed row carries the sha
// that broke. Both failure paths mark + emit, then bail.
let sha_short = sha[..sha.len().min(12)].to_owned();
if let Err(e) = crate::flake_check::check_lock_in_sync(&applied_dir, &tag, id).await {
let note = format!("{e:#}");
let _ = coord.approvals.mark_failed(id, &note);
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id,
agent,
approval_kind: "apply_commit",
sha_short: Some(sha_short.clone()),
status: "failed",
note: Some(note),
description: description.map(str::to_owned),
});
return Err(anyhow::anyhow!("flake lock-sync check: {e:#}"));
}
if let Err(e) = crate::flake_check::check_no_duplicate_inputs(&applied_dir, &tag).await {
let note = format!("{e:#}");
let _ = coord.approvals.mark_failed(id, &note);
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id,
agent,
approval_kind: "apply_commit",
sha_short: Some(sha_short.clone()),
status: "failed",
note: Some(note),
description: description.map(str::to_owned),
});
return Err(anyhow::anyhow!("flake dedup check: {e:#}"));
}
// Mirror the freshly-planted proposal/<id> tag to the forge.
if let Err(e) = crate::forge::push_config(agent).await {
tracing::warn!(%agent, %id, error = ?e, "forge: push_config after submit failed");
}
// Phase 5b: surface the new pending approval on the dashboard
// event channel. Compute the diff once here so live subscribers
// get a fully-formed row without a snapshot refetch. `sha_short`
// is reused from the dedup gate above.
let diff = crate::dashboard::approval_diff(agent, id).await;
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
agent,
approval_kind: "apply_commit",
sha_short: Some(sha_short),
diff: Some(diff),
description: description.map(str::to_owned),
pr_number: None,
});
Ok((id, sha))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_short_and_full_sha() {
assert!(validate_commit_ref("e194f78").is_ok());
assert!(validate_commit_ref("e194f7812ab").is_ok());
assert!(validate_commit_ref(&"a".repeat(40)).is_ok());
// Uppercase hex resolves fine through `git rev-parse`.
assert!(validate_commit_ref("E194F78").is_ok());
}
#[test]
fn rejects_branch_and_tag_names() {
// The exact bug class this guard exists for.
assert!(validate_commit_ref("main").is_err());
assert!(validate_commit_ref("HEAD").is_err());
assert!(validate_commit_ref("deployed/0").is_err());
assert!(validate_commit_ref("feature-branch").is_err());
}
#[test]
fn rejects_too_short_too_long_and_empty() {
assert!(validate_commit_ref("").is_err());
assert!(validate_commit_ref("abc123").is_err()); // 6 chars
assert!(validate_commit_ref(&"a".repeat(41)).is_err());
}
}