//! Operator question answer/cancel endpoints for the dashboard. //! //! `POST /answer-question/{id}` records the operator's answer and fires a //! `QuestionAnswered` event to the asker; `POST /cancel-question/{id}` //! resolves a pending question with a `[cancelled]` sentinel. Both carry a //! permissive CORS header so the per-agent web UI (different origin) can //! POST here until the unifying gateway makes it same-origin. use axum::{ extract::{Form, Path as AxumPath, State}, http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; use problem_details::ProblemDetails; use super::{AppState, error_response}; #[derive(Deserialize)] pub(super) struct AnswerForm { answer: String, } /// Attach a permissive CORS header so the per-agent web UI — served on /// a different port — can POST an operator answer here and read the /// result. The dashboard has no auth, so `*` exposes nothing a plain /// cross-origin form-POST couldn't already reach. This shim disappears /// once the unifying gateway makes the agent page same-origin; see /// `docs/boundary.md`. fn with_cors(resp: impl IntoResponse) -> Response { let mut resp = resp.into_response(); resp.headers_mut().insert( axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, axum::http::HeaderValue::from_static("*"), ); resp } pub(super) async fn post_answer_question( State(state): State, AxumPath(id): AxumPath, Form(form): Form, ) -> Response { let answer = form.answer.trim(); if answer.is_empty() { return with_cors( ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) .with_detail("answer: required"), ); } let resp = match state .coord .questions .answer(id, answer, hive_sh4re::OPERATOR_RECIPIENT) { Ok((question, asker, target)) => { tracing::info!(%id, %asker, "operator answered question"); state.coord.notify_agent( &asker, &hive_sh4re::HelperEvent::QuestionAnswered { id, question, answer: answer.to_owned(), answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), }, ); state.coord.emit_question_resolved( id, answer, hive_sh4re::OPERATOR_RECIPIENT, false, target.as_deref(), ); (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("answer {id} failed: {e:#}")), }; with_cors(resp) } /// Resolve a pending operator question with a sentinel answer when /// the operator decides not to / can't answer. The asker harness /// receives a `QuestionAnswered` event with `answer = "[cancelled]"` /// so it can fall back on whatever default it had. Same code path as /// a real answer — just lets the operator close the loop instead of /// letting the question dangle forever. pub(super) async fn post_cancel_question( State(state): State, AxumPath(id): AxumPath, ) -> Response { const SENTINEL: &str = "[cancelled]"; match state .coord .questions .answer(id, SENTINEL, hive_sh4re::OPERATOR_RECIPIENT) { Ok((question, asker, target)) => { tracing::info!(%id, %asker, "operator cancelled question"); state.coord.emit_question_resolved( id, SENTINEL, hive_sh4re::OPERATOR_RECIPIENT, true, target.as_deref(), ); state.coord.notify_agent_from( hive_sh4re::OPERATOR_RECIPIENT, &asker, &hive_sh4re::HelperEvent::QuestionAnswered { id, question, answer: SENTINEL.to_owned(), answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), }, ); (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")), } }