refactor(#2416): remove the non-pr config-change flow (request_apply_commit / applycommit)

This commit is contained in:
damocles 2026-07-15 20:45:29 +02:00 committed by mara
commit c2bd7db998
34 changed files with 293 additions and 1635 deletions

View file

@ -1,17 +1,14 @@
//! 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`).
//! `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 this;
//! opening a config PR on `agent-configs/<agent>` is enough to trigger
//! hive-c0re's webhook-driven queue path.
//! (`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 anyhow::{Context, Result};
use hive_sh4re::AgentResponse;
use super::require_new_child;
@ -40,30 +37,6 @@ pub(super) fn handle_request_init_config(
}
}
/// `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).
@ -105,7 +78,6 @@ pub(super) fn handle_request_update_meta_inputs(
agent: requester,
approval_kind: "update_meta_inputs",
sha_short: None,
diff: None,
description: description.map(str::to_owned),
pr_number: None,
});
@ -118,10 +90,10 @@ pub(super) fn handle_request_update_meta_inputs(
///
/// 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.
/// 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,
@ -134,7 +106,7 @@ pub(crate) async fn submit_merge_config_pr(
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",
spawn the agent first (operator spawn) before opening config PRs",
applied_dir.display()
);
}
@ -199,32 +171,12 @@ pub(crate) async fn submit_merge_config_pr(
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.
///
@ -247,7 +199,8 @@ pub(crate) fn submit_init_config(
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",
nothing to init; config changes go through a forge PR on \
agent-configs/{name}",
proposed_dir.display()
);
}
@ -271,176 +224,8 @@ pub(crate) fn submit_init_config(
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());
}
}

View file

@ -29,9 +29,7 @@ pub(crate) use config_approvals::submit_merge_config_pr;
pub(crate) use schedules::filter_ghost_schedule_targets;
pub use schedules::schedule_to_wire_public;
use config_approvals::{
handle_request_apply_commit, handle_request_init_config, handle_request_update_meta_inputs,
};
use config_approvals::{handle_request_init_config, handle_request_update_meta_inputs};
use lifecycle_handlers::{
handle_kill, handle_list_descendants, handle_restart, handle_start, handle_update,
};
@ -565,20 +563,6 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
AgentRequest::RequestInitConfig { name, description } => {
handle_request_init_config(coord, agent, name, description.clone())
}
AgentRequest::RequestApplyCommit {
agent: target_agent,
commit_ref,
description,
} => {
handle_request_apply_commit(
coord,
agent,
target_agent,
commit_ref,
description.as_deref(),
)
.await
}
// Agent-state queries: own subtree is free; other agents + the
// hive-wide `"*"` sweep require `QueryAgentState`.
AgentRequest::GetLooseEnds { agent: target } => {
@ -722,9 +706,9 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option<AgentResponse
}
}
/// Topology guard for `request_init_config` / `request_apply_commit`,
/// which may legitimately target a child that does not exist *yet*
/// (spawning a brand-new sub-agent). The caller may act on a
/// Topology guard for `request_init_config`, which may legitimately target a
/// child that does not exist *yet* (seeding a brand-new sub-agent's config
/// repo). The caller may act on a
/// `target` that is EITHER already its direct child (re-init / config
/// update of an existing child) OR brand-new (absent from the topology
/// tree — the requester becomes its parent). A name that already

View file

@ -84,7 +84,6 @@ pub(super) fn handle_request_schedule_prompt(
agent: requester,
approval_kind: "schedule_prompt",
sha_short: None,
diff: None,
description: payload.description.clone(),
pr_number: None,
});