//! 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/` 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, agent: &str, name: &str, description: Option, ) -> Response { 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) => Response::Ok, 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, 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, agent: &str, pr_number: u64, description: Option<&str>, submitter: &str, ) -> anyhow::Result { 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, name: &str, parent: Option<&str>, description: Option, ) -> anyhow::Result { 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) }