//! Approval endpoints for the dashboard. //! //! Approve/deny actions plus the orphan-approval GC sweep used by the //! `/api/state` builder. use axum::{ extract::{Form, Path as AxumPath, State}, http::StatusCode, response::{IntoResponse, Response}, }; use hive_sh4re::approvals::Approval; use serde::Deserialize; use utoipa::ToSchema; use super::{AppState, error_response}; use crate::actions; use crate::coordinator::Coordinator; /// Approve a pending approval row. #[utoipa::path( post, path = "/api/approve/{id}", params(("id" = i64, Path, description = "approval row id")), responses( (status = 200, description = "approved", body = String), (status = 500, description = "approve failed"), ), tag = "approvals" )] pub(super) async fn post_approve( State(state): State, AxumPath(id): AxumPath, ) -> Response { match actions::approve(state.coord.clone(), id).await { // 200 instead of 303 — `actions::approve` fires // `ApprovalResolved` (success path) or the eventual failure // event, both of which the dashboard's derived store applies // live. The matching form carries `data-no-refresh`. Ok(()) => (StatusCode::OK, "ok").into_response(), Err(e) => error_response(&format!("approve {id} failed: {e:#}")), } } #[derive(Deserialize, Default, ToSchema)] pub(super) struct DenyForm { #[serde(default)] note: Option, } /// Deny a pending approval row, with an optional /// note (form field `note`). #[utoipa::path( post, path = "/api/deny/{id}", params(("id" = i64, Path, description = "approval row id")), request_body(content = DenyForm, content_type = "application/x-www-form-urlencoded"), responses( (status = 200, description = "denied", body = String), (status = 500, description = "deny failed"), ), tag = "approvals" )] pub(super) async fn post_deny( State(state): State, AxumPath(id): AxumPath, Form(form): Form, ) -> Response { let note = form .note .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); match actions::deny(&state.coord, id, note) { Ok(()) => (StatusCode::OK, "ok").into_response(), Err(e) => error_response(&format!("deny {id} failed: {e:#}")), } } /// Filter out approvals whose agent state dir was wiped out from under us /// (e.g. by a test script's cleanup). Marks them failed so they fall out of /// `pending` on next render. pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec { approvals .into_iter() .filter(|a| { // Spawn and InitConfig approvals are for not-yet-existent agents; // the proposed dir is supposed to be missing. if matches!( a.kind, hive_sh4re::approvals::ApprovalKind::Spawn | hive_sh4re::approvals::ApprovalKind::InitConfig ) { return true; } if Coordinator::agent_proposed_dir(&a.agent).exists() { true } else { let note = "agent state dir missing"; let _ = coord.approvals.mark_failed(a.id, note); tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval"); let sha_short = a .fetched_sha .as_deref() .map(|s| s[..s.len().min(12)].to_owned()); coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { id: a.id, agent: a.agent.as_str(), approval_kind: a.kind.as_str(), sha_short, status: "failed", note: Some(note.to_owned()), description: a.description.clone(), }); false } }) .collect() }