Compare commits

..
6 changed files with 52 additions and 196 deletions

View file

@ -19,19 +19,8 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched)
src/client.rs admin-socket client src/client.rs admin-socket client
src/manager_server.rs manager-privileged socket (ManagerRequest) src/manager_server.rs manager-privileged socket (ManagerRequest)
src/agent_server.rs per-sub-agent socket listener (long-poll Recv) src/agent_server.rs per-sub-agent socket listener (long-poll Recv)
src/broker.rs sqlite Message store + intra-process broadcast src/broker.rs sqlite Message store + broadcast channel for SSE +
channel (`MessageEvent`) for `recv_blocking` + hourly vacuum of delivered>30d
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/approvals.rs sqlite Approval queue + kinds
src/operator_questions.rs sqlite question queue backing `ask` / src/operator_questions.rs sqlite question queue backing `ask` /
`answer` (both operator + agent-to-agent) `answer` (both operator + agent-to-agent)
@ -60,26 +49,9 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched)
(idempotent, marker-guarded phase 4) (idempotent, marker-guarded phase 4)
src/dashboard.rs axum HTTP: static shell + /api/state JSON + actions src/dashboard.rs axum HTTP: static shell + /api/state JSON + actions
+ journald viewer + bind-with-retry (SO_REUSEADDR) + 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!) 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 hive-ag3nt/ in-container harness crate; produces TWO binaries
src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT
src/client.rs generic JSON-line request/response over unix socket src/client.rs generic JSON-line request/response over unix socket
@ -193,37 +165,6 @@ Prune freely.
domain tooling — the agent flake's `inputs` block pulls domain tooling — the agent flake's `inputs` block pulls
the external flake, `agent.nix` references it via the external flake, `agent.nix` references it via
`flakeInputs.<name>.packages.${pkgs.system}.default`. `flakeInputs.<name>.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 - **Just landed:** `ask_operator``ask` rename + optional
`to: <agent>` param for agent-to-agent structured Q&A. `to: <agent>` param for agent-to-agent structured Q&A.
Recipient defaults to the operator (dashboard); peer Recipient defaults to the operator (dashboard); peer

11
TODO.md
View file

@ -8,11 +8,18 @@
- **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" - **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) - **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. - **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<String> }` on both `AgentRequest` + `ManagerRequest`. `to = None` (or `Some("operator")`) = dashboard path; `to = Some(<agent>)` 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). - **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 ## 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) - 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-<ts>.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/<agent>/state/...` to host `/var/lib/hyperhive/agents/<agent>/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) - **Scheduler shutdown**: add graceful shutdown signal when coordinator is destroyed (currently runs forever)
- **DB lock contention**: under high reminder volume, the broker's `Mutex<Connection>` serializes every delivery transaction. Consider batching multiple deliveries into one tx, or moving reminders onto a separate sqlite connection. - **DB lock contention**: under high reminder volume, the broker's `Mutex<Connection>` serializes every delivery transaction. Consider batching multiple deliveries into one tx, or moving reminders onto a separate sqlite connection.
@ -24,8 +31,10 @@
- Per-agent reminder status (pending, delivered) - Per-agent reminder status (pending, delivered)
- Reminder query interface for debugging - Reminder query interface for debugging
- Display reminder delivery errors (failed sends, mark failures) - Display reminder delivery errors (failed sends, mark failures)
- **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. - ~~**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.
## Bugs ## 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. - **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.

View file

@ -24,12 +24,9 @@ admin socket.
## Wire protocol ## Wire protocol
JSON line-delimited over unix sockets in both directions (host admin JSON line-delimited over unix sockets in both directions (host admin
/ manager / agent). SSE streams (`/dashboard/stream` on hive-c0re, / manager / agent). SSE streams (`/messages/stream`,
`/events/stream` on the per-agent web UIs) are `text/event-stream`; `/events/stream`) are `text/event-stream`. Request/response types
each frame carries a `seq` field for the snapshot-dedupe dance live in `hive-sh4re` — change them in one place.
(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 ## Async forms

View file

@ -10,31 +10,10 @@ and the per-agent UIs (manager on :8000, sub-agents on a hashed
- `GET /``assets/index.html` (placeholders for state-driven - `GET /``assets/index.html` (placeholders for state-driven
sections, shipped via `include_str!` so the binary has no runtime sections, shipped via `include_str!` so the binary has no runtime
file dependency). file dependency).
- `GET /static/*.css` + `GET /static/*.js` → static assets. Both - `GET /static/*.css` + `GET /static/*.js` → static assets.
pages prepend `hive_fr0nt::BASE_CSS` + `TERMINAL_CSS` to their - `GET /api/state` → JSON snapshot the JS app renders into the DOM.
per-page stylesheet, and `GET /static/hive-fr0nt.js` serves the - `GET /events/stream` (per-agent) / `GET /messages/stream`
shared `window.HiveTerminal.create` runtime. The dashboard's (dashboard) → `text/event-stream` SSE for live updates.
`#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 The JS app handles all `form[data-async]` submissions via a delegated
listener: read `data-confirm`, swap the button to a spinner, POST listener: read `data-confirm`, swap the button to a spinner, POST
@ -92,37 +71,26 @@ the previous process's socket release resolves itself.
with `[cancelled]`. Questions with a `ttl_seconds` show a with `[cancelled]`. Questions with a `ttl_seconds` show a
`⏳ MM:SS` chip; the host-side watchdog auto-cancels with `⏳ MM:SS` chip; the host-side watchdog auto-cancels with
`[expired]` when the deadline fires. `[expired]` when the deadline fires.
5. **0PER4T0R 1NB0X** — recent messages addressed to `operator`, 5. **0PER4T0R 1NB0X** — recent messages addressed to `operator`
derived client-side from the dashboard event stream (no longer (last 50, from the broker).
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 6. **P3NDING APPR0VALS** — the queue. The R3QU3ST SP4WN form
lives at the top of this section since submitting it lives at the top of this section since submitting it
immediately queues an approval that lands directly below. immediately queues an approval that lands directly below.
7. **MESS4GE FL0W** — live broker tail wrapped in a 7. **MESS4GE FL0W** — live broker SSE tail (newest-first).
`.terminal-wrap` (same chrome as the per-agent terminal). Each row is one broker event — `sent` or `delivered` — with
Cold load backfills the last ~200 messages from `from → to: body`; per-agent thinking / tool calls / claude
`/dashboard/history`; live frames arrive on chatter stay out of this view, only what passes through
`/dashboard/stream` and dispatch through hive-c0re's broker. Below the stream sits a terminal-style
`HiveTerminal.create`. Each row is one broker event — compose box: `@name` picks the recipient (sticky across
`sent` or `delivered` — with `from → to: body`; per-agent sends via localStorage; auto-complete from the live
thinking / tool calls / claude chatter stay out of this container list, Tab/Enter to confirm), starting a message
view, only what passes through hive-c0re's broker. Sticky- with `@<name> body` retargets in one stroke, plain text
bottom auto-scroll + "↓ N new" pill match the per-agent sends to the sticky recipient. `POST /op-send` drops
page. Below the stream sits a terminal-style compose box: `{from:"operator", to, body}` into the broker — same shape
`@name` picks the recipient (sticky across sends via any sub-agent sees as a regular inbox message. Manager is
localStorage; auto-complete from the live container list, addressed as `@manager` (the broker recipient string), not
Tab/Enter to confirm; `@*` broadcasts to every registered `@hm1nd` (the container name); the auto-complete swaps
agent), starting a message with `@<name> body` retargets automatically.
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 ### Container row
@ -175,13 +143,10 @@ not ours.
### Dashboard endpoints ### Dashboard endpoints
- `POST /approve/{id}` — approve a pending approval. Fires - `POST /approve/{id}` — approve a pending approval.
`ApprovalResolved` on the dashboard event channel; client
updates derived approvals state from the event.
- `POST /deny/{id}` (`note=<reason>`, optional) — deny a pending - `POST /deny/{id}` (`note=<reason>`, optional) — deny a pending
approval with an optional operator-supplied reason. The reason 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. Dashboard prompts via `window.prompt()` on click.
- `POST /{rebuild,kill,restart,start,destroy}/{name}` — lifecycle. - `POST /{rebuild,kill,restart,start,destroy}/{name}` — lifecycle.
`destroy` accepts `purge=on` to also wipe state dirs. `destroy` accepts `purge=on` to also wipe state dirs.
@ -192,52 +157,12 @@ not ours.
- `POST /request-spawn` — queue a Spawn approval. - `POST /request-spawn` — queue a Spawn approval.
- `POST /update-all` — rebuild every stale container. - `POST /update-all` — rebuild every stale container.
- `POST /op-send` (`to=<name>`, `body=<text>`) — drop an - `POST /op-send` (`to=<name>`, `body=<text>`) — drop an
operator-authored message into `<name>`'s inbox. `to=*` fans operator-authored message into `<name>`'s inbox. Used by the
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. compose textbox under MESS4GE FL0W.
- `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for - `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for
a managed container. a managed container.
- `GET /api/agent-config/{name}` — read-only view of the applied - `GET /api/agent-config/{name}` — read-only view of the applied
`agent.nix`. `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 Generalised form helpers: `form[data-confirm="…"]` pops
`confirm()` before submit; `form[data-prompt="…"]` pops `confirm()` before submit; `form[data-prompt="…"]` pops

View file

@ -22,14 +22,10 @@
} }
return e; return e;
}; };
const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => { const form = (action, btnClass, btnLabel, confirmMsg, extra = {}) => {
const f = el('form', { const f = el('form', {
method: 'POST', action, class: 'inline', 'data-async': '', method: 'POST', action, class: 'inline', 'data-async': '',
...(confirmMsg ? { 'data-confirm': confirmMsg } : {}), ...(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)) { for (const [name, value] of Object.entries(extra)) {
f.append(el('input', { type: 'hidden', name, value })); f.append(el('input', { type: 'hidden', name, value }));
@ -199,15 +195,7 @@
if (btn) { btn.disabled = false; btn.innerHTML = original; } if (btn) { btn.disabled = false; btn.innerHTML = original; }
// Clear text inputs whose value was just submitted. // Clear text inputs whose value was just submitted.
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; }); f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
// 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(); refreshState();
}
} catch (err) { } catch (err) {
alert('action failed: ' + err); alert('action failed: ' + err);
if (btn) { btn.disabled = false; btn.innerHTML = original; } if (btn) { btn.disabled = false; btn.innerHTML = original; }
@ -599,7 +587,7 @@
li.append(head, el('div', { class: 'q-body' }, q.question)); li.append(head, el('div', { class: 'q-body' }, q.question));
const f = el('form', { const f = el('form', {
method: 'POST', action: '/answer-question/' + q.id, method: 'POST', action: '/answer-question/' + q.id,
class: 'qform', 'data-async': '', 'data-no-refresh': '', class: 'qform', 'data-async': '',
}); });
const hasOptions = q.options && q.options.length; const hasOptions = q.options && q.options.length;
const isMulti = !!q.multi && hasOptions; const isMulti = !!q.multi && hasOptions;
@ -650,7 +638,7 @@
// merge-on-submit handler attached to the main form. // merge-on-submit handler attached to the main form.
const cancelForm = el('form', { const cancelForm = el('form', {
method: 'POST', action: '/cancel-question/' + q.id, method: 'POST', action: '/cancel-question/' + q.id,
class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '', class: 'qform-cancel', 'data-async': '',
'data-confirm': 'cancel this question? manager will see ' 'data-confirm': 'cancel this question? manager will see '
+ '"[cancelled]" as the answer.', + '"[cancelled]" as the answer.',
}); });
@ -780,7 +768,7 @@
// the containers list (the agent doesn't exist yet). // the containers list (the agent doesn't exist yet).
const spawn = el('form', { const spawn = el('form', {
method: 'POST', action: '/request-spawn', method: 'POST', action: '/request-spawn',
class: 'spawnform', 'data-async': '', 'data-no-refresh': '', class: 'spawnform', 'data-async': '',
}); });
spawn.append( spawn.append(
el('input', { el('input', {
@ -863,13 +851,13 @@
// HelperEvent::ApprovalResolved { note }. // HelperEvent::ApprovalResolved { note }.
const denyForm = el('form', { const denyForm = el('form', {
method: 'POST', action: '/deny/' + a.id, method: 'POST', action: '/deny/' + a.id,
class: 'inline', 'data-async': '', 'data-no-refresh': '', class: 'inline', 'data-async': '',
'data-prompt': 'reason for denying (optional, sent to manager):', 'data-prompt': 'reason for denying (optional, sent to manager):',
}); });
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY')); denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
row.append( row.append(
' ', ' ',
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }), form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE'),
' ', ' ',
denyForm, denyForm,
); );

View file

@ -783,11 +783,7 @@ async fn dashboard_stream(
async fn post_approve(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -> Response { async fn post_approve(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -> Response {
match actions::approve(state.coord.clone(), id).await { match actions::approve(state.coord.clone(), id).await {
// 200 instead of 303 — `actions::approve` fires Ok(()) => Redirect::to("/").into_response(),
// `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:#}")), Err(e) => error_response(&format!("approve {id} failed: {e:#}")),
} }
} }
@ -809,7 +805,7 @@ async fn post_deny(
.map(str::trim) .map(str::trim)
.filter(|s| !s.is_empty()); .filter(|s| !s.is_empty());
match actions::deny(&state.coord, id, note).await { match actions::deny(&state.coord, id, note).await {
Ok(()) => (StatusCode::OK, "ok").into_response(), Ok(()) => Redirect::to("/").into_response(),
Err(e) => error_response(&format!("deny {id} failed: {e:#}")), Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
} }
} }
@ -857,7 +853,7 @@ async fn post_answer_question(
false, false,
); );
} }
(StatusCode::OK, "ok").into_response() Redirect::to("/").into_response()
} }
Err(e) => error_response(&format!("answer {id} failed: {e:#}")), Err(e) => error_response(&format!("answer {id} failed: {e:#}")),
} }
@ -898,7 +894,7 @@ async fn post_cancel_question(
answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
}, },
); );
(StatusCode::OK, "ok").into_response() Redirect::to("/").into_response()
} }
Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")), Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")),
} }
@ -1203,7 +1199,7 @@ async fn post_request_spawn(
state state
.coord .coord
.emit_approval_added(id, &name, "spawn", None, None, None); .emit_approval_added(id, &name, "spawn", None, None, None);
(StatusCode::OK, "ok").into_response() Redirect::to("/").into_response()
} }
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")), Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),
} }