dashboard: question_added / question_resolved mutation events + client derived state
This commit is contained in:
parent
56d615b51f
commit
1879b2f485
6 changed files with 175 additions and 15 deletions
|
|
@ -477,15 +477,57 @@
|
|||
root.append(ul);
|
||||
}
|
||||
|
||||
function renderQuestions(s) {
|
||||
// Derived question state — cold-loaded from /api/state, then mutated
|
||||
// live by `question_added` / `question_resolved` dashboard events.
|
||||
const QUESTION_HISTORY_LIMIT = 20;
|
||||
const questionsState = { pending: [], history: [] };
|
||||
function syncQuestionsFromSnapshot(s) {
|
||||
questionsState.pending = (s.questions || []).slice();
|
||||
questionsState.history = (s.question_history || []).slice();
|
||||
}
|
||||
function applyQuestionAdded(ev) {
|
||||
if (questionsState.pending.some((q) => q.id === ev.id)) return;
|
||||
questionsState.pending.push({
|
||||
id: ev.id,
|
||||
asker: ev.asker,
|
||||
question: ev.question,
|
||||
options: ev.options || [],
|
||||
multi: !!ev.multi,
|
||||
asked_at: ev.asked_at,
|
||||
deadline_at: ev.deadline_at ?? null,
|
||||
});
|
||||
renderQuestions();
|
||||
}
|
||||
function applyQuestionResolved(ev) {
|
||||
const idx = questionsState.pending.findIndex((q) => q.id === ev.id);
|
||||
const existing = idx >= 0 ? questionsState.pending[idx] : null;
|
||||
if (idx >= 0) questionsState.pending.splice(idx, 1);
|
||||
questionsState.history.unshift({
|
||||
id: ev.id,
|
||||
asker: existing?.asker || '?',
|
||||
question: existing?.question || '',
|
||||
options: existing?.options || [],
|
||||
multi: existing?.multi || false,
|
||||
asked_at: existing?.asked_at || ev.answered_at,
|
||||
answered_at: ev.answered_at,
|
||||
answer: ev.answer,
|
||||
answerer: ev.answerer,
|
||||
});
|
||||
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
|
||||
questionsState.history.length = QUESTION_HISTORY_LIMIT;
|
||||
}
|
||||
renderQuestions();
|
||||
}
|
||||
function renderQuestions() {
|
||||
const root = $('questions-section');
|
||||
root.innerHTML = '';
|
||||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||
if (!s.questions || !s.questions.length) {
|
||||
const pending = questionsState.pending;
|
||||
if (!pending.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'no pending questions'));
|
||||
}
|
||||
const ul = el('ul', { class: 'questions' });
|
||||
for (const q of s.questions) {
|
||||
for (const q of pending) {
|
||||
const li = el('li', { class: 'question' });
|
||||
const head = el('div', { class: 'q-head' },
|
||||
el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ',
|
||||
|
|
@ -567,10 +609,10 @@
|
|||
li.append(cancelForm);
|
||||
ul.append(li);
|
||||
}
|
||||
if (s.questions && s.questions.length) root.append(ul);
|
||||
if (pending.length) root.append(ul);
|
||||
|
||||
// Answered question history
|
||||
const hist = s.question_history || [];
|
||||
const hist = questionsState.history;
|
||||
if (hist.length) {
|
||||
const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' });
|
||||
details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
|
||||
|
|
@ -997,12 +1039,13 @@
|
|||
const openDetails = snapshotOpenDetails();
|
||||
renderContainers(s);
|
||||
renderTombstones(s);
|
||||
renderQuestions(s);
|
||||
// Sync the derived approvals + questions stores from the
|
||||
// snapshot, then render. Live `*_added` / `*_resolved` events
|
||||
// mutate the stores directly and re-render without a snapshot
|
||||
// refetch.
|
||||
syncQuestionsFromSnapshot(s);
|
||||
renderQuestions();
|
||||
renderInbox();
|
||||
// Sync the derived approvals store from the snapshot, then
|
||||
// render. Live `approval_added` / `approval_resolved` events
|
||||
// mutate the store directly and call renderApprovals() without
|
||||
// a snapshot refetch.
|
||||
syncApprovalsFromSnapshot(s);
|
||||
renderApprovals();
|
||||
renderMetaInputs(s);
|
||||
|
|
@ -1069,6 +1112,8 @@
|
|||
// for broker traffic, not state-change chatter).
|
||||
approval_added: (ev) => { applyApprovalAdded(ev); },
|
||||
approval_resolved: (ev) => { applyApprovalResolved(ev); },
|
||||
question_added: (ev) => { applyQuestionAdded(ev); },
|
||||
question_resolved: (ev) => { applyQuestionResolved(ev); },
|
||||
},
|
||||
// Both history backfill and live frames flow through here, so the
|
||||
// inbox section ends up populated correctly on first paint and
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue