121 lines
4.1 KiB
Rust
121 lines
4.1 KiB
Rust
//! 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<AppState>,
|
|
AxumPath(id): AxumPath<i64>,
|
|
Form(form): Form<AnswerForm>,
|
|
) -> 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<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:#}")),
|
|
}
|
|
}
|