dashboard: question_added / question_resolved mutation events + client derived state

This commit is contained in:
müde 2026-05-17 13:33:02 +02:00
parent 56d615b51f
commit 1879b2f485
6 changed files with 175 additions and 15 deletions

View file

@ -218,6 +218,64 @@ 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.
pub fn emit_question_added(
&self,
id: i64,
asker: &str,
question: &str,
options: &[String],
multi: bool,
deadline_at: Option<i64>,
) {
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);
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,
deadline_at,
});
}
/// 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.
pub fn emit_question_resolved(
&self,
id: i64,
answer: &str,
answerer: &str,
cancelled: bool,
) {
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);
self.emit_dashboard_event(DashboardEvent::QuestionResolved {
seq: self.next_seq(),
id,
answer: answer.to_owned(),
answerer: answerer.to_owned(),
answered_at,
cancelled,
});
}
pub fn register_agent(self: &Arc<Self>, name: &str) -> Result<PathBuf> {
// Idempotent: drop any existing listener so re-registration (e.g. on rebuild,
// or after a hive-c0re restart cleared /run/hyperhive) gets a fresh socket.

View file

@ -834,7 +834,7 @@ async fn post_answer_question(
.questions
.answer(id, answer, hive_sh4re::OPERATOR_RECIPIENT)
{
Ok((question, asker, _target)) => {
Ok((question, asker, target)) => {
tracing::info!(%id, %asker, "operator answered question");
state.coord.notify_agent(
&asker,
@ -845,6 +845,14 @@ 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,
);
}
Redirect::to("/").into_response()
}
Err(e) => error_response(&format!("answer {id} failed: {e:#}")),
@ -867,8 +875,16 @@ async fn post_cancel_question(
.questions
.answer(id, SENTINEL, hive_sh4re::OPERATOR_RECIPIENT)
{
Ok((question, asker, _target)) => {
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.notify_agent(
&asker,
&hive_sh4re::HelperEvent::QuestionAnswered {

View file

@ -77,4 +77,32 @@ 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()`.
QuestionAdded {
seq: u64,
id: i64,
asker: String,
question: String,
options: Vec<String>,
multi: bool,
asked_at: i64,
deadline_at: Option<i64>,
},
/// 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.
QuestionResolved {
seq: u64,
id: i64,
answer: String,
answerer: String,
answered_at: i64,
cancelled: bool,
},
}

View file

@ -439,7 +439,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::OPERATOR_RECIPIENT)
@ -454,6 +454,9 @@ 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);
}
}
});
}

View file

@ -73,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,
@ -85,6 +86,8 @@ pub fn handle_ask(
multi,
},
);
} else {
coord.emit_question_added(id, asker, question, options, multi, deadline_at);
}
if let Some(t) = ttl {
spawn_question_watchdog(coord, id, t);
@ -103,7 +106,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:#}"))?;
@ -117,6 +120,13 @@ 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);
}
Ok(())
}