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:
parent
abfd2cce4b
commit
2770630f33
17 changed files with 426 additions and 79 deletions
|
|
@ -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('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue