use axum::extract::{Path, State}; use axum::Json; use cm_safety::{approvals, Approval, Decision, ResumeReady, SafetyError}; use serde_json::{json, Value}; use uuid::Uuid; use crate::{ApiError, AppState, Authed}; impl From for ApiError { fn from(err: SafetyError) -> Self { match err { SafetyError::NotFound => ApiError::NotFound, SafetyError::AlreadyDecided => ApiError::Conflict, _ => ApiError::Internal, } } } /// Loads an approval, hiding other workspaces' approvals entirely. async fn workspace_approval( state: &AppState, user: &cm_auth::AuthedUser, id: Uuid, ) -> Result { let approval = approvals::get(&state.pool, id).await?; if approval.workspace_id != user.workspace_id { return Err(ApiError::NotFound); } Ok(approval) } /// GET /api/approvals — the pending review queue (§10), oldest first. pub async fn list( State(state): State, Authed(user): Authed, ) -> Result>, ApiError> { let pending = approvals::list_pending(&state.pool, user.workspace_id).await?; Ok(Json(pending)) } /// GET /api/approvals/{id} pub async fn get( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { Ok(Json(workspace_approval(&state, &user, id).await?)) } async fn decide( state: AppState, user: cm_auth::AuthedUser, id: Uuid, decision: Decision, ) -> Result, ApiError> { workspace_approval(&state, &user, id).await?; let approval = approvals::decide(&state.pool, id, user.user_id, decision).await?; // Kick the resume before returning. resume_run's awaited portion is only // the setup (claim + checkpoint load + open the broadcast channel); it // spawns the actual multi-step work internally, so this doesn't block the // client on the run itself — but it does guarantee the channel exists by // the time the client reconnects via /api/gateway, closing the observable // race where subscribe(run_id) would return None. The sweeper remains the // durable fallback if this process dies before setup completes. let ready = ResumeReady { run_id: approval.run_id, approval_id: approval.id, approved: decision == Decision::Approve, }; let _ = state.runtime.resume_run(ready).await; Ok(Json( json!({ "id": approval.id, "status": approval.status }), )) } /// POST /api/approvals/{id}/approve — executes the gated action (§15: /// approve-only execution, idempotent, audited). pub async fn approve( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { decide(state, user, id, Decision::Approve).await } /// POST /api/approvals/{id}/reject — the agent learns the refusal in-band. pub async fn reject( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { decide(state, user, id, Decision::Reject).await }