hive-c0re: remove the dashboard's ask/answer surface

This commit is contained in:
damocles 2026-08-29 23:20:17 +02:00
commit 47cac50e6e
8 changed files with 25 additions and 463 deletions

View file

@ -44,7 +44,6 @@ use crate::lifecycle;
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats, audit log"),
(name = "permissions", description = "tool-group + capability assignment for agents"),
(name = "questions", description = "answer/cancel pending operator questions"),
(name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"),
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
@ -71,7 +70,6 @@ mod matrix_accounts;
mod meta_inputs;
mod misc_api;
pub(crate) mod permissions;
mod questions;
mod schedules;
mod state_files;
mod state_snapshot;
@ -197,8 +195,6 @@ pub async fn serve(
.routes(routes!(lifecycle_ops::post_resource_limits))
.routes(routes!(lifecycle_ops::post_update_all))
.routes(routes!(infra_containers::post_infra_container))
.routes(routes!(questions::post_answer_question))
.routes(routes!(questions::post_cancel_question))
.routes(routes!(tombstones::post_purge_tombstone))
.routes(routes!(meta_inputs::post_meta_update))
.routes(routes!(build_logs::get_build_log_stream))
@ -442,8 +438,6 @@ mod router_build_probe {
.routes(routes!(lifecycle_ops::post_resource_limits))
.routes(routes!(lifecycle_ops::post_update_all))
.routes(routes!(infra_containers::post_infra_container))
.routes(routes!(questions::post_answer_question))
.routes(routes!(questions::post_cancel_question))
.routes(routes!(tombstones::post_purge_tombstone))
.routes(routes!(meta_inputs::post_meta_update))
.routes(routes!(build_logs::get_build_log_stream))

View file

@ -1,149 +0,0 @@
//! 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 utoipa::ToSchema;
use problem_details::ProblemDetails;
use super::{AppState, error_response};
#[derive(Deserialize, ToSchema)]
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
}
/// Record the operator's answer and
/// notify the asker.
#[utoipa::path(
post,
path = "/api/answer-question/{id}",
params(("id" = i64, Path, description = "question row id")),
request_body(content = AnswerForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "answered", body = String),
(status = 400, description = "empty answer"),
(status = 500, description = "answer failed (already answered, unknown id, ...)"),
),
tag = "questions"
)]
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::manager::OPERATOR_RECIPIENT)
{
Ok((question, asker, target)) => {
tracing::info!(%id, %asker, "operator answered question");
state.coord.notify_agent(
&asker,
&hive_sh4re::manager::HelperEvent::QuestionAnswered {
id,
question,
answer: answer.to_owned(),
answerer: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(),
},
);
state.coord.emit_question_resolved(
id,
answer,
hive_sh4re::manager::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 question with the
/// `[cancelled]` sentinel answer.
///
/// Used 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.
#[utoipa::path(
post,
path = "/api/cancel-question/{id}",
params(("id" = i64, Path, description = "question row id")),
responses(
(status = 200, description = "cancelled", body = String),
(status = 500, description = "cancel failed (already answered, unknown id, ...)"),
),
tag = "questions"
)]
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::manager::OPERATOR_RECIPIENT)
{
Ok((question, asker, target)) => {
tracing::info!(%id, %asker, "operator cancelled question");
state.coord.emit_question_resolved(
id,
SENTINEL,
hive_sh4re::manager::OPERATOR_RECIPIENT,
true,
target.as_deref(),
);
state.coord.notify_agent_from(
hive_sh4re::manager::OPERATOR_RECIPIENT,
&asker,
&hive_sh4re::manager::HelperEvent::QuestionAnswered {
id,
question,
answer: SENTINEL.to_owned(),
answerer: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(),
},
);
(StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")),
}
}

View file

@ -52,15 +52,6 @@ pub(super) struct StateSnapshot {
/// Last 30 resolved approvals (approved / denied / failed), newest-
/// first. Drives the "history" tab on the approvals section.
approval_history: Vec<ApprovalHistoryView>,
/// Pending operator-targeted questions (`target IS NULL`). Any
/// agent can `ask` the operator and `ask` returns immediately with
/// the id; on `/answer-question` we mark the row answered and
/// fire `HelperEvent::QuestionAnswered` back into the asker's
/// inbox. Peer-to-peer questions live in the same table but never
/// surface here (see `OperatorQuestions::pending`).
questions: Vec<QuestionView>,
/// Last 20 answered questions, newest-first.
question_history: Vec<QuestionView>,
/// State dirs (config history + claude creds + /state/ notes) that
/// survive after a destroy-without-purge. The operator can re-spawn
/// with the same name to resume, or PURG3 to wipe them.
@ -155,35 +146,6 @@ async fn infra_container_views() -> Vec<InfraContainerView> {
infra_containers
}
/// `OpQuestion` + computed `question_refs` / `answer_refs`. Built
/// from the snapshot read; the live channel attaches the same
/// fields directly on `QuestionAdded` / `QuestionResolved`.
#[derive(Serialize)]
struct QuestionView {
#[serde(flatten)]
inner: crate::operator_questions::OpQuestion,
#[serde(skip_serializing_if = "Vec::is_empty")]
question_refs: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
answer_refs: Vec<String>,
}
impl QuestionView {
fn from_question(q: crate::operator_questions::OpQuestion) -> Self {
let question_refs = scan_validated_paths(&q.question);
let answer_refs = q
.answer
.as_deref()
.map(scan_validated_paths)
.unwrap_or_default();
Self {
inner: q,
question_refs,
answer_refs,
}
}
}
#[derive(Serialize)]
struct PortConflict {
port: u16,
@ -275,12 +237,12 @@ const CRASH_WARNING_WINDOW: std::time::Duration = std::time::Duration::from_mins
/// Cold-load snapshot of the whole dashboard.
///
/// Includes the roster, approvals (+ history), questions (+ history),
/// Includes the roster, approvals (+ history),
/// tombstones, job queue, meta inputs, and more. Live clients then
/// follow `/api/dashboard/stream` (SSE) for incremental updates keyed
/// off `seq`.
// `StateSnapshot` is a large tree of nested view types (`ContainerView`,
// `ApprovalView`, `QuestionView`, ...) with no `ToSchema` anywhere in that
// `ApprovalView`, ...) with no `ToSchema` anywhere in that
// graph; wiring it up is a schema-modelling project of its own, well past
// "annotate what's reachable". `serde_json::Value` placeholder for now —
// see the batch report.
@ -354,23 +316,6 @@ pub(super) async fn api_state(
let tombstones = build_tombstone_views(&state.coord, &containers);
let port_conflicts = build_port_conflicts(&containers);
// Both operator-targeted and peer threads surface on the dashboard
// (the client filters by target). Each row is wrapped in QuestionView
// so the snapshot carries the same file_refs the live event variants
// attach.
let questions: Vec<QuestionView> =
log_default("questions.pending_all", state.coord.questions.pending_all())
.into_iter()
.map(QuestionView::from_question)
.collect();
let question_history: Vec<QuestionView> = log_default(
"questions.recent_answered_all",
state.coord.questions.recent_answered_all(20),
)
.into_iter()
.map(QuestionView::from_question)
.collect();
// Banner warnings: host probes (disk) + agent-state (pending logins,
// crashing agents). Built before the response struct because the
// agent-state producer borrows `containers`, which moves in below.
@ -396,8 +341,6 @@ pub(super) async fn api_state(
approval_history,
meta_inputs: read_meta_inputs(),
meta_update_running: state.coord.meta_update_in_progress(),
questions,
question_history,
tombstones,
port_conflicts,
forge_present: crate::forge::is_present().await,