From 76c4a67b1c336d7dfe05099d8b3692a6e5af2055 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 09:51:38 +0200 Subject: [PATCH 1/9] feat: live SSE updates for the SCH3DUL3S tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `SchedulesChanged` to the dashboard event channel so the operator's schedule list updates in real time without requiring a tab-activation or form-submit refresh. Backend: - `dashboard_events.rs`: new `SchedulesChanged { seq, schedules }` variant carrying a full `Vec` snapshot (same snapshot-over-diff rationale as `RebuildQueueChanged`). - `coordinator.rs`: `emit_schedules_snapshot()` helper — queries the scheduled_prompts list, converts to wire shape, broadcasts the event. - `dashboard.rs`: call `emit_schedules_snapshot()` at the end of each operator API handler that mutates a schedule: `post_schedule_new`, `post_schedule_fire_now`, `patch_schedule`, `post_schedule_cancel`. - `scheduled_prompts_worker.rs`: call `emit_schedules_snapshot()` after each tick that fires schedules, so `last_fired_at_unix`, `next_fire_at_unix`, and reaped one-shots surface live. Frontend: - `tabs.js`: add `applySchedulesChanged(ev)` — replaces `schedulesState` from the snapshot and calls `renderSchedulesList()`. Registered in `MUTATION_HANDLERS` as `schedules_changed`. Tab-activation re-fetch kept as safety net for approval-path inserts and disconnect windows; comment updated to reflect this. Docs: - `docs/web-ui/dashboard.md`: document `schedules_changed` event. - `CLAUDE.md`: add `SchedulesChanged` to the file-map entry. --- CLAUDE.md | 3 ++- docs/web-ui/dashboard.md | 9 +++++++++ frontend/packages/dashboard/src/tabs.js | 17 +++++++++++++---- hive-c0re/src/coordinator.rs | 21 +++++++++++++++++++++ hive-c0re/src/dashboard.rs | 19 +++++++++++++++---- hive-c0re/src/dashboard_events.rs | 15 +++++++++++++++ hive-c0re/src/scheduled_prompts_worker.rs | 3 +++ 7 files changed, 78 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2bd17f51..c4416d0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,8 @@ hive-c0re/ host daemon + sibling operator CLI (lib + 2 bins) (`ApprovalAdded` / `ApprovalResolved`, `QuestionAdded` / `QuestionResolved`, `TransientSet` / `TransientCleared`, - `RebuildQueueChanged`). Each frame carries a + `RebuildQueueChanged`, `SchedulesChanged`). + Each frame carries a monotonic per-process `seq` clients use to dedupe against snapshot reads. src/approvals.rs sqlite Approval queue + kinds diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 3d97d628..610163fd 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -843,6 +843,15 @@ payload): `meta_inputs_changed`: the list is small and the client's `parent_id` grouping is most naturally re-derived from the full list. Cold-loaded from `/api/state.rebuild_queue`. +- `schedules_changed` (seq, schedules: `Vec`) — + full snapshot of all scheduled prompts. Emitted after every + operator mutation via the `/api/schedules` surface (new / + edit / cancel / fire-now) and after the worker fires or + rearms a row. Same snapshot-shape rationale as + `rebuild_queue_changed`. The SCH3DUL3S tab subscribes and + re-renders `schedulesState` on receipt; tab activation still + re-fetches as a safety net for approval-path inserts and + disconnect windows. `/api/state` is **only fetched on cold-load and on the few forms that mutate non-event-derived state** (PURG3 + diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 5802dcda..2f0eaaaa 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -223,6 +223,10 @@ window.marked = marked; // See docs/web-ui.md::Container row for the badge taxonomy. renderContainersFromState(); } + function applySchedulesChanged(ev) { + schedulesState = (ev.schedules || []).slice(); + renderSchedulesList(); + } // Map from agent name → highest-priority in-flight queue entry // (`running` beats `queued`). Used by the container row renderer // to surface "building..." / "meta-updating..." badges on the @@ -2471,8 +2475,11 @@ window.marked = marked; // ─── scheduled prompts ───────────────────────────────────────────────── // Backend exposes `/api/schedules` (snapshot), `/api/schedules` // (POST, operator-direct submit), `/api/schedules/{id}/cancel` - // (whole or per-target). No SSE channel for schedule mutations yet, - // so we refresh on tab activation + after every submit/cancel POST. + // (whole or per-target), `/api/schedules/{id}` (PATCH edit), + // `/api/schedules/{id}/fire-now` (POST). Mutations now emit a + // `schedules_changed` SSE event so the list updates live; + // `applySchedulesChanged` handles it. Tab-activation re-fetch kept as + // a safety net for approval-path inserts and disconnect windows. // Local cache lets `refreshTabCounts` show the active count without // re-fetching every second. let schedulesState = []; @@ -3596,6 +3603,7 @@ window.marked = marked; meta_inputs_changed: applyMetaInputsChanged, meta_update_running: applyMetaUpdateRunning, rebuild_queue_changed: applyRebuildQueueChanged, + schedules_changed: applySchedulesChanged, }; (function bindDashboardStream() { // Route through the SharedWorker so all open hyperhive tabs share @@ -3650,8 +3658,9 @@ window.marked = marked; renderSelectionBar(Array.from(containersState.values())); // Keep overflow button active state in sync after tab change. updateTabbarOverflow(); - // Schedules pane has no SSE channel for mutations, so re-fetch - // on activation so the operator never lands on stale data. + // Re-fetch schedules on activation as a safety net (SSE covers + // live mutations but re-sync ensures consistency after disconnect + // windows or approval-path inserts that don't yet emit). if (target === 'schedules') refreshSchedules(); // Permissions tables (capabilities + tool-groups) have no SSE channel; // fetch both on each activation so the operator sees fresh data. diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index ee0b8440..496f3083 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -368,6 +368,27 @@ impl Coordinator { }); } + /// Emit a `SchedulesChanged` snapshot event. Called from every + /// schedule mutation site (operator API handlers + the worker + /// after each tick that fires or rearms a row) so the dashboard's + /// scheduled-prompts tab updates live without polling. + pub fn emit_schedules_snapshot(self: &Arc) { + let schedules = match self.scheduled_prompts.list() { + Ok(rows) => rows + .into_iter() + .map(crate::manager_server::schedule_to_wire_public) + .collect(), + Err(e) => { + tracing::warn!(error = ?e, "emit_schedules_snapshot: list failed"); + return; + } + }; + self.emit_dashboard_event(DashboardEvent::SchedulesChanged { + seq: self.next_seq(), + schedules, + }); + } + /// Update the `step` label on a running queue entry and (if it /// actually changed) re-emit the queue snapshot so the dashboard /// renders the new phase. Returns `true` when the label was new diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 24dd5392..bdb78bcf 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -1964,7 +1964,10 @@ async fn post_schedule_new( source: crate::scheduled_prompts::ScheduleSource::Operator, }; match state.coord.scheduled_prompts.submit(&new) { - Ok(id) => axum::Json(serde_json::json!({"id": id})).into_response(), + Ok(id) => { + state.coord.emit_schedules_snapshot(); + axum::Json(serde_json::json!({"id": id})).into_response() + } Err(e) => error_response(&format!("schedule submit: {e:#}")), } } @@ -1982,7 +1985,10 @@ async fn post_schedule_fire_now( AxumPath(id): AxumPath, ) -> Response { match crate::scheduled_prompts_worker::fire_now(&state.coord, id).await { - Ok(report) => axum::Json(report).into_response(), + Ok(report) => { + state.coord.emit_schedules_snapshot(); + axum::Json(report).into_response() + } Err(e) => error_response(&format!("fire schedule {id} now: {e:#}")), } } @@ -2093,7 +2099,9 @@ async fn patch_schedule( } match state.coord.scheduled_prompts.get(id) { Ok(Some(s)) => { - axum::Json(crate::manager_server::schedule_to_wire_public(s)).into_response() + let wire = crate::manager_server::schedule_to_wire_public(s); + state.coord.emit_schedules_snapshot(); + axum::Json(wire).into_response() } Ok(None) => error_response(&format!("edit schedule {id}: row vanished post-update")), Err(e) => error_response(&format!("re-read schedule {id}: {e:#}")), @@ -2117,7 +2125,10 @@ async fn post_schedule_cancel( None => state.coord.scheduled_prompts.cancel_all(id), }; match result { - Ok(()) => (StatusCode::OK, "ok").into_response(), + Ok(()) => { + state.coord.emit_schedules_snapshot(); + (StatusCode::OK, "ok").into_response() + } Err(e) => error_response(&format!("cancel schedule {id}: {e:#}")), } } diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 60e89795..631547b6 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -192,6 +192,16 @@ pub enum DashboardEvent { /// dashboard's grouping (`parent_id`) is most naturally re-derived /// from the full list. RebuildQueueChanged { seq: u64, queue: Vec }, + /// Full snapshot of all scheduled prompts. Emitted after every + /// operator mutation (new / edit / cancel / fire-now) and after the + /// worker fires or rearms a row. Same snapshot-shape rationale as + /// `RebuildQueueChanged` — the list is small and the client's + /// per-target `last_result` / `last_fired_at_unix` fields are most + /// naturally re-derived from the full list. + SchedulesChanged { + seq: u64, + schedules: Vec, + }, } impl DashboardEvent { @@ -222,6 +232,7 @@ impl DashboardEvent { DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed", DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running", DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed", + DashboardEvent::SchedulesChanged { .. } => "schedules_changed", } } } @@ -340,6 +351,10 @@ mod tests { seq: 1, queue: Vec::new(), }, + DashboardEvent::SchedulesChanged { + seq: 1, + schedules: Vec::new(), + }, ]; for ev in samples { let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise"); diff --git a/hive-c0re/src/scheduled_prompts_worker.rs b/hive-c0re/src/scheduled_prompts_worker.rs index ad9b35f8..08523bda 100644 --- a/hive-c0re/src/scheduled_prompts_worker.rs +++ b/hive-c0re/src/scheduled_prompts_worker.rs @@ -69,6 +69,9 @@ fn tick(coord: &Arc) { if let Err(e) = coord.scheduled_prompts.reap_cancelled(cutoff) { tracing::warn!(error = ?e, "scheduled_prompts: reap cancelled failed"); } + // Emit after all fires + reaps so the dashboard reflects updated + // last_fired_at_unix, next_fire_at_unix, and any reaped one-shots. + coord.emit_schedules_snapshot(); } /// Fan out one schedule's body to every active target. Records From 68108fe5f8144d341d01ace976b31d9fa94a694c Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 09:56:36 +0200 Subject: [PATCH 2/9] fix: emit SchedulesChanged from manager-server + approval paths Agent-triggered schedule mutations (cancel_schedule, fire_schedule_now, edit_schedule MCP tools) go through manager_server.rs, not the HTTP API handlers. Approval-resolved SchedulePrompt inserts go through actions.rs. Neither was emitting SchedulesChanged. - manager_server.rs: add emit_schedules_snapshot() on Ok in handle_cancel_schedule, handle_fire_schedule_now, handle_edit_schedule - actions.rs: emit_schedules_snapshot() after successful run_approval_schedule_prompt (covers request_schedule_prompt approval resolving) Coverage is now complete: every path that writes a scheduled_prompts row emits the SSE snapshot. --- hive-c0re/src/actions.rs | 6 +++++- hive-c0re/src/manager_server.rs | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 9b573ec6..0364d8ed 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -116,7 +116,11 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { // at the scheduled time). Run inline + fire // `ApprovalResolved` so the approval row leaves Pending // immediately. - run_approval_schedule_prompt(&coord, approval).await + let result = run_approval_schedule_prompt(&coord, approval).await; + if result.is_ok() { + coord.emit_schedules_snapshot(); + } + result } } } diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index ff26c197..27a31d4d 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -610,7 +610,10 @@ fn handle_cancel_schedule( .map_err(|e| format!("cancel all: {e:#}")), }; match result { - Ok(()) => ManagerResponse::Ok, + Ok(()) => { + coord.emit_schedules_snapshot(); + ManagerResponse::Ok + } Err(message) => ManagerResponse::Err { message }, } } @@ -647,7 +650,10 @@ async fn handle_fire_schedule_now( }; } match crate::scheduled_prompts_worker::fire_now(coord, schedule_id).await { - Ok(_report) => ManagerResponse::Ok, + Ok(_report) => { + coord.emit_schedules_snapshot(); + ManagerResponse::Ok + } Err(e) => ManagerResponse::Err { message: format!("fire schedule {schedule_id} now: {e:#}"), }, @@ -709,7 +715,10 @@ fn handle_edit_schedule( targets_remove, }; match coord.scheduled_prompts.update(schedule_id, patch) { - Ok(()) => ManagerResponse::Ok, + Ok(()) => { + coord.emit_schedules_snapshot(); + ManagerResponse::Ok + } Err(e) => ManagerResponse::Err { message: format!("edit schedule {schedule_id}: {e:#}"), }, From a1e46e2b3d8b1c4b7d72afa91d0a719a6310615e Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 10:13:16 +0200 Subject: [PATCH 3/9] feat: live SSE updates for the SYST3M reminders section Add RemindersChanged SSE event so the pending-reminders list in the SYST3M tab updates live without polling. Backend emission sites (every path that mutates the reminders table): - agent_server: store_remind (remind MCP call) - dashboard.rs: post_cancel_reminder, post_retry_reminder - questions.rs: cancel_loose_end Reminder kind - reminder_scheduler: after each delivery batch (any_delivered) Coordinator gets emit_reminders_snapshot() mirroring the existing emit_schedules_snapshot() pattern: lists PendingReminder rows from the broker and emits DashboardEvent::RemindersChanged. Frontend: applyRemindersChanged(ev) calls renderReminders(ev.reminders) and is registered as reminders_changed in MUTATION_HANDLERS. Docs: dashboard.md reminders_changed entry; CLAUDE.md file map updated. --- CLAUDE.md | 3 ++- docs/web-ui/dashboard.md | 8 ++++++++ frontend/packages/dashboard/src/tabs.js | 4 ++++ hive-c0re/src/agent_server.rs | 1 + hive-c0re/src/coordinator.rs | 18 ++++++++++++++++++ hive-c0re/src/dashboard.rs | 2 ++ hive-c0re/src/dashboard_events.rs | 13 +++++++++++++ hive-c0re/src/questions.rs | 1 + hive-c0re/src/reminder_scheduler.rs | 6 ++++++ 9 files changed, 55 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c4416d0b..4cf65892 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,8 @@ hive-c0re/ host daemon + sibling operator CLI (lib + 2 bins) (`ApprovalAdded` / `ApprovalResolved`, `QuestionAdded` / `QuestionResolved`, `TransientSet` / `TransientCleared`, - `RebuildQueueChanged`, `SchedulesChanged`). + `RebuildQueueChanged`, `SchedulesChanged`, + `RemindersChanged`). Each frame carries a monotonic per-process `seq` clients use to dedupe against snapshot reads. diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 610163fd..f02b23c0 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -852,6 +852,14 @@ payload): re-renders `schedulesState` on receipt; tab activation still re-fetches as a safety net for approval-path inserts and disconnect windows. +- `reminders_changed` (seq, reminders: `Vec`) — + full snapshot of all pending reminders. Emitted after every + reminder mutation: agent `remind` calls (`agent_server`), + operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end` + with Reminder kind, and the scheduler tick after each delivery + batch (`reminder_scheduler`). The SYST3M tab's reminders section + subscribes and calls `renderReminders` on receipt, so the list + updates live without polling. `/api/state` is **only fetched on cold-load and on the few forms that mutate non-event-derived state** (PURG3 + diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 2f0eaaaa..31e6b7a2 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -227,6 +227,9 @@ window.marked = marked; schedulesState = (ev.schedules || []).slice(); renderSchedulesList(); } + function applyRemindersChanged(ev) { + renderReminders(ev.reminders || []); + } // Map from agent name → highest-priority in-flight queue entry // (`running` beats `queued`). Used by the container row renderer // to surface "building..." / "meta-updating..." badges on the @@ -3604,6 +3607,7 @@ window.marked = marked; meta_update_running: applyMetaUpdateRunning, rebuild_queue_changed: applyRebuildQueueChanged, schedules_changed: applySchedulesChanged, + reminders_changed: applyRemindersChanged, }; (function bindDashboardStream() { // Route through the SharedWorker so all open hyperhive tabs share diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index b7f50963..92401386 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -811,6 +811,7 @@ pub(crate) fn store_remind( .store_reminder(agent, &stored_message, stored_path.as_deref(), due_at) .map_err(|e| format!("failed to store reminder: {e:#}"))?; tracing::info!(%id, %agent, %due_at, "reminder scheduled"); + coord.emit_reminders_snapshot(); Ok(()) } diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 496f3083..6a13c03c 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -389,6 +389,24 @@ impl Coordinator { }); } + /// Emit a `RemindersChanged` snapshot event. Called from every + /// reminder mutation site (agent `remind` calls, operator cancel / + /// retry, and the scheduler after each delivery batch) so the + /// dashboard's pending-reminders list stays live without polling. + pub fn emit_reminders_snapshot(self: &Arc) { + let reminders = match self.broker.list_pending_reminders() { + Ok(rows) => rows, + Err(e) => { + tracing::warn!(error = ?e, "emit_reminders_snapshot: list failed"); + return; + } + }; + self.emit_dashboard_event(DashboardEvent::RemindersChanged { + seq: self.next_seq(), + reminders, + }); + } + /// Update the `step` label on a running queue entry and (if it /// actually changed) re-emit the queue snapshot so the dashboard /// renders the new phase. Returns `true` when the label was new diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index bdb78bcf..9290ab9f 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -2141,6 +2141,7 @@ async fn post_cancel_reminder( Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")), Ok(_) => { tracing::info!(%id, "operator cancelled reminder"); + state.coord.emit_reminders_snapshot(); (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("cancel reminder {id} failed: {e:#}")), @@ -2160,6 +2161,7 @@ async fn post_retry_reminder( Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")), Ok(_) => { tracing::info!(%id, "operator reset reminder failure for retry"); + state.coord.emit_reminders_snapshot(); (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("retry reminder {id} failed: {e:#}")), diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 631547b6..818f452d 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -202,6 +202,14 @@ pub enum DashboardEvent { seq: u64, schedules: Vec, }, + /// Full snapshot of all pending reminders. Emitted after every + /// reminder mutation: agent `remind` calls, operator cancel / retry, + /// and the scheduler tick after each delivery batch. Lets the + /// dashboard's reminders section stay live without polling. + RemindersChanged { + seq: u64, + reminders: Vec, + }, } impl DashboardEvent { @@ -233,6 +241,7 @@ impl DashboardEvent { DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running", DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed", DashboardEvent::SchedulesChanged { .. } => "schedules_changed", + DashboardEvent::RemindersChanged { .. } => "reminders_changed", } } } @@ -355,6 +364,10 @@ mod tests { seq: 1, schedules: Vec::new(), }, + DashboardEvent::RemindersChanged { + seq: 1, + reminders: Vec::new(), + }, ]; for ev in samples { let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise"); diff --git a/hive-c0re/src/questions.rs b/hive-c0re/src/questions.rs index 35eaa1c8..1d91647b 100644 --- a/hive-c0re/src/questions.rs +++ b/hive-c0re/src/questions.rs @@ -178,6 +178,7 @@ pub fn handle_cancel_loose_end( .cancel_reminder_as(id, canceller) .map_err(|e| format!("{e:#}"))?; tracing::info!(%id, %canceller, %owner, "reminder cancelled"); + coord.emit_reminders_snapshot(); Ok(()) } hive_sh4re::CancelLooseEndKind::Approval => { diff --git a/hive-c0re/src/reminder_scheduler.rs b/hive-c0re/src/reminder_scheduler.rs index c213882b..df00fb8d 100644 --- a/hive-c0re/src/reminder_scheduler.rs +++ b/hive-c0re/src/reminder_scheduler.rs @@ -63,6 +63,7 @@ fn tick(coord: &Arc) { // Single-transaction batch: one DB lock acquisition for N reminders // instead of N sequential lock/unlock cycles. let results = coord.broker.deliver_reminders_batch(&items); + let any_delivered = results.iter().any(|r| r.is_ok()); for ((id, agent, _body), result) in items.iter().zip(results.iter()) { if let Err(e) = result { let reason = format!("{e:#}"); @@ -82,6 +83,11 @@ fn tick(coord: &Arc) { } } } + // Emit after the batch so the dashboard's pending-reminders list + // updates when deliveries land (removes delivered rows). + if any_delivered { + coord.emit_reminders_snapshot(); + } } /// Build the inbox body for a due reminder. When `file_path` is None From d0b038e17d611b373d123c86bdb01b2647fb631a Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 10:18:23 +0200 Subject: [PATCH 4/9] fix(dashboard): refresh reminders on SCH3DUL3S tab activation + update stale comments - activateTab('schedules') now calls both refreshSchedules() and refreshReminders() since both sections live on the same tab. (The previous SYST3M/system target was wrong.) - Update index.html comment to reflect schedules_changed SSE coverage - Update index.html reminders comment to mention reminders_changed SSE - Update tabs.js reminders section comment to reflect SSE coverage --- frontend/packages/dashboard/src/index.html | 12 +++++++----- frontend/packages/dashboard/src/tabs.js | 15 ++++++++------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index 6584ea29..91ff3a94 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -229,9 +229,10 @@ toggle for existing schedules. Schedules list driven by GET /api/schedules; POST /api/schedules to create, PATCH /api/schedules/{id} to edit, POST /api/schedules/{id}/cancel - for per-target / whole-row cancel. No SchedulesChanged SSE - event yet, so the list re-fetches on tab activation + after - each submit / cancel. See docs/web-ui.md::SCH3DUL3S tab. --> + for per-target / whole-row cancel. Live updates via + `schedules_changed` SSE; tab activation re-fetches as a + safety net for disconnect windows. + See docs/web-ui.md::SCH3DUL3S tab. -->

◆ SCH3DUL3S ◆

@@ -245,8 +246,9 @@ on this tab so the operator has one place for everything that fires at a future time — operator-set schedules above, agent-self reminders here. Backed by GET - /api/reminders; refresh handled by refreshReminders() - (called from refreshState). --> + /api/reminders; live updates via `reminders_changed` SSE; + refreshReminders() called from refreshState + tab + activation as safety net for disconnect windows. -->

◆ QU3U3D R3M1ND3RS ◆

══════════════════════════════════════════════════════════════

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

diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 31e6b7a2..db49837f 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -2375,11 +2375,10 @@ window.marked = marked; // ─── 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. + // mutation cadence). refreshReminders() is called from refreshState() for + // cold-load and reconnect recovery. Live mutations are covered by the + // `reminders_changed` SSE event → `applyRemindersChanged` so no periodic + // poll is needed. async function refreshReminders() { const liveRoot = $('reminders-section'); if (!liveRoot) return; @@ -3664,8 +3663,10 @@ window.marked = marked; updateTabbarOverflow(); // Re-fetch schedules on activation as a safety net (SSE covers // live mutations but re-sync ensures consistency after disconnect - // windows or approval-path inserts that don't yet emit). - if (target === 'schedules') refreshSchedules(); + // windows or approval-path inserts that don't yet emit). Also + // re-fetch reminders on SCH3DUL3S activation since both sections + // live on the same tab. + if (target === 'schedules') { refreshSchedules(); refreshReminders(); } // Permissions tables (capabilities + tool-groups) have no SSE channel; // fetch both on each activation so the operator sees fresh data. if (target === 'permissions') { From 2080fd386648da27885a85547960490a1f6ee006 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 10:21:37 +0200 Subject: [PATCH 5/9] feat: live SSE updates for P3RM1SS10NS tab (capabilities + tool groups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CapabilitiesChanged and ToolGroupsChanged DashboardEvent variants so the P3RM1SS10NS tab reflects perm changes without the operator navigating away and back. Backend: - DashboardEvent::CapabilitiesChanged { seq, caps, descriptions, assignments } — same payload shape as GET /api/capabilities - DashboardEvent::ToolGroupsChanged { seq, groups, descriptions, assignments } — same payload shape as GET /api/tool-groups - Coordinator::emit_capabilities_snapshot() and emit_tool_groups_snapshot() — read from the JSON files and broadcast - rebuild_queue.rs PermChange worker: emit after each successful commit_capabilities / commit_tool_groups call Frontend: - applyCapabilitiesChanged(ev): calls renderCapabilities(root, ev) - applyToolGroupsChanged(ev): calls renderToolGroups(root, ev) - Both registered in MUTATION_HANDLERS - activateTab comment updated (SSE now covers perm changes) Docs: dashboard.md and CLAUDE.md updated. This completes SSE coverage for all dashboard sections: SW4RM, Y3R C4LL, SYST3M, SCH3DUL3S/reminders, and P3RM1SS10NS all derive live updates from /dashboard/stream. --- CLAUDE.md | 3 +- docs/web-ui/dashboard.md | 14 ++++++++- frontend/packages/dashboard/src/tabs.js | 22 ++++++++++++-- hive-c0re/src/coordinator.rs | 38 +++++++++++++++++++++++ hive-c0re/src/dashboard_events.rs | 40 +++++++++++++++++++++++++ hive-c0re/src/rebuild_queue.rs | 5 ++++ 6 files changed, 117 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4cf65892..ff3e1e61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,8 @@ hive-c0re/ host daemon + sibling operator CLI (lib + 2 bins) `QuestionAdded` / `QuestionResolved`, `TransientSet` / `TransientCleared`, `RebuildQueueChanged`, `SchedulesChanged`, - `RemindersChanged`). + `RemindersChanged`, `CapabilitiesChanged`, + `ToolGroupsChanged`). Each frame carries a monotonic per-process `seq` clients use to dedupe against snapshot reads. diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index f02b23c0..232232fa 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -857,9 +857,21 @@ payload): reminder mutation: agent `remind` calls (`agent_server`), operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end` with Reminder kind, and the scheduler tick after each delivery - batch (`reminder_scheduler`). The SYST3M tab's reminders section + batch (`reminder_scheduler`). The SCH3DUL3S tab's reminders section subscribes and calls `renderReminders` on receipt, so the list updates live without polling. +- `capabilities_changed` (seq, caps: `Vec`, descriptions: map, + assignments: `BTreeMap>`) — full snapshot of + capability grants. Emitted from the rebuild-queue worker after a + `PermChange` / Capabilities entry commits the JSON file. Payload + matches `GET /api/capabilities` shape so `renderCapabilities` can + be called directly. P3RM1SS10NS tab subscribes; activation + re-fetch still runs as a safety net. +- `tool_groups_changed` (seq, groups: `Vec`, descriptions: map, + assignments: `BTreeMap>`) — full snapshot of + tool-group assignments. Emitted from the rebuild-queue worker after + a `PermChange` / ToolGroups entry commits the JSON file. Same + shape as `GET /api/tool-groups`; P3RM1SS10NS tab subscribes. `/api/state` is **only fetched on cold-load and on the few forms that mutate non-event-derived state** (PURG3 + diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index db49837f..2efbe319 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -1227,7 +1227,19 @@ window.marked = marked; // ── tool-groups (permissions) table ───────────────────────────────────── // Fetched from GET /api/tool-groups on system tab activation and after // each save. Groups (columns) come from the backend so the UI doesn't - // need updating when a new group is added. + // need updating when a new group is added. Live updates via + // `capabilities_changed` / `tool_groups_changed` SSE events fired + // after the rebuild-queue worker commits the perm JSON file. + function applyCapabilitiesChanged(ev) { + const root = $('capabilities-section'); + if (!root) return; + renderCapabilities(root, ev); + } + function applyToolGroupsChanged(ev) { + const root = $('tool-groups-section'); + if (!root) return; + renderToolGroups(root, ev); + } async function fetchAndRenderCapabilities() { const root = $('capabilities-section'); @@ -3607,6 +3619,8 @@ window.marked = marked; rebuild_queue_changed: applyRebuildQueueChanged, schedules_changed: applySchedulesChanged, reminders_changed: applyRemindersChanged, + capabilities_changed: applyCapabilitiesChanged, + tool_groups_changed: applyToolGroupsChanged, }; (function bindDashboardStream() { // Route through the SharedWorker so all open hyperhive tabs share @@ -3667,8 +3681,10 @@ window.marked = marked; // re-fetch reminders on SCH3DUL3S activation since both sections // live on the same tab. if (target === 'schedules') { refreshSchedules(); refreshReminders(); } - // Permissions tables (capabilities + tool-groups) have no SSE channel; - // fetch both on each activation so the operator sees fresh data. + // Permissions tables: SSE covers worker-applied changes + // (capabilities_changed / tool_groups_changed); re-fetch on + // activation as a safety net for any gap between SSE events and + // the cold-load snapshot. if (target === 'permissions') { fetchAndRenderCapabilities(); fetchAndRenderToolGroups(); diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 6a13c03c..c8260098 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -407,6 +407,44 @@ impl Coordinator { }); } + /// Emit a `CapabilitiesChanged` snapshot event. Called from the + /// rebuild-queue worker after a `PermChange` / Capabilities entry + /// commits the JSON file, so the P3RM1SS10NS tab updates live. + pub fn emit_capabilities_snapshot(self: &Arc) { + use hive_sh4re::Capability; + let caps = Capability::ALL.iter().map(|c| c.as_str()).collect(); + let descriptions = Capability::ALL + .iter() + .map(|c| (c.as_str(), c.description())) + .collect(); + let assignments = crate::capabilities::read(); + self.emit_dashboard_event(DashboardEvent::CapabilitiesChanged { + seq: self.next_seq(), + caps, + descriptions, + assignments, + }); + } + + /// Emit a `ToolGroupsChanged` snapshot event. Called from the + /// rebuild-queue worker after a `PermChange` / ToolGroups entry + /// commits the JSON file, so the P3RM1SS10NS tab updates live. + pub fn emit_tool_groups_snapshot(self: &Arc) { + use hive_sh4re::ToolGroup; + let groups = ToolGroup::ALL.iter().map(|g| g.as_str()).collect(); + let descriptions = ToolGroup::ALL + .iter() + .map(|g| (g.as_str(), g.description())) + .collect(); + let assignments = crate::tool_groups::read(); + self.emit_dashboard_event(DashboardEvent::ToolGroupsChanged { + seq: self.next_seq(), + groups, + descriptions, + assignments, + }); + } + /// Update the `step` label on a running queue entry and (if it /// actually changed) re-emit the queue snapshot so the dashboard /// renders the new phase. Returns `true` when the label was new diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 818f452d..52bbf3c2 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -210,6 +210,32 @@ pub enum DashboardEvent { seq: u64, reminders: Vec, }, + /// Full snapshot of capability grants (per-agent `Vec`). + /// Emitted from the rebuild-queue worker after a `PermChange` + /// `Capabilities` entry commits the JSON file. Lets the P3RM1SS10NS + /// tab update live when the worker applies a queued change. + CapabilitiesChanged { + seq: u64, + /// Ordered list of all known capability names (column headers). + caps: Vec<&'static str>, + /// Short description for each capability name (tooltip). + descriptions: std::collections::BTreeMap<&'static str, &'static str>, + /// Per-agent capability grant map; absent agents have no extra caps. + assignments: std::collections::BTreeMap>, + }, + /// Full snapshot of tool-group assignments (per-agent `Vec`). + /// Emitted from the rebuild-queue worker after a `PermChange` + /// `ToolGroups` entry commits the JSON file. Lets the P3RM1SS10NS + /// tab update live when the worker applies a queued change. + ToolGroupsChanged { + seq: u64, + /// Ordered list of all known tool-group names (column headers). + groups: Vec<&'static str>, + /// Short description for each group name (tooltip). + descriptions: std::collections::BTreeMap<&'static str, &'static str>, + /// Per-agent assignment map; absent agents use the role default. + assignments: std::collections::BTreeMap>, + }, } impl DashboardEvent { @@ -242,6 +268,8 @@ impl DashboardEvent { DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed", DashboardEvent::SchedulesChanged { .. } => "schedules_changed", DashboardEvent::RemindersChanged { .. } => "reminders_changed", + DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed", + DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed", } } } @@ -368,6 +396,18 @@ mod tests { seq: 1, reminders: Vec::new(), }, + DashboardEvent::CapabilitiesChanged { + seq: 1, + caps: Vec::new(), + descriptions: std::collections::BTreeMap::new(), + assignments: std::collections::BTreeMap::new(), + }, + DashboardEvent::ToolGroupsChanged { + seq: 1, + groups: Vec::new(), + descriptions: std::collections::BTreeMap::new(), + assignments: std::collections::BTreeMap::new(), + }, ]; for ev in samples { let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise"); diff --git a/hive-c0re/src/rebuild_queue.rs b/hive-c0re/src/rebuild_queue.rs index e299b243..f415ac1d 100644 --- a/hive-c0re/src/rebuild_queue.rs +++ b/hive-c0re/src/rebuild_queue.rs @@ -770,11 +770,16 @@ async fn dispatch( crate::meta::commit_tool_groups(name, groups) .await .with_context(|| format!("commit tool-groups for {name}"))?; + // Emit after the commit so the P3RM1SS10NS tab + // reflects the new assignment without the operator + // needing to navigate away and back. + coord.emit_tool_groups_snapshot(); } Some(PermPayload::Capabilities { caps }) => { crate::meta::commit_capabilities(name, caps) .await .with_context(|| format!("commit capabilities for {name}"))?; + coord.emit_capabilities_snapshot(); } None => { anyhow::bail!( From 04b313b5e7a5a6a3d528cdea1f247cd1d3e96e3f Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 10:25:53 +0200 Subject: [PATCH 6/9] docs(dashboard): note SSE coverage in P3RM1SS10NS tab description --- docs/web-ui/dashboard.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 232232fa..0b6ce9f7 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -128,7 +128,12 @@ authoritative — adding a new tool-group or capability to the backend requires no UI change; the new column appears automatically. Fetches fire on tab activation (not page-load) to avoid unnecessary -work when the operator never visits this tab. +work when the operator never visits this tab. Live mutations from the +rebuild-queue worker are also pushed via the `capabilities_changed` / +`tool_groups_changed` SSE events (same payload shape as the GET +endpoints), so an open P3RM1SS10NS tab reflects worker-applied changes +without requiring navigation. Tab-activation re-fetches remain as a +safety net for reconnect windows. **C4P4B1L1T13S** — per-agent capability grants. Capabilities unlock gated MCP tools and system-level access beyond the default agent From 13b0844c4136d077f427738f1e82fe7cee729881 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 10:27:13 +0200 Subject: [PATCH 7/9] fix(dashboard): SYST3M tab count was always 0 (PascalCase vs snake_case) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshTabCounts() compared entry.state against 'Queued' / 'Running' (PascalCase) but QueueState serialises as snake_case per #[serde(rename_all = "snake_case")] — wire values are 'queued' / 'running'. Tab badge was always 0 regardless of rebuild-queue depth. --- frontend/packages/dashboard/src/tabs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 2efbe319..c2a23c90 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -3894,7 +3894,7 @@ window.marked = marked; let sysCount = 0; if (rebuildQueueState) { for (const e of rebuildQueueState) { - if (e.state === 'Queued' || e.state === 'Running') sysCount++; + if (e.state === 'queued' || e.state === 'running') sysCount++; } } setTabCount('system', sysCount); From f174c5d32c8e36b01c5690d845ee9b9559a22de1 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 10:29:24 +0200 Subject: [PATCH 8/9] fix(dashboard): guard capabilities/tool-groups SSE handlers against in-progress edits - Add 'capabilities-section' and 'tool-groups-section' to MANAGED_SECTION_IDS so operatorIsTyping() covers them too - applyCapabilitiesChanged and applyToolGroupsChanged skip re-render when the operator has focus inside the section, preventing the table from being torn down under an in-progress checkbox edit. Tab-activation re-fetch is the recovery path for any missed event. --- frontend/packages/dashboard/src/tabs.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index c2a23c90..ab4974b0 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -1233,11 +1233,15 @@ window.marked = marked; function applyCapabilitiesChanged(ev) { const root = $('capabilities-section'); if (!root) return; + // Skip re-render while operator has a checkbox focused in this + // section — the tab-activation re-fetch is the recovery path. + if (root.contains(document.activeElement)) return; renderCapabilities(root, ev); } function applyToolGroupsChanged(ev) { const root = $('tool-groups-section'); if (!root) return; + if (root.contains(document.activeElement)) return; renderToolGroups(root, ev); } @@ -3457,6 +3461,8 @@ window.marked = marked; 'rebuild-queue-section', 'reminders-section', 'schedules-section', + 'capabilities-section', + 'tool-groups-section', ]; //
sections that should survive a refresh need a stable // `data-restore-key` attribute. snapshotOpenDetails walks managed From 97857cfdc54224d58ad3340855d355771313ff7a Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 10:35:21 +0200 Subject: [PATCH 9/9] docs(coordinator): note CapabilitiesChanged/ToolGroupsChanged SSE in PermChange description --- docs/coordinator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/coordinator.md b/docs/coordinator.md index fadfd605..44749143 100644 --- a/docs/coordinator.md +++ b/docs/coordinator.md @@ -39,7 +39,7 @@ somewhere." | `Spawn` | First-deploy of a new agent (approval-driven). Same serialisation as `Rebuild` from the operator's POV. | | `Destroy` | For future use (`destroy --purge` does real I/O). Variant exists so the wire shape doesn't change later; not currently routed through the queue. | | `Restart` | Stop + start a container without touching config (~5-10s). Routed through the queue so it serialises against in-flight rebuilds for the same agent — prevents a restart racing a rebuild mid-flight. Sources: dashboard ↺ button, manager `restart` MCP tool. | -| `PermChange` | Write a tool-group or capability change to the shared JSON file (`tool-groups.json` / `capabilities.json`), then rebuild the agent so the updated `HIVE_TOOL_GROUPS` / `HIVE_CAPABILITIES` env var takes effect. Serialising the file write through the queue prevents concurrent dashboard batch-apply actions from racing on the shared file. | +| `PermChange` | Write a tool-group or capability change to the shared JSON file (`tool-groups.json` / `capabilities.json`), then rebuild the agent so the updated `HIVE_TOOL_GROUPS` / `HIVE_CAPABILITIES` env var takes effect. Serialising the file write through the queue prevents concurrent dashboard batch-apply actions from racing on the shared file. After a successful file write, emits `CapabilitiesChanged` or `ToolGroupsChanged` SSE snapshot so the P3RM1SS10NS tab updates live. | **Intentionally not queued** (sub-second ops): `start`, `stop`, `kill`.