diff --git a/docs/approvals.md b/docs/approvals.md index 88f7c63b..8afa0bd2 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -635,13 +635,10 @@ 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 }` — 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. +- `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]"`). - `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 27ba8c01..a37b300e 100644 --- a/docs/boundary.md +++ b/docs/boundary.md @@ -28,12 +28,14 @@ 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 — destroying or rebuilding a container is a -`POST /api/{destroy,rebuild}/{name}` on the core dashboard, _never_ a +Worked example — answering an operator-targeted question is a +`POST /api/answer-question/{id}` on the core dashboard, _never_ a per-agent-socket `Request` variant. If it were a per-agent-socket -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. +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). ## Why network isolation is the load-bearing step @@ -65,10 +67,9 @@ 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 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 + `/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 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 472c6075..f434cc68 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -174,16 +174,14 @@ 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-question queue -or in a peer agent's inbox. The recipient is the `to` field: +structured question that either lands in the operator's dashboard +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'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. + 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. - `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 @@ -193,23 +191,21 @@ or in a peer agent's inbox. The recipient is the `to` field: Shape fields are uniform across both targets: -- `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 `", "`. +- `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 `", "`. - `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, 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.) +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. ### Loose-ends wire shape diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 31ce3a6e..835c9853 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -1172,6 +1172,13 @@ 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. @@ -1376,6 +1383,12 @@ 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/coordinator.rs b/hive-c0re/src/coordinator.rs index f72beebe..d2823532 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -445,6 +445,19 @@ 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, @@ -826,6 +839,71 @@ 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 c95b36fd..1a820d13 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -44,6 +44,7 @@ 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"), @@ -70,6 +71,7 @@ mod matrix_accounts; mod meta_inputs; mod misc_api; pub(crate) mod permissions; +mod questions; mod schedules; mod state_files; mod state_snapshot; @@ -195,6 +197,8 @@ 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)) @@ -438,6 +442,8 @@ 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 new file mode 100644 index 00000000..c56417cf --- /dev/null +++ b/hive-c0re/src/dashboard/questions.rs @@ -0,0 +1,149 @@ +//! 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 6e75ce50..2258608a 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -52,6 +52,15 @@ 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. @@ -146,6 +155,35 @@ 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, @@ -237,12 +275,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), +/// Includes the roster, approvals (+ history), questions (+ 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`, ...) with no `ToSchema` anywhere in that +// `ApprovalView`, `QuestionView`, ...) 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. @@ -316,6 +354,23 @@ 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. @@ -341,6 +396,8 @@ 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 b2112f01..04a6686f 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -97,6 +97,44 @@ 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" @@ -259,6 +297,8 @@ 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", @@ -337,6 +377,28 @@ 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/loose_ends.rs b/hive-c0re/src/loose_ends.rs index f521fab1..93d9d9a7 100644 --- a/hive-c0re/src/loose_ends.rs +++ b/hive-c0re/src/loose_ends.rs @@ -7,12 +7,10 @@ //! 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 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. +//! 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. //! //! 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/questions.rs b/hive-c0re/src/questions.rs index fa745270..0977f4c1 100644 --- a/hive-c0re/src/questions.rs +++ b/hive-c0re/src/questions.rs @@ -5,17 +5,9 @@ //! //! Routing rules at a glance: //! -//! - `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: None | Some("operator") }` → stored with `target = NULL`; +//! the dashboard's `pending()` query surfaces it; operator answers +//! via the dashboard. //! - `Ask { to: Some() }` → stored with `target = `; //! a `HelperEvent::QuestionAsked` is pushed into ``'s //! inbox so they can `Answer { id, answer }` on their own socket. @@ -81,7 +73,8 @@ 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. + // dashboard's pending pane via `pending()` instead, plus a + // `QuestionAdded` dashboard event so the browser updates live. if let Some(target_agent) = target { coord.notify_agent( target_agent, @@ -94,6 +87,17 @@ 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); } @@ -111,7 +115,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:#}"))?; @@ -128,6 +132,11 @@ 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(()) } @@ -151,7 +160,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:#}"))?; @@ -173,6 +182,7 @@ 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 74e60967..59446dcc 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,6 +1114,7 @@ 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 398d88dd..876eaf72 100644 --- a/hive-c0re/src/stores/operator_questions.rs +++ b/hive-c0re/src/stores/operator_questions.rs @@ -1,10 +1,6 @@ //! Question queue. Agents submit via `Ask`; the answer comes from -//! 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. +//! either the operator (via the dashboard, for `target IS NULL`) or +//! a peer agent (via `Answer`, for agent-to-agent questions). //! //! Despite the file name (kept for git history sanity), this table //! now stores *all* asynchronous questions in the hive — both the @@ -18,8 +14,9 @@ use std::sync::Mutex; use anyhow::{Context, Result, bail}; -use chrono::Utc; +use chrono::{DateTime, Utc}; use rusqlite::{Connection, OptionalExtension, params}; +use serde::Serialize; use crate::db::Migration; @@ -54,13 +51,36 @@ 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. + // default); non-null = peer-to-peer question. Dashboard's `pending()` + // filters on `target IS NULL` so peer questions never leak to the operator. 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, } @@ -105,15 +125,13 @@ impl OperatorQuestions { } /// Mark a pending question answered. `answerer` is who's actually - /// answering: `"operator"`, or an agent's own name when responding - /// via `Answer`. Authorisation: + /// answering: `"operator"` for the dashboard path, 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 — 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.) + /// answers to operator questions — the dashboard is the + /// privileged 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 @@ -213,4 +231,59 @@ 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)?, + }) }