feat(dashboard): operator inbox with mark-as-read on Y3R C4LL
Agents that `send(to: "operator")` were easy to miss — they only surfaced on the FL0W firehose with no read-state (#1469). Surface them on the Y3R C4LL ("things waiting on you") tab as a proper inbox. Backend: - broker: `unread_for_recipient(recipient, limit)` — unacked messages for a recipient, newest-first. Mirrors `mark_all_read`'s filter EXACTLY (`recipient = ?1 AND acked_at IS NULL`, no `delivered_at` condition) so everything listed is exactly what mark-read clears — operator rows never get `delivered_at` set (no agent-socket recv). - dashboard: `GET /api/operator-inbox` → `{ messages: [...] }` (id, from, body, at, in_reply_to, validated file_refs). Mark-read reuses the existing `POST /api/agent/operator/mark-all-read` (the route format-validates the name; "operator" passes; `mark_all_read` already acks `to="operator"` rows). Frontend (Y3R C4LL): - New ◆ 1NB0X ◆ section listing unread messages (sender · time · body, path-linkified) + a "✓ mark all read" button. - Cold-loaded on page load + on tab activation; appended live from the broker `sent` stream (deduped on row id); cleared on mark-all-read. - Unread count folds into the Y3R C4LL tab pill + the browser-title `(N)` prefix, so messages are visible from any tab. Removing the now-redundant FL0W operator-inbox UI is a clean follow-up (deferred to avoid a flow.js conflict with the in-flight #1473). Backend (broker + route) is host-side — @damocles to review per plan. Closes #1469.
This commit is contained in:
parent
4c77eefc51
commit
7d00928c69
4 changed files with 172 additions and 2 deletions
|
|
@ -153,6 +153,16 @@
|
||||||
can decide without leaving the pane. -->
|
can decide without leaving the pane. -->
|
||||||
<section class="tab-pane" id="tab-pane-call"
|
<section class="tab-pane" id="tab-pane-call"
|
||||||
role="tabpanel" aria-labelledby="tab-call">
|
role="tabpanel" aria-labelledby="tab-call">
|
||||||
|
<!-- 1NB0X: unread agent→operator messages (#1469). Fetched on
|
||||||
|
tab activation + cold load, appended live from the broker
|
||||||
|
stream, cleared via "mark all read". Count folds into the
|
||||||
|
Y3R C4LL pill so messages aren't missed. -->
|
||||||
|
<h2>◆ 1NB0X ◆</h2>
|
||||||
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
|
<div id="operator-inbox-section">
|
||||||
|
<p class="meta">loading…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>◆ P3NDING APPR0VALS ◆</h2>
|
<h2>◆ P3NDING APPR0VALS ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<div id="approvals-section">
|
<div id="approvals-section">
|
||||||
|
|
|
||||||
|
|
@ -3970,6 +3970,10 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshState();
|
refreshState();
|
||||||
|
// Cold-load the operator inbox (#1469) so the Y3R C4LL pill + browser
|
||||||
|
// title reflect unread agent→operator messages immediately, before the
|
||||||
|
// operator opens the tab. Live updates arrive via the broker stream.
|
||||||
|
refreshOperatorInbox();
|
||||||
NOTIF.bind();
|
NOTIF.bind();
|
||||||
Panel.bind();
|
Panel.bind();
|
||||||
|
|
||||||
|
|
@ -4017,6 +4021,12 @@ window.marked = marked;
|
||||||
es.onmessage = (e) => {
|
es.onmessage = (e) => {
|
||||||
let ev;
|
let ev;
|
||||||
try { ev = JSON.parse(e.data); } catch { return; }
|
try { ev = JSON.parse(e.data); } catch { return; }
|
||||||
|
// Broker `sent` frames aren't mutation events, but the operator
|
||||||
|
// inbox (#1469) cares about ones addressed to "operator".
|
||||||
|
if (ev.kind === 'sent' && ev.to === 'operator') {
|
||||||
|
operatorInboxAppendFromEvent(ev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const h = MUTATION_HANDLERS[ev.kind];
|
const h = MUTATION_HANDLERS[ev.kind];
|
||||||
if (!h) return; // broker rows + future kinds — dashboard doesn't care
|
if (!h) return; // broker rows + future kinds — dashboard doesn't care
|
||||||
try { h(ev); }
|
try { h(ev); }
|
||||||
|
|
@ -4076,6 +4086,7 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
// ST4TS: hive-wide rollup is a pull (no SSE) — fetch on activation.
|
// ST4TS: hive-wide rollup is a pull (no SSE) — fetch on activation.
|
||||||
if (target === 'stats') { refreshHiveStats(); }
|
if (target === 'stats') { refreshHiveStats(); }
|
||||||
|
if (target === 'call') { refreshOperatorInbox(); }
|
||||||
// SYST3M › C0NT41N3R L04D: live cgroup poll only while the tab is
|
// SYST3M › C0NT41N3R L04D: live cgroup poll only while the tab is
|
||||||
// open (cpu needs a short two-sample read each refresh).
|
// open (cpu needs a short two-sample read each refresh).
|
||||||
if (target === 'system') { startContainerLoadPolling(); } else { stopContainerLoadPolling(); }
|
if (target === 'system') { startContainerLoadPolling(); } else { stopContainerLoadPolling(); }
|
||||||
|
|
@ -4484,6 +4495,72 @@ window.marked = marked;
|
||||||
// stores so SSE-driven updates flow through without extra plumbing.
|
// stores so SSE-driven updates flow through without extra plumbing.
|
||||||
// Set `hidden` when the count is zero so the pill doesn't draw
|
// Set `hidden` when the count is zero so the pill doesn't draw
|
||||||
// attention to an empty room.
|
// attention to an empty room.
|
||||||
|
// ─── operator inbox (#1469) — unread agent→operator messages ────────────
|
||||||
|
// The Y3R C4LL tab surfaces messages agents `send(to: "operator")` so
|
||||||
|
// the operator stops missing them. Unread = broker rows to "operator"
|
||||||
|
// with `acked_at IS NULL`; cold-loaded from `/api/operator-inbox`,
|
||||||
|
// appended live from the broker `sent` stream, and cleared via the
|
||||||
|
// existing per-recipient ack (`POST /api/agent/operator/mark-all-read`).
|
||||||
|
// Count folds into the Y3R C4LL pill + browser-title prefix.
|
||||||
|
let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first
|
||||||
|
async function refreshOperatorInbox() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/operator-inbox');
|
||||||
|
if (r.ok) {
|
||||||
|
const data = await r.json();
|
||||||
|
operatorInbox = Array.isArray(data.messages) ? data.messages : [];
|
||||||
|
}
|
||||||
|
} catch { /* keep prior list on transient failure */ }
|
||||||
|
renderOperatorInbox();
|
||||||
|
refreshTabCounts();
|
||||||
|
}
|
||||||
|
function renderOperatorInbox() {
|
||||||
|
const root = $('operator-inbox-section');
|
||||||
|
if (!root) return;
|
||||||
|
root.replaceChildren();
|
||||||
|
if (!operatorInbox.length) {
|
||||||
|
root.append(el('p', { class: 'meta' }, 'no unread messages'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mark = el('button', { type: 'button', class: 'btn', id: 'op-inbox-mark-read' },
|
||||||
|
`✓ mark all read (${operatorInbox.length})`);
|
||||||
|
mark.addEventListener('click', markOperatorInboxRead);
|
||||||
|
root.append(el('div', { class: 'inbox-toolbar' }, mark));
|
||||||
|
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
const ul = el('ul', { class: 'inbox' });
|
||||||
|
for (const m of operatorInbox) {
|
||||||
|
const body = el('span', { class: 'msg-body' });
|
||||||
|
appendLinkified(body, m.body, m.file_refs);
|
||||||
|
ul.append(el('li', {},
|
||||||
|
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
|
||||||
|
el('span', { class: 'msg-from' }, m.from), ' ',
|
||||||
|
el('span', { class: 'msg-sep' }, '→ '),
|
||||||
|
body,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
root.append(ul);
|
||||||
|
}
|
||||||
|
async function markOperatorInboxRead() {
|
||||||
|
try { await fetch('/api/agent/operator/mark-all-read', { method: 'POST' }); }
|
||||||
|
catch { /* best-effort; the next refresh reconciles */ }
|
||||||
|
operatorInbox = [];
|
||||||
|
renderOperatorInbox();
|
||||||
|
refreshTabCounts();
|
||||||
|
}
|
||||||
|
// Live append from the broker stream — a `sent` frame addressed to
|
||||||
|
// "operator". De-dupes on broker row id so a history/live overlap or
|
||||||
|
// a refresh racing the stream doesn't double-list.
|
||||||
|
function operatorInboxAppendFromEvent(ev) {
|
||||||
|
if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return;
|
||||||
|
operatorInbox.unshift({
|
||||||
|
id: ev.id, from: ev.from, body: ev.body, at: ev.at,
|
||||||
|
file_refs: ev.file_refs || [],
|
||||||
|
});
|
||||||
|
if (operatorInbox.length > 100) operatorInbox.length = 100;
|
||||||
|
renderOperatorInbox();
|
||||||
|
refreshTabCounts();
|
||||||
|
}
|
||||||
|
|
||||||
function setTabCount(tab, n) {
|
function setTabCount(tab, n) {
|
||||||
const el_ = $('tab-count-' + tab);
|
const el_ = $('tab-count-' + tab);
|
||||||
if (!el_) return;
|
if (!el_) return;
|
||||||
|
|
@ -4501,10 +4578,12 @@ window.marked = marked;
|
||||||
if (c.needs_update) swarm++;
|
if (c.needs_update) swarm++;
|
||||||
}
|
}
|
||||||
setTabCount('swarm', swarm);
|
setTabCount('swarm', swarm);
|
||||||
// Y3R C4LL — pending approvals + operator-targeted questions.
|
// Y3R C4LL — pending approvals + operator-targeted questions +
|
||||||
|
// unread agent→operator messages (#1469).
|
||||||
const callCount =
|
const callCount =
|
||||||
(approvalsState?.pending?.length ?? 0) +
|
(approvalsState?.pending?.length ?? 0) +
|
||||||
(questionsState?.pending?.length ?? 0);
|
(questionsState?.pending?.length ?? 0) +
|
||||||
|
operatorInbox.length;
|
||||||
setTabCount('call', callCount);
|
setTabCount('call', callCount);
|
||||||
// Browser tab title prefix — lets the operator see the pending
|
// Browser tab title prefix — lets the operator see the pending
|
||||||
// call count without switching to the window. Strips any existing
|
// call count without switching to the window. Strips any existing
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,44 @@ impl Broker {
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unacknowledged messages addressed to `recipient`, newest-first.
|
||||||
|
/// Backs the dashboard's operator inbox (#1469): the operator never
|
||||||
|
/// `recv`s over an agent socket, so messages to `"operator"` sit in
|
||||||
|
/// the broker with `acked_at IS NULL` until the operator hits "mark
|
||||||
|
/// all read" (which calls [`Broker::mark_all_read`]). This read
|
||||||
|
/// mirrors that filter EXACTLY — `recipient = ?1 AND acked_at IS
|
||||||
|
/// NULL`, with no `delivered_at` condition — so everything listed
|
||||||
|
/// here is precisely what `mark_all_read` will clear (operator rows
|
||||||
|
/// may never get `delivered_at` set). Returned as
|
||||||
|
/// [`MessageEvent::Sent`] so the dashboard reuses its live renderer.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `Err` if the `SQLite` prepare or query fails.
|
||||||
|
pub fn unread_for_recipient(&self, recipient: &str, limit: u64) -> Result<Vec<MessageEvent>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let limit_i = i64::try_from(limit.min(i64::MAX as u64)).unwrap_or(i64::MAX);
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, sender, recipient, body, sent_at, in_reply_to
|
||||||
|
FROM messages
|
||||||
|
WHERE recipient = ?1 AND acked_at IS NULL
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?2",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![recipient, limit_i], |row| {
|
||||||
|
Ok(MessageEvent::Sent {
|
||||||
|
id: row.get(0)?,
|
||||||
|
from: row.get(1)?,
|
||||||
|
to: row.get(2)?,
|
||||||
|
body: row.get(3)?,
|
||||||
|
at: row.get(4)?,
|
||||||
|
in_reply_to: row.get(5)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
/// Number of undelivered messages addressed to `recipient`. Non-mutating
|
/// Number of undelivered messages addressed to `recipient`. Non-mutating
|
||||||
/// — used by the harness to surface "N unread" in tool-result status
|
/// — used by the harness to surface "N unread" in tool-result status
|
||||||
/// lines without popping the queue.
|
/// lines without popping the queue.
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/api/approval-diff/{id}", get(get_approval_diff))
|
.route("/api/approval-diff/{id}", get(get_approval_diff))
|
||||||
.route("/api/state-file", get(get_state_file))
|
.route("/api/state-file", get(get_state_file))
|
||||||
.route("/api/reminders", get(api_reminders))
|
.route("/api/reminders", get(api_reminders))
|
||||||
|
.route("/api/operator-inbox", get(api_operator_inbox))
|
||||||
.route("/api/stats-hive", get(api_stats_hive))
|
.route("/api/stats-hive", get(api_stats_hive))
|
||||||
.route("/api/container-resources", get(api_container_resources))
|
.route("/api/container-resources", get(api_container_resources))
|
||||||
.route("/api/build-logs", get(get_build_logs_all))
|
.route("/api/build-logs", get(get_build_logs_all))
|
||||||
|
|
@ -1736,6 +1737,48 @@ async fn api_reminders(State(state): State<AppState>) -> Response {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox
|
||||||
|
/// (#1469). Returns messages addressed to `"operator"` that haven't been
|
||||||
|
/// acked yet (the operator clears them via the existing
|
||||||
|
/// `POST /api/agent/operator/mark-all-read`). Newest-first; path-shaped
|
||||||
|
/// tokens are validated so the client renders file links like the
|
||||||
|
/// terminal does. Shape: `{ "messages": [{ id, from, body, at,
|
||||||
|
/// in_reply_to, file_refs }] }`.
|
||||||
|
async fn api_operator_inbox(State(state): State<AppState>) -> Response {
|
||||||
|
const INBOX_LIMIT: u64 = 100;
|
||||||
|
match state.coord.broker.unread_for_recipient("operator", INBOX_LIMIT) {
|
||||||
|
Ok(messages) => {
|
||||||
|
let items: Vec<serde_json::Value> = messages
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|m| match m {
|
||||||
|
crate::broker::MessageEvent::Sent {
|
||||||
|
id,
|
||||||
|
from,
|
||||||
|
body,
|
||||||
|
at,
|
||||||
|
in_reply_to,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let file_refs = scan_validated_paths(&body);
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"from": from,
|
||||||
|
"body": body,
|
||||||
|
"at": at,
|
||||||
|
"in_reply_to": in_reply_to,
|
||||||
|
"file_refs": file_refs,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
crate::broker::MessageEvent::Delivered { .. }
|
||||||
|
| crate::broker::MessageEvent::Ping { .. } => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
axum::Json(serde_json::json!({ "messages": items })).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("operator-inbox failed: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct StatsHiveQuery {
|
struct StatsHiveQuery {
|
||||||
window: Option<String>,
|
window: Option<String>,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue