From 62784d4933fcd9291c9b760cbac6bc721d4617f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 14:22:47 +0200 Subject: [PATCH 1/4] todo: prune resolved items --- TODO.md | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index 7a44fdc4..355bd8c4 100644 --- a/TODO.md +++ b/TODO.md @@ -8,18 +8,11 @@ - **Broadcast messaging**: allow sending messages with recipient "*" to all agents; deliver with hint "this was a broadcast and may not need any action from you" - **Multi-agent restart coordination**: when rebuilding all agents, manager should start first so it can coordinate post-restart confusion (notify agents, suppress unnecessary retries, etc) - **Shared docs/skills repo (RO)**: a single repo on the hive forge that every agent has read-only access to — common references, prompts, runbooks, "skills" the operator wants every agent to inherit without baking into the system prompt or `/shared`. Implementation likely: seed an `org-shared/docs` repo on first hive-forge boot, grant every per-agent user a read membership in the org. Agents `git clone` it (or use the API) to read; only the manager + operator can push. -- ~~**Rename `ask_operator` → `ask` with optional `to` param**~~ ✓ done — `Ask { question, options, multi, ttl_seconds, to: Option }` on both `AgentRequest` + `ManagerRequest`. `to = None` (or `Some("operator")`) = dashboard path; `to = Some()` pushes `HelperEvent::QuestionAsked` into the target's inbox. New `Answer { id, answer }` request on both surfaces — target answers via `mcp__hyperhive__answer`; answer flows back to the asker as `HelperEvent::QuestionAnswered { id, question, answer, answerer }` (renamed from `OperatorAnswered`; carries who answered so the asker can distinguish operator vs peer vs `ttl-watchdog`). Authorisation: only the question's `target` agent or the operator can answer; self-ask is rejected. DB gets a nullable `target` column (NULL = operator path, back-compat). Dashboard's `pending()` / `recent_answered()` filter on `target IS NULL` so peer questions never leak into the operator's queue. Shared dispatch lives in `hive-c0re/src/questions.rs` so both surfaces stay aligned. - **Loose-ends tracker + `get_open_threads` tool**: hive-c0re already knows about pending approvals + unanswered questions; soon will also know about open PRs on hive-forge. Aggregate these into a per-agent "open threads" view (e.g. `[{kind: "approval", id: 7, summary: "spawn alice"}, {kind: "question", id: 12, asker: "alice", summary: "deploy now?"}]`). New MCP tool `mcp__hyperhive__get_open_threads` returns the list so an agent can see what's still pending against it without rebuilding context from inbox history. Manager's version includes hive-wide threads. **Also surface this list on the per-agent web UI** so the operator can see at a glance what each agent has hanging open — same data source as the MCP tool, just rendered into the existing per-agent dashboard page (next to inbox view / model chip / etc). ## Reminder Tool -- ~~Handle text overflow → suggest file_path option for long messages~~ ✓ fixed — Remind dispatch rejects `message.len() > 4096` (when no `file_path` was supplied) with an error pointing at the `file_path` escape hatch. - Per-agent reminder limits (burst capacity, rate limiting) -- ~~**Expose `remind` MCP tool**~~ ✓ fixed — `mcp__hyperhive__remind` now on `AgentServer`; takes `message`, exactly one of `delay_seconds` / `at_unix_timestamp`, optional `file_path`. Manager surface still missing (no `ManagerRequest::Remind` variant) — separate item below. -- ~~**Manager-side `remind`**~~ ✓ fixed — `ManagerRequest::Remind` variant added, dispatch reuses `agent_server::store_remind` helper (shared across both surfaces), `mcp__hyperhive__remind` now on `ManagerServer` (auto-file lands at `/state/reminders/auto-.md` — manager's legacy state mount). -- ~~**File path delivery**~~ ✓ fixed — scheduler now writes the reminder body to the requested `file_path` (mapped from container `/agents//state/...` to host `/var/lib/hyperhive/agents//state/...`) and delivers a short pointer message in its place. Path-traversal + foreign-agent-state writes are rejected; on rejection or write failure the body falls back to inline delivery with a noted warning. New module `hive-c0re/src/reminder_scheduler.rs` (extracted from main.rs). -- ~~**Orphan reminders**~~ ✓ fixed — `Broker::deliver_reminder` wraps the inbox INSERT + reminders UPDATE in one sqlite transaction; partial failure can no longer cause duplicate delivery on the next tick. -- ~~**Unbounded batches**~~ ✓ fixed — scheduler now calls `get_due_reminders(REMINDER_BATCH_LIMIT)` (cap = 100/tick); overflow stays due and gets picked up next cycle. - **Scheduler shutdown**: add graceful shutdown signal when coordinator is destroyed (currently runs forever) - **DB lock contention**: under high reminder volume, the broker's `Mutex` serializes every delivery transaction. Consider batching multiple deliveries into one tx, or moving reminders onto a separate sqlite connection. @@ -31,10 +24,8 @@ - Per-agent reminder status (pending, delivered) - Reminder query interface for debugging - Display reminder delivery errors (failed sends, mark failures) -- ~~**Phase 5b: per-domain mutation event types + client derived state**~~ ✓ landed across 56d615b (approvals), 1879b2f (questions), 7956e1c (transients). `DashboardEvent` now carries `ApprovalAdded` / `ApprovalResolved`, `QuestionAdded` / `QuestionResolved`, `TransientSet` / `TransientCleared`; emit sites cover `actions::approve`/`deny`/`finish_approval`, dashboard's orphan-approval GC, manager-socket `request_spawn` + `request_apply_commit` (success + git_fetch failure), `questions::handle_ask`/`handle_answer` (operator-targeted only), dashboard's `/answer-question` + `/cancel-question`, ttl-watchdog, `Coordinator::set_transient`/`clear_transient`. `/api/state` still serves these arrays for cold-start; live updates flow through the events. Container-list events still deferred — `ContainerView` is sourced from external `nixos-container list`, so the 5s poll continues to drive `/containers-section`. Phase 6 remaining redirect conversions (`/approve`, `/deny`, `/restart`, `/destroy`, `/kill`, `/rebuild`, `/api/cancel`, `/api/compact`, `/api/model`, `/api/new-session`, `/request-spawn`, `/answer-question`, `/cancel-question`, `/meta-update`, `/purge-tombstone`) are now unblocked for the event-covered domains; container-lifecycle ones still need either container-list events or to live with the 5s poll-refresh delay. +- **Phase 6 leftovers**: convert the remaining redirect-and-refetch endpoints whose mutations now fire dashboard events. `/approve`, `/deny`, `/answer-question`, `/cancel-question`, `/request-spawn` are 5-line `Redirect::to("/")` → `(StatusCode::OK, "ok")` changes; the client already updates derived state from the events. Container-lifecycle endpoints (`/restart`, `/destroy`, `/kill`, `/rebuild`, `/start`, `/api/{cancel,compact,model,new-session}`, `/meta-update`, `/purge-tombstone`) need a `ContainerListChanged` event first — `ContainerView` is currently sourced from external `nixos-container list`, so the 5s poll still drives that section. ## Bugs -- ~~**Pending message wake-up**~~ ✓ fixed (e423d57) — subscribe-before-check race in `broker.recv_blocking` meant a send landing between the initial `recv()` and `subscribe()` was missed; agent then sat on the 180s long-poll until another, unrelated message woke it. Now subscribe first. - **Post-rebuild system-message missed wake**: at 09:13:14 the dashboard showed `system → damocles container rebuilt` as ✓ delivered, but the agent harness never ran a turn for it (no claude invocation, no operator-visible activity). A subsequent `recv()` from inside the agent returned `(empty)`, confirming the message was popped + marked delivered server-side — yet drove no turn. Most likely cause: the agent_server `serve_agent_stdio` task is up and answering MCP/socket calls, but the `hive-ag3nt::serve` long-poll loop that drives `drive_turn` either died silently during rebuild or never restarted. Investigate: (a) does hive-ag3nt's serve loop survive `nixos-container update` cleanly, or does its tokio runtime get torn down mid-loop? (b) is there an early-exit path on a transient socket error during rebuild that drops the serve task without notifying the manager? (c) compare timeline with manager's own post-rebuild wake to see if this is rebuilt-agents-only or universal. Could be related to the `recv_blocking` fix in `e423d57` if the rebuild restarts the broker mid-subscribe. -- ~~**`LiveEvent::Note(String)` never reaches the browser**~~ ✓ fixed — converted to struct variant `Note { text: String }`; wire shape `{"kind":"note","text":"..."}` matches what the JS already reads via `ev.text`. Historical sqlite rows persisted as the literal string `"null"` (from when serialization silently failed) get filtered out by the `rows.flatten().flatten()` pipeline in `EventStore::recent`, so replay tolerates them. From d8d393da6d4666ab6ccd70ae2c08101cbece4430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 14:24:47 +0200 Subject: [PATCH 2/4] docs: dashboard event channel, hive-fr0nt crate, mutation events, seq dedupe --- CLAUDE.md | 65 +++++++++++++++++++++-- docs/conventions.md | 9 ++-- docs/web-ui.md | 123 +++++++++++++++++++++++++++++++++++--------- 3 files changed, 167 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c7db0ed7..0b2bf157 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,8 +19,19 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched) src/client.rs admin-socket client src/manager_server.rs manager-privileged socket (ManagerRequest) src/agent_server.rs per-sub-agent socket listener (long-poll Recv) - src/broker.rs sqlite Message store + broadcast channel for SSE + - hourly vacuum of delivered>30d + src/broker.rs sqlite Message store + intra-process broadcast + channel (`MessageEvent`) for `recv_blocking` + + the dashboard forwarder; hourly vacuum of + delivered>30d + src/dashboard_events.rs unified wire-facing event channel feeding + `/dashboard/stream`. Carries broker `Sent` / + `Delivered` (mirrored by the forwarder task + in main.rs) + mutation events + (`ApprovalAdded` / `ApprovalResolved`, + `QuestionAdded` / `QuestionResolved`, + `TransientSet` / `TransientCleared`). Each + frame carries a monotonic per-process `seq` + clients use to dedupe against snapshot reads. src/approvals.rs sqlite Approval queue + kinds src/operator_questions.rs sqlite question queue backing `ask` / `answer` (both operator + agent-to-agent) @@ -49,9 +60,26 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched) (idempotent, marker-guarded phase 4) src/dashboard.rs axum HTTP: static shell + /api/state JSON + actions + journald viewer + bind-with-retry (SO_REUSEADDR) - + deployed_sha chip per container + + deployed_sha chip per container + + /dashboard/{stream,history} subscribing to the + unified DashboardEvent channel assets/ index.html, dashboard.css, app.js (include_str!) +hive-fr0nt/ shared frontend-assets crate (browser only). + src/lib.rs pub const BASE_CSS / TERMINAL_CSS / TERMINAL_JS + re-exports; both binaries `include_str!` them + and prepend to their per-page serving routes. + assets/base.css Catppuccin palette + body typography (one source + of truth, no per-page redeclaration). + assets/terminal.css `.terminal-wrap` + `.live` + `.tail-pill` + + `.row` / `details.row` styling for both + pages' lit log panes. + assets/terminal.js `window.HiveTerminal.create(opts)`: scroll- + sticky log + "↓ N new" pill + history + backfill + SSE subscribe-buffer-snapshot- + dedupe dance. Pages register a kind→renderer + map; the terminal owns the lifecycle. + hive-ag3nt/ in-container harness crate; produces TWO binaries src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT src/client.rs generic JSON-line request/response over unix socket @@ -165,6 +193,37 @@ Prune freely. domain tooling — the agent flake's `inputs` block pulls the external flake, `agent.nix` references it via `flakeInputs..packages.${pkgs.system}.default`. +- **Just landed:** dashboard event refactor. New `hive-fr0nt` + workspace crate hosts shared frontend assets (palette + terminal + CSS + `window.HiveTerminal.create` JS) so both the dashboard and + the per-agent web UI render their live panes through the same + code; the dashboard's `#msgflow` now feels like the agent's + terminal (sticky-bottom + pill + lit chrome). New unified + `DashboardEvent` channel on `Coordinator` (replaces the + broker-only `/messages/stream`); a background forwarder mirrors + broker traffic onto it as `Sent` / `Delivered` variants, and + the mutation-event variants + (`ApprovalAdded` / `ApprovalResolved`, `QuestionAdded` / + `QuestionResolved`, `TransientSet` / `TransientCleared`) cover + every in-process state change the dashboard cares about. Each + frame carries a monotonic per-process `seq`; snapshot endpoints + return their seq alongside the state, and the terminal's + open-buffer-then-fetch-history dance drops any buffered frame + with `seq <= history_seq` so an event landing between subscribe + and history-fetch is neither shown twice nor lost. Operator + inbox + approvals + questions + transients are now derived + client-side from the event stream (cold-loaded from + `/api/state` for first paint, mutated live from SSE + thereafter); `/op-send` + per-agent `/send` return 200 instead + of 303-and-refetch. Container-list events still pending — + `ContainerView` is sourced from external `nixos-container list`, + so the 5s `/api/state` poll continues to drive the containers + section. Approval diffs are now raw unified-diff text on the + wire (per-line classification happens in JS) so they fit in + SSE payloads without HTML escaping. Bug fix: `LiveEvent::Note` + was a newtype variant that serde silently failed to serialize + — converted to `Note { text: String }` (wire shape matches what + the JS already read). - **Just landed:** `ask_operator` → `ask` rename + optional `to: ` param for agent-to-agent structured Q&A. Recipient defaults to the operator (dashboard); peer diff --git a/docs/conventions.md b/docs/conventions.md index 8a709ec4..a72c6e34 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -24,9 +24,12 @@ admin socket. ## Wire protocol JSON line-delimited over unix sockets in both directions (host admin -/ manager / agent). SSE streams (`/messages/stream`, -`/events/stream`) are `text/event-stream`. Request/response types -live in `hive-sh4re` — change them in one place. +/ manager / agent). SSE streams (`/dashboard/stream` on hive-c0re, +`/events/stream` on the per-agent web UIs) are `text/event-stream`; +each frame carries a `seq` field for the snapshot-dedupe dance +(see `docs/web-ui.md`). Request/response types live in `hive-sh4re` +— change them in one place. The dashboard event vocabulary lives +in `hive-c0re::dashboard_events::DashboardEvent`. ## Async forms diff --git a/docs/web-ui.md b/docs/web-ui.md index c00f5483..79c9ca2f 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -10,10 +10,31 @@ and the per-agent UIs (manager on :8000, sub-agents on a hashed - `GET /` → `assets/index.html` (placeholders for state-driven sections, shipped via `include_str!` so the binary has no runtime file dependency). -- `GET /static/*.css` + `GET /static/*.js` → static assets. -- `GET /api/state` → JSON snapshot the JS app renders into the DOM. -- `GET /events/stream` (per-agent) / `GET /messages/stream` - (dashboard) → `text/event-stream` SSE for live updates. +- `GET /static/*.css` + `GET /static/*.js` → static assets. Both + pages prepend `hive_fr0nt::BASE_CSS` + `TERMINAL_CSS` to their + per-page stylesheet, and `GET /static/hive-fr0nt.js` serves the + shared `window.HiveTerminal.create` runtime. The dashboard's + `#msgflow` and the per-agent `#live` log are both backed by + this terminal — sticky-bottom auto-scroll, "↓ N new" pill, + history backfill, SSE plumbing all live there. Each page + registers a kind→renderer map; unknown kinds fall through to + a JSON-dump note row. +- `GET /api/state` → JSON snapshot the JS app renders into the + DOM. Includes a top-level `seq` (the dashboard event channel's + high-water mark at the moment the snapshot was assembled); + clients use it to dedupe their buffered SSE traffic against + the snapshot (drop frames with `seq <= snapshot.seq`). +- `GET /dashboard/stream` (dashboard) / `GET /events/stream` + (per-agent) → `text/event-stream` SSE for live updates. The + dashboard stream carries broker `Sent` / `Delivered` (mirrored + by a forwarder task from the broker's intra-process channel) + plus mutation events (`approval_added` / `approval_resolved`, + `question_added` / `question_resolved`, `transient_set` / + `transient_cleared`). Each frame carries a `seq`. The + matching backfill endpoint is `GET /dashboard/history` (last + ~200 broker messages wrapped in `{ seq, events }`) on the + dashboard and `GET /events/history` (last 2000 `LiveEvent`s + also wrapped in `{ seq, events }`) on the agent. The JS app handles all `form[data-async]` submissions via a delegated listener: read `data-confirm`, swap the button to a spinner, POST @@ -71,26 +92,37 @@ the previous process's socket release resolves itself. with `[cancelled]`. Questions with a `ttl_seconds` show a `⏳ MM:SS` chip; the host-side watchdog auto-cancels with `[expired]` when the deadline fires. -5. **0PER4T0R 1NB0X** — recent messages addressed to `operator` - (last 50, from the broker). +5. **0PER4T0R 1NB0X** — recent messages addressed to `operator`, + derived client-side from the dashboard event stream (no longer + a snapshot field). Cold load seeds from + `/dashboard/history`'s 200-message backfill; subsequent + `sent` events with `to == "operator"` are appended live. Cap + 50, newest-first. 6. **P3NDING APPR0VALS** — the queue. The R3QU3ST SP4WN form lives at the top of this section since submitting it immediately queues an approval that lands directly below. -7. **MESS4GE FL0W** — live broker SSE tail (newest-first). - Each row is one broker event — `sent` or `delivered` — with - `from → to: body`; per-agent thinking / tool calls / claude - chatter stay out of this view, only what passes through - hive-c0re's broker. Below the stream sits a terminal-style - compose box: `@name` picks the recipient (sticky across - sends via localStorage; auto-complete from the live - container list, Tab/Enter to confirm), starting a message - with `@ body` retargets in one stroke, plain text - sends to the sticky recipient. `POST /op-send` drops - `{from:"operator", to, body}` into the broker — same shape - any sub-agent sees as a regular inbox message. Manager is - addressed as `@manager` (the broker recipient string), not - `@hm1nd` (the container name); the auto-complete swaps - automatically. +7. **MESS4GE FL0W** — live broker tail wrapped in a + `.terminal-wrap` (same chrome as the per-agent terminal). + Cold load backfills the last ~200 messages from + `/dashboard/history`; live frames arrive on + `/dashboard/stream` and dispatch through + `HiveTerminal.create`. Each row is one broker event — + `sent` or `delivered` — with `from → to: body`; per-agent + thinking / tool calls / claude chatter stay out of this + view, only what passes through hive-c0re's broker. Sticky- + bottom auto-scroll + "↓ N new" pill match the per-agent + page. Below the stream sits a terminal-style compose box: + `@name` picks the recipient (sticky across sends via + localStorage; auto-complete from the live container list, + Tab/Enter to confirm; `@*` broadcasts to every registered + agent), starting a message with `@ body` retargets + in one stroke, plain text sends to the sticky recipient. + `POST /op-send` drops `{from:"operator", to, body}` into + the broker and returns 200; the resulting SSE frame + re-renders both the terminal row and the inbox section + (no `/api/state` refetch). Manager is addressed as + `@manager` (the broker recipient string), not `@hm1nd` + (the container name); the auto-complete swaps automatically. ### Container row @@ -143,10 +175,13 @@ not ours. ### Dashboard endpoints -- `POST /approve/{id}` — approve a pending approval. +- `POST /approve/{id}` — approve a pending approval. Fires + `ApprovalResolved` on the dashboard event channel; client + updates derived approvals state from the event. - `POST /deny/{id}` (`note=`, optional) — deny a pending approval with an optional operator-supplied reason. The reason - travels to the manager as `HelperEvent::ApprovalResolved.note`. + travels to the manager as `HelperEvent::ApprovalResolved.note` + and also rides on the dashboard's `ApprovalResolved` event. Dashboard prompts via `window.prompt()` on click. - `POST /{rebuild,kill,restart,start,destroy}/{name}` — lifecycle. `destroy` accepts `purge=on` to also wipe state dirs. @@ -157,12 +192,52 @@ not ours. - `POST /request-spawn` — queue a Spawn approval. - `POST /update-all` — rebuild every stale container. - `POST /op-send` (`to=`, `body=`) — drop an - operator-authored message into ``'s inbox. Used by the + operator-authored message into ``'s inbox. `to=*` fans + out to every registered agent. Returns 200; the broker + `Sent` event re-renders both the message-flow terminal and + the operator inbox without a snapshot refetch. Used by the compose textbox under MESS4GE FL0W. - `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for a managed container. - `GET /api/agent-config/{name}` — read-only view of the applied `agent.nix`. +- `GET /dashboard/stream` — unified live event channel: + broker `sent` / `delivered`, plus the mutation events listed + below. Each frame carries `seq`. +- `GET /dashboard/history` — last ~200 broker messages + (wrapped as `{ seq, events }`) for the message-flow + terminal's backfill on page load. + +### Dashboard event channel + +Wire vocabulary on `/dashboard/stream` (kind tag is in the JSON +payload): + +- `sent` / `delivered` — broker traffic, mirrored from the + intra-process channel by a forwarder task. Used by the + message-flow terminal renderer and the operator-inbox + derived state. +- `approval_added` (id, agent, approval_kind, sha_short, diff, + description) / `approval_resolved` (id, agent, approval_kind, + sha_short, status, resolved_at, note, description) — pending + queue + history mutations. Client mutates a derived store and + re-renders only the approvals section. +- `question_added` (id, asker, question, options, multi, + asked_at, deadline_at) / `question_resolved` (id, answer, + answerer, answered_at, cancelled) — operator-targeted + questions only (peer-to-peer questions never fire these). The + ttl watchdog fires `question_resolved` with + `answerer = "ttl-watchdog"` on expiry. +- `transient_set` (name, transient_kind, since_unix) / + `transient_cleared` (name) — lifecycle action spinners. The + client ticks the elapsed-seconds badge off `since_unix` + client-side, no polling. + +`/api/state` still serves `approvals` / `approval_history` / +`questions` / `question_history` / `transients` for cold-start +on first page load and as a safety-net resync from the 5s poll; +the client maintains the same arrays in derived stores and +applies the events on top. Generalised form helpers: `form[data-confirm="…"]` pops `confirm()` before submit; `form[data-prompt="…"]` pops From f559441a068c135d77b3401c02422edbf8ad957c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 14:26:53 +0200 Subject: [PATCH 3/4] dashboard: /approve, /deny, /answer-question, /cancel-question, /request-spawn return 200; matching forms opt out of refetch --- hive-c0re/assets/app.js | 26 +++++++++++++++++++------- hive-c0re/src/dashboard.rs | 14 +++++++++----- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 5fd00305..4d247a73 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -22,10 +22,14 @@ } return e; }; - const form = (action, btnClass, btnLabel, confirmMsg, extra = {}) => { + const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => { const f = el('form', { method: 'POST', action, class: 'inline', 'data-async': '', ...(confirmMsg ? { 'data-confirm': confirmMsg } : {}), + // Endpoints whose mutation fires a DashboardEvent (and whose + // derived store applies it live) opt out of the post-submit + // /api/state refetch. See the async-form handler. + ...(opts.noRefresh ? { 'data-no-refresh': '' } : {}), }); for (const [name, value] of Object.entries(extra)) { f.append(el('input', { type: 'hidden', name, value })); @@ -195,7 +199,15 @@ if (btn) { btn.disabled = false; btn.innerHTML = original; } // Clear text inputs whose value was just submitted. f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; }); - refreshState(); + // Forms whose endpoint already emits a DashboardEvent that + // updates the derived store can opt out of the post-submit + // /api/state refetch (the event delivers the new row faster + // than the snapshot poll anyway). Container-lifecycle forms + // still rely on the refresh since `ContainerView` isn't yet + // event-derivable. + if (!f.hasAttribute('data-no-refresh')) { + refreshState(); + } } catch (err) { alert('action failed: ' + err); if (btn) { btn.disabled = false; btn.innerHTML = original; } @@ -587,7 +599,7 @@ li.append(head, el('div', { class: 'q-body' }, q.question)); const f = el('form', { method: 'POST', action: '/answer-question/' + q.id, - class: 'qform', 'data-async': '', + class: 'qform', 'data-async': '', 'data-no-refresh': '', }); const hasOptions = q.options && q.options.length; const isMulti = !!q.multi && hasOptions; @@ -638,7 +650,7 @@ // merge-on-submit handler attached to the main form. const cancelForm = el('form', { method: 'POST', action: '/cancel-question/' + q.id, - class: 'qform-cancel', 'data-async': '', + class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '', 'data-confirm': 'cancel this question? manager will see ' + '"[cancelled]" as the answer.', }); @@ -768,7 +780,7 @@ // the containers list (the agent doesn't exist yet). const spawn = el('form', { method: 'POST', action: '/request-spawn', - class: 'spawnform', 'data-async': '', + class: 'spawnform', 'data-async': '', 'data-no-refresh': '', }); spawn.append( el('input', { @@ -851,13 +863,13 @@ // HelperEvent::ApprovalResolved { note }. const denyForm = el('form', { method: 'POST', action: '/deny/' + a.id, - class: 'inline', 'data-async': '', + class: 'inline', 'data-async': '', 'data-no-refresh': '', 'data-prompt': 'reason for denying (optional, sent to manager):', }); denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY')); row.append( ' ', - form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE'), + form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }), ' ', denyForm, ); diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index f5720e91..8054fff6 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -783,7 +783,11 @@ async fn dashboard_stream( async fn post_approve(State(state): State, AxumPath(id): AxumPath) -> Response { match actions::approve(state.coord.clone(), id).await { - Ok(()) => Redirect::to("/").into_response(), + // 200 instead of 303 — `actions::approve` fires + // `ApprovalResolved` (success path) or the eventual failure + // event, both of which the dashboard's derived store applies + // live. The matching form carries `data-no-refresh`. + Ok(()) => (StatusCode::OK, "ok").into_response(), Err(e) => error_response(&format!("approve {id} failed: {e:#}")), } } @@ -805,7 +809,7 @@ async fn post_deny( .map(str::trim) .filter(|s| !s.is_empty()); match actions::deny(&state.coord, id, note).await { - Ok(()) => Redirect::to("/").into_response(), + Ok(()) => (StatusCode::OK, "ok").into_response(), Err(e) => error_response(&format!("deny {id} failed: {e:#}")), } } @@ -853,7 +857,7 @@ async fn post_answer_question( false, ); } - Redirect::to("/").into_response() + (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("answer {id} failed: {e:#}")), } @@ -894,7 +898,7 @@ async fn post_cancel_question( answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), }, ); - Redirect::to("/").into_response() + (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")), } @@ -1199,7 +1203,7 @@ async fn post_request_spawn( state .coord .emit_approval_added(id, &name, "spawn", None, None, None); - Redirect::to("/").into_response() + (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")), } From e4438d1a6eb680a5de56c9d67d73312ae5b6f6b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 14:27:03 +0200 Subject: [PATCH 4/4] todo: phase 6 event-covered redirects converted --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 355bd8c4..54d5442a 100644 --- a/TODO.md +++ b/TODO.md @@ -24,7 +24,7 @@ - Per-agent reminder status (pending, delivered) - Reminder query interface for debugging - Display reminder delivery errors (failed sends, mark failures) -- **Phase 6 leftovers**: convert the remaining redirect-and-refetch endpoints whose mutations now fire dashboard events. `/approve`, `/deny`, `/answer-question`, `/cancel-question`, `/request-spawn` are 5-line `Redirect::to("/")` → `(StatusCode::OK, "ok")` changes; the client already updates derived state from the events. Container-lifecycle endpoints (`/restart`, `/destroy`, `/kill`, `/rebuild`, `/start`, `/api/{cancel,compact,model,new-session}`, `/meta-update`, `/purge-tombstone`) need a `ContainerListChanged` event first — `ContainerView` is currently sourced from external `nixos-container list`, so the 5s poll still drives that section. +- **Phase 6 leftovers** — event-covered endpoints (`/approve`, `/deny`, `/answer-question`, `/cancel-question`, `/request-spawn`) now return 200 (f559441); the matching forms carry `data-no-refresh` so the post-submit `/api/state` refetch is skipped (the SSE event delivers the update). Container-lifecycle endpoints (`/restart`, `/destroy`, `/kill`, `/rebuild`, `/start`, `/api/{cancel,compact,model,new-session}`, `/meta-update`, `/purge-tombstone`) still need a `ContainerListChanged` event before their redirects can drop — `ContainerView` is currently sourced from external `nixos-container list`, so the 5s poll continues to drive that section. ## Bugs