diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 31d4758..7c2a0b7 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -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 diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 9ace31c..d9d6575 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -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, + ) { + 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, name: &str) -> Result { // 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. diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 682e0ab..f5720e9 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -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 { diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index f5cdf66..0075bf2 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -77,4 +77,32 @@ pub enum DashboardEvent { note: Option, description: Option, }, + /// An operator-targeted question landed in the queue + /// (`Ask { to: None | Some("operator") }`). Peer-to-peer + /// questions (target = Some()) 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, + multi: bool, + asked_at: i64, + deadline_at: Option, + }, + /// 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, + }, } diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index a9786ad..5fafc39 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -439,7 +439,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::OPERATOR_RECIPIENT) @@ -454,6 +454,9 @@ pub fn spawn_question_watchdog(coord: &Arc, id: i64, ttl_secs: u64) answerer: TTL_ANSWERER.to_owned(), }, ); + if target.is_none() { + coord.emit_question_resolved(id, TTL_SENTINEL, TTL_ANSWERER, false); + } } }); } diff --git a/hive-c0re/src/questions.rs b/hive-c0re/src/questions.rs index d522eff..d94ad8f 100644 --- a/hive-c0re/src/questions.rs +++ b/hive-c0re/src/questions.rs @@ -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(()) }