From 4ec401a6c72bf142c7384cb8cd43e7137733165e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 23:54:35 +0200 Subject: [PATCH] question/answer text: server-side file_refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DashboardEvent::QuestionAdded gains question_refs and QuestionResolved gains answer_refs — both populated via scan_validated_paths at emit time, same helper the broker forwarder uses for Sent/Delivered. cold-load snapshot wraps each OpQuestion in QuestionView with the same fields computed once per /api/state. client threads refs through questionsState rows (pending + history) and passes them to appendLinkified at every render site (live pane, history details). path tokens in question and answer bodies now linkify with the same server-vouched guarantee broker messages already enjoyed. --- hive-c0re/assets/app.js | 9 ++++-- hive-c0re/src/coordinator.rs | 4 +++ hive-c0re/src/dashboard.rs | 50 +++++++++++++++++++++++++++---- hive-c0re/src/dashboard_events.rs | 8 +++++ 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index ced15d2..012aeef 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -748,6 +748,7 @@ asked_at: ev.asked_at, deadline_at: ev.deadline_at ?? null, target: ev.target || null, + question_refs: ev.question_refs || [], }); renderQuestions(); } @@ -766,6 +767,8 @@ answer: ev.answer, answerer: ev.answerer, target: existing?.target ?? ev.target ?? null, + question_refs: existing?.question_refs || [], + answer_refs: ev.answer_refs || [], }); if (questionsState.history.length > QUESTION_HISTORY_LIMIT) { questionsState.history.length = QUESTION_HISTORY_LIMIT; @@ -857,7 +860,7 @@ head.append(' ', el('span', { class: 'q-ttl' }, txt)); } const qBody = el('div', { class: 'q-body' }); - const qPreviews = appendLinkified(qBody, q.question); + const qPreviews = appendLinkified(qBody, q.question, q.question_refs); li.append(head, qBody); for (const d of qPreviews) li.appendChild(d); const f = el('form', { @@ -953,9 +956,9 @@ el('span', { class: 'msg-sep' }, 'asked:'), ); const histBody = el('div', { class: 'q-body' }); - const histBodyPreviews = appendLinkified(histBody, q.question); + const histBodyPreviews = appendLinkified(histBody, q.question, q.question_refs); const ansText = el('span', { class: 'q-answer-text' }); - const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)'); + const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)', q.answer_refs); const ansLine = el('div', { class: 'q-answer' }, el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `), ansText, diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 98e9c77..1128c6b 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -262,6 +262,7 @@ impl Coordinator { .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, @@ -272,6 +273,7 @@ impl Coordinator { asked_at, deadline_at, target: target.map(str::to_owned), + question_refs, }); } @@ -293,6 +295,7 @@ impl Coordinator { .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, @@ -301,6 +304,7 @@ impl Coordinator { answered_at, cancelled, target: target.map(str::to_owned), + answer_refs, }); } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 6c1d2db..da25b15 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -170,9 +170,9 @@ struct StateSnapshot { /// 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, + questions: Vec, /// Last 20 answered questions, newest-first. - question_history: Vec, + question_history: Vec, /// 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. @@ -186,6 +186,35 @@ struct StateSnapshot { meta_inputs: Vec, } +/// 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, + #[serde(skip_serializing_if = "Vec::is_empty")] + answer_refs: Vec, +} + +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, @@ -311,12 +340,21 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J // dashboard now derives it client-side from the message stream // (terminal backfill + live SSE), so the snapshot stops shipping it. // 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( + // dashboard. Client filters by target client-side. Each row is + // wrapped in QuestionView so the snapshot carries the same + // file_refs the live event variants attach. + let questions: Vec = + log_default("questions.pending_all", state.coord.questions.pending_all()) + .into_iter() + .map(QuestionView::from_question) + .collect(); + let question_history: Vec = log_default( "questions.recent_answered_all", state.coord.questions.recent_answered_all(20), - ); + ) + .into_iter() + .map(QuestionView::from_question) + .collect(); axum::Json(StateSnapshot { seq, diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 86f7e94..fe9f57c 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -106,6 +106,11 @@ pub enum DashboardEvent { asked_at: i64, deadline_at: Option, target: Option, + /// 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, }, /// A question was answered (operator answer, peer answer, /// operator override on a peer thread, or ttl watchdog @@ -120,6 +125,9 @@ pub enum DashboardEvent { answered_at: i64, cancelled: bool, target: Option, + /// Verified file-path tokens that appear in `answer`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + answer_refs: Vec, }, /// A lifecycle action started for an agent (spawn / start / stop /// / restart / rebuild / destroy). Clients render a spinner next