From 47cac50e6ed40bc7597172bf8971921272163bad Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 29 Aug 2026 23:20:17 +0200 Subject: [PATCH] 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)?, - }) }