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:
parent
378e8bf9df
commit
4ec401a6c7
4 changed files with 62 additions and 9 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue