hive-c0re: remove the dashboard's ask/answer surface
This commit is contained in:
parent
a295fba09c
commit
47cac50e6e
8 changed files with 25 additions and 463 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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:#}")),
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue