dashboard: surface peer questions + operator override

questions pane now shows both operator-targeted threads
(target IS NULL) and agent-to-agent threads (target = some
agent). filter chips above the list: all / @operator / @peer /
per-participant. peer rows get a mauve left rule + a 0V3RR1D3
button that POSTs the same /answer-question endpoint
(OperatorQuestions::answer already permits the operator as
answerer on any target).

wire changes: OperatorQuestions gains pending_all +
recent_answered_all; QuestionAdded + QuestionResolved events
carry target: Option<String>; emit sites drop their
target.is_none() guard. answered-history rows show the
answerer prefix so override answers are auditable at a glance.
This commit is contained in:
müde 2026-05-17 22:06:53 +02:00
parent e7ce35c503
commit a15fafb5de
9 changed files with 187 additions and 71 deletions

View file

@ -243,11 +243,10 @@ impl Coordinator {
});
}
/// Emit `QuestionAdded` after an operator-targeted question is
/// inserted. Peer-to-peer questions (those with a non-null
/// `target` agent) never fire this — they don't surface on the
/// dashboard at all. Caller is responsible for the
/// `target.is_none()` guard.
/// 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,
id: i64,
@ -256,6 +255,7 @@ impl Coordinator {
options: &[String],
multi: bool,
deadline_at: Option<i64>,
target: Option<&str>,
) {
let asked_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -271,20 +271,22 @@ impl Coordinator {
multi,
asked_at,
deadline_at,
target: target.map(str::to_owned),
});
}
/// Emit `QuestionResolved` when an operator-targeted question
/// transitions to answered (operator answer, peer override,
/// cancel, or ttl watchdog). Caller filters on the original
/// question's `target.is_none()` — peer questions are dashboard-
/// invisible.
/// 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)
@ -298,6 +300,7 @@ impl Coordinator {
answerer: answerer.to_owned(),
answered_at,
cancelled,
target: target.map(str::to_owned),
});
}

View file

@ -307,9 +307,13 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
// operator_inbox used to be served here as a 50-row array; the
// dashboard now derives it client-side from the message stream
// (terminal backfill + live SSE), so the snapshot stops shipping it.
let questions = log_default("questions.pending", state.coord.questions.pending());
let question_history =
log_default("questions.recent_answered", state.coord.questions.recent_answered(20));
// Both operator-targeted and peer threads now surface on the
// dashboard. Client filters by target client-side.
let questions = log_default("questions.pending_all", state.coord.questions.pending_all());
let question_history = log_default(
"questions.recent_answered_all",
state.coord.questions.recent_answered_all(20),
);
axum::Json(StateSnapshot {
seq,
@ -734,14 +738,13 @@ async fn post_answer_question(
answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
},
);
if target.is_none() {
state.coord.emit_question_resolved(
id,
answer,
hive_sh4re::OPERATOR_RECIPIENT,
false,
);
}
state.coord.emit_question_resolved(
id,
answer,
hive_sh4re::OPERATOR_RECIPIENT,
false,
target.as_deref(),
);
(StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("answer {id} failed: {e:#}")),
@ -766,14 +769,13 @@ async fn post_cancel_question(
{
Ok((question, asker, target)) => {
tracing::info!(%id, %asker, "operator cancelled question");
if target.is_none() {
state.coord.emit_question_resolved(
id,
SENTINEL,
hive_sh4re::OPERATOR_RECIPIENT,
true,
);
}
state.coord.emit_question_resolved(
id,
SENTINEL,
hive_sh4re::OPERATOR_RECIPIENT,
true,
target.as_deref(),
);
state.coord.notify_agent(
&asker,
&hive_sh4re::HelperEvent::QuestionAnswered {

View file

@ -79,11 +79,11 @@ pub enum DashboardEvent {
note: Option<String>,
description: Option<String>,
},
/// An operator-targeted question landed in the queue
/// (`Ask { to: None | Some("operator") }`). Peer-to-peer
/// questions (target = Some(<agent>)) never fire this event —
/// the dashboard only ever shows operator-bound questions, so
/// the emit site filters on `target.is_none()`.
/// 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,
@ -93,12 +93,13 @@ pub enum DashboardEvent {
multi: bool,
asked_at: i64,
deadline_at: Option<i64>,
target: Option<String>,
},
/// An operator-targeted question was answered (operator answer,
/// peer override, or ttl watchdog `[expired]`). Clients move the
/// row from pending to history. `cancelled = true` when the
/// operator dismissed via the cancel button — same code path on
/// the server but useful to surface differently in the UI.
/// 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,
@ -106,6 +107,7 @@ pub enum DashboardEvent {
answerer: String,
answered_at: i64,
cancelled: bool,
target: Option<String>,
},
/// A lifecycle action started for an agent (spawn / start / stop
/// / restart / rebuild / destroy). Clients render a spinner next

View file

@ -454,9 +454,7 @@ pub fn spawn_question_watchdog(coord: &Arc<Coordinator>, id: i64, ttl_secs: u64)
answerer: TTL_ANSWERER.to_owned(),
},
);
if target.is_none() {
coord.emit_question_resolved(id, TTL_SENTINEL, TTL_ANSWERER, false);
}
coord.emit_question_resolved(id, TTL_SENTINEL, TTL_ANSWERER, false, target.as_deref());
}
});
}

View file

@ -209,15 +209,15 @@ impl OperatorQuestions {
.map_err(Into::into)
}
/// Pending operator-targeted questions only (`target IS NULL`).
/// Drives the dashboard's pending-question pane — agent-to-agent
/// questions never appear here so the operator's queue stays clean.
pub fn pending(&self) -> Result<Vec<OpQuestion>> {
/// 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 AND target IS NULL
WHERE answered_at IS NULL
ORDER BY id ASC",
)?;
let rows = stmt.query_map([], row_to_question)?;
@ -225,15 +225,14 @@ impl OperatorQuestions {
.map_err(Into::into)
}
/// Last `limit` answered operator-targeted questions, newest-first.
/// Same `target IS NULL` filter as `pending()` so the dashboard's
/// history view only shows operator-relevant rows.
pub fn recent_answered(&self, limit: u64) -> Result<Vec<OpQuestion>> {
/// 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 AND target IS NULL
WHERE answered_at IS NOT NULL
ORDER BY answered_at DESC
LIMIT ?1",
)?;
@ -241,6 +240,7 @@ impl OperatorQuestions {
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
}
fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {

View file

@ -86,9 +86,10 @@ pub fn handle_ask(
multi,
},
);
} else {
coord.emit_question_added(id, asker, question, options, multi, deadline_at);
}
// Always fire on the dashboard channel — both operator-targeted
// and peer threads now surface in the dashboard's questions pane.
coord.emit_question_added(id, asker, question, options, multi, deadline_at, target);
if let Some(t) = ttl {
spawn_question_watchdog(coord, id, t);
}
@ -120,13 +121,11 @@ pub fn handle_answer(
answerer: answerer.to_owned(),
},
);
// Only operator-targeted questions surface on the dashboard;
// peer-to-peer answers are invisible to it. `cancelled = false`
// because this path is a real answer (operator cancel goes
// through `post_cancel_question` directly).
if target.is_none() {
coord.emit_question_resolved(id, answer, answerer, false);
}
// 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(())
}