hyperhive/hive-c0re/src/socket_server/config_approvals.rs
atlas d5a81f9195 feat(#2377): forge-webhook-triggered config-PR merge flow
Replace the request_merge_config_pr MCP tool with a Forgejo
pull_request webhook on the agent-configs org. Agents now open a
config PR normally; hive-c0re auto-queues the MergeConfigPr approval
from the webhook event — no extra tool call needed.

Changes:
- dashboard/webhook.rs: add POST /webhook/config-pr handler
  - parses Forgejo pull_request payload (opened/synchronize)
  - strips agent-configs/<agent> prefix to extract agent name
  - calls submit_merge_config_pr → queues dashboard approval card
  - always 200 to prevent Forgejo retries; errors logged at warn
- dashboard/mod.rs: wire /webhook/config-pr route
- forge/mod.rs: add ensure_config_pr_webhook() — idempotent org-level
  hook registration on agent-configs at startup; CONFIG_ORG now
  pub(crate) for webhook handler
- main.rs: call ensure_config_pr_webhook alongside knowledge webhook
- socket_server/config_approvals.rs: drop handle_request_merge_config_pr;
  make submit_merge_config_pr pub(crate) for webhook handler
- socket_server/mod.rs: re-export submit_merge_config_pr; drop dispatch arm
- hive-sh4re/src/lib.rs: remove AgentRequest::RequestMergeConfigPr
  wire type; drop from ToolGroup::Approvals tool list
- hive-ag3nt/src/mcp/: drop request_merge_config_pr tool + args struct
- docs: update approvals.md (webhook trigger), conventions.md (tool
  group), agent-hierarchy.md, tools/lifecycle.md

Hardening from #2375-merge-config-pr-hardening branch preserved:
- pr_is_open check at queue time (rejects closed/merged PRs)
- atomic fetched_sha INSERT via submit_kind(fetched_sha: Some(&sha))

Approve-handler machinery unchanged (run_merge_config_pr,
ff_push_to_main, fetch_pr_head_into_applied, mark_pr_merged).
2026-07-11 12:19:52 +02:00

421 lines
17 KiB
Rust

//! Config-approval request handlers: `RequestInitConfig` /
//! `RequestApplyCommit` / `RequestUpdateMetaInputs`,
//! plus the shared submit helpers (`submit_init_config` / `submit_apply_commit`
//! / `submit_merge_config_pr`) and the commit-sha shape check
//! (`validate_commit_ref`).
//!
//! `submit_merge_config_pr` is called from the dashboard webhook handler
//! (`dashboard::webhook`) — agents no longer need an MCP tool for this;
//! opening a config PR on `agent-configs/<agent>` is enough to trigger
//! hive-c0re's webhook-driven queue path.
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,
None,
)
.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
}
/// 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 approve handler (`run_merge_config_pr`) drift-gates
/// against before doing anything irreversible. Unlike `submit_apply_commit`
/// 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; \
use request_apply_commit for the first config deploy",
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 between submit and set_fetched_sha cannot leave a stranded 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}"))?;
let id = coord
.approvals
.submit_kind(
agent,
hive_sh4re::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),
diff: None, // diff is not pre-computed; the dashboard fetches it on demand
description: description.map(str::to_owned),
pr_number: Some(pr_number),
});
Ok(id)
}
/// `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"),
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,
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,
None, // sha resolved after git_fetch_to_tag below; set via set_fetched_sha
)
.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());
}
}