dashboard: surface peer questions + operator override
questions pane now shows both operator-targeted threads (target IS NULL) and agent-to-agent threads (target = some agent). filter chips above the list: all / @operator / @peer / per-participant. peer rows get a mauve left rule + a 0V3RR1D3 button that POSTs the same /answer-question endpoint (OperatorQuestions::answer already permits the operator as answerer on any target). wire changes: OperatorQuestions gains pending_all + recent_answered_all; QuestionAdded + QuestionResolved events carry target: Option<String>; emit sites drop their target.is_none() guard. answered-history rows show the answerer prefix so override answers are auditable at a glance.
This commit is contained in:
parent
e7ce35c503
commit
a15fafb5de
9 changed files with 187 additions and 71 deletions
5
TODO.md
5
TODO.md
|
|
@ -21,7 +21,10 @@
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
- **UI for agent-to-agent questions** (follow-up to the `ask` rename): now that agents can `ask(to: <agent>)` each other, surface those threads in the per-agent dashboard view. Replace the existing read/unread tabs with THREE filters: `unread`, `from: <agent>`, `to: <agent>`. The `to:` filter makes agent-targeted questions visible so the operator can see at a glance "alice has 3 questions outstanding from bob" and intervene if a thread is stuck. Same UI is useful for general inbox filtering too. Data lives in the existing `operator_questions` table (with the new `target` column) + the broker inbox; no new schema needed. Also expose a "respond" affordance so the operator can override-answer a peer question when an agent is offline / stuck (the answerer-auth check in `OperatorQuestions::answer` already permits the operator on any target).
|
<!-- Landed: dashboard questions pane now shows both operator-targeted
|
||||||
|
and peer (agent-to-agent) threads, with filter chips (all /
|
||||||
|
@operator / @peer / per-participant) and an 0V3RR1D3 button on
|
||||||
|
peer rows so the operator can answer when an agent is stuck. -->
|
||||||
- **Clickable file paths in message bodies**: agents drop pointer strings like `/agents/<name>/state/foo.md` constantly (it's the whole 1 KiB-cap escape hatch). Right now they're plain text — operator has to copy-paste into a terminal to peek. Detect path-shaped tokens (start with `/agents/`, `/shared/`, `/state/`, or absolute `/var/lib/hyperhive/...`) in rendered message bodies + question text + answer text + helper-event payloads, render as clickable links that hit a new `/api/state-file?path=…` dashboard endpoint. Endpoint serves the file as text (with a strict allow-list — only paths under `/var/lib/hyperhive/agents/*/state/`, `/var/lib/hyperhive/shared/`, never anything else), syntax-highlighting where it makes sense, falling back to download for binaries. Reuses the existing `<details>` collapse pattern so inline preview doesn't blow up the message-flow stream.
|
- **Clickable file paths in message bodies**: agents drop pointer strings like `/agents/<name>/state/foo.md` constantly (it's the whole 1 KiB-cap escape hatch). Right now they're plain text — operator has to copy-paste into a terminal to peek. Detect path-shaped tokens (start with `/agents/`, `/shared/`, `/state/`, or absolute `/var/lib/hyperhive/...`) in rendered message bodies + question text + answer text + helper-event payloads, render as clickable links that hit a new `/api/state-file?path=…` dashboard endpoint. Endpoint serves the file as text (with a strict allow-list — only paths under `/var/lib/hyperhive/agents/*/state/`, `/var/lib/hyperhive/shared/`, never anything else), syntax-highlighting where it makes sense, falling back to download for binaries. Reuses the existing `<details>` collapse pattern so inline preview doesn't blow up the message-flow stream.
|
||||||
- **UI for pending reminders**: show pending/queued reminders in dashboard, allow operator to view/debug/cancel
|
- **UI for pending reminders**: show pending/queued reminders in dashboard, allow operator to view/debug/cancel
|
||||||
- Per-agent reminder status (pending, delivered)
|
- Per-agent reminder status (pending, delivered)
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,9 @@
|
||||||
for (const q of questions) {
|
for (const q of questions) {
|
||||||
if (seenQuestions.has(q.id)) continue;
|
if (seenQuestions.has(q.id)) continue;
|
||||||
seenQuestions.add(q.id);
|
seenQuestions.add(q.id);
|
||||||
NOTIF.show('◆ manager asks', q.question.slice(0, 120),
|
const targetLabel = q.target || 'operator';
|
||||||
|
NOTIF.show(`◆ ${q.asker} → ${targetLabel} asks`,
|
||||||
|
q.question.slice(0, 120),
|
||||||
'hyperhive:question:' + q.id);
|
'hyperhive:question:' + q.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -610,6 +612,7 @@
|
||||||
multi: !!ev.multi,
|
multi: !!ev.multi,
|
||||||
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,
|
||||||
});
|
});
|
||||||
renderQuestions();
|
renderQuestions();
|
||||||
}
|
}
|
||||||
|
|
@ -627,26 +630,84 @@
|
||||||
answered_at: ev.answered_at,
|
answered_at: ev.answered_at,
|
||||||
answer: ev.answer,
|
answer: ev.answer,
|
||||||
answerer: ev.answerer,
|
answerer: ev.answerer,
|
||||||
|
target: existing?.target ?? ev.target ?? null,
|
||||||
});
|
});
|
||||||
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;
|
||||||
}
|
}
|
||||||
renderQuestions();
|
renderQuestions();
|
||||||
}
|
}
|
||||||
|
// Filter selection for the questions section. Persisted so the
|
||||||
|
// operator's preferred view (all / operator-targeted / peer)
|
||||||
|
// survives a reload.
|
||||||
|
const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter';
|
||||||
|
function getQuestionsFilter() {
|
||||||
|
return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all';
|
||||||
|
}
|
||||||
|
function setQuestionsFilter(v) {
|
||||||
|
localStorage.setItem(QUESTIONS_FILTER_KEY, v);
|
||||||
|
renderQuestions();
|
||||||
|
}
|
||||||
|
function questionMatchesFilter(q, filter) {
|
||||||
|
if (filter === 'all') return true;
|
||||||
|
if (filter === 'operator') return !q.target;
|
||||||
|
if (filter === 'peer') return !!q.target;
|
||||||
|
// `agent:<name>` matches when the agent appears as asker OR target.
|
||||||
|
if (filter.startsWith('agent:')) {
|
||||||
|
const name = filter.slice('agent:'.length);
|
||||||
|
return q.asker === name || q.target === name;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
function renderQuestions() {
|
function renderQuestions() {
|
||||||
const root = $('questions-section');
|
const root = $('questions-section');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||||
const pending = questionsState.pending;
|
const allPending = questionsState.pending;
|
||||||
|
const activeFilter = getQuestionsFilter();
|
||||||
|
const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter));
|
||||||
|
|
||||||
|
// Filter chips. Always include `all` / `operator` / `peer`; add
|
||||||
|
// per-agent chips for any agent that appears as asker or target
|
||||||
|
// in the pending list so the operator can isolate a single
|
||||||
|
// thread without typing.
|
||||||
|
const participants = new Set();
|
||||||
|
for (const q of allPending) {
|
||||||
|
participants.add(q.asker);
|
||||||
|
if (q.target) participants.add(q.target);
|
||||||
|
}
|
||||||
|
const filterRow = el('div', { class: 'questions-filters' });
|
||||||
|
const mkChip = (value, label) => {
|
||||||
|
const b = el('button', {
|
||||||
|
type: 'button',
|
||||||
|
class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''),
|
||||||
|
}, label);
|
||||||
|
b.addEventListener('click', () => setQuestionsFilter(value));
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
filterRow.append(
|
||||||
|
mkChip('all', `all · ${allPending.length}`),
|
||||||
|
mkChip('operator', '@operator'),
|
||||||
|
mkChip('peer', '@peer'),
|
||||||
|
);
|
||||||
|
for (const name of Array.from(participants).sort()) {
|
||||||
|
filterRow.append(mkChip('agent:' + name, '@' + name));
|
||||||
|
}
|
||||||
|
root.append(filterRow);
|
||||||
|
|
||||||
if (!pending.length) {
|
if (!pending.length) {
|
||||||
root.append(el('p', { class: 'empty' }, 'no pending questions'));
|
root.append(el('p', { class: 'empty' },
|
||||||
|
activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter'));
|
||||||
}
|
}
|
||||||
const ul = el('ul', { class: 'questions' });
|
const ul = el('ul', { class: 'questions' });
|
||||||
for (const q of pending) {
|
for (const q of pending) {
|
||||||
const li = el('li', { class: 'question' });
|
const targetLabel = q.target || 'operator';
|
||||||
|
const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
|
||||||
const head = el('div', { class: 'q-head' },
|
const head = el('div', { class: 'q-head' },
|
||||||
el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ',
|
el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ',
|
||||||
el('span', { class: 'msg-from' }, q.asker), ' ',
|
el('span', { class: 'msg-from' }, q.asker), ' ',
|
||||||
|
el('span', { class: 'msg-sep' }, '→'), ' ',
|
||||||
|
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
|
||||||
el('span', { class: 'msg-sep' }, 'asks:'),
|
el('span', { class: 'msg-sep' }, 'asks:'),
|
||||||
);
|
);
|
||||||
if (q.deadline_at) {
|
if (q.deadline_at) {
|
||||||
|
|
@ -701,9 +762,19 @@
|
||||||
}, true);
|
}, true);
|
||||||
if (hasOptions) f.append(optionGroup);
|
if (hasOptions) f.append(optionGroup);
|
||||||
const buttons = el('div', { class: 'q-buttons' });
|
const buttons = el('div', { class: 'q-buttons' });
|
||||||
|
// On peer threads the operator's answer is an override —
|
||||||
|
// mark the button so it's clear what the click does (the
|
||||||
|
// backend permits it via OperatorQuestions::answer's
|
||||||
|
// answerer-auth rule).
|
||||||
|
const answerLabel = q.target
|
||||||
|
? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3')
|
||||||
|
: (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R');
|
||||||
buttons.append(
|
buttons.append(
|
||||||
el('button', { type: 'submit', class: 'btn btn-approve' },
|
el('button', {
|
||||||
isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'),
|
type: 'submit',
|
||||||
|
class: 'btn btn-approve' + (q.target ? ' btn-override' : ''),
|
||||||
|
title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '',
|
||||||
|
}, answerLabel),
|
||||||
);
|
);
|
||||||
f.append(
|
f.append(
|
||||||
el('div', { class: 'q-free' }, freeText),
|
el('div', { class: 'q-free' }, freeText),
|
||||||
|
|
@ -712,10 +783,11 @@
|
||||||
li.append(f);
|
li.append(f);
|
||||||
// Separate form so the cancel button doesn't get the answer
|
// Separate form so the cancel button doesn't get the answer
|
||||||
// merge-on-submit handler attached to the main form.
|
// merge-on-submit handler attached to the main form.
|
||||||
|
const cancelTargetLabel = q.target ? q.target : 'asker';
|
||||||
const cancelForm = el('form', {
|
const cancelForm = el('form', {
|
||||||
method: 'POST', action: '/cancel-question/' + q.id,
|
method: 'POST', action: '/cancel-question/' + q.id,
|
||||||
class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '',
|
class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '',
|
||||||
'data-confirm': 'cancel this question? manager will see '
|
'data-confirm': `cancel this question? ${cancelTargetLabel} will see `
|
||||||
+ '"[cancelled]" as the answer.',
|
+ '"[cancelled]" as the answer.',
|
||||||
});
|
});
|
||||||
cancelForm.append(
|
cancelForm.append(
|
||||||
|
|
@ -733,17 +805,20 @@
|
||||||
details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
|
details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
|
||||||
const hul = el('ul', { class: 'questions questions-answered' });
|
const hul = el('ul', { class: 'questions questions-answered' });
|
||||||
for (const q of hist) {
|
for (const q of hist) {
|
||||||
const li = el('li', { class: 'question question-answered' });
|
const targetLabel = q.target || 'operator';
|
||||||
|
const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') });
|
||||||
const head = el('div', { class: 'q-head' },
|
const head = el('div', { class: 'q-head' },
|
||||||
el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ',
|
el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ',
|
||||||
el('span', { class: 'msg-from' }, q.asker), ' ',
|
el('span', { class: 'msg-from' }, q.asker), ' ',
|
||||||
|
el('span', { class: 'msg-sep' }, '→'), ' ',
|
||||||
|
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
|
||||||
el('span', { class: 'msg-sep' }, 'asked:'),
|
el('span', { class: 'msg-sep' }, 'asked:'),
|
||||||
);
|
);
|
||||||
li.append(
|
li.append(
|
||||||
head,
|
head,
|
||||||
el('div', { class: 'q-body' }, q.question),
|
el('div', { class: 'q-body' }, q.question),
|
||||||
el('div', { class: 'q-answer' },
|
el('div', { class: 'q-answer' },
|
||||||
el('span', { class: 'msg-sep' }, 'answer: '),
|
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
|
||||||
el('span', { class: 'q-answer-text' }, q.answer || '(none)'),
|
el('span', { class: 'q-answer-text' }, q.answer || '(none)'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -450,6 +450,40 @@ summary:hover { color: var(--purple); }
|
||||||
0%, 100% { box-shadow: 0 0 12px -4px rgba(250, 179, 135, 0.55); }
|
0%, 100% { box-shadow: 0 0 12px -4px rgba(250, 179, 135, 0.55); }
|
||||||
50% { box-shadow: 0 0 22px -2px rgba(250, 179, 135, 0.95); }
|
50% { box-shadow: 0 0 22px -2px rgba(250, 179, 135, 0.95); }
|
||||||
}
|
}
|
||||||
|
/* Filter chip row above the questions list. The active chip lights
|
||||||
|
up amber to match the rest of the dashboard's selection accents. */
|
||||||
|
.questions-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
.q-filter-chip {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15em 0.7em;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.q-filter-chip:hover { color: var(--fg); }
|
||||||
|
.q-filter-chip.active {
|
||||||
|
color: var(--amber);
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
/* Peer (agent-to-agent) question rows get a left rule + dim
|
||||||
|
target-name styling so they read distinctly from operator-bound
|
||||||
|
threads at a glance. */
|
||||||
|
.questions li.question-peer {
|
||||||
|
border-left: 2px solid var(--mauve, #cba6f7);
|
||||||
|
padding-left: 0.6em;
|
||||||
|
}
|
||||||
|
.questions .msg-to-peer { color: var(--mauve, #cba6f7); }
|
||||||
|
/* The override button on peer threads picks up a non-default colour
|
||||||
|
so the operator notices they're answering on someone's behalf. */
|
||||||
|
.btn-override { background: var(--mauve, #cba6f7) !important; color: var(--bg) !important; }
|
||||||
.questions li.question {
|
.questions li.question {
|
||||||
padding: 0.4em 0;
|
padding: 0.4em 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
|
|
||||||
|
|
@ -243,11 +243,10 @@ impl Coordinator {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit `QuestionAdded` after an operator-targeted question is
|
/// Emit `QuestionAdded` after a question is inserted. Fires for
|
||||||
/// inserted. Peer-to-peer questions (those with a non-null
|
/// both operator-targeted (`target = None`) and peer-to-peer
|
||||||
/// `target` agent) never fire this — they don't surface on the
|
/// (`target = Some(agent)`) threads — the dashboard surfaces
|
||||||
/// dashboard at all. Caller is responsible for the
|
/// both, distinguishing visually + offering operator override.
|
||||||
/// `target.is_none()` guard.
|
|
||||||
pub fn emit_question_added(
|
pub fn emit_question_added(
|
||||||
&self,
|
&self,
|
||||||
id: i64,
|
id: i64,
|
||||||
|
|
@ -256,6 +255,7 @@ impl Coordinator {
|
||||||
options: &[String],
|
options: &[String],
|
||||||
multi: bool,
|
multi: bool,
|
||||||
deadline_at: Option<i64>,
|
deadline_at: Option<i64>,
|
||||||
|
target: Option<&str>,
|
||||||
) {
|
) {
|
||||||
let asked_at = std::time::SystemTime::now()
|
let asked_at = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|
@ -271,20 +271,22 @@ impl Coordinator {
|
||||||
multi,
|
multi,
|
||||||
asked_at,
|
asked_at,
|
||||||
deadline_at,
|
deadline_at,
|
||||||
|
target: target.map(str::to_owned),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit `QuestionResolved` when an operator-targeted question
|
/// Emit `QuestionResolved` when a question transitions to
|
||||||
/// transitions to answered (operator answer, peer override,
|
/// answered (operator answer, peer answer, operator override on
|
||||||
/// cancel, or ttl watchdog). Caller filters on the original
|
/// a peer thread, operator cancel, or ttl watchdog). Both
|
||||||
/// question's `target.is_none()` — peer questions are dashboard-
|
/// operator-targeted and peer threads fire so the dashboard's
|
||||||
/// invisible.
|
/// derived store can move the row from pending to history.
|
||||||
pub fn emit_question_resolved(
|
pub fn emit_question_resolved(
|
||||||
&self,
|
&self,
|
||||||
id: i64,
|
id: i64,
|
||||||
answer: &str,
|
answer: &str,
|
||||||
answerer: &str,
|
answerer: &str,
|
||||||
cancelled: bool,
|
cancelled: bool,
|
||||||
|
target: Option<&str>,
|
||||||
) {
|
) {
|
||||||
let answered_at = std::time::SystemTime::now()
|
let answered_at = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|
@ -298,6 +300,7 @@ impl Coordinator {
|
||||||
answerer: answerer.to_owned(),
|
answerer: answerer.to_owned(),
|
||||||
answered_at,
|
answered_at,
|
||||||
cancelled,
|
cancelled,
|
||||||
|
target: target.map(str::to_owned),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -307,9 +307,13 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
||||||
// operator_inbox used to be served here as a 50-row array; the
|
// operator_inbox used to be served here as a 50-row array; the
|
||||||
// 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.
|
||||||
let questions = log_default("questions.pending", state.coord.questions.pending());
|
// Both operator-targeted and peer threads now surface on the
|
||||||
let question_history =
|
// dashboard. Client filters by target client-side.
|
||||||
log_default("questions.recent_answered", state.coord.questions.recent_answered(20));
|
let questions = log_default("questions.pending_all", state.coord.questions.pending_all());
|
||||||
|
let question_history = log_default(
|
||||||
|
"questions.recent_answered_all",
|
||||||
|
state.coord.questions.recent_answered_all(20),
|
||||||
|
);
|
||||||
|
|
||||||
axum::Json(StateSnapshot {
|
axum::Json(StateSnapshot {
|
||||||
seq,
|
seq,
|
||||||
|
|
@ -734,14 +738,13 @@ async fn post_answer_question(
|
||||||
answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
|
answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if target.is_none() {
|
state.coord.emit_question_resolved(
|
||||||
state.coord.emit_question_resolved(
|
id,
|
||||||
id,
|
answer,
|
||||||
answer,
|
hive_sh4re::OPERATOR_RECIPIENT,
|
||||||
hive_sh4re::OPERATOR_RECIPIENT,
|
false,
|
||||||
false,
|
target.as_deref(),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
(StatusCode::OK, "ok").into_response()
|
(StatusCode::OK, "ok").into_response()
|
||||||
}
|
}
|
||||||
Err(e) => error_response(&format!("answer {id} failed: {e:#}")),
|
Err(e) => error_response(&format!("answer {id} failed: {e:#}")),
|
||||||
|
|
@ -766,14 +769,13 @@ async fn post_cancel_question(
|
||||||
{
|
{
|
||||||
Ok((question, asker, target)) => {
|
Ok((question, asker, target)) => {
|
||||||
tracing::info!(%id, %asker, "operator cancelled question");
|
tracing::info!(%id, %asker, "operator cancelled question");
|
||||||
if target.is_none() {
|
state.coord.emit_question_resolved(
|
||||||
state.coord.emit_question_resolved(
|
id,
|
||||||
id,
|
SENTINEL,
|
||||||
SENTINEL,
|
hive_sh4re::OPERATOR_RECIPIENT,
|
||||||
hive_sh4re::OPERATOR_RECIPIENT,
|
true,
|
||||||
true,
|
target.as_deref(),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
state.coord.notify_agent(
|
state.coord.notify_agent(
|
||||||
&asker,
|
&asker,
|
||||||
&hive_sh4re::HelperEvent::QuestionAnswered {
|
&hive_sh4re::HelperEvent::QuestionAnswered {
|
||||||
|
|
|
||||||
|
|
@ -79,11 +79,11 @@ pub enum DashboardEvent {
|
||||||
note: Option<String>,
|
note: Option<String>,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
},
|
},
|
||||||
/// An operator-targeted question landed in the queue
|
/// A question landed in the queue. `target = None` means
|
||||||
/// (`Ask { to: None | Some("operator") }`). Peer-to-peer
|
/// operator-targeted (`Ask { to: None | Some("operator") }`);
|
||||||
/// questions (target = Some(<agent>)) never fire this event —
|
/// `target = Some(<agent>)` means a peer-to-peer question. Both
|
||||||
/// the dashboard only ever shows operator-bound questions, so
|
/// are surfaced on the dashboard so the operator can monitor /
|
||||||
/// the emit site filters on `target.is_none()`.
|
/// override-answer stuck threads.
|
||||||
QuestionAdded {
|
QuestionAdded {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
id: i64,
|
id: i64,
|
||||||
|
|
@ -93,12 +93,13 @@ pub enum DashboardEvent {
|
||||||
multi: bool,
|
multi: bool,
|
||||||
asked_at: i64,
|
asked_at: i64,
|
||||||
deadline_at: Option<i64>,
|
deadline_at: Option<i64>,
|
||||||
|
target: Option<String>,
|
||||||
},
|
},
|
||||||
/// An operator-targeted question was answered (operator answer,
|
/// A question was answered (operator answer, peer answer,
|
||||||
/// peer override, or ttl watchdog `[expired]`). Clients move the
|
/// operator override on a peer thread, or ttl watchdog
|
||||||
/// row from pending to history. `cancelled = true` when the
|
/// `[expired]`). Clients move the row from pending to history.
|
||||||
/// operator dismissed via the cancel button — same code path on
|
/// `cancelled = true` when the operator dismissed via the cancel
|
||||||
/// the server but useful to surface differently in the UI.
|
/// button.
|
||||||
QuestionResolved {
|
QuestionResolved {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
id: i64,
|
id: i64,
|
||||||
|
|
@ -106,6 +107,7 @@ pub enum DashboardEvent {
|
||||||
answerer: String,
|
answerer: String,
|
||||||
answered_at: i64,
|
answered_at: i64,
|
||||||
cancelled: bool,
|
cancelled: bool,
|
||||||
|
target: Option<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
|
||||||
|
|
|
||||||
|
|
@ -454,9 +454,7 @@ pub fn spawn_question_watchdog(coord: &Arc<Coordinator>, id: i64, ttl_secs: u64)
|
||||||
answerer: TTL_ANSWERER.to_owned(),
|
answerer: TTL_ANSWERER.to_owned(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if target.is_none() {
|
coord.emit_question_resolved(id, TTL_SENTINEL, TTL_ANSWERER, false, target.as_deref());
|
||||||
coord.emit_question_resolved(id, TTL_SENTINEL, TTL_ANSWERER, false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,15 +209,15 @@ impl OperatorQuestions {
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pending operator-targeted questions only (`target IS NULL`).
|
/// Every pending question, operator-targeted or peer-to-peer.
|
||||||
/// Drives the dashboard's pending-question pane — agent-to-agent
|
/// Drives the dashboard's questions pane now that peer threads
|
||||||
/// questions never appear here so the operator's queue stays clean.
|
/// are surfaced for visibility + operator override-answer.
|
||||||
pub fn pending(&self) -> Result<Vec<OpQuestion>> {
|
pub fn pending_all(&self) -> Result<Vec<OpQuestion>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
|
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
|
||||||
FROM operator_questions
|
FROM operator_questions
|
||||||
WHERE answered_at IS NULL AND target IS NULL
|
WHERE answered_at IS NULL
|
||||||
ORDER BY id ASC",
|
ORDER BY id ASC",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], row_to_question)?;
|
let rows = stmt.query_map([], row_to_question)?;
|
||||||
|
|
@ -225,15 +225,14 @@ impl OperatorQuestions {
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Last `limit` answered operator-targeted questions, newest-first.
|
/// Last `limit` answered questions across both target kinds,
|
||||||
/// Same `target IS NULL` filter as `pending()` so the dashboard's
|
/// newest-first. Companion to `pending_all`.
|
||||||
/// history view only shows operator-relevant rows.
|
pub fn recent_answered_all(&self, limit: u64) -> Result<Vec<OpQuestion>> {
|
||||||
pub fn recent_answered(&self, limit: u64) -> Result<Vec<OpQuestion>> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
|
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
|
||||||
FROM operator_questions
|
FROM operator_questions
|
||||||
WHERE answered_at IS NOT NULL AND target IS NULL
|
WHERE answered_at IS NOT NULL
|
||||||
ORDER BY answered_at DESC
|
ORDER BY answered_at DESC
|
||||||
LIMIT ?1",
|
LIMIT ?1",
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -241,6 +240,7 @@ impl OperatorQuestions {
|
||||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
|
fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
|
||||||
|
|
|
||||||
|
|
@ -86,9 +86,10 @@ pub fn handle_ask(
|
||||||
multi,
|
multi,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
coord.emit_question_added(id, asker, question, options, multi, deadline_at);
|
|
||||||
}
|
}
|
||||||
|
// Always fire on the dashboard channel — both operator-targeted
|
||||||
|
// and peer threads now surface in the dashboard's questions pane.
|
||||||
|
coord.emit_question_added(id, asker, question, options, multi, deadline_at, target);
|
||||||
if let Some(t) = ttl {
|
if let Some(t) = ttl {
|
||||||
spawn_question_watchdog(coord, id, t);
|
spawn_question_watchdog(coord, id, t);
|
||||||
}
|
}
|
||||||
|
|
@ -120,13 +121,11 @@ pub fn handle_answer(
|
||||||
answerer: answerer.to_owned(),
|
answerer: answerer.to_owned(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Only operator-targeted questions surface on the dashboard;
|
// Dashboard surfaces both operator-targeted and peer threads;
|
||||||
// peer-to-peer answers are invisible to it. `cancelled = false`
|
// emit unconditionally so the derived store moves the row.
|
||||||
// because this path is a real answer (operator cancel goes
|
// `cancelled = false` because this path is a real answer (the
|
||||||
// through `post_cancel_question` directly).
|
// operator-cancel button goes through `post_cancel_question`).
|
||||||
if target.is_none() {
|
coord.emit_question_resolved(id, answer, answerer, false, target.as_deref());
|
||||||
coord.emit_question_resolved(id, answer, answerer, false);
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue