docs: sort the top-level directories into five sections by what the reader wants
Every docs directory moves under 1-getting-started, 2-handbook, 4-implementation or 5-project; the options reference (section 3) comes from the website and has no directory here. trust-boundary stays whole under the handbook for now. web-ui, swarm and tools split between handbook and implementation, and the command-line references move to 2-handbook/reference. Links, path references in code and nix, and the docs index follow the moves; no page content changes.
This commit is contained in:
parent
53ccbcb4b3
commit
6739bb903d
1 changed files with 736 additions and 701 deletions
|
|
@ -1,307 +0,0 @@
|
|||
# Shape (shared by both)
|
||||
|
||||
> Part of [Web UI](README.md). See also:
|
||||
> [Dashboard layout](dashboard.md) · [Per-agent page](agent.md)
|
||||
|
||||
## Shared routes
|
||||
|
||||
- `GET /` → `index.html` from the bundled frontend dist (see
|
||||
`frontend/`). Both binaries' routers declare their dynamic
|
||||
endpoints first and then `fallback_service(ServeDir::new(...))`
|
||||
pointed at `HIVE_STATIC_DIR` — that fallback serves anything not
|
||||
matched by an API or action route from the dist. Dashboard dist lives at
|
||||
`${frontend}/dashboard`; per-agent dist is the merged
|
||||
`services.hyperhive.agent.frontend.mergedDist` (default agent dist + per-agent
|
||||
`extraFiles` overlay).
|
||||
- `GET /static/*` → bundled CSS + JS produced by esbuild
|
||||
(`frontend/packages/{dashboard,agent}/build.mjs`). Both pages
|
||||
pull the shared terminal pane + Catppuccin palette + typography
|
||||
from `@hive/shared` (was `hive-fr0nt`); the dashboard ships
|
||||
`colors.css` + `theme.css` + `common.css` + `dashboard.css`;
|
||||
`common.css` inlines `@hive/shared`'s
|
||||
`base.css` + `terminal.css` + `tabs.css` +
|
||||
`pill.css` via esbuild's `@import` resolution.
|
||||
`terminal.js` exports `{ create, linkify }` as ES module
|
||||
members; a back-compat shim in the IIFE bodies still exposes
|
||||
a `window.HiveTerminal` global for callers that need it. The
|
||||
per-agent `#live` log runs on
|
||||
this terminal — sticky-bottom autoscroll, "↓ 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. Bare `http(s)://` URLs in row text are
|
||||
turned into clickable new-tab links by `linkify` (text-node
|
||||
based, no `innerHTML` — XSS-safe); markdown bodies go through
|
||||
`marked` (npm dep, replacing the vendored UMD bundle) and then
|
||||
`DOMPurify.sanitize()` before hitting `innerHTML` — untrusted row
|
||||
text (peer-agent / matrix-relayed message bodies, agent-authored
|
||||
state files) can carry arbitrary HTML/script via markdown, so
|
||||
`DOMPurify.sanitize()` cleans the markdown path rather than it being
|
||||
XSS-safe by construction the way the text-node `linkify` path is.
|
||||
Rendered `<a>`s are rewritten to `target="_blank"`.
|
||||
- `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 server assembled the snapshot);
|
||||
clients use it to dedupe their buffered SSE traffic against
|
||||
the snapshot (drop frames with `seq <= snapshot.seq`).
|
||||
- `GET /api/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 (`transient_set` / `transient_cleared`). Each frame carries a `seq`. The
|
||||
agent's matching backfill endpoint is `GET /events/history` (last 2000
|
||||
`LiveEvent`s wrapped in `{ seq, events }`).
|
||||
**One unified channel**: browsers cap concurrent SSE
|
||||
connections per origin (~6 in Chrome). Using one channel per
|
||||
domain would exhaust this budget on a live hive; dispatching
|
||||
by `kind` on the client is a one-liner. Per-domain splits are
|
||||
reserved for high-volume sub-streams most consumers skip (none
|
||||
exist yet). The broker's intra-process channel stays separate
|
||||
from the dashboard channel to avoid coupling `recv_blocking_batch`
|
||||
(hot path inside the harness turn loop) to presentation concerns.
|
||||
**SSE multiplexing**: the dashboard uses a
|
||||
`SharedWorker` (`stream-worker.js`) to hold one upstream
|
||||
`EventSource` per URL. All same-origin tabs share this worker
|
||||
— a second dashboard tab joins the existing connection rather
|
||||
than opening a duplicate. The worker fans SSE events out to
|
||||
each subscribed tab via `MessagePort`; on bfcache restore the
|
||||
page re-subscribes (gets a synthetic `open` event immediately
|
||||
if the upstream is already connected). Falls back gracefully
|
||||
when `SharedWorker` is unavailable (for example some private-mode
|
||||
browsers). **Worker-death self-heal**: Firefox kills
|
||||
"idle" SharedWorkers under memory pressure with no client-side
|
||||
signal — the port silently goes no-op. The worker now pings
|
||||
every connected port every 30s; the client bumps a
|
||||
last-activity timestamp on every message (incl. pings, which
|
||||
carry no URL — bumped before the URL filter). A
|
||||
visibility-gated watchdog polls every 15s and, if the page is
|
||||
visible AND has active subs AND hasn't heard from the worker
|
||||
in >90s (three missed pings), presumes the worker dead and
|
||||
re-subscribes on a fresh `SharedWorker` port (same code path
|
||||
bfcache-restore uses). Recovery is per-tab; pings are
|
||||
invisible on the healthy path.
|
||||
|
||||
## Shared terminal pane
|
||||
|
||||
The shared terminal factory in `@hive/shared/terminal.js` backs the
|
||||
per-agent page's scrollable log stream (`#live`). The factory wires up
|
||||
sticky-bottom autoscroll, a "↓ N new" pill, history backfill, and
|
||||
SSE replay. Pages register a `kind → renderer` map; unknown kinds
|
||||
fall through to a JSON-dump note row. The factory ships three row
|
||||
shapes the renderers call:
|
||||
|
||||
- `api.row(cls, text, icon?)` — single-line row with an inline `linkify`
|
||||
pass over the text; optional `icon` lands in a fixed-width `.row-glyph`
|
||||
cell so glyphs of varying rendered width all align.
|
||||
- `api.details(cls, summary, body, icon?)` — collapsible `<details>` with
|
||||
a `<pre>` body (used by long tool-results and stack traces).
|
||||
- `api.detailsDiff(cls, summary, body, icon?)` — same shape, splits the
|
||||
body on newlines and tags each line as `diff-add` / `diff-del` /
|
||||
`diff-ctx` so the renderer's diff bodies get coloured without
|
||||
emitting raw HTML.
|
||||
- `api.placeholder(text)` — replaces the log with a single muted row,
|
||||
cleared automatically when the next real row arrives.
|
||||
- `api.fromHistory` — `true` while backfill replay is running; renderers
|
||||
use it to suppress live-only side effects.
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
**`create(opts)` options:** `logEl` (log container element), `streamUrl`
|
||||
(SSE endpoint), `historyUrl?` (optional replay endpoint — skipped if absent
|
||||
or if the fetch fails), `renderers` (kind→fn map; unknown kinds fall through
|
||||
to `renderers._default`, itself defaulting to a JSON-dump note row),
|
||||
`onLiveEvent?(ev)` (live-only side effects), `onAnyEvent?(ev, {fromHistory})`
|
||||
(runs for both replay and live — use for derived views that need the full
|
||||
event picture), `onBackfillDone?(count)` (one-shot after replay; `count=0` on
|
||||
failed/skipped fetch), `onStreamOpen?()` (fires on every EventSource
|
||||
(re)connect — use to re-sync snapshot-derived state after a reconnect gap),
|
||||
`pillAnchor?` (parent element for the "↓ N new" pill; defaults to
|
||||
`logEl.parentElement`), `expandDetails?` (boolean or zero-arg function
|
||||
returning one, re-read on every `api.details`/`api.detailsDiff` call rather
|
||||
than captured once — lets a page default otherwise-collapsed panels open
|
||||
per a live browser-local preference; renderers that force a row open
|
||||
regardless, for example message-bearing tool_use, are unaffected either way).
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
**Sticky-bottom + snap animation.** `stickToBottom` is the
|
||||
operator's intent: true means "keep snapping to bottom on every
|
||||
mutation," false means "the operator scrolled up, leave them alone." The flag
|
||||
flips when a scroll event lands further than `NEAR_BOTTOM_PX = 48`
|
||||
from the bottom. New rows then either snap to bottom (when sticky)
|
||||
or bump the unseen-count and surface the "↓ N new" pill. The snap
|
||||
is a brief 140ms ease-out (`SCROLL_ANIM_MS`) — the browser's
|
||||
default `behavior: 'smooth'` ~500ms reads as "still smooth, but
|
||||
visibly slow"; 140ms feels snap-y while still reading as motion
|
||||
rather than a jump. Distances under `SCROLL_SNAP_PX = 24`
|
||||
short-circuit to instant — animating a 12px nudge would just be
|
||||
jitter. Each new snap cancels the previous `requestAnimationFrame`
|
||||
so a burst of mutations coalesces into one ride to the latest
|
||||
bottom; the per-frame step re-reads `scrollHeight - clientHeight`
|
||||
so mutations landing mid-animation extend the destination smoothly
|
||||
rather than land short.
|
||||
|
||||
**Mid-animation scroll-event guard.** The scroll handler's
|
||||
`isNearBottom` check would flip `stickToBottom` false mid-snap as
|
||||
the smooth animation eases through positions that are technically
|
||||
"not near bottom yet," which would strand the operator partway. A
|
||||
`smoothScrollingUntil` timestamp gates the scroll handler — set to
|
||||
the animation end + ~80ms headroom, re-armed on each fresh snap.
|
||||
Programmatic `scrollTop` writes (the animation's per-frame update)
|
||||
fire scroll events that the gate swallows. One caveat: if live events
|
||||
arrive faster than the animation window (< 220ms apart), the
|
||||
MutationObserver keeps re-firing `snapToBottom()` which perpetually
|
||||
re-arms the gate — trapping the operator at the bottom with no way
|
||||
to scroll up or trigger `loadMore()`. Safety valve: even inside the
|
||||
guard the handler checks `isNearBottom()`; if the operator has
|
||||
scrolled away from the bottom, `stickToBottom` is immediately forced
|
||||
false. The MO's `if (stickToBottom)` check then stops re-arming
|
||||
snaps, and the gate expires within ≤220ms.
|
||||
|
||||
**Post-append `MutationObserver`.** Renderers commonly call
|
||||
`api.row(cls, text)` to create the row shell then append more
|
||||
children (badges, multi-line bodies, tool result panes) after the
|
||||
factory returned. The initial sticky-snap fires off the row's
|
||||
empty shape; the renderer's later appends grow the row past the
|
||||
visible bottom. A `MutationObserver` on the log subtree fires once
|
||||
per microtask after each batch of synchronous mutations and snaps
|
||||
again when `stickToBottom` is true. Programmatic `scrollTop`
|
||||
writes don't re-trigger the observer (scroll isn't a DOM
|
||||
mutation), so no feedback loop. The pre-append
|
||||
`nearBottomBeforeAppend` snapshot is still useful — it keeps the
|
||||
initial visual lag to one frame instead of one microtask + frame.
|
||||
|
||||
**Backfill + SSE.** Cold load fetches `historyUrl` (replay), then
|
||||
subscribes to `streamUrl` (live tail). Both endpoints return
|
||||
`{ seq, events }` so the client can dedupe — the client silently
|
||||
drops events with `seq <= snapshot.seq` from the SSE stream
|
||||
(the snapshot already covers them). History rows render with a
|
||||
`.no-anim` class so they don't stagger in like live events. The
|
||||
optional `streamFactory(url)` callback lets the dashboard hand
|
||||
the factory a `SharedWorker`-backed `EventSource` facade (so
|
||||
multiple tabs share one upstream connection — see _SSE
|
||||
multiplexing_ above); when omitted, the factory falls back to a
|
||||
plain `new EventSource(url)`.
|
||||
|
||||
**`linkify` (text-node based).** Bare `http(s)://` URLs in row
|
||||
text get wrapped in `<a target="_blank" rel="noopener noreferrer">`
|
||||
inside a fresh text node, so the autolinker never touches
|
||||
`innerHTML` and untrusted row content can't smuggle markup. The
|
||||
trailing-punctuation strip keeps `.,;:` outside the link surface.
|
||||
Markdown bodies go through `marked` separately, then
|
||||
`DOMPurify.sanitize()` on the resulting HTML before it's assigned to
|
||||
`innerHTML` (see `mdNode` in `app.js` / `common.js`) —
|
||||
`DOMPurify.sanitize()` cleans this path rather than it being
|
||||
text-node-safe like `linkify`, since markdown can carry raw HTML.
|
||||
Rendered `<a>`s get the same target rewrite.
|
||||
|
||||
The JS app handles all `form[data-async]` submissions via a delegated
|
||||
listener: read `data-confirm`, swap the button to a spinner, POST
|
||||
`application/x-www-form-urlencoded`, re-enable the button on success
|
||||
(refreshState may keep the form mounted, so the handler doesn't rely on a
|
||||
re-render), call `refreshState()`. State shapes live in
|
||||
`hive-c0re/src/dashboard/state_snapshot.rs::StateSnapshot` and
|
||||
`web_ui/state.rs::StateSnapshot` — when
|
||||
adding state fields, plumb through the snapshot struct and the
|
||||
relevant domain module (`agents.js`, `permissions.js`, etc.) render function.
|
||||
|
||||
**Focus preservation:** `refreshState` checks whether
|
||||
`document.activeElement` sits inside one of the managed sections
|
||||
and, if so, skips the refresh (defers 2s). The operator never has
|
||||
the form yanked out from under them mid-type; the update lands as
|
||||
soon as they blur.
|
||||
|
||||
**Atomic section repaint:** every managed-section renderer goes
|
||||
through `paintAtomic(liveRoot, build)`: the builder appends into
|
||||
a fresh `DocumentFragment` (off-DOM) and the commit is one
|
||||
`replaceChildren` call, so the intermediate empty state is never
|
||||
visible — clearing and re-appending directly into the live root can
|
||||
leave it empty for a paint (on async builders) or a whole frame (on
|
||||
large synchronous ones), which reads as a visible flash on every
|
||||
poll cycle. Builders receive the fragment as their
|
||||
`root`, so existing renderer code carries over unchanged; early
|
||||
returns inside the builder still commit whatever it appended
|
||||
before returning.
|
||||
|
||||
**Keyed DOM caching:** for sections whose rows hold interactive state
|
||||
(textarea drafts, checkboxes, focused inputs) `paintAtomic` isn't
|
||||
enough — wiping and rebuilding still destroys the state even if
|
||||
`paintAtomic` hides the flash. The keyed pattern keeps a
|
||||
`Map<id, {el, fingerprint}>` where the fingerprint is
|
||||
`JSON.stringify({...visible fields...})`. On each render, the pattern
|
||||
reuses cache-hit rows verbatim (preserving textarea draft, checkbox
|
||||
state, and event listeners) and rebuilds only cache-miss rows. Used for: containers (`containerRowCache`),
|
||||
rebuild-queue entries (`rebuildQueueRowCache`). The spawn-form input+focus
|
||||
uses a lighter snapshot-then-restore pattern (snapshot
|
||||
before `replaceChildren`, restore after) since it's a single
|
||||
value rather than a per-row cache.
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
**`<details>` open-state preservation:** any collapsible element
|
||||
tagged with `data-restore-key="<stable-key>"` survives the
|
||||
refresh. `snapshotOpenDetails()` walks managed sections before
|
||||
render, `restoreOpenDetails()` re-applies after. The
|
||||
dashboard's own answered-questions history list was the only
|
||||
`<details data-restore-key>` consumer and is gone
|
||||
— `snapshotOpenDetails()` / `restoreOpenDetails()` currently have
|
||||
nothing to restore, kept in place for the next collapsible section
|
||||
that needs it rather than ripped out along with their one caller.
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
## Listener bind
|
||||
|
||||
Both bind their TCP listener with `SO_REUSEADDR` via
|
||||
`tokio::net::TcpSocket` plus a retry loop on `AddrInUse`
|
||||
(exponential backoff capped at 2s) so an nspawn restart that races
|
||||
the previous process's socket release resolves itself, but the two
|
||||
binaries differ on the attempt budget. The dashboard's
|
||||
`bind_with_retry` (`hive-c0re/src/dashboard/mod.rs`) has **no
|
||||
attempt cap** — retrying forever is deliberate, since
|
||||
`lifecycle::{spawn,rebuild}` preflights genuine port collisions
|
||||
host-side (refuses with a clear error, surfaced on the dashboard as a banner),
|
||||
so at this layer a persistent `AddrInUse` always reflects a
|
||||
recoverable stale socket. Its first 12 attempts log at WARN; after
|
||||
that the level drops to INFO so a long-held stale socket doesn't
|
||||
flood the journal. The per-agent UI's `bind_with_retry`
|
||||
(`hive-agent/src/web_ui/mod.rs`) instead gives up after
|
||||
`MAX_BIND_ATTEMPTS` (12) and returns the `AddrInUse` error rather
|
||||
than looping forever.
|
||||
|
||||
The per-agent UI optionally binds a `UnixListener` instead of
|
||||
TCP when the nix module sets `HIVE_WEB_SOCKET` — the unix-socket transition
|
||||
mechanics (per-agent `/run/hive-agent/<name>/` bind-mount,
|
||||
`.bound` marker filtering, `agent-sockets.json` consumer on the
|
||||
gateway side) live in [`docs/networking/gateway.md::Per-agent unix-socket
|
||||
upstream`](../networking/gateway.md). The env var is opt-in per agent so the
|
||||
two modes coexist while sub-agents transition.
|
||||
|
||||
## Per-agent relative paths
|
||||
|
||||
The per-agent UI uses **document-relative paths everywhere** for
|
||||
assets, API calls, form actions, and the screen WebSocket. Bare
|
||||
references like `static/main.js`, `api/state`, `events/stream`,
|
||||
`screen/ws` resolve against `document.baseURI` — the page's URL
|
||||
without its last path segment.
|
||||
|
||||
That makes the page work under any prefix the agent ends up mounted
|
||||
at without rebuilding the dist. The cases that matter:
|
||||
|
||||
| served at | `api/state` resolves to |
|
||||
| ------------------------------------------------ | ----------------------- |
|
||||
| `/` (own port, today's shape) | `/api/state` |
|
||||
| `/agent/iris/` (gateway-prefixed) | `/agent/iris/api/state` |
|
||||
| `/agent/iris/stats` (subpage, no trailing slash) | `/agent/iris/api/state` |
|
||||
|
||||
The gateway upstream config strips the prefix before forwarding to
|
||||
the per-agent server, so the agent's Rust routes (`api/state`,
|
||||
`events/stream`, `screen/ws`, `login/start`, …) keep their absolute
|
||||
paths server-side. Only the browser-facing URLs key off the
|
||||
mount prefix.
|
||||
|
||||
The router serves subpages (`stats`, `screen`) without a trailing slash so
|
||||
the relative-path resolution stays correct: `static/stats.js` from
|
||||
`/stats` becomes `/static/stats.js` (last segment `stats` gets
|
||||
replaced), not `/stats/static/stats.js`. Adding a trailing slash to
|
||||
those routes would break the resolution; either keep them
|
||||
slash-less or use `<base href>` injection at serve time.
|
||||
Loading…
Reference in a new issue