From 47cac50e6ed40bc7597172bf8971921272163bad Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 29 Aug 2026 23:20:17 +0200 Subject: [PATCH 1/2] hive-c0re: remove the dashboard's ask/answer surface --- hive-c0re/src/coordinator.rs | 78 ----------- hive-c0re/src/dashboard/mod.rs | 6 - hive-c0re/src/dashboard/questions.rs | 149 --------------------- hive-c0re/src/dashboard/state_snapshot.rs | 61 +-------- hive-c0re/src/dashboard_events.rs | 62 --------- hive-c0re/src/questions.rs | 38 ++---- hive-c0re/src/socket_server/mod.rs | 3 +- hive-c0re/src/stores/operator_questions.rs | 91 ++----------- 8 files changed, 25 insertions(+), 463 deletions(-) delete mode 100644 hive-c0re/src/dashboard/questions.rs diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index d2823532..f72beebe 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -445,19 +445,6 @@ pub struct ApprovalAdded<'a> { pub pr_number: Option, } -/// Field-named payload for [`Coordinator::emit_question_added`]. -/// Mirrors the `QuestionAdded` dashboard-event fields; all references -/// share the caller's lifetime. -pub struct QuestionAdded<'a> { - pub id: i64, - pub asker: &'a str, - pub question: &'a str, - pub options: &'a [String], - pub multi: bool, - pub deadline_at: Option, - pub target: Option<&'a str>, -} - impl Coordinator { pub fn open( db_path: &Path, @@ -839,71 +826,6 @@ impl Coordinator { }); } - /// Emit `QuestionAdded` after a question is inserted. Fires for - /// both operator-targeted (`target = None`) and peer-to-peer - /// (`target = Some(agent)`) threads — the dashboard surfaces - /// both, distinguishing visually + offering operator override. - pub fn emit_question_added(&self, ev: &QuestionAdded<'_>) { - let &QuestionAdded { - id, - asker, - question, - options, - multi, - deadline_at, - target, - } = ev; - let asked_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok() - .and_then(|d| i64::try_from(d.as_secs()).ok()) - .unwrap_or(0); - let question_refs = crate::dashboard::scan_validated_paths(question); - self.emit_dashboard_event(DashboardEvent::QuestionAdded { - seq: self.next_seq(), - id, - asker: asker.to_owned(), - question: question.to_owned(), - options: options.to_vec(), - multi, - asked_at: hive_sh4re::wire_time::from_secs(asked_at), - deadline_at: deadline_at.map(hive_sh4re::wire_time::from_secs), - target: target.map(str::to_owned), - question_refs, - }); - } - - /// Emit `QuestionResolved` when a question transitions to - /// answered (operator answer, peer answer, operator override on - /// a peer thread, operator cancel, or ttl watchdog). Both - /// operator-targeted and peer threads fire so the dashboard's - /// derived store can move the row from pending to history. - pub fn emit_question_resolved( - &self, - id: i64, - answer: &str, - answerer: &str, - cancelled: bool, - target: Option<&str>, - ) { - let answered_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok() - .and_then(|d| i64::try_from(d.as_secs()).ok()) - .unwrap_or(0); - let answer_refs = crate::dashboard::scan_validated_paths(answer); - self.emit_dashboard_event(DashboardEvent::QuestionResolved { - seq: self.next_seq(), - id, - answer: answer.to_owned(), - answerer: answerer.to_owned(), - answered_at: hive_sh4re::wire_time::from_secs(answered_at), - cancelled, - target: target.map(str::to_owned), - answer_refs, - }); - } - /// Rebuild the per-container snapshot, diff it against the last /// one cached on `self`, and emit one /// `DashboardEvent::ContainerStateChanged` per added/changed row diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index 1a820d13..c95b36fd 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -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)) diff --git a/hive-c0re/src/dashboard/questions.rs b/hive-c0re/src/dashboard/questions.rs deleted file mode 100644 index c56417cf..00000000 --- a/hive-c0re/src/dashboard/questions.rs +++ /dev/null @@ -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, - AxumPath(id): AxumPath, - Form(form): Form, -) -> 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, - AxumPath(id): AxumPath, -) -> 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:#}")), - } -} diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 2258608a..6e75ce50 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -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, - /// 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, - /// Last 20 answered questions, newest-first. - question_history: Vec, /// 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 { 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, - #[serde(skip_serializing_if = "Vec::is_empty")] - answer_refs: Vec, -} - -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 = - log_default("questions.pending_all", state.coord.questions.pending_all()) - .into_iter() - .map(QuestionView::from_question) - .collect(); - let question_history: Vec = 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, diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 04a6686f..b2112f01 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -97,44 +97,6 @@ pub enum DashboardEvent { note: Option, description: Option, }, - /// A question landed in the queue. `target = None` means - /// operator-targeted (`Ask { to: None | Some("operator") }`); - /// `target = Some()` means a peer-to-peer question. Both - /// are surfaced on the dashboard so the operator can monitor / - /// override-answer stuck threads. - QuestionAdded { - seq: u64, - id: i64, - asker: String, - question: String, - options: Vec, - multi: bool, - asked_at: DateTime, - deadline_at: Option>, - target: Option, - /// Verified file-path tokens that appear in `question`. - /// Same shape as broker `Sent`/`Delivered` events; the - /// client linkifies only what hive-c0re vouched for. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - question_refs: Vec, - }, - /// A question was answered (operator answer, peer answer, - /// operator override on a peer thread, or ttl watchdog - /// `[expired]`). Clients move the row from pending to history. - /// `cancelled = true` when the operator dismissed via the cancel - /// button. - QuestionResolved { - seq: u64, - id: i64, - answer: String, - answerer: String, - answered_at: DateTime, - cancelled: bool, - target: Option, - /// Verified file-path tokens that appear in `answer`. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - answer_refs: Vec, - }, /// A lifecycle action started for an agent (spawn / start / stop /// / restart / rebuild / destroy). Clients render a spinner next /// to the row; the client computes "seconds in this state" @@ -297,8 +259,6 @@ impl DashboardEvent { DashboardEvent::Delivered { .. } => "delivered", DashboardEvent::ApprovalAdded { .. } => "approval_added", DashboardEvent::ApprovalResolved { .. } => "approval_resolved", - DashboardEvent::QuestionAdded { .. } => "question_added", - DashboardEvent::QuestionResolved { .. } => "question_resolved", DashboardEvent::TransientSet { .. } => "transient_set", DashboardEvent::TransientCleared { .. } => "transient_cleared", DashboardEvent::ContainerStateChanged { .. } => "container_state_changed", @@ -377,28 +337,6 @@ mod tests { note: None, description: None, }, - DashboardEvent::QuestionAdded { - seq: 1, - id: 1, - asker: "a".into(), - question: String::new(), - options: Vec::new(), - multi: false, - asked_at: hive_sh4re::wire_time::from_secs(0), - deadline_at: None, - target: None, - question_refs: Vec::new(), - }, - DashboardEvent::QuestionResolved { - seq: 1, - id: 1, - answer: String::new(), - answerer: "a".into(), - answered_at: hive_sh4re::wire_time::from_secs(0), - cancelled: false, - target: None, - answer_refs: Vec::new(), - }, DashboardEvent::TransientSet { seq: 1, name: "x".into(), diff --git a/hive-c0re/src/questions.rs b/hive-c0re/src/questions.rs index 0977f4c1..fa745270 100644 --- a/hive-c0re/src/questions.rs +++ b/hive-c0re/src/questions.rs @@ -5,9 +5,17 @@ //! //! Routing rules at a glance: //! -//! - `Ask { to: None | Some("operator") }` → stored with `target = NULL`; -//! the dashboard's `pending()` query surfaces it; operator answers -//! via the dashboard. +//! - `Ask { to: None | Some("operator") }` → stored with `target = NULL`. +//! ⚠️ As of the ask/answer removal's dashboard-backend slice, nothing +//! surfaces or answers an operator-targeted row any more — the +//! dashboard's questions pane, its `/api/answer-question` / +//! `/api/cancel-question` endpoints, and the `pending_all()`/ +//! `recent_answered_all()` reads that fed them are all gone. An +//! operator-targeted `ask()` (if anything still calls it — the MCP +//! tool itself was removed earlier in the same effort) would queue a +//! row nothing can ever resolve. Left as-is rather than special-cased, +//! since the whole `Ask`/`Answer` flow this file implements is itself +//! slated for removal next. //! - `Ask { to: Some() }` → stored with `target = `; //! a `HelperEvent::QuestionAsked` is pushed into ``'s //! inbox so they can `Answer { id, answer }` on their own socket. @@ -73,8 +81,7 @@ pub fn handle_ask( // Agent-targeted questions need to wake the recipient — drop a // QuestionAsked event into their inbox so the answerer doesn't // have to poll. Operator-targeted questions show up on the - // dashboard's pending pane via `pending()` instead, plus a - // `QuestionAdded` dashboard event so the browser updates live. + // dashboard's pending pane via `pending()` instead. if let Some(target_agent) = target { coord.notify_agent( target_agent, @@ -87,17 +94,6 @@ pub fn handle_ask( }, ); } - // Always fire on the dashboard channel — both operator-targeted - // and peer threads now surface in the dashboard's questions pane. - coord.emit_question_added(&crate::coordinator::QuestionAdded { - id, - asker, - question, - options, - multi, - deadline_at, - target, - }); if let Some(t) = ttl { spawn_question_watchdog(coord, id, t); } @@ -115,7 +111,7 @@ pub fn handle_answer( answer: &str, ) -> Result<(), String> { limits::check_size("answer", answer)?; - let (question, asker, target) = coord + let (question, asker, _target) = coord .questions .answer(id, answer, answerer) .map_err(|e| format!("{e:#}"))?; @@ -132,11 +128,6 @@ pub fn handle_answer( answerer: answerer.to_owned(), }, ); - // Dashboard surfaces both operator-targeted and peer threads; - // emit unconditionally so the derived store moves the row. - // `cancelled = false` because this path is a real answer (the - // operator-cancel button goes through `post_cancel_question`). - coord.emit_question_resolved(id, answer, answerer, false, target.as_deref()); Ok(()) } @@ -160,7 +151,7 @@ pub fn handle_cancel_loose_end( // Agent-socket path: never privileged — an agent may only cancel // its own question (ownership). The operator's cancel-anything // path goes through a separate handler with `privileged = true`. - let (question, asker, target) = coord + let (question, asker, _target) = coord .questions .cancel(id, canceller, false) .map_err(|e| format!("{e:#}"))?; @@ -182,7 +173,6 @@ pub fn handle_cancel_loose_end( }, ); } - coord.emit_question_resolved(id, &sentinel, canceller, true, target.as_deref()); Ok(()) } hive_sh4re::inbox::CancelLooseEndKind::Reminder => { diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 59446dcc..74e60967 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -1099,7 +1099,7 @@ pub fn spawn_question_watchdog(coord: &Arc, id: i64, ttl_secs: u64) // the public `answer()` path by calling it with the operator // identity, since the operator is always permitted; the // event we fire carries the real watchdog label for observers. - if let Ok((question, asker, target)) = + if let Ok((question, asker, _target)) = coord .questions .answer(id, TTL_SENTINEL, hive_sh4re::manager::OPERATOR_RECIPIENT) @@ -1114,7 +1114,6 @@ pub fn spawn_question_watchdog(coord: &Arc, id: i64, ttl_secs: u64) answerer: TTL_ANSWERER.to_owned(), }, ); - coord.emit_question_resolved(id, TTL_SENTINEL, TTL_ANSWERER, false, target.as_deref()); } }); } diff --git a/hive-c0re/src/stores/operator_questions.rs b/hive-c0re/src/stores/operator_questions.rs index 876eaf72..ae549036 100644 --- a/hive-c0re/src/stores/operator_questions.rs +++ b/hive-c0re/src/stores/operator_questions.rs @@ -1,6 +1,10 @@ //! Question queue. Agents submit via `Ask`; the answer comes from -//! either the operator (via the dashboard, for `target IS NULL`) or -//! a peer agent (via `Answer`, for agent-to-agent questions). +//! either the operator (for `target IS NULL`) or a peer agent (via +//! `Answer`, for agent-to-agent questions). ⚠️ The dashboard no longer +//! has any UI or endpoint for the operator to actually answer a +//! `target IS NULL` row (removed along with the rest of the dashboard's +//! question surface) — see `questions.rs`'s module doc for the current +//! state of that gap. //! //! Despite the file name (kept for git history sanity), this table //! now stores *all* asynchronous questions in the hive — both the @@ -14,9 +18,8 @@ use std::sync::Mutex; use anyhow::{Context, Result, bail}; -use chrono::{DateTime, Utc}; +use chrono::Utc; use rusqlite::{Connection, OptionalExtension, params}; -use serde::Serialize; use crate::db::Migration; @@ -51,36 +54,13 @@ const MIGRATIONS: &[Migration] = &[ adds_column: Some(("operator_questions", "deadline_at")), }, // v3: `target` — recipient of the question. NULL = operator (back-compat - // default); non-null = peer-to-peer question. Dashboard's `pending()` - // filters on `target IS NULL` so peer questions never leak to the operator. + // default); non-null = peer-to-peer question. Migration { sql: "ALTER TABLE operator_questions ADD COLUMN target TEXT", adds_column: Some(("operator_questions", "target")), }, ]; -#[derive(Debug, Clone, Serialize)] -pub struct OpQuestion { - pub id: i64, - pub asker: String, - pub question: String, - pub options: Vec, - pub multi: bool, - pub asked_at: DateTime, - /// Deadline after which a watchdog auto-resolves the question with - /// answer `[expired]`. `None` = no expiry. Surfaced on the - /// dashboard as a remaining-time chip. - pub deadline_at: Option>, - pub answered_at: Option>, - pub answer: Option, - /// Recipient of the question. `None` = the operator (dashboard - /// path); `Some()` = a peer agent asked via - /// `Ask { to: Some(), ... }`. Agent-to-agent questions - /// never appear in `pending()` so the operator's queue stays clean. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target: Option, -} - pub struct OperatorQuestions { conn: Mutex, } @@ -231,59 +211,4 @@ impl OperatorQuestions { )?; Ok((question, asker, target)) } - - /// Every pending question, operator-targeted or peer-to-peer. - /// Drives the dashboard's questions pane now that peer threads - /// are surfaced for visibility + operator override-answer. - pub fn pending_all(&self) -> Result> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target - FROM operator_questions - WHERE answered_at IS NULL - ORDER BY id ASC", - )?; - let rows = stmt.query_map([], row_to_question)?; - rows.collect::>>() - .map_err(Into::into) - } - - /// Last `limit` answered questions across both target kinds, - /// newest-first. Companion to `pending_all`. - pub fn recent_answered_all(&self, limit: u64) -> Result> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target - FROM operator_questions - WHERE answered_at IS NOT NULL - ORDER BY answered_at DESC - LIMIT ?1", - )?; - let limit_i = i64::try_from(limit).unwrap_or(i64::MAX); - let rows = stmt.query_map(params![limit_i], row_to_question)?; - rows.collect::>>() - .map_err(Into::into) - } -} - -fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let options_json: String = row.get(3)?; - let options: Vec = serde_json::from_str(&options_json).unwrap_or_default(); - let multi: i64 = row.get(4)?; - Ok(OpQuestion { - id: row.get(0)?, - asker: row.get(1)?, - question: row.get(2)?, - options, - multi: multi != 0, - asked_at: hive_sh4re::wire_time::from_secs(row.get(5)?), - answered_at: row - .get::<_, Option>(6)? - .map(hive_sh4re::wire_time::from_secs), - answer: row.get(7)?, - deadline_at: row - .get::<_, Option>(8)? - .map(hive_sh4re::wire_time::from_secs), - target: row.get(9)?, - }) } From 46183795dd305541ff0f4b463573c570aaead272 Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 29 Aug 2026 23:28:32 +0200 Subject: [PATCH 2/2] hive-c0re, docs: sweep remaining stale ask/answer-dashboard references --- docs/approvals.md | 11 ++++--- docs/boundary.md | 19 ++++++------ docs/conventions.md | 34 ++++++++++++---------- docs/web-ui/dashboard.md | 13 --------- hive-c0re/src/loose_ends.rs | 10 ++++--- hive-c0re/src/stores/operator_questions.rs | 10 ++++--- 6 files changed, 47 insertions(+), 50 deletions(-) diff --git a/docs/approvals.md b/docs/approvals.md index 8afa0bd2..88f7c63b 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -635,10 +635,13 @@ root agent. Variants (`hive_sh4re::manager::HelperEvent`): - `NeedsUpdate { agent }` — sub-agent's recorded flake rev is stale. The root agent calls `update(name)` to rebuild — idempotent, no approval required. -- `QuestionAnswered { id, question, answer, answerer }` — - dashboard `/answer-question/{id}` (answerer = `"operator"`), - peer `Answer` request (answerer = agent name), or ttl watchdog - expiry (answerer = `"ttl-watchdog"`, answer = `"[expired]"`). +- `QuestionAnswered { id, question, answer, answerer }` — peer + `Answer` request (answerer = agent name) or ttl watchdog expiry + (answerer = `"ttl-watchdog"`, answer = `"[expired]"`). The dashboard's + own `/answer-question/{id}` trigger (answerer = `"operator"`) is + gone — removed along with the rest of the dashboard's ask/answer + surface, so an operator-targeted question now has no path to an + answer at all. - `QuestionAsked { id, asker, question, options, multi }` — fired when an agent calls `Ask { to: Some(), ... }`. The recipient responds via `Answer { id, answer }` and the diff --git a/docs/boundary.md b/docs/boundary.md index a37b300e..27ba8c01 100644 --- a/docs/boundary.md +++ b/docs/boundary.md @@ -28,14 +28,12 @@ always on), the gateway proxies all operator-facing traffic, and **Operator-authority actions never get a per-agent-socket entry point.** They live on the core backend. -Worked example — answering an operator-targeted question is a -`POST /api/answer-question/{id}` on the core dashboard, _never_ a +Worked example — destroying or rebuilding a container is a +`POST /api/{destroy,rebuild}/{name}` on the core dashboard, _never_ a per-agent-socket `Request` variant. If it were a per-agent-socket -request, an agent could `curl` its own socket and spoof an operator -answer. -The per-agent web UI POSTs cross-origin to the core for these -(see the inline-answer feature — the loose-ends section on each -agent page). +request, a compromised agent could `curl` its own socket and destroy +or rebuild itself (or, if the variant took an arbitrary target, another +agent) without ever touching the core's own authenticated surface. ## Why network isolation is the load-bearing step @@ -67,9 +65,10 @@ The boundary rests on three layers: 1. **Gateway** — fronts all surfaces (dashboard + every per-agent UI) on one origin. An nginx nixos-container proxies per-agent UIs under - `/agent//`, which is what lets the inline-answer POST to - `/answer-question/{id}` go same-origin instead of needing a - cross-origin CORS shim. Pure ergonomics — no behavioural risk on + `/agent//`, which is what lets each agent page's inbox panel + POST `mark-all-read` to the core dashboard's + `/api/agent/{name}/mark-all-read` go same-origin instead of needing + a cross-origin CORS shim. Pure ergonomics — no behavioural risk on its own. 2. **Network isolation** — the load-bearing layer: every agent container runs in a private netns behind the hive bridge, always diff --git a/docs/conventions.md b/docs/conventions.md index f434cc68..472c6075 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -174,14 +174,16 @@ the dashboard's "mark all read" (unbounded, per-agent). ### Question routing (Ask / Answer) `AgentRequest::Ask` (and the manager-flavour mirror) surfaces a -structured question that either lands in the operator's dashboard -queue or in a peer agent's inbox. The recipient is the `to` field: +structured question that either lands in the operator-question queue +or in a peer agent's inbox. The recipient is the `to` field: - `to = None` or `to = Some("operator")` — routes to the - operator-question queue. The dashboard renders the question with - any `options` as a chip strip plus a free-text fallback (`Other…`) - so the operator is never trapped by an incomplete list. The - legacy `AskOperator` variant collapses into this case. + operator-question queue. ⚠️ The dashboard's whole ask/answer + surface (the UI that used to render this queue and let the operator + reply) has been removed — an operator-targeted question currently + has no reader anywhere; this routing case is being removed too as + the same effort continues. The legacy `AskOperator` variant + collapses into this case. - `to = Some()` — peer Q&A. The target agent receives a `HelperEvent::QuestionAsked { id, asker, question, options, multi }` in their inbox. They reply via `AgentRequest::Answer` (or @@ -191,21 +193,23 @@ queue or in a peer agent's inbox. The recipient is the `to` field: Shape fields are uniform across both targets: -- `options` is advisory — the dashboard chips are decoration over a - free-text fallback; peer-agent recipients see the list in their - `QuestionAsked` event and can return any string. -- `multi = true` lets the answerer pick multiple options (checkboxes - in the dashboard, a hint in the peer-agent event). The answer - comes back as a single string with selections joined by `", "`. +- `options` is advisory — a list is never enforced; peer-agent + recipients see it in their `QuestionAsked` event and can return any + string regardless. +- `multi = true` is a hint that the answerer should treat `options` as + a multi-select rather than pick-one. The answer comes back as a + single string with selections joined by `", "`. - `ttl_seconds` auto-cancels with answer `[expired]` (and `answerer: "ttl-watchdog"`) when the wait becomes moot. `None` = wait indefinitely or until manual cancel. Response shape is always `QuestionQueued { id }` — the asker stores the id and correlates the asynchronous answer event when it lands. -Authorisation on `Answer`: only the question's `target` agent (or -the operator via the dashboard) is permitted to reply; an answer -attempt from anyone else fails the wire-side check. +Authorisation on `Answer`: only the question's `target` agent, or the +operator, is permitted to reply; an answer attempt from anyone else +fails the wire-side check. (The operator's only path to invoke this +was the now-removed dashboard — see the note above; the +authorisation rule itself is unchanged.) ### Loose-ends wire shape diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 835c9853..31ce3a6e 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -1172,13 +1172,6 @@ below — some endpoints aren't in it yet. - `POST /api/{rebuild,kill,restart,start,destroy}/{name}` — lifecycle. `destroy` accepts `purge=on` to also wipe state dirs. - `POST /api/purge-tombstone/{name}` — wipe a tombstone's state dirs. -- `POST /api/answer-question/{id}` — answer a pending operator question. - No dashboard UI calls this any more (the questions pane is gone — - see hyperhive#3721); kept as long as the backend `ask`/`answer` - mechanism itself still exists. -- `POST /api/cancel-question/{id}` — cancel a pending question with - the sentinel `[cancelled]`. Same code path as a real answer. Same - no-UI-consumer note as above. - `POST /api/request-spawn` — queue a Spawn approval. - `POST /api/update-all` — rebuild every stale container. - `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry. @@ -1383,12 +1376,6 @@ payload): sha_short, status, resolved_at, note, description) — pending queue + history mutations. Client mutates a derived store and re-renders only the approvals section. -- `question_added` (id, asker, question, options, multi, - asked_at, deadline_at, target) / `question_resolved` (id, - answer, answerer, answered_at, cancelled, target) — still - fired by the backend `ask`/`answer` mechanism, but the - dashboard no longer subscribes to either kind: the questions - pane that used to surface them is gone (hyperhive#3721). - `transient_set` (name, transient_kind, since_unix) / `transient_cleared` (name) — lifecycle action spinners. The client ticks the elapsed-seconds badge off `since_unix` diff --git a/hive-c0re/src/loose_ends.rs b/hive-c0re/src/loose_ends.rs index 93d9d9a7..f521fab1 100644 --- a/hive-c0re/src/loose_ends.rs +++ b/hive-c0re/src/loose_ends.rs @@ -7,10 +7,12 @@ //! and no longer sourced from here (loose-ends-v2's questions phase) — //! c0re remains the `Ask`/`Answer` routing + delivery rendezvous //! (`coord.questions`), it just isn't asked for the *pending-view* -//! rendering anymore. The -//! operator dashboard's questions pane is unaffected: it reads -//! `coord.questions.pending_all()` directly (`dashboard/state_snapshot.rs`), -//! independent of this module. +//! rendering anymore. ⚠️ The operator dashboard no longer has any +//! question-pending view either — its whole questions pane, the +//! `pending_all()`/`recent_answered_all()` reads that fed it, and the +//! `/api/answer-question`/`/api/cancel-question` endpoints are gone +//! (removed along with the rest of the dashboard's ask/answer surface). +//! An operator-targeted `ask()` now has no reader anywhere. //! //! Call frequency is low (an agent doing self-introspection between //! turns), so the sweep happens fresh every time — no caching, no diff --git a/hive-c0re/src/stores/operator_questions.rs b/hive-c0re/src/stores/operator_questions.rs index ae549036..398d88dd 100644 --- a/hive-c0re/src/stores/operator_questions.rs +++ b/hive-c0re/src/stores/operator_questions.rs @@ -105,13 +105,15 @@ impl OperatorQuestions { } /// Mark a pending question answered. `answerer` is who's actually - /// answering: `"operator"` for the dashboard path, or an agent's - /// own name when responding via `Answer`. Authorisation: + /// answering: `"operator"`, or an agent's own name when responding + /// via `Answer`. Authorisation: /// /// - Operator-targeted questions (`target IS NULL`) can only be /// answered by `"operator"`. (Agents must not be able to spoof - /// answers to operator questions — the dashboard is the - /// privileged path.) + /// answers to operator questions — though as of the dashboard's + /// ask/answer surface being removed, nothing currently calls + /// this with `answerer = "operator"` for a `target IS NULL` row + /// at all; the check stays as a guard, not a live path.) /// - Agent-targeted questions can only be answered by the /// declared target agent, OR by `"operator"` (operator override /// for stuck threads — useful when an agent is offline/down