hive-c0re: remove the dashboard's ask/answer surface
This commit is contained in:
parent
a295fba09c
commit
47cac50e6e
8 changed files with 25 additions and 463 deletions
|
|
@ -445,19 +445,6 @@ pub struct ApprovalAdded<'a> {
|
|||
pub pr_number: Option<u64>,
|
||||
}
|
||||
|
||||
/// 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<i64>,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ use crate::lifecycle;
|
|||
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
||||
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats, audit log"),
|
||||
(name = "permissions", description = "tool-group + capability assignment for agents"),
|
||||
(name = "questions", description = "answer/cancel pending operator questions"),
|
||||
(name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"),
|
||||
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
||||
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
||||
|
|
@ -71,7 +70,6 @@ mod matrix_accounts;
|
|||
mod meta_inputs;
|
||||
mod misc_api;
|
||||
pub(crate) mod permissions;
|
||||
mod questions;
|
||||
mod schedules;
|
||||
mod state_files;
|
||||
mod state_snapshot;
|
||||
|
|
@ -197,8 +195,6 @@ pub async fn serve(
|
|||
.routes(routes!(lifecycle_ops::post_resource_limits))
|
||||
.routes(routes!(lifecycle_ops::post_update_all))
|
||||
.routes(routes!(infra_containers::post_infra_container))
|
||||
.routes(routes!(questions::post_answer_question))
|
||||
.routes(routes!(questions::post_cancel_question))
|
||||
.routes(routes!(tombstones::post_purge_tombstone))
|
||||
.routes(routes!(meta_inputs::post_meta_update))
|
||||
.routes(routes!(build_logs::get_build_log_stream))
|
||||
|
|
@ -442,8 +438,6 @@ mod router_build_probe {
|
|||
.routes(routes!(lifecycle_ops::post_resource_limits))
|
||||
.routes(routes!(lifecycle_ops::post_update_all))
|
||||
.routes(routes!(infra_containers::post_infra_container))
|
||||
.routes(routes!(questions::post_answer_question))
|
||||
.routes(routes!(questions::post_cancel_question))
|
||||
.routes(routes!(tombstones::post_purge_tombstone))
|
||||
.routes(routes!(meta_inputs::post_meta_update))
|
||||
.routes(routes!(build_logs::get_build_log_stream))
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
//! Operator question answer/cancel endpoints for the dashboard.
|
||||
//!
|
||||
//! `POST /answer-question/{id}` records the operator's answer and fires a
|
||||
//! `QuestionAnswered` event to the asker; `POST /cancel-question/{id}`
|
||||
//! resolves a pending question with a `[cancelled]` sentinel. Both carry a
|
||||
//! permissive CORS header so the per-agent web UI (different origin) can
|
||||
//! POST here until the unifying gateway makes it same-origin.
|
||||
|
||||
use axum::{
|
||||
extract::{Form, Path as AxumPath, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use problem_details::ProblemDetails;
|
||||
|
||||
use super::{AppState, error_response};
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(super) struct AnswerForm {
|
||||
answer: String,
|
||||
}
|
||||
|
||||
/// Attach a permissive CORS header so the per-agent web UI — served on
|
||||
/// a different port — can POST an operator answer here and read the
|
||||
/// result. The dashboard has no auth, so `*` exposes nothing a plain
|
||||
/// cross-origin form-POST couldn't already reach. This shim disappears
|
||||
/// once the unifying gateway makes the agent page same-origin; see
|
||||
/// `docs/boundary.md`.
|
||||
fn with_cors(resp: impl IntoResponse) -> Response {
|
||||
let mut resp = resp.into_response();
|
||||
resp.headers_mut().insert(
|
||||
axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
axum::http::HeaderValue::from_static("*"),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Record the operator's answer and
|
||||
/// notify the asker.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/answer-question/{id}",
|
||||
params(("id" = i64, Path, description = "question row id")),
|
||||
request_body(content = AnswerForm, content_type = "application/x-www-form-urlencoded"),
|
||||
responses(
|
||||
(status = 200, description = "answered", body = String),
|
||||
(status = 400, description = "empty answer"),
|
||||
(status = 500, description = "answer failed (already answered, unknown id, ...)"),
|
||||
),
|
||||
tag = "questions"
|
||||
)]
|
||||
pub(super) async fn post_answer_question(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
Form(form): Form<AnswerForm>,
|
||||
) -> Response {
|
||||
let answer = form.answer.trim();
|
||||
if answer.is_empty() {
|
||||
return with_cors(
|
||||
ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail("answer: required"),
|
||||
);
|
||||
}
|
||||
let resp =
|
||||
match state
|
||||
.coord
|
||||
.questions
|
||||
.answer(id, answer, hive_sh4re::manager::OPERATOR_RECIPIENT)
|
||||
{
|
||||
Ok((question, asker, target)) => {
|
||||
tracing::info!(%id, %asker, "operator answered question");
|
||||
state.coord.notify_agent(
|
||||
&asker,
|
||||
&hive_sh4re::manager::HelperEvent::QuestionAnswered {
|
||||
id,
|
||||
question,
|
||||
answer: answer.to_owned(),
|
||||
answerer: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(),
|
||||
},
|
||||
);
|
||||
state.coord.emit_question_resolved(
|
||||
id,
|
||||
answer,
|
||||
hive_sh4re::manager::OPERATOR_RECIPIENT,
|
||||
false,
|
||||
target.as_deref(),
|
||||
);
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
Err(e) => error_response(&format!("answer {id} failed: {e:#}")),
|
||||
};
|
||||
with_cors(resp)
|
||||
}
|
||||
|
||||
/// Resolve a pending question with the
|
||||
/// `[cancelled]` sentinel answer.
|
||||
///
|
||||
/// Used when the operator decides not to / can't answer. The asker
|
||||
/// harness receives a `QuestionAnswered` event with
|
||||
/// `answer = "[cancelled]"` so it can fall back on whatever default
|
||||
/// it had. Same code path as a real answer — just lets the operator
|
||||
/// close the loop instead of letting the question dangle forever.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/cancel-question/{id}",
|
||||
params(("id" = i64, Path, description = "question row id")),
|
||||
responses(
|
||||
(status = 200, description = "cancelled", body = String),
|
||||
(status = 500, description = "cancel failed (already answered, unknown id, ...)"),
|
||||
),
|
||||
tag = "questions"
|
||||
)]
|
||||
pub(super) async fn post_cancel_question(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
) -> Response {
|
||||
const SENTINEL: &str = "[cancelled]";
|
||||
match state
|
||||
.coord
|
||||
.questions
|
||||
.answer(id, SENTINEL, hive_sh4re::manager::OPERATOR_RECIPIENT)
|
||||
{
|
||||
Ok((question, asker, target)) => {
|
||||
tracing::info!(%id, %asker, "operator cancelled question");
|
||||
state.coord.emit_question_resolved(
|
||||
id,
|
||||
SENTINEL,
|
||||
hive_sh4re::manager::OPERATOR_RECIPIENT,
|
||||
true,
|
||||
target.as_deref(),
|
||||
);
|
||||
state.coord.notify_agent_from(
|
||||
hive_sh4re::manager::OPERATOR_RECIPIENT,
|
||||
&asker,
|
||||
&hive_sh4re::manager::HelperEvent::QuestionAnswered {
|
||||
id,
|
||||
question,
|
||||
answer: SENTINEL.to_owned(),
|
||||
answerer: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(),
|
||||
},
|
||||
);
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
|
@ -52,15 +52,6 @@ pub(super) struct StateSnapshot {
|
|||
/// Last 30 resolved approvals (approved / denied / failed), newest-
|
||||
/// first. Drives the "history" tab on the approvals section.
|
||||
approval_history: Vec<ApprovalHistoryView>,
|
||||
/// Pending operator-targeted questions (`target IS NULL`). Any
|
||||
/// agent can `ask` the operator and `ask` returns immediately with
|
||||
/// the id; on `/answer-question` we mark the row answered and
|
||||
/// fire `HelperEvent::QuestionAnswered` back into the asker's
|
||||
/// inbox. Peer-to-peer questions live in the same table but never
|
||||
/// surface here (see `OperatorQuestions::pending`).
|
||||
questions: Vec<QuestionView>,
|
||||
/// Last 20 answered questions, newest-first.
|
||||
question_history: Vec<QuestionView>,
|
||||
/// State dirs (config history + claude creds + /state/ notes) that
|
||||
/// survive after a destroy-without-purge. The operator can re-spawn
|
||||
/// with the same name to resume, or PURG3 to wipe them.
|
||||
|
|
@ -155,35 +146,6 @@ async fn infra_container_views() -> Vec<InfraContainerView> {
|
|||
infra_containers
|
||||
}
|
||||
|
||||
/// `OpQuestion` + computed `question_refs` / `answer_refs`. Built
|
||||
/// from the snapshot read; the live channel attaches the same
|
||||
/// fields directly on `QuestionAdded` / `QuestionResolved`.
|
||||
#[derive(Serialize)]
|
||||
struct QuestionView {
|
||||
#[serde(flatten)]
|
||||
inner: crate::operator_questions::OpQuestion,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
question_refs: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
answer_refs: Vec<String>,
|
||||
}
|
||||
|
||||
impl QuestionView {
|
||||
fn from_question(q: crate::operator_questions::OpQuestion) -> Self {
|
||||
let question_refs = scan_validated_paths(&q.question);
|
||||
let answer_refs = q
|
||||
.answer
|
||||
.as_deref()
|
||||
.map(scan_validated_paths)
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
inner: q,
|
||||
question_refs,
|
||||
answer_refs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PortConflict {
|
||||
port: u16,
|
||||
|
|
@ -275,12 +237,12 @@ const CRASH_WARNING_WINDOW: std::time::Duration = std::time::Duration::from_mins
|
|||
|
||||
/// Cold-load snapshot of the whole dashboard.
|
||||
///
|
||||
/// Includes the roster, approvals (+ history), questions (+ history),
|
||||
/// Includes the roster, approvals (+ history),
|
||||
/// tombstones, job queue, meta inputs, and more. Live clients then
|
||||
/// follow `/api/dashboard/stream` (SSE) for incremental updates keyed
|
||||
/// off `seq`.
|
||||
// `StateSnapshot` is a large tree of nested view types (`ContainerView`,
|
||||
// `ApprovalView`, `QuestionView`, ...) with no `ToSchema` anywhere in that
|
||||
// `ApprovalView`, ...) with no `ToSchema` anywhere in that
|
||||
// graph; wiring it up is a schema-modelling project of its own, well past
|
||||
// "annotate what's reachable". `serde_json::Value` placeholder for now —
|
||||
// see the batch report.
|
||||
|
|
@ -354,23 +316,6 @@ pub(super) async fn api_state(
|
|||
let tombstones = build_tombstone_views(&state.coord, &containers);
|
||||
let port_conflicts = build_port_conflicts(&containers);
|
||||
|
||||
// Both operator-targeted and peer threads surface on the dashboard
|
||||
// (the client filters by target). Each row is wrapped in QuestionView
|
||||
// so the snapshot carries the same file_refs the live event variants
|
||||
// attach.
|
||||
let questions: Vec<QuestionView> =
|
||||
log_default("questions.pending_all", state.coord.questions.pending_all())
|
||||
.into_iter()
|
||||
.map(QuestionView::from_question)
|
||||
.collect();
|
||||
let question_history: Vec<QuestionView> = log_default(
|
||||
"questions.recent_answered_all",
|
||||
state.coord.questions.recent_answered_all(20),
|
||||
)
|
||||
.into_iter()
|
||||
.map(QuestionView::from_question)
|
||||
.collect();
|
||||
|
||||
// Banner warnings: host probes (disk) + agent-state (pending logins,
|
||||
// crashing agents). Built before the response struct because the
|
||||
// agent-state producer borrows `containers`, which moves in below.
|
||||
|
|
@ -396,8 +341,6 @@ pub(super) async fn api_state(
|
|||
approval_history,
|
||||
meta_inputs: read_meta_inputs(),
|
||||
meta_update_running: state.coord.meta_update_in_progress(),
|
||||
questions,
|
||||
question_history,
|
||||
tombstones,
|
||||
port_conflicts,
|
||||
forge_present: crate::forge::is_present().await,
|
||||
|
|
|
|||
|
|
@ -97,44 +97,6 @@ pub enum DashboardEvent {
|
|||
note: Option<String>,
|
||||
description: Option<String>,
|
||||
},
|
||||
/// A question landed in the queue. `target = None` means
|
||||
/// operator-targeted (`Ask { to: None | Some("operator") }`);
|
||||
/// `target = Some(<agent>)` 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<String>,
|
||||
multi: bool,
|
||||
asked_at: DateTime<Utc>,
|
||||
deadline_at: Option<DateTime<Utc>>,
|
||||
target: Option<String>,
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<Utc>,
|
||||
cancelled: bool,
|
||||
target: Option<String>,
|
||||
/// Verified file-path tokens that appear in `answer`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
answer_refs: Vec<String>,
|
||||
},
|
||||
/// 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(),
|
||||
|
|
|
|||
|
|
@ -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(<agent>) }` → stored with `target = <agent>`;
|
||||
//! a `HelperEvent::QuestionAsked` is pushed into `<agent>`'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 => {
|
||||
|
|
|
|||
|
|
@ -1099,7 +1099,7 @@ pub fn spawn_question_watchdog(coord: &Arc<Coordinator>, 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<Coordinator>, id: i64, ttl_secs: u64)
|
|||
answerer: TTL_ANSWERER.to_owned(),
|
||||
},
|
||||
);
|
||||
coord.emit_question_resolved(id, TTL_SENTINEL, TTL_ANSWERER, false, target.as_deref());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub multi: bool,
|
||||
pub asked_at: DateTime<Utc>,
|
||||
/// 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<DateTime<Utc>>,
|
||||
pub answered_at: Option<DateTime<Utc>>,
|
||||
pub answer: Option<String>,
|
||||
/// Recipient of the question. `None` = the operator (dashboard
|
||||
/// path); `Some(<agent>)` = a peer agent asked via
|
||||
/// `Ask { to: Some(<agent>), ... }`. 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<String>,
|
||||
}
|
||||
|
||||
pub struct OperatorQuestions {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
|
@ -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<Vec<OpQuestion>> {
|
||||
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::<rusqlite::Result<Vec<_>>>()
|
||||
.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<Vec<OpQuestion>> {
|
||||
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::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
|
||||
let options_json: String = row.get(3)?;
|
||||
let options: Vec<String> = 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<i64>>(6)?
|
||||
.map(hive_sh4re::wire_time::from_secs),
|
||||
answer: row.get(7)?,
|
||||
deadline_at: row
|
||||
.get::<_, Option<i64>>(8)?
|
||||
.map(hive_sh4re::wire_time::from_secs),
|
||||
target: row.get(9)?,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue