//! 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, agent: &str, name: &str, description: Option, ) -> 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, 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, 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/` 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, name: &str, parent: Option<&str>, description: Option, ) -> anyhow::Result { 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/`. 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, 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/ 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, ¬e); 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 // `: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, ¬e); 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, ¬e); 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/ 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()); } }