From 6cf66e23dc0c67fbadbf69e528d56d38322985cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 23:01:22 +0200 Subject: [PATCH] actions: deny plants annotated denied/ tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply-commit denials now leave a git object behind: tag denied/ annotated with the operator's note (or empty body if they didn't supply one) at proposal/ inside the applied repo. rejected configs become first-class git history — git show denied/ in the manager's applied.git mount yields the tree the operator rejected plus the reason. helper event carries the tag for parity with deployed/failed. spawn denials fall through unannotated since they have no proposal commit. deny becomes async (single git plumbing call); dashboard + admin-socket callers grow .await. --- hive-c0re/src/actions.rs | 34 ++++++++++++++++++++++++++++++++-- hive-c0re/src/dashboard.rs | 2 +- hive-c0re/src/server.rs | 2 +- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 6a20c91..078aeac 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -276,12 +276,42 @@ pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()> Ok(()) } -pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> { +pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> { let approval = coord.approvals.get(id)?; coord.approvals.mark_denied(id, note)?; tracing::info!(%id, note, "approval denied"); + let mut tag = None; if let Some(a) = approval { let sha = a.fetched_sha.clone(); + // ApplyCommit approvals leave a `denied/` tag on the + // proposal commit so rejected configs are first-class git + // objects — `git show denied/` in the manager's applied + // mount yields both the tree the operator rejected and (in + // the annotated body) the reason. Spawn approvals have no + // commit to tag, so they fall through unannotated. + if matches!(a.kind, ApprovalKind::ApplyCommit) { + let applied_dir = Coordinator::agent_applied_dir(&a.agent); + let proposal_ref = format!("refs/tags/proposal/{id}"); + if lifecycle::git_rev_parse(&applied_dir, &proposal_ref) + .await + .is_ok() + { + let tag_name = format!("denied/{id}"); + let body = note.unwrap_or("").to_owned(); + if let Err(e) = lifecycle::git_tag_annotated( + &applied_dir, + &tag_name, + &proposal_ref, + &body, + ) + .await + { + tracing::warn!(%id, error = ?e, "plant denied tag failed"); + } else { + tag = Some(tag_name); + } + } + } coord.notify_manager(&HelperEvent::ApprovalResolved { id: a.id, agent: a.agent, @@ -289,7 +319,7 @@ pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> { status: ApprovalStatus::Denied, note: note.map(String::from), sha, - tag: None, + tag, }); } Ok(()) diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 746dcbb..8942524 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -460,7 +460,7 @@ async fn post_deny( .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); - match actions::deny(&state.coord, id, note) { + match actions::deny(&state.coord, id, note).await { Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("deny {id} failed: {e:#}")), } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 40e76a1..69f5d72 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -146,7 +146,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { HostResponse::success() } HostRequest::Deny { id } => { - actions::deny(&coord, *id, None)?; + actions::deny(&coord, *id, None).await?; HostResponse::success() } })