From 1db6b8ffedd8a64683d2d47f43d3abc88ed354ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 22:10:02 +0200 Subject: [PATCH] dashboard: queued reminders surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new 'qu3u3d r3m1nd3rs' section between approvals and operator inbox. lists every pending reminder with agent, due-relative timestamp, body, payload path (path-linkified), and a cancel button. drives off a new /api/reminders endpoint and a POST /cancel-reminder/{id} that hard-deletes the row. failure surface (last_error / attempt_count + retry) deferred — needs a sqlite migration; tracked in TODO.md. --- TODO.md | 10 +++-- hive-c0re/assets/app.js | 75 ++++++++++++++++++++++++++++++++++ hive-c0re/assets/dashboard.css | 21 ++++++++++ hive-c0re/assets/index.html | 7 ++++ hive-c0re/src/broker.rs | 51 +++++++++++++++++++++++ hive-c0re/src/dashboard.rs | 23 +++++++++++ 6 files changed, 183 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index b0554c5..fcc5e6b 100644 --- a/TODO.md +++ b/TODO.md @@ -32,10 +32,12 @@ `/var/lib/hyperhive/shared/...`. Legacy bare `/state/...` is intentionally NOT matched (ambiguous from host's perspective); prefer `/agents//state/...` in agent outputs. --> -- **UI for pending reminders**: show pending/queued reminders in dashboard, allow operator to view/debug/cancel -- Per-agent reminder status (pending, delivered) -- Reminder query interface for debugging -- Display reminder delivery errors (failed sends, mark failures) + +- **Reminder delivery-error surface**: `reminder_scheduler::tick` logs failed deliveries but doesn't persist. Add `last_error TEXT, attempt_count INTEGER` columns + a banner on the dashboard row + a "retry" affordance. Needs a sqlite migration (idempotent ALTER TABLE). +- **Per-agent reminder status / query interface**: surface pending vs. delivered counts per agent (manager + each sub-agent) as a small chip on the container row. - **Phase 6 follow-ups** — dashboard side is fully event-driven (Phase 6 leftovers landed); the per-agent web UI's lifecycle endpoints (`/api/{cancel,compact,model,new-session}`, `/login/*`) still 303-redirect-and-poll. Convert them to 200 + `data-no-refresh` so the per-agent page stops refetching `/api/state` on every operator click — `LiveEvent::Note` already covers cancel/compact/model/new-session, login state needs its own `NeedsLogin` / `LoggedIn` events on the per-agent bus. - **Tombstones + meta_inputs events**: not yet event-derived. PURG3 + meta-update still trigger a post-submit `/api/state` refetch on the dashboard. Add `TombstoneAdded`/`TombstoneRemoved` + `MetaInputsChanged` so those forms can drop their refetch too and the cold-load is the only `/api/state` fetch in normal operation. diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index b2f370c..a2206eb 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -1240,6 +1240,79 @@ return s.length <= n ? s : s.slice(0, n - 1) + '…'; } + // ─── reminders ────────────────────────────────────────────────────────── + // Reminders aren't part of /api/state (separate sqlite table, separate + // mutation cadence). Refresh fires alongside refreshState() so a + // cancel POST or a cold load both reflect within the same tick. A + // periodic poll isn't necessary — new reminders are queued by the + // agents themselves and the operator already sees them next time + // they interact with the page. + async function refreshReminders() { + const root = $('reminders-section'); + if (!root) return; + try { + const resp = await fetch('/api/reminders'); + if (!resp.ok) { + root.innerHTML = ''; + root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status)); + return; + } + const rows = await resp.json(); + renderReminders(rows); + } catch (err) { + root.innerHTML = ''; + root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err)); + } + } + function renderReminders(rows) { + const root = $('reminders-section'); + if (!root) return; + root.innerHTML = ''; + if (!rows.length) { + root.append(el('p', { class: 'empty' }, 'no queued reminders')); + return; + } + const ul = el('ul', { class: 'reminders' }); + for (const r of rows) { + const li = el('li', { class: 'reminder-row' }); + const dueIn = r.due_at - Math.floor(Date.now() / 1000); + const dueLabel = dueIn <= 0 + ? `overdue ${fmtAgo(r.due_at)}` + : `in ${fmtDuration(dueIn)}`; + const head = el('div', { class: 'reminder-head' }, + el('span', { class: 'agent' }, r.agent), ' ', + el('span', { class: 'meta', title: new Date(r.due_at * 1000).toISOString() }, dueLabel), + ' ', + el('span', { class: 'meta' }, `· id ${r.id}`), + ); + if (r.file_path) { + head.append(' ', el('span', { class: 'meta' }, '· payload → ')); + appendLinkified(head, r.file_path); + } + const body = el('div', { class: 'reminder-body' }); + const previews = appendLinkified(body, r.message); + li.append(head, body); + for (const d of previews) li.appendChild(d); + // Cancel form omits `data-no-refresh` — the resulting refreshState + // re-fires refreshReminders so the row drops on its own. + const cancelForm = el('form', { + method: 'POST', action: '/cancel-reminder/' + r.id, + class: 'inline', 'data-async': '', + 'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`, + }); + cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L')); + li.append(cancelForm); + ul.append(li); + } + root.append(ul); + } + function fmtDuration(secs) { + if (secs < 60) return secs + 's'; + if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's'; + if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm'; + return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h'; + } + // ─── state polling ────────────────────────────────────────────────────── let pollTimer = null; // Sections whose innerHTML gets blown away on each refresh. If the @@ -1252,6 +1325,7 @@ 'inbox-section', 'approvals-section', 'meta-inputs-section', + 'reminders-section', ]; //
sections that should survive a refresh need a stable // `data-restore-key` attribute. snapshotOpenDetails walks managed @@ -1326,6 +1400,7 @@ syncApprovalsFromSnapshot(s); renderApprovals(); renderMetaInputs(s); + refreshReminders(); restoreOpenDetails(openDetails); notifyDeltas(s); // No periodic refresh timer. Phase 6 covers every container diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index d6648cc..2f439bb 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -450,6 +450,27 @@ summary:hover { color: var(--purple); } 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); } } +/* Reminders list — rendered from /api/reminders, separate from the + main /api/state snapshot. Each row stacks identity, head meta, + body, and a small cancel form. */ +.reminders { + list-style: none; + padding: 0; + margin: 0; +} +.reminder-row { + padding: 0.4em 0; + border-bottom: 1px solid var(--border); +} +.reminder-row:last-child { border-bottom: 0; } +.reminder-head { font-size: 0.9em; } +.reminder-body { + color: var(--fg); + white-space: pre-wrap; + word-break: break-word; + margin: 0.3em 0; +} + /* Path linkification — agents drop pointer strings into messages constantly; clicking the anchor expands a sibling
that lazy-loads from /api/state-file. */ diff --git a/hive-c0re/assets/index.html b/hive-c0re/assets/index.html index 257ecb9..3ce2c51 100644 --- a/hive-c0re/assets/index.html +++ b/hive-c0re/assets/index.html @@ -45,6 +45,13 @@

loading…

+

◆ QU3U3D R3M1ND3RS ◆

+
══════════════════════════════════════════════════════════════
+

reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.

+
+

loading…

+
+

◆ P3NDING APPR0VALS ◆

══════════════════════════════════════════════════════════════
diff --git a/hive-c0re/src/broker.rs b/hive-c0re/src/broker.rs index c25d2c1..e20e11d 100644 --- a/hive-c0re/src/broker.rs +++ b/hive-c0re/src/broker.rs @@ -46,6 +46,19 @@ const EVENT_CHANNEL: usize = 256; /// self-documenting. pub type DueReminder = (String, i64, String, Option); +/// Row shape for [`Broker::list_pending_reminders`], shipped on the +/// dashboard `/api/reminders` response. +#[derive(Debug, Clone, Serialize)] +pub struct PendingReminder { + pub id: i64, + pub agent: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub file_path: Option, + pub due_at: i64, + pub created_at: i64, +} + /// Intra-process broker event. `recv_blocking` listens on the same /// channel as the dashboard forwarder; the forwarder re-emits each /// event as a `DashboardEvent` with a freshly-stamped seq from the @@ -286,6 +299,44 @@ impl Broker { Ok(id) } + /// Every reminder still pending delivery, newest-first. Used by the + /// dashboard's reminders pane so the operator can see what's queued + /// + cancel rows that are no longer wanted. + pub fn list_pending_reminders(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, agent, message, file_path, due_at, created_at \ + FROM reminders \ + WHERE sent_at IS NULL \ + ORDER BY due_at ASC", + )?; + let rows = stmt.query_map([], |row| { + Ok(PendingReminder { + id: row.get(0)?, + agent: row.get(1)?, + message: row.get(2)?, + file_path: row.get(3)?, + due_at: row.get(4)?, + created_at: row.get(5)?, + }) + })?; + rows.collect::>>() + .context("list pending reminders") + } + + /// Delete a reminder by id. Returns the number of rows removed (0 + /// when the id never existed or was already delivered). Hard + /// delete rather than soft so the row doesn't linger and confuse a + /// re-creation under the same id. + pub fn cancel_reminder(&self, id: i64) -> Result { + let conn = self.conn.lock().unwrap(); + let n = conn.execute( + "DELETE FROM reminders WHERE id = ?1 AND sent_at IS NULL", + params![id], + )?; + Ok(n) + } + /// Get up to `limit` due reminders across all agents in a single query. /// Returns `(agent, id, message, file_path)` tuples. Pass a small limit /// (e.g. 100) so a burst of overdue reminders doesn't flood the broker diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index c513e51..d854691 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -55,6 +55,8 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/purge-tombstone/{name}", post(post_purge_tombstone)) .route("/api/journal/{name}", get(get_journal)) .route("/api/state-file", get(get_state_file)) + .route("/api/reminders", get(api_reminders)) + .route("/cancel-reminder/{id}", post(post_cancel_reminder)) .route("/api/agent-config/{name}", get(get_agent_config)) .route("/request-spawn", post(post_request_spawn)) .route("/op-send", post(post_op_send)) @@ -983,6 +985,27 @@ async fn get_state_file( ([("content-type", "text/plain; charset=utf-8")], body).into_response() } +async fn api_reminders(State(state): State) -> Response { + match state.coord.broker.list_pending_reminders() { + Ok(rows) => axum::Json(rows).into_response(), + Err(e) => error_response(&format!("reminders: {e:#}")), + } +} + +async fn post_cancel_reminder( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.coord.broker.cancel_reminder(id) { + Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")), + Ok(_) => { + tracing::info!(%id, "operator cancelled reminder"); + (StatusCode::OK, "ok").into_response() + } + Err(e) => error_response(&format!("cancel reminder {id} failed: {e:#}")), + } +} + async fn post_purge_tombstone( State(state): State, AxumPath(name): AxumPath,