question/answer text: server-side file_refs

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.
This commit is contained in:
müde 2026-05-17 23:54:35 +02:00
parent 378e8bf9df
commit 4ec401a6c7
4 changed files with 62 additions and 9 deletions

View file

@ -748,6 +748,7 @@
asked_at: ev.asked_at, asked_at: ev.asked_at,
deadline_at: ev.deadline_at ?? null, deadline_at: ev.deadline_at ?? null,
target: ev.target || null, target: ev.target || null,
question_refs: ev.question_refs || [],
}); });
renderQuestions(); renderQuestions();
} }
@ -766,6 +767,8 @@
answer: ev.answer, answer: ev.answer,
answerer: ev.answerer, answerer: ev.answerer,
target: existing?.target ?? ev.target ?? null, target: existing?.target ?? ev.target ?? null,
question_refs: existing?.question_refs || [],
answer_refs: ev.answer_refs || [],
}); });
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) { if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
questionsState.history.length = QUESTION_HISTORY_LIMIT; questionsState.history.length = QUESTION_HISTORY_LIMIT;
@ -857,7 +860,7 @@
head.append(' ', el('span', { class: 'q-ttl' }, txt)); head.append(' ', el('span', { class: 'q-ttl' }, txt));
} }
const qBody = el('div', { class: 'q-body' }); 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); li.append(head, qBody);
for (const d of qPreviews) li.appendChild(d); for (const d of qPreviews) li.appendChild(d);
const f = el('form', { const f = el('form', {
@ -953,9 +956,9 @@
el('span', { class: 'msg-sep' }, 'asked:'), el('span', { class: 'msg-sep' }, 'asked:'),
); );
const histBody = el('div', { class: 'q-body' }); 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 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' }, const ansLine = el('div', { class: 'q-answer' },
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `), el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
ansText, ansText,

View file

@ -262,6 +262,7 @@ impl Coordinator {
.ok() .ok()
.and_then(|d| i64::try_from(d.as_secs()).ok()) .and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0); .unwrap_or(0);
let question_refs = crate::dashboard::scan_validated_paths(question);
self.emit_dashboard_event(DashboardEvent::QuestionAdded { self.emit_dashboard_event(DashboardEvent::QuestionAdded {
seq: self.next_seq(), seq: self.next_seq(),
id, id,
@ -272,6 +273,7 @@ impl Coordinator {
asked_at, asked_at,
deadline_at, deadline_at,
target: target.map(str::to_owned), target: target.map(str::to_owned),
question_refs,
}); });
} }
@ -293,6 +295,7 @@ impl Coordinator {
.ok() .ok()
.and_then(|d| i64::try_from(d.as_secs()).ok()) .and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0); .unwrap_or(0);
let answer_refs = crate::dashboard::scan_validated_paths(answer);
self.emit_dashboard_event(DashboardEvent::QuestionResolved { self.emit_dashboard_event(DashboardEvent::QuestionResolved {
seq: self.next_seq(), seq: self.next_seq(),
id, id,
@ -301,6 +304,7 @@ impl Coordinator {
answered_at, answered_at,
cancelled, cancelled,
target: target.map(str::to_owned), target: target.map(str::to_owned),
answer_refs,
}); });
} }

View file

@ -170,9 +170,9 @@ struct StateSnapshot {
/// fire `HelperEvent::QuestionAnswered` back into the asker's /// fire `HelperEvent::QuestionAnswered` back into the asker's
/// inbox. Peer-to-peer questions live in the same table but never /// inbox. Peer-to-peer questions live in the same table but never
/// surface here (see `OperatorQuestions::pending`). /// surface here (see `OperatorQuestions::pending`).
questions: Vec<crate::operator_questions::OpQuestion>, questions: Vec<QuestionView>,
/// Last 20 answered questions, newest-first. /// Last 20 answered questions, newest-first.
question_history: Vec<crate::operator_questions::OpQuestion>, question_history: Vec<QuestionView>,
/// State dirs (config history + claude creds + /state/ notes) that /// State dirs (config history + claude creds + /state/ notes) that
/// survive after a destroy-without-purge. The operator can re-spawn /// survive after a destroy-without-purge. The operator can re-spawn
/// with the same name to resume, or PURG3 to wipe them. /// with the same name to resume, or PURG3 to wipe them.
@ -186,6 +186,35 @@ struct StateSnapshot {
meta_inputs: Vec<MetaInputView>, meta_inputs: Vec<MetaInputView>,
} }
/// 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<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
answer_refs: Vec<String>,
}
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)] #[derive(Serialize)]
struct PortConflict { struct PortConflict {
port: u16, port: u16,
@ -311,12 +340,21 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
// dashboard now derives it client-side from the message stream // dashboard now derives it client-side from the message stream
// (terminal backfill + live SSE), so the snapshot stops shipping it. // (terminal backfill + live SSE), so the snapshot stops shipping it.
// Both operator-targeted and peer threads now surface on the // Both operator-targeted and peer threads now surface on the
// dashboard. Client filters by target client-side. // dashboard. Client filters by target client-side. Each row is
let questions = log_default("questions.pending_all", state.coord.questions.pending_all()); // wrapped in QuestionView so the snapshot carries the same
let question_history = log_default( // file_refs the live event variants attach.
let questions: Vec<QuestionView> =
log_default("questions.pending_all", state.coord.questions.pending_all())
.into_iter()
.map(QuestionView::from_question)
.collect();
let question_history: Vec<QuestionView> = log_default(
"questions.recent_answered_all", "questions.recent_answered_all",
state.coord.questions.recent_answered_all(20), state.coord.questions.recent_answered_all(20),
); )
.into_iter()
.map(QuestionView::from_question)
.collect();
axum::Json(StateSnapshot { axum::Json(StateSnapshot {
seq, seq,

View file

@ -106,6 +106,11 @@ pub enum DashboardEvent {
asked_at: i64, asked_at: i64,
deadline_at: Option<i64>, deadline_at: Option<i64>,
target: Option<String>, target: Option<String>,
/// 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<String>,
}, },
/// A question was answered (operator answer, peer answer, /// A question was answered (operator answer, peer answer,
/// operator override on a peer thread, or ttl watchdog /// operator override on a peer thread, or ttl watchdog
@ -120,6 +125,9 @@ pub enum DashboardEvent {
answered_at: i64, answered_at: i64,
cancelled: bool, cancelled: bool,
target: Option<String>, target: Option<String>,
/// Verified file-path tokens that appear in `answer`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
answer_refs: Vec<String>,
}, },
/// A lifecycle action started for an agent (spawn / start / stop /// A lifecycle action started for an agent (spawn / start / stop
/// / restart / rebuild / destroy). Clients render a spinner next /// / restart / rebuild / destroy). Clients render a spinner next