refactor(#1456): extract dashboard question answer/cancel endpoints into dashboard/questions.rs

This commit is contained in:
damocles 2026-06-08 22:50:13 +02:00 committed by mara
commit aa6e422b78
2 changed files with 124 additions and 101 deletions

View file

@ -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<Coordinator>) -> 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<String>,
}
#[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<AppState>,
AxumPath(id): AxumPath<i64>,
Form(form): Form<AnswerForm>,
) -> 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<AppState>,
AxumPath(id): AxumPath<i64>,
) -> 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.

View file

@ -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<AppState>,
AxumPath(id): AxumPath<i64>,
Form(form): Form<AnswerForm>,
) -> 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<AppState>,
AxumPath(id): AxumPath<i64>,
) -> 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:#}")),
}
}