ask_operator tool: non-blocking; operator answer arrives as helper event

new mcp tool on the manager surface that queues a question on the
dashboard and returns the question id immediately. operator submits an
answer via /answer-question/<id>; the dashboard fires
HelperEvent::OperatorAnswered { id, question, answer } into the manager
inbox so the next turn picks it up.

also: fix async-form button stuck on spinner after successful submit
(refreshState skipped re-rendering, so the button was never re-enabled).
This commit is contained in:
müde 2026-05-15 18:44:42 +02:00
parent abfd2cce4b
commit 2770630f33
17 changed files with 426 additions and 79 deletions

View file

@ -50,6 +50,7 @@ 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("/request-spawn", post(post_request_spawn))
.route("/messages/stream", get(messages_stream))
.with_state(AppState { coord });
@ -75,7 +76,10 @@ async fn serve_index() -> impl IntoResponse {
}
async fn serve_css() -> impl IntoResponse {
([("content-type", "text/css")], include_str!("../assets/dashboard.css"))
(
[("content-type", "text/css")],
include_str!("../assets/dashboard.css"),
)
}
async fn serve_app_js() -> impl IntoResponse {
@ -97,6 +101,11 @@ struct StateSnapshot {
/// asynchronously so the operator can see them without watching the
/// live panel during a turn.
operator_inbox: Vec<crate::broker::InboxRow>,
/// Pending operator questions (currently only from the manager).
/// `ask_operator` returns immediately with the id; on `/answer-question`
/// we mark the row answered and fire `HelperEvent::OperatorAnswered`
/// into the manager's inbox.
questions: Vec<crate::operator_questions::OpQuestion>,
}
#[derive(Serialize)]
@ -131,10 +140,7 @@ struct ApprovalView {
diff_html: Option<String>,
}
async fn api_state(
headers: HeaderMap,
State(state): State<AppState>,
) -> axum::Json<StateSnapshot> {
async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::Json<StateSnapshot> {
let host = headers
.get("host")
.and_then(|h| h.to_str().ok())
@ -227,6 +233,7 @@ async fn api_state(
.broker
.recent_for(hive_sh4re::OPERATOR_RECIPIENT, 50)
.unwrap_or_default();
let questions = state.coord.questions.pending().unwrap_or_default();
axum::Json(StateSnapshot {
hostname,
@ -236,6 +243,7 @@ async fn api_state(
transients,
approvals: approval_views,
operator_inbox,
questions,
})
}
@ -271,6 +279,36 @@ struct RequestSpawnForm {
name: String,
}
#[derive(Deserialize)]
struct AnswerForm {
answer: String,
}
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 error_response("answer: required");
}
match state.coord.questions.answer(id, answer) {
Ok(question) => {
tracing::info!(%id, "operator answered question");
state
.coord
.notify_manager(&hive_sh4re::HelperEvent::OperatorAnswered {
id,
question,
answer: answer.to_owned(),
});
Redirect::to("/").into_response()
}
Err(e) => error_response(&format!("answer {id} failed: {e:#}")),
}
}
async fn post_request_spawn(
State(state): State<AppState>,
Form(form): Form<RequestSpawnForm>,
@ -325,7 +363,10 @@ async fn post_kill(State(state): State<AppState>, AxumPath(name): AxumPath<Strin
}
}
async fn post_restart(State(_state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
async fn post_restart(
State(_state): State<AppState>,
AxumPath(name): AxumPath<String>,
) -> Response {
let logical = strip_container_prefix(&name);
match lifecycle::restart(&logical).await {
Ok(()) => Redirect::to("/").into_response(),
@ -368,7 +409,10 @@ async fn post_update_all(State(state): State<AppState>) -> Response {
if errors.is_empty() {
Redirect::to("/").into_response()
} else {
error_response(&format!("update-all partial failure:\n{}", errors.join("\n")))
error_response(&format!(
"update-all partial failure:\n{}",
errors.join("\n")
))
}
}
@ -393,8 +437,6 @@ fn error_response(message: &str) -> Response {
(StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response()
}
/// Filter out approvals whose agent state dir was wiped out from under us
/// (e.g. by a test script's cleanup). Marks them failed so they fall out of
/// `pending` on next render.
@ -508,4 +550,3 @@ fn html_escape(s: &str) -> String {
.replace('<', "&lt;")
.replace('>', "&gt;")
}