From aa6e422b78bbf45cd74293c3990132471cff8501 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 22:50:13 +0200 Subject: [PATCH] refactor(#1456): extract dashboard question answer/cancel endpoints into dashboard/questions.rs --- hive-c0re/src/dashboard.rs | 110 +++---------------------- hive-c0re/src/dashboard/questions.rs | 115 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 101 deletions(-) create mode 100644 hive-c0re/src/dashboard/questions.rs diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 51deb00c..4bef6606 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -32,6 +32,7 @@ use crate::lifecycle::{self, MANAGER_NAME}; mod journal; mod permissions; +mod questions; mod reminders; mod schedules; mod webhook; @@ -65,8 +66,14 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/start/{name}", post(post_start)) .route("/rebuild/{name}", post(post_rebuild)) .route("/update-all", post(post_update_all)) - .route("/answer-question/{id}", post(post_answer_question)) - .route("/cancel-question/{id}", post(post_cancel_question)) + .route( + "/answer-question/{id}", + post(questions::post_answer_question), + ) + .route( + "/cancel-question/{id}", + post(questions::post_cancel_question), + ) .route("/purge-tombstone/{name}", post(post_purge_tombstone)) .route("/api/journal/{name}", get(journal::get_journal)) .route("/api/journal-host", get(journal::get_journal_host)) @@ -1100,105 +1107,6 @@ struct SetParentBulkEntry { new_parent: Option, } -#[derive(Deserialize)] -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(mut resp: Response) -> Response { - resp.headers_mut().insert( - axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, - axum::http::HeaderValue::from_static("*"), - ); - resp -} - -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(error_response("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. -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:#}")), - } -} - #[derive(Deserialize)] struct BuildLogsAllQuery { /// Max rows to return. Capped at 100. Default 30. diff --git a/hive-c0re/src/dashboard/questions.rs b/hive-c0re/src/dashboard/questions.rs new file mode 100644 index 00000000..5d6530e0 --- /dev/null +++ b/hive-c0re/src/dashboard/questions.rs @@ -0,0 +1,115 @@ +//! 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 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(mut resp: Response) -> 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(error_response("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:#}")), + } +}