Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97857cfdc5 | ||
|
|
f174c5d32c | ||
|
|
13b0844c41 | ||
|
|
04b313b5e7 | ||
|
|
2080fd3866 | ||
|
|
d0b038e17d | ||
|
|
a1e46e2b3d | ||
|
|
68108fe5f8 | ||
|
|
76c4a67b1c |
15 changed files with 292 additions and 30 deletions
|
|
@ -82,7 +82,10 @@ hive-c0re/ host daemon + sibling operator CLI (lib + 2 bins)
|
||||||
(`ApprovalAdded` / `ApprovalResolved`,
|
(`ApprovalAdded` / `ApprovalResolved`,
|
||||||
`QuestionAdded` / `QuestionResolved`,
|
`QuestionAdded` / `QuestionResolved`,
|
||||||
`TransientSet` / `TransientCleared`,
|
`TransientSet` / `TransientCleared`,
|
||||||
`RebuildQueueChanged`). Each frame carries a
|
`RebuildQueueChanged`, `SchedulesChanged`,
|
||||||
|
`RemindersChanged`, `CapabilitiesChanged`,
|
||||||
|
`ToolGroupsChanged`).
|
||||||
|
Each frame carries a
|
||||||
monotonic per-process `seq` clients use to
|
monotonic per-process `seq` clients use to
|
||||||
dedupe against snapshot reads.
|
dedupe against snapshot reads.
|
||||||
src/approvals.rs sqlite Approval queue + kinds
|
src/approvals.rs sqlite Approval queue + kinds
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ somewhere."
|
||||||
| `Spawn` | First-deploy of a new agent (approval-driven). Same serialisation as `Rebuild` from the operator's POV. |
|
| `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. |
|
| `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. |
|
| `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`.
|
**Intentionally not queued** (sub-second ops): `start`, `stop`, `kill`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,12 @@ authoritative — adding a new tool-group or capability to the backend
|
||||||
requires no UI change; the new column appears automatically.
|
requires no UI change; the new column appears automatically.
|
||||||
|
|
||||||
Fetches fire on tab activation (not page-load) to avoid unnecessary
|
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
|
**C4P4B1L1T13S** — per-agent capability grants. Capabilities unlock
|
||||||
gated MCP tools and system-level access beyond the default agent
|
gated MCP tools and system-level access beyond the default agent
|
||||||
|
|
@ -843,6 +848,35 @@ payload):
|
||||||
`meta_inputs_changed`: the list is small and the client's
|
`meta_inputs_changed`: the list is small and the client's
|
||||||
`parent_id` grouping is most naturally re-derived from the
|
`parent_id` grouping is most naturally re-derived from the
|
||||||
full list. Cold-loaded from `/api/state.rebuild_queue`.
|
full list. Cold-loaded from `/api/state.rebuild_queue`.
|
||||||
|
- `schedules_changed` (seq, schedules: `Vec<WireSchedule>`) —
|
||||||
|
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.
|
||||||
|
- `reminders_changed` (seq, reminders: `Vec<PendingReminder>`) —
|
||||||
|
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 SCH3DUL3S tab's reminders section
|
||||||
|
subscribes and calls `renderReminders` on receipt, so the list
|
||||||
|
updates live without polling.
|
||||||
|
- `capabilities_changed` (seq, caps: `Vec<str>`, descriptions: map,
|
||||||
|
assignments: `BTreeMap<String, Vec<String>>`) — 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<str>`, descriptions: map,
|
||||||
|
assignments: `BTreeMap<String, Vec<String>>`) — 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
|
`/api/state` is **only fetched on cold-load and on the few
|
||||||
forms that mutate non-event-derived state** (PURG3 +
|
forms that mutate non-event-derived state** (PURG3 +
|
||||||
|
|
|
||||||
|
|
@ -229,9 +229,10 @@
|
||||||
toggle for existing schedules. Schedules list driven by
|
toggle for existing schedules. Schedules list driven by
|
||||||
GET /api/schedules; POST /api/schedules to create, PATCH
|
GET /api/schedules; POST /api/schedules to create, PATCH
|
||||||
/api/schedules/{id} to edit, POST /api/schedules/{id}/cancel
|
/api/schedules/{id} to edit, POST /api/schedules/{id}/cancel
|
||||||
for per-target / whole-row cancel. No SchedulesChanged SSE
|
for per-target / whole-row cancel. Live updates via
|
||||||
event yet, so the list re-fetches on tab activation + after
|
`schedules_changed` SSE; tab activation re-fetches as a
|
||||||
each submit / cancel. See docs/web-ui.md::SCH3DUL3S tab. -->
|
safety net for disconnect windows.
|
||||||
|
See docs/web-ui.md::SCH3DUL3S tab. -->
|
||||||
<section class="tab-pane" id="tab-pane-schedules"
|
<section class="tab-pane" id="tab-pane-schedules"
|
||||||
role="tabpanel" aria-labelledby="tab-schedules">
|
role="tabpanel" aria-labelledby="tab-schedules">
|
||||||
<h2>◆ SCH3DUL3S ◆</h2>
|
<h2>◆ SCH3DUL3S ◆</h2>
|
||||||
|
|
@ -245,8 +246,9 @@
|
||||||
on this tab so the operator has one place for everything
|
on this tab so the operator has one place for everything
|
||||||
that fires at a future time — operator-set schedules
|
that fires at a future time — operator-set schedules
|
||||||
above, agent-self reminders here. Backed by GET
|
above, agent-self reminders here. Backed by GET
|
||||||
/api/reminders; refresh handled by refreshReminders()
|
/api/reminders; live updates via `reminders_changed` SSE;
|
||||||
(called from refreshState). -->
|
refreshReminders() called from refreshState + tab
|
||||||
|
activation as safety net for disconnect windows. -->
|
||||||
<h2>◆ QU3U3D R3M1ND3RS ◆</h2>
|
<h2>◆ QU3U3D R3M1ND3RS ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<p class="meta">reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.</p>
|
<p class="meta">reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.</p>
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,13 @@ window.marked = marked;
|
||||||
// See docs/web-ui.md::Container row for the badge taxonomy.
|
// See docs/web-ui.md::Container row for the badge taxonomy.
|
||||||
renderContainersFromState();
|
renderContainersFromState();
|
||||||
}
|
}
|
||||||
|
function applySchedulesChanged(ev) {
|
||||||
|
schedulesState = (ev.schedules || []).slice();
|
||||||
|
renderSchedulesList();
|
||||||
|
}
|
||||||
|
function applyRemindersChanged(ev) {
|
||||||
|
renderReminders(ev.reminders || []);
|
||||||
|
}
|
||||||
// Map from agent name → highest-priority in-flight queue entry
|
// Map from agent name → highest-priority in-flight queue entry
|
||||||
// (`running` beats `queued`). Used by the container row renderer
|
// (`running` beats `queued`). Used by the container row renderer
|
||||||
// to surface "building..." / "meta-updating..." badges on the
|
// to surface "building..." / "meta-updating..." badges on the
|
||||||
|
|
@ -1220,7 +1227,23 @@ window.marked = marked;
|
||||||
// ── tool-groups (permissions) table ─────────────────────────────────────
|
// ── tool-groups (permissions) table ─────────────────────────────────────
|
||||||
// Fetched from GET /api/tool-groups on system tab activation and after
|
// 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
|
// 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;
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAndRenderCapabilities() {
|
async function fetchAndRenderCapabilities() {
|
||||||
const root = $('capabilities-section');
|
const root = $('capabilities-section');
|
||||||
|
|
@ -2368,11 +2391,10 @@ window.marked = marked;
|
||||||
|
|
||||||
// ─── reminders ──────────────────────────────────────────────────────────
|
// ─── reminders ──────────────────────────────────────────────────────────
|
||||||
// Reminders aren't part of /api/state (separate sqlite table, separate
|
// Reminders aren't part of /api/state (separate sqlite table, separate
|
||||||
// mutation cadence). Refresh fires alongside refreshState() so a
|
// mutation cadence). refreshReminders() is called from refreshState() for
|
||||||
// cancel POST or a cold load both reflect within the same tick. A
|
// cold-load and reconnect recovery. Live mutations are covered by the
|
||||||
// periodic poll isn't necessary — new reminders are queued by the
|
// `reminders_changed` SSE event → `applyRemindersChanged` so no periodic
|
||||||
// agents themselves and the operator already sees them next time
|
// poll is needed.
|
||||||
// they interact with the page.
|
|
||||||
async function refreshReminders() {
|
async function refreshReminders() {
|
||||||
const liveRoot = $('reminders-section');
|
const liveRoot = $('reminders-section');
|
||||||
if (!liveRoot) return;
|
if (!liveRoot) return;
|
||||||
|
|
@ -2471,8 +2493,11 @@ window.marked = marked;
|
||||||
// ─── scheduled prompts ─────────────────────────────────────────────────
|
// ─── scheduled prompts ─────────────────────────────────────────────────
|
||||||
// Backend exposes `/api/schedules` (snapshot), `/api/schedules`
|
// Backend exposes `/api/schedules` (snapshot), `/api/schedules`
|
||||||
// (POST, operator-direct submit), `/api/schedules/{id}/cancel`
|
// (POST, operator-direct submit), `/api/schedules/{id}/cancel`
|
||||||
// (whole or per-target). No SSE channel for schedule mutations yet,
|
// (whole or per-target), `/api/schedules/{id}` (PATCH edit),
|
||||||
// so we refresh on tab activation + after every submit/cancel POST.
|
// `/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
|
// Local cache lets `refreshTabCounts` show the active count without
|
||||||
// re-fetching every second.
|
// re-fetching every second.
|
||||||
let schedulesState = [];
|
let schedulesState = [];
|
||||||
|
|
@ -3436,6 +3461,8 @@ window.marked = marked;
|
||||||
'rebuild-queue-section',
|
'rebuild-queue-section',
|
||||||
'reminders-section',
|
'reminders-section',
|
||||||
'schedules-section',
|
'schedules-section',
|
||||||
|
'capabilities-section',
|
||||||
|
'tool-groups-section',
|
||||||
];
|
];
|
||||||
// <details> sections that should survive a refresh need a stable
|
// <details> sections that should survive a refresh need a stable
|
||||||
// `data-restore-key` attribute. snapshotOpenDetails walks managed
|
// `data-restore-key` attribute. snapshotOpenDetails walks managed
|
||||||
|
|
@ -3596,6 +3623,10 @@ window.marked = marked;
|
||||||
meta_inputs_changed: applyMetaInputsChanged,
|
meta_inputs_changed: applyMetaInputsChanged,
|
||||||
meta_update_running: applyMetaUpdateRunning,
|
meta_update_running: applyMetaUpdateRunning,
|
||||||
rebuild_queue_changed: applyRebuildQueueChanged,
|
rebuild_queue_changed: applyRebuildQueueChanged,
|
||||||
|
schedules_changed: applySchedulesChanged,
|
||||||
|
reminders_changed: applyRemindersChanged,
|
||||||
|
capabilities_changed: applyCapabilitiesChanged,
|
||||||
|
tool_groups_changed: applyToolGroupsChanged,
|
||||||
};
|
};
|
||||||
(function bindDashboardStream() {
|
(function bindDashboardStream() {
|
||||||
// Route through the SharedWorker so all open hyperhive tabs share
|
// Route through the SharedWorker so all open hyperhive tabs share
|
||||||
|
|
@ -3650,11 +3681,16 @@ window.marked = marked;
|
||||||
renderSelectionBar(Array.from(containersState.values()));
|
renderSelectionBar(Array.from(containersState.values()));
|
||||||
// Keep overflow button active state in sync after tab change.
|
// Keep overflow button active state in sync after tab change.
|
||||||
updateTabbarOverflow();
|
updateTabbarOverflow();
|
||||||
// Schedules pane has no SSE channel for mutations, so re-fetch
|
// Re-fetch schedules on activation as a safety net (SSE covers
|
||||||
// on activation so the operator never lands on stale data.
|
// live mutations but re-sync ensures consistency after disconnect
|
||||||
if (target === 'schedules') refreshSchedules();
|
// windows or approval-path inserts that don't yet emit). Also
|
||||||
// Permissions tables (capabilities + tool-groups) have no SSE channel;
|
// re-fetch reminders on SCH3DUL3S activation since both sections
|
||||||
// fetch both on each activation so the operator sees fresh data.
|
// live on the same tab.
|
||||||
|
if (target === 'schedules') { refreshSchedules(); refreshReminders(); }
|
||||||
|
// 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') {
|
if (target === 'permissions') {
|
||||||
fetchAndRenderCapabilities();
|
fetchAndRenderCapabilities();
|
||||||
fetchAndRenderToolGroups();
|
fetchAndRenderToolGroups();
|
||||||
|
|
@ -3864,7 +3900,7 @@ window.marked = marked;
|
||||||
let sysCount = 0;
|
let sysCount = 0;
|
||||||
if (rebuildQueueState) {
|
if (rebuildQueueState) {
|
||||||
for (const e of 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);
|
setTabCount('system', sysCount);
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,11 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
||||||
// at the scheduled time). Run inline + fire
|
// at the scheduled time). Run inline + fire
|
||||||
// `ApprovalResolved` so the approval row leaves Pending
|
// `ApprovalResolved` so the approval row leaves Pending
|
||||||
// immediately.
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -811,6 +811,7 @@ pub(crate) fn store_remind(
|
||||||
.store_reminder(agent, &stored_message, stored_path.as_deref(), due_at)
|
.store_reminder(agent, &stored_message, stored_path.as_deref(), due_at)
|
||||||
.map_err(|e| format!("failed to store reminder: {e:#}"))?;
|
.map_err(|e| format!("failed to store reminder: {e:#}"))?;
|
||||||
tracing::info!(%id, %agent, %due_at, "reminder scheduled");
|
tracing::info!(%id, %agent, %due_at, "reminder scheduled");
|
||||||
|
coord.emit_reminders_snapshot();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,83 @@ 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<Self>) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Self>) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Self>) {
|
||||||
|
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<Self>) {
|
||||||
|
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
|
/// Update the `step` label on a running queue entry and (if it
|
||||||
/// actually changed) re-emit the queue snapshot so the dashboard
|
/// actually changed) re-emit the queue snapshot so the dashboard
|
||||||
/// renders the new phase. Returns `true` when the label was new
|
/// renders the new phase. Returns `true` when the label was new
|
||||||
|
|
|
||||||
|
|
@ -1964,7 +1964,10 @@ async fn post_schedule_new(
|
||||||
source: crate::scheduled_prompts::ScheduleSource::Operator,
|
source: crate::scheduled_prompts::ScheduleSource::Operator,
|
||||||
};
|
};
|
||||||
match state.coord.scheduled_prompts.submit(&new) {
|
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:#}")),
|
Err(e) => error_response(&format!("schedule submit: {e:#}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1982,7 +1985,10 @@ async fn post_schedule_fire_now(
|
||||||
AxumPath(id): AxumPath<i64>,
|
AxumPath(id): AxumPath<i64>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match crate::scheduled_prompts_worker::fire_now(&state.coord, id).await {
|
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:#}")),
|
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) {
|
match state.coord.scheduled_prompts.get(id) {
|
||||||
Ok(Some(s)) => {
|
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")),
|
Ok(None) => error_response(&format!("edit schedule {id}: row vanished post-update")),
|
||||||
Err(e) => error_response(&format!("re-read schedule {id}: {e:#}")),
|
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),
|
None => state.coord.scheduled_prompts.cancel_all(id),
|
||||||
};
|
};
|
||||||
match result {
|
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:#}")),
|
Err(e) => error_response(&format!("cancel schedule {id}: {e:#}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2130,6 +2141,7 @@ async fn post_cancel_reminder(
|
||||||
Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")),
|
Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")),
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
tracing::info!(%id, "operator cancelled reminder");
|
tracing::info!(%id, "operator cancelled reminder");
|
||||||
|
state.coord.emit_reminders_snapshot();
|
||||||
(StatusCode::OK, "ok").into_response()
|
(StatusCode::OK, "ok").into_response()
|
||||||
}
|
}
|
||||||
Err(e) => error_response(&format!("cancel reminder {id} failed: {e:#}")),
|
Err(e) => error_response(&format!("cancel reminder {id} failed: {e:#}")),
|
||||||
|
|
@ -2149,6 +2161,7 @@ async fn post_retry_reminder(
|
||||||
Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")),
|
Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")),
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
tracing::info!(%id, "operator reset reminder failure for retry");
|
tracing::info!(%id, "operator reset reminder failure for retry");
|
||||||
|
state.coord.emit_reminders_snapshot();
|
||||||
(StatusCode::OK, "ok").into_response()
|
(StatusCode::OK, "ok").into_response()
|
||||||
}
|
}
|
||||||
Err(e) => error_response(&format!("retry reminder {id} failed: {e:#}")),
|
Err(e) => error_response(&format!("retry reminder {id} failed: {e:#}")),
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,50 @@ pub enum DashboardEvent {
|
||||||
/// dashboard's grouping (`parent_id`) is most naturally re-derived
|
/// dashboard's grouping (`parent_id`) is most naturally re-derived
|
||||||
/// from the full list.
|
/// from the full list.
|
||||||
RebuildQueueChanged { seq: u64, queue: Vec<QueueEntry> },
|
RebuildQueueChanged { seq: u64, queue: Vec<QueueEntry> },
|
||||||
|
/// 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<hive_sh4re::WireSchedule>,
|
||||||
|
},
|
||||||
|
/// 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<crate::broker::PendingReminder>,
|
||||||
|
},
|
||||||
|
/// Full snapshot of capability grants (per-agent `Vec<cap_name>`).
|
||||||
|
/// 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<String, Vec<String>>,
|
||||||
|
},
|
||||||
|
/// Full snapshot of tool-group assignments (per-agent `Vec<group_name>`).
|
||||||
|
/// 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<String, Vec<String>>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DashboardEvent {
|
impl DashboardEvent {
|
||||||
|
|
@ -222,6 +266,10 @@ impl DashboardEvent {
|
||||||
DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed",
|
DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed",
|
||||||
DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running",
|
DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running",
|
||||||
DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed",
|
DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed",
|
||||||
|
DashboardEvent::SchedulesChanged { .. } => "schedules_changed",
|
||||||
|
DashboardEvent::RemindersChanged { .. } => "reminders_changed",
|
||||||
|
DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed",
|
||||||
|
DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -340,6 +388,26 @@ mod tests {
|
||||||
seq: 1,
|
seq: 1,
|
||||||
queue: Vec::new(),
|
queue: Vec::new(),
|
||||||
},
|
},
|
||||||
|
DashboardEvent::SchedulesChanged {
|
||||||
|
seq: 1,
|
||||||
|
schedules: Vec::new(),
|
||||||
|
},
|
||||||
|
DashboardEvent::RemindersChanged {
|
||||||
|
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 {
|
for ev in samples {
|
||||||
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise");
|
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise");
|
||||||
|
|
|
||||||
|
|
@ -610,7 +610,10 @@ fn handle_cancel_schedule(
|
||||||
.map_err(|e| format!("cancel all: {e:#}")),
|
.map_err(|e| format!("cancel all: {e:#}")),
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => ManagerResponse::Ok,
|
Ok(()) => {
|
||||||
|
coord.emit_schedules_snapshot();
|
||||||
|
ManagerResponse::Ok
|
||||||
|
}
|
||||||
Err(message) => ManagerResponse::Err { message },
|
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 {
|
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 {
|
Err(e) => ManagerResponse::Err {
|
||||||
message: format!("fire schedule {schedule_id} now: {e:#}"),
|
message: format!("fire schedule {schedule_id} now: {e:#}"),
|
||||||
},
|
},
|
||||||
|
|
@ -709,7 +715,10 @@ fn handle_edit_schedule(
|
||||||
targets_remove,
|
targets_remove,
|
||||||
};
|
};
|
||||||
match coord.scheduled_prompts.update(schedule_id, patch) {
|
match coord.scheduled_prompts.update(schedule_id, patch) {
|
||||||
Ok(()) => ManagerResponse::Ok,
|
Ok(()) => {
|
||||||
|
coord.emit_schedules_snapshot();
|
||||||
|
ManagerResponse::Ok
|
||||||
|
}
|
||||||
Err(e) => ManagerResponse::Err {
|
Err(e) => ManagerResponse::Err {
|
||||||
message: format!("edit schedule {schedule_id}: {e:#}"),
|
message: format!("edit schedule {schedule_id}: {e:#}"),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,7 @@ pub fn handle_cancel_loose_end(
|
||||||
.cancel_reminder_as(id, canceller)
|
.cancel_reminder_as(id, canceller)
|
||||||
.map_err(|e| format!("{e:#}"))?;
|
.map_err(|e| format!("{e:#}"))?;
|
||||||
tracing::info!(%id, %canceller, %owner, "reminder cancelled");
|
tracing::info!(%id, %canceller, %owner, "reminder cancelled");
|
||||||
|
coord.emit_reminders_snapshot();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
hive_sh4re::CancelLooseEndKind::Approval => {
|
hive_sh4re::CancelLooseEndKind::Approval => {
|
||||||
|
|
|
||||||
|
|
@ -770,11 +770,16 @@ async fn dispatch(
|
||||||
crate::meta::commit_tool_groups(name, groups)
|
crate::meta::commit_tool_groups(name, groups)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("commit tool-groups for {name}"))?;
|
.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 }) => {
|
Some(PermPayload::Capabilities { caps }) => {
|
||||||
crate::meta::commit_capabilities(name, caps)
|
crate::meta::commit_capabilities(name, caps)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("commit capabilities for {name}"))?;
|
.with_context(|| format!("commit capabilities for {name}"))?;
|
||||||
|
coord.emit_capabilities_snapshot();
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ fn tick(coord: &Arc<Coordinator>) {
|
||||||
// Single-transaction batch: one DB lock acquisition for N reminders
|
// Single-transaction batch: one DB lock acquisition for N reminders
|
||||||
// instead of N sequential lock/unlock cycles.
|
// instead of N sequential lock/unlock cycles.
|
||||||
let results = coord.broker.deliver_reminders_batch(&items);
|
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()) {
|
for ((id, agent, _body), result) in items.iter().zip(results.iter()) {
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
let reason = format!("{e:#}");
|
let reason = format!("{e:#}");
|
||||||
|
|
@ -82,6 +83,11 @@ fn tick(coord: &Arc<Coordinator>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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
|
/// Build the inbox body for a due reminder. When `file_path` is None
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ fn tick(coord: &Arc<Coordinator>) {
|
||||||
if let Err(e) = coord.scheduled_prompts.reap_cancelled(cutoff) {
|
if let Err(e) = coord.scheduled_prompts.reap_cancelled(cutoff) {
|
||||||
tracing::warn!(error = ?e, "scheduled_prompts: reap cancelled failed");
|
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
|
/// Fan out one schedule's body to every active target. Records
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue