diff options
| author | Adam Malczewski <[email protected]> | 2026-06-06 18:55:53 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-06 18:55:53 +0900 |
| commit | 22936857685c318b71752d625808100b1a96e63e (patch) | |
| tree | 5e10a73d616c206e3820a8d8568e5f3d4c8a302e | |
| parent | 969afc45f895230fe3da1c737f18e64452efc8f2 (diff) | |
| download | dispatch-22936857685c318b71752d625808100b1a96e63e.tar.gz dispatch-22936857685c318b71752d625808100b1a96e63e.zip | |
feat(frontend,wire): surface system (FE slice 1) + @dispatch/wire types-only split (B2)
FE slice 1 — backend-declared, frontend-agnostic surface system (verified live): new types-only @dispatch/ui-contract (SurfaceSpec / field kinds / region / ActionRef / catalog), surface-registry (typed service handle), transport-ws (Bun WS :24205, path-agnostic upgrade), surface-loaded-extensions (first real surface); kernel HostAPI.getExtensions; host-bin wiring; bin/up. Harness: retire AGENTS 'backend only', ORCHESTRATOR §3/§7/§8, frontend-design.md locked.
B2 — wire-types split (chat-slice prerequisite): new types-only @dispatch/wire single-sources the wire ABI (AgentEvent + 11 variants; conversation model Chunk/ChatMessage/Role/TurnId/StepId + 6 chunk variants; Usage) with zero @dispatch/* deps. @dispatch/kernel re-exports via shims so its public surface is byte-identical (zero consumer blast radius). transport-contract re-exports AgentEvent from @dispatch/wire and drops its @dispatch/kernel dependency, so HTTP clients (the web frontend) consume the wire without the kernel runtime.
tsc -b + biome clean; 460 vitest + 77 bun pass.
53 files changed, 2479 insertions, 313 deletions
diff --git a/.dispatch/package-agent.md b/.dispatch/package-agent.md index 782a543..9a769d3 100644 --- a/.dispatch/package-agent.md +++ b/.dispatch/package-agent.md @@ -31,6 +31,12 @@ it, test it, and write a report — nothing else. If no single package is named, exports (and manifest, if any). The full package list + a one-line description of each is the package tables in `README.md`. Don't read unrelated packages' internals. +## Headless read boundary (you run non-interactively) +You run HEADLESS: a Read of any file OUTSIDE this repo triggers a permission prompt that +CANNOT be answered → the run HANGS until aborted. Read ONLY within this repo. If you believe +you need a file outside it, do NOT attempt the read — STOP and write the need in your report, +then end. + ## Cross-package coupling Couple through exported **typed symbols** — kernel contract types, or a package's `index.ts` exports. A package that is a **library** is itself a sanctioned shared surface (others import @@ -7,7 +7,9 @@ ## What this project is A **minimal kernel + extensions** agent runtime. The kernel runs ONE agent turn and hosts extensions. Every feature is an extension. Tiers: **kernel → core → -standard**. Backend only for now (no frontend). +standard**. The web frontend is a SEPARATE repo (`../dispatch-web`), built to +the same methodology and consuming the backend's typed contracts (see +`notes/frontend-design.md`). ## Stack Bun + TypeScript (strict, project references via `tsc -b`). Biome for diff --git a/ORCHESTRATOR.md b/ORCHESTRATOR.md index 73afe06..128823e 100644 --- a/ORCHESTRATOR.md +++ b/ORCHESTRATOR.md @@ -113,6 +113,16 @@ log into context as a hard failure. in `tasks.md`. **GOTCHAS (learned the hard way):** +- **Headless cross-`--dir` read = HANG.** An agent's Read of any file OUTSIDE its + `--dir` triggers an interactive permission prompt that CANNOT be answered headlessly + → the run wedges until aborted. This bites CROSS-REPO: a `file:` dep symlink (e.g. + `dispatch-web/node_modules/@dispatch/ui-contract` → the sibling repo) resolves OUTSIDE + `--dir`, so an agent reading the dep's source hangs. Fixes: (a) keep everything the + agent must READ inside `--dir` — ship an **in-repo reference snapshot** of a cross-repo + contract and FORBID reading `node_modules/@dispatch/*`; OR (b) set `--dir` to a parent + containing all needed paths — but then the repo's `AGENTS.md` won't auto-load (you lose + the constitution). The briefs now tell agents: never read outside your scope — if you + think you need to, REPORT it and STOP, never attempt the read. - `-f/--file` is an ARRAY flag and greedily eats your trailing message as another filename → "File not found". **Inline with `"$(cat prompts/X.md)"` instead.** - A quick smoke test works: `opencode run -m opencode-go/mimo-v2.5-pro "Reply with @@ -150,6 +160,9 @@ Keep it scoped (P6): state only the project-specific, non-inferable task — the *(pending — authored with the observability substrate, see `notes/observability-design.md` §9; keystone: each extension self-redacts its OWN secrets in its OWN code — NO shared redaction helper).* +- **Frontend units** are summoned from the SEPARATE `../dispatch-web` repo using ITS + OWN harness (`package-agent.md` + `frontend-*.md` rules) + ITS OWN scoping map — NOT + these backend rules. See that repo's `ORCHESTRATOR.md`. --- @@ -290,7 +303,10 @@ git status --short # confirm the agent stayed in its lane (no out-of-scope edit packages/ kernel/ contracts (ABI), bus, runtime (runTurn), host + wire/ types-only wire ABI (AgentEvent + conversation model + Usage); kernel + + transport-contract re-export it so clients consume the wire w/o the kernel runtime transport-contract/ types-only HTTP API contract (CLI + future web + server share it) + ui-contract/ types-only surface ABI (frontend-agnostic; web + CLI render it) storage-sqlite/ conversation-store/ auth-apikey/ provider-openai-compat/ credential-store/ named credentials + model catalog (resolve / listCatalog) session-orchestrator/ transport-http/ (core extensions) @@ -304,6 +320,13 @@ The genesis commit deleted all prior source; we rebuilt from scratch. The OLD project lives at `/home/tradam/projects/dispatch/dispatch-source` (reference only — do not edit). +The **web frontend is a SEPARATE repo** at `/home/tradam/projects/dispatch/dispatch-web` +(own git, own harness — its own `AGENTS.md`/`ORCHESTRATOR.md`/`GLOSSARY.md`/`.dispatch/`). +It consumes `packages/ui-contract` + the wire types as a pinned `file:` dependency. +`lsp references` does NOT span the two repos, so cross-repo contract changes are +**couriered via the user** (see the FE `ORCHESTRATOR.md` §5). Design + plan: +`notes/frontend-design.md`. Do NOT edit the FE repo from here. + --- ## 8. Current status & how to run @@ -342,6 +365,15 @@ literal pattern `[h]ost-bin` does not match itself. ALWAYS clean up the backgrou counts (this is precisely what made a correct supervisor look like it spawned 3 collectors and left 2 behind). +**Live boot-probe in ONE command WILL hit the tool timeout — that is NOT failure (scar tissue).** +A single bash command that boots the app (even detached via `setsid … & disown`), sleeps, runs a +probe, then kills it will still run to the tool's timeout: the tool waits on the spawned +server/collector session. The probe already ran — **read the probe's printed `RESULT: OK/FAIL` +line as the signal**, ignore the timeout, then run a SEPARATE `pkill` (bracket-trick) + `ps` +cleanup command (it returns immediately and confirms no leaks). Don't try to make the boot+probe +command "return cleanly" — it won't. (For a frontend-agnostic surface, the probe is a tiny +`bun` WebSocket client that asserts `catalog → subscribe → surface`.) + **Next suggested work** (post-MVP, see `tasks.md` "Open items"): wire auth→provider properly (auth-apikey is currently vestigial), then add the first TOOL extension to exercise the dispatch loop (turns currently run with `tools: @@ -2,7 +2,7 @@ A **minimal kernel + extensions** agent runtime. The kernel runs one agent turn and hosts extensions; every feature is an extension. Backend + a line-oriented CLI; a web frontend lives -in a separate repo and talks to the same HTTP API. +in a separate repo and talks to the same typed contracts over HTTP + a surface WebSocket. - **Stack:** Bun + TypeScript (strict, project references), Biome, Vitest, SQLite (`bun:sqlite`), the Vercel AI SDK for providers. @@ -54,6 +54,9 @@ bun install Dispatch listening on http://localhost:24203 ``` + It also serves a **surface WebSocket** on `:24205` (the `transport-ws` extension) — the channel + the web frontend uses to discover and render backend-declared *surfaces*. + 3. **Smoke-test it:** ```sh @@ -127,6 +130,34 @@ bun run dispatch -- opencode/deepseek-v4-flash --conversation <id> --text "and i --- +## Web frontend (dispatch-web) + +The web UI is a **separate repo** at `../dispatch-web` (Svelte 5 + Vite), built to the same +methodology and consuming the backend's typed contracts. As of slice 1 it renders the backend's +**surface system** (e.g. the live "Loaded Extensions" surface); chat UI is a later slice. + +It needs **this server running** — it connects to the surface WebSocket on `:24205`. To run both: + +```sh +# terminal 1 — backend (this repo) +bun run dev # HTTP :24203 + surface WS :24205 + +# terminal 2 — frontend (sibling repo) +cd ../dispatch-web +bun install # links @dispatch/ui-contract via a file: dep to this repo +bun run dev # Vite dev server on http://localhost:24204 +``` + +Then open **http://localhost:24204**. See `../dispatch-web/README.md` for full setup, including +visiting over a LAN / Tailscale. + +**Or run both at once with live reload:** `bin/up` (also `bun run dev:all`) starts the backend +(`bun --watch`) and the frontend (Vite HMR) together and **Ctrl-C stops both** — including the +backend's observability collector. (The backend reloads via a full process restart; the frontend +hot-reloads in place.) + +--- + ## Packages ### Kernel & extensions @@ -145,6 +176,9 @@ topologically at activation). Every extension also depends implicitly on the ker | **session-orchestrator** | core | Drives one turn end-to-end: load history → resolve provider/model/tools → call `runTurn` → persist. | conversation-store, credential-store | | **transport-http** | core | Hono HTTP transport exposing `POST /chat` (NDJSON event stream) and `GET /models` (the catalog). | credential-store, session-orchestrator | | **tool-read-file** | standard | A `read_file` tool with offset/limit pagination and two-layer workdir containment, honoring the per-turn `cwd`. | — | +| **surface-registry** | standard | In-process registry where extensions contribute UI **surfaces** (frontend-agnostic data); exposes a typed `surfaceRegistryHandle` service. | — | +| **transport-ws** | standard | WebSocket transport (`:24205`) serving the surface catalog + per-surface subscribe / update / invoke to clients. | surface-registry | +| **surface-loaded-extensions** | standard | Contributes the live "Loaded Extensions" surface (a `stat` per activated extension) — the first real surface. | surface-registry | ### Supporting packages (not extensions) @@ -153,8 +187,9 @@ The **Depends on** column is each package's `@dispatch/*` workspace dependencies | Package | Description | Depends on | |---|---|---| | **transport-contract** | Types-only description of the HTTP API (`ChatRequest`, `ModelsResponse`, `AgentEvent`) shared by the server and every client. | kernel | +| **ui-contract** | Types-only, **frontend-agnostic** vocabulary for backend-declared **surfaces** (`SurfaceSpec`, field kinds, the surface WS protocol) — shared by the backend and any client (web, CLI). | — | | **cli** | The bundled one-shot terminal client documented above. | transport-contract | -| **host-bin** | The composition root: loads config, activates all extensions through the host, serves HTTP, and supervises the observability collector. | kernel, all 8 extensions, journal-sink | +| **host-bin** | The composition root: loads config, activates all extensions through the host, serves HTTP, and supervises the observability collector. | kernel, all extensions, journal-sink | | **journal-sink** | Bootstrap `LogSink` that appends structured logs/spans to an NDJSON journal (rotation, fail-safe). | kernel | | **observability-collector** | Out-of-process binary that tails the journal and inserts records into the trace store (idempotent, at-least-once). | kernel, trace-store | | **trace-store** | `bun:sqlite` store for trace records/bodies, plus a `trace` CLI to render a turn's timeline. | kernel | @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# bin/up — run the Dispatch backend + web frontend together, both live-reloading. +# Ctrl-C stops BOTH cleanly (including the backend's spawned observability collector). +# +# backend (this repo) bun --watch → HTTP :24203 + surface WS :24205 (FULL restart on change) +# frontend (../dispatch-web) vite → http://localhost:24204 (HMR, in-place) +# +# Assumes dispatch-web is a sibling of this repo. Run: bin/up (or: bun run dev:all) + +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # .../arch-rewrite/bin +BACKEND="$(cd "$HERE/.." && pwd)" # backend repo root +FRONTEND="$(cd "$HERE/../.." && pwd)/dispatch-web" # sibling repo + +if [ ! -d "$FRONTEND" ]; then + echo "up: frontend repo not found at $FRONTEND" >&2 + echo "up: check out 'dispatch-web' beside this repo and retry." >&2 + exit 1 +fi + +cleanup() { + echo + echo "[up] stopping backend + frontend..." + # Each child runs in its OWN process group (setsid) → signal the whole subtree. + [ -n "${BACK_PG:-}" ] && kill -TERM "-$BACK_PG" 2>/dev/null + [ -n "${FRONT_PG:-}" ] && kill -TERM "-$FRONT_PG" 2>/dev/null + sleep 1 + # Safety net for the backend's collector child + any straggler (bracket trick: + # the literal pattern '[h]ost-bin' can't match the pkill command line itself). + pkill -9 -f '[h]ost-bin/src/main' 2>/dev/null + pkill -9 -f '[o]bservability-collector/src/main' 2>/dev/null + [ -n "${BACK_PG:-}" ] && kill -KILL "-$BACK_PG" 2>/dev/null + [ -n "${FRONT_PG:-}" ] && kill -KILL "-$FRONT_PG" 2>/dev/null + echo "[up] stopped." + return 0 +} +trap cleanup EXIT +trap 'exit 130' INT TERM + +echo "[up] backend → http://localhost:24203 (surface WS :24205) [bun --watch — restarts on change]" +echo "[up] frontend → http://localhost:24204 [vite HMR]" +echo "[up] Ctrl-C to stop both." +echo + +setsid bash -c "cd '$BACKEND' && exec bun --watch packages/host-bin/src/main.ts" \ + > >(sed -u 's/^/[backend] /') 2>&1 & +BACK_PG=$! + +setsid bash -c "cd '$FRONTEND' && exec bun run dev" \ + > >(sed -u 's/^/[frontend] /') 2>&1 & +FRONT_PG=$! + +wait @@ -51,8 +51,11 @@ "@dispatch/provider-openai-compat": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", "@dispatch/storage-sqlite": "workspace:*", + "@dispatch/surface-loaded-extensions": "workspace:*", + "@dispatch/surface-registry": "workspace:*", "@dispatch/tool-read-file": "workspace:*", "@dispatch/transport-http": "workspace:*", + "@dispatch/transport-ws": "workspace:*", }, }, "packages/journal-sink": { @@ -65,6 +68,9 @@ "packages/kernel": { "name": "@dispatch/kernel", "version": "0.0.0", + "dependencies": { + "@dispatch/wire": "workspace:*", + }, }, "packages/observability-collector": { "name": "@dispatch/observability-collector", @@ -98,6 +104,23 @@ "@dispatch/kernel": "workspace:*", }, }, + "packages/surface-loaded-extensions": { + "name": "@dispatch/surface-loaded-extensions", + "version": "0.0.0", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/surface-registry": "workspace:*", + "@dispatch/ui-contract": "workspace:*", + }, + }, + "packages/surface-registry": { + "name": "@dispatch/surface-registry", + "version": "0.0.0", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/ui-contract": "workspace:*", + }, + }, "packages/tool-read-file": { "name": "@dispatch/tool-read-file", "version": "0.0.0", @@ -120,7 +143,7 @@ "name": "@dispatch/transport-contract", "version": "0.0.0", "dependencies": { - "@dispatch/kernel": "workspace:*", + "@dispatch/wire": "workspace:*", }, }, "packages/transport-http": { @@ -134,6 +157,23 @@ "hono": "^4.0.0", }, }, + "packages/transport-ws": { + "name": "@dispatch/transport-ws", + "version": "0.0.0", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/surface-registry": "workspace:*", + "@dispatch/ui-contract": "workspace:*", + }, + }, + "packages/ui-contract": { + "name": "@dispatch/ui-contract", + "version": "0.0.0", + }, + "packages/wire": { + "name": "@dispatch/wire", + "version": "0.0.0", + }, }, "packages": { "@biomejs/biome": ["@biomejs/[email protected]", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], @@ -176,6 +216,10 @@ "@dispatch/storage-sqlite": ["@dispatch/storage-sqlite@workspace:packages/storage-sqlite"], + "@dispatch/surface-loaded-extensions": ["@dispatch/surface-loaded-extensions@workspace:packages/surface-loaded-extensions"], + + "@dispatch/surface-registry": ["@dispatch/surface-registry@workspace:packages/surface-registry"], + "@dispatch/tool-read-file": ["@dispatch/tool-read-file@workspace:packages/tool-read-file"], "@dispatch/trace-replay": ["@dispatch/trace-replay@workspace:packages/trace-replay"], @@ -186,6 +230,12 @@ "@dispatch/transport-http": ["@dispatch/transport-http@workspace:packages/transport-http"], + "@dispatch/transport-ws": ["@dispatch/transport-ws@workspace:packages/transport-ws"], + + "@dispatch/ui-contract": ["@dispatch/ui-contract@workspace:packages/ui-contract"], + + "@dispatch/wire": ["@dispatch/wire@workspace:packages/wire"], + "@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], diff --git a/notes/frontend-design.md b/notes/frontend-design.md index b9145e5..16cb41f 100644 --- a/notes/frontend-design.md +++ b/notes/frontend-design.md @@ -1,79 +1,455 @@ -# Frontend MVP — Design Scratch +# Frontend — Design (decisions LOCKED) -> **Status:** IDEATION / scratch. NOT decided, NOT building yet. This is the HOME for the -> "carefully plan the frontend" pass the user asked for (per ORCHESTRATOR "write up before -> pivoting"). Promote settled parts into `notes/restructure-plan.md` + `GLOSSARY.md` + -> harness files when we commit to building. +> **Status:** DECISIONS LOCKED (2026-06-06). **Building slice 1 = the surface system + WS +> transport (front-loaded)** — the user chose to prove the novel architecture first, not a quick +> chat MVP. This is the design HOME for the web frontend; promote settled vocab/parts into the FE +> repo's `GLOSSARY.md`/harness (and "surface" into the backend `GLOSSARY.md`) when slice 1 starts. > -> **Read order (fresh agent picking this up):** `ORCHESTRATOR.md` → `AGENTS.md` (the -> backend methodology we are MIRRORING) → `GLOSSARY.md` → this file. -> **Mode = IDEATION WITH the user** (design/discuss, do NOT build yet). The user owns the -> boundary (§5.2) + vocabulary (§5.6) calls. -> **Driver:** a minimal chat frontend, **Svelte + DaisyUI** (same stack family as old -> Dispatch), built with the SAME methodology as the backend — NOT a default-SvelteKit ball -> of mud. Old FE at `/home/tradam/projects/dispatch/dispatch-source` is REFERENCE-ONLY. -> Ports reserved: `FRONTEND_PORT=24204` (.env). +> **Read order (fresh agent):** `ORCHESTRATOR.md` → `AGENTS.md` (the backend methodology we +> MIRROR) → `GLOSSARY.md` → `notes/restructure-plan.md` (P1–P8, §-refs) → this file. +> **The user owns boundary (§5.2) + vocabulary (§5.6) calls.** +> **Driver:** a minimal chat frontend, **Svelte 5 + DaisyUI**, in a **SEPARATE repo** (`../`), +> built with the SAME discipline as the backend. Old FE at +> `/home/tradam/projects/dispatch/dispatch-source` is REFERENCE-ONLY (UX/tech, NOT structure). +> Port reserved: `FRONTEND_PORT=24204` (.env). --- -## 0. Goal in one paragraph -A minimal browser chat client — the FE analogue of the curl MVP: send a message and render -the streamed, multi-turn response (`conversationId` threads history). Svelte + DaisyUI for -the view; but the architecture must be a **minimal core + feature modules** with the same -discipline that makes the backend testable and agent-buildable. - -## 1. The hard constraint — methodology parity with the backend (why this needs care) -Translate each backend principle to the frontend (these are the constraints, not yet the -"how"): -- **Minimal core + feature modules / tiers.** A FE "kernel" that owns app shell + routing + - state-core + a module host, and **names no concrete feature**; every feature (chat view, - conversation list, composer, message-stream renderer, settings…) is a module/"extension". -- **Contracts are the only cross-unit surface.** Cross-module coupling anchored to **typed - symbols** (no string-keyed lookups → must be a compile error so `lsp references` finds - every consumer). The **FE↔BE seam** is the backend's HTTP/event contract (the - `AgentEvent` union + `/chat` NDJSON + `conversationId`) — ideally a **shared typed - contract** so `lsp references` spans the boundary. -- **Pure-core / inject-effects + no ambient state.** Pure view-models / stores / reducers / - formatters: zero DOM, zero I/O, exhaustively unit-testable. Svelte components + transport - (`fetch`/streaming) + browser effects (localStorage, history, clipboard) are the - **injected imperative shell**. -- **One owner per unit**; orchestrator summons owner-agents; units communicate via - contracts; the orchestrator never edits implementation. -- **Asymmetric testing** — strict zero-internal-mock + high coverage on pure logic; lenient - integration on components/shell. Mocking our own module = a design bug. -- **Durability where it matters** (e.g. optimistic UI + reconcile on reconnect) — pure - `reconcile(state, events)`. - -## 2. Open questions (DECIDE in the design pass — all UNDECIDED) -- **FE "kernel" shape:** what exactly is core vs. feature? Module-host mechanism (manifest - analogue?) vs. simpler composition. How far to take the kernel/extension metaphor before - it's cargo-culting the backend (P6 — don't copy structure that earns nothing). -- **Unit boundaries / first units** for the MVP (composer, transport client, message-stream - store + renderer, conversation state). Granularity = USER's call. -- **The FE↔BE contract package:** reuse kernel `AgentEvent`/types directly? a new shared - `@dispatch/protocol` package both sides depend on? how do FE pure-cores import it. -- **Transport in the browser:** consume the `/chat` NDJSON stream (fetch + ReadableStream - reader) — framing, backpressure, abort, reconnect, `conversationId` threading. (Note: - `trace-replay`'s fixture model could even feed FE transport tests hermetically.) -- **State approach:** Svelte stores vs runes; keep ALL logic framework-thin & pure so it's - testable without mounting components. -- **Testing tools:** vitest for pure logic (already in repo); component/integration via - `@testing-library/svelte`; e2e via Playwright? — decide + how it joins `bun run test`. -- **Build/tooling + monorepo placement:** `packages/frontend` vs `apps/web`; Vite + Svelte; - Tailwind + DaisyUI; how it fits `tsc -b` project refs, biome, the bun workspace. -- **Harness artifacts to author:** new scoped `.dispatch/rules/frontend-*.md` (the FE - pure-core/shell + inject-effects + no-ambient-state rules), GLOSSARY terms (no - synonym-drift with backend vocab), ORCHESTRATOR additions for FE summons, and the - AGENTS.md scope update (**the current "Backend only for now (no frontend)" line retires - when FE build starts** — leave it until then). -- **MVP scope cut:** what's in v1 (send + stream + multi-turn render) vs. deferred (history - list, tool-call/▷thinking rendering, settings, theming). - -## 3. Decisions settled -- (none yet — IDEATION.) - -## 4. References (do NOT copy blindly — keep our methodology) -- Old Dispatch FE: `/home/tradam/projects/dispatch/dispatch-source` (Svelte + DaisyUI) — - reference-only for UX + tech, NOT structure. -- Backend seam: `packages/kernel/src/contracts/events.ts` (`AgentEvent`), - `packages/transport-http` (`/chat` NDJSON), `GLOSSARY.md`. +## 0. Goal +A minimal browser chat client — the FE analogue of the curl/CLI MVP: send a message, render the +streamed multi-turn response (`conversationId` threads history). The architecture is a **thin +shell + pure feature libraries** with the discipline that makes the backend testable and +agent-buildable, PLUS a **backend-declared, frontend-agnostic UI surface system** so backend +extensions expose UI without any per-extension frontend code. + +--- + +## 1. The methodology call: port the PRINCIPLES, not the STRUCTURE (P4) + +The biggest risk is cargo-culting our own backend. The kernel/extension-host/manifest/DAG/ +capability-gate machinery exists to solve backend problems a browser app does **not** have: + +| Backend machinery | Why it exists | FE has that problem? | +|---|---|---| +| Extension host + manifests + DAG | dynamic, runtime-loaded, third-party features | No — Vite bundles at build time | +| Capability gate (fs/shell/net/secrets) | untrusted code touching real I/O | No — the browser sandbox gates | +| `runTurn` / turn loop | the product *is* the loop | No — the FE renders a stream | +| On-disk durability / `reconcile` | crash-safe persisted history | Smaller (a client cache; §6) | + +**What ports (the load-bearing parts):** P1 (feature-as-a-library), P2 (functional core / inject +effects), P3 (no ambient state), one-owner-per-unit, asymmetric testing, typed coupling (§5.4), +and the harness (P5–P8). These solve *real* FE problems — the old FE's "tools leak across tabs" / +"model resets on tab switch" were textbook **P3 ambient-state bugs**. + +**What replaces the host:** an ordinary **composition root** (the FE's `host-bin`) — one place +that imports feature modules and wires them with typed calls. No registry-of-code, no manifest. + +**The one place a discovery seam DOES earn its place (P4 cuts both ways):** because backend +extensions must expose UI that the FE surfaces, we DO need a lightweight **surface discovery** +seam (§4). This is the backend's own split — *contracts are static TYPES; loading is dynamic* +(§5.4) — applied at the FE↔BE boundary, NOT the heavy host machinery. + +**No mandatory spine — composition over privilege.** The FE is a **composition of feature +modules + a surface host**, assembled per-frontend (or per-route) at the composition root. NO +feature is globally privileged: chat is the central *product* feature but not the structural +root — legitimate frontends compose without it (an agent editor, a read-only history explorer, a +project viewer that mounts chat only after a project is picked). "Core" is whatever a given app +composes; the teeth — dependency-direction + "works without optional surfaces" — apply to that +chosen set. Skip the backend's 3-tier ceremony. +- **Feature modules** = bespoke, contract-backed UI (chat, agent-editor-if-bespoke, history + explorer); each is feature-as-a-library, includable or not. +- **Surfaces** (§4) = generic, backend-declared declarative UI for the long tail (and for tools + that fit the semantic catalog). +- **Chat is a feature, NOT a surface** — different data lifecycle (append-only/cached/`seq` vs. + live/ephemeral, §6.2) + no genericity payoff (a transcript always needs bespoke rendering). It + **decomposes** into a read-side (transcript/history) + write-side (composer/live) so a history + explorer reuses just the read-side. +- Dependency-direction rule: features depend on `core`/`wire`, never on each other; the shell + composes features, never the reverse. + +--- + +## 2. Principle translation (P1–P8 → FE) + +| Principle | FE translation | +|---|---| +| **P1 feature-as-a-library** | each feature is a self-contained module with a clean typed surface, importable alone | +| **P2 functional core / inject effects** | pure reducers/view-models/formatters; Svelte + WS/`fetch` + storage are the injected shell. The conversation state machine is `reduce(state, AgentEvent) → state` — unit-tested with zero component mounting | +| **P3 no ambient state** | per-conversation state owned explicitly; runes/stores are a thin reactive wrapper over the pure reducer, not the home of logic | +| **P4 don't adopt by reputation** | the surface system, tiers, transport — each earns its place against a named need; grow the catalog from real demand | +| **§5.4 typed coupling** | cross-feature links are typed imports/callbacks; no stringly-typed event bus. Discovery-by-id (catalog, subscribe) is sanctioned *data flow*, not a code reference | +| **one owner + asymmetric testing** | one owner-agent per feature module; strict zero-mock on `logic/`, thin component/e2e on `ui/` | +| **P5–P8 harness** | repo-scoped harness travels with the FE (§8); vocabulary shared with the backend verbatim | + +--- + +## 3. Repo structure (Vite + Svelte 5 SPA — SETTLED; not SvelteKit) + +``` +dispatch-web/ (../dispatch-web — NEW repo, own git) + AGENTS.md ORCHESTRATOR.md GLOSSARY.md + .dispatch/{package-agent.md, rules/frontend-*.md} + src/ + app/ SHELL + composition root (the ONLY place that names features) + core/ PURE, framework-free, zero I/O: + transcript/ events → Chunk[] reducer (the single render model, §6) + cache/ reconcileCache / selectEvictions (pure; injected IndexedDB) + surfaces/ the surface interpreter core (pure: spec → view-model) + protocol/ the transport-agnostic op-protocol core (pure state machine, §5) + wire/ imported wire + ui contracts (types only) + features/ feature-as-a-library modules (logic/ pure · ui/ svelte · adapter/ effects) + adapters/ injected browser effects: WS client, fetch, IndexedDB, history, clipboard + vite + tsconfig + biome + vitest (+ @testing-library/svelte; Playwright later) +``` + +--- + +## 4. The surface model — backend-declared, frontend-agnostic UI (the centerpiece) + +### 4.1 What a surface is +A **surface** is a backend-declared **"data transportation surface"**: a typed data structure +describing *what data exists, its semantics, and what actions can act on it* — NOT UI. The +backend transports **structure + semantics + actions**; the client owns **100% of presentation**. +Field kinds are *semantic*, not visual: `toggle` = "a boolean + an action" (not "draw a switch"); +`progress` = "a bounded ratio + a label" (not "draw a bar"). + +This is the disciplined variant of Server-Driven UI: the server says *what the data means*, it +**never dictates how it looks**. It is the same principle that already lets the CLI and web share +one wire contract (`transport-contract`), generalized from the chat wire to *all* UI intent: one +surface renders as a DaisyUI switch on web, a `[y/n]` prompt in the CLI, a tap-switch on mobile — +same data, three renderers, zero backend awareness of any of them. + +### 4.2 The shape (semantic; names are hints, the contract is the data shape) +```ts +SurfaceSpec = { id; region; title; fields: SurfaceField[] } // ordered +SurfaceField = + | { kind: "toggle"; label; value: boolean; action: ActionRef } + | { kind: "progress"; label; value: number /* 0..1 */ } + | { kind: "selector"; label; value; options: Option[]; action: ActionRef } + | { kind: "stat"; label; value: string } + | { kind: "button"; label; action: ActionRef } + | { kind: "custom"; rendererId; payload } // the escape hatch — see guardrail 2 +ActionRef = a typed reference the client passes back to invoke a backend action +``` +`region` = where the surface mounts (placement). `kind` = the field's semantic type. Names are +training-baked (P8 — no need to invent "BoundedRatioQuantity"). + +### 4.3 The frontend-agnostic invariant (load-bearing) +**The backend depends only on the semantic surface contract and on ZERO rendering technology — +so swapping Svelte→React, or adding a TUI/mobile client, is a zero-backend-change event.** The +contract carries coarse placement (which region, title, field order) + semantics + actions — +**never styling, never pixels, never a CSS/DaisyUI token.** DaisyUI is purely the Svelte +renderer's private business; the backend has never heard of it. + +### 4.4 Discovery + opt-in subscription (no firehose) +The FE is in control of what it observes — the backend never pushes everything continuously. +The system has three interaction kinds; **only the live part is transport-coupled:** + +| Interaction | Shape | +|---|---| +| Discovery — the **surface catalog** | `GET /surfaces` → `[{ id, title, region, kind }]` (metadata only, no data) | +| One-shot read — current spec | `GET /surfaces/:id` → full `SurfaceSpec` + values | +| Fire an action | `POST /surfaces/:id/actions/:actionId` | +| **subscribe / unsubscribe + live updates** | the WS op-protocol (§5) — pushes patches for subscribed surfaces ONLY | + +``` +connect → GET /surfaces (catalog) → user selects X → GET /surfaces/X (+ subscribe X) → + patches for X stream until unsubscribe(X) → close X → unsubscribe → traffic for X stops +``` +Catalog changes (extension toggled) are low-frequency → a lightweight "catalog-invalidated" +ping or re-pull, not a stream. The backend builds a spec **lazily** — only for queried/ +subscribed surfaces. + +### 4.5 Isolation guardrails (why this is isolation-aligned — the audit rationale) +The surface-as-data approach is the **isolation-maximal** design: extension↔view coupling +collapses to ONE typed contract (the sanctioned shared surface), the extension imports zero +frontend, the FE imports zero extension code. These invariants keep it that way: + +1. **The interpreter stays generic — forbid any `if (surface.id === "...")`.** The shared + interpreter + widget catalog is sanctioned *platform* (justified like the kernel ABI). The + instant it special-cases a known surface, it has imported a feature's identity and isolation + breaks. Rule: the interpreter knows field *kinds*, never surface *identities*. +2. **`custom` is the one isolation compromise — minimize and type it.** A client-local renderer + for a `custom` payload recouples a FE unit to one extension. Keep it rare (P4/P6). The + `custom` payload type must be **exported from the owning extension's contract** so the + bespoke renderer imports a typed symbol (lsp-traceable), not a blind `unknown`. +3. **A surface owns only its OWN data + actions.** It must never reference another extension's + action/state — cross-extension needs go through the normal typed service/hook path, never + surface data. (Actions are intra-extension: the surface and its handler share one owner.) +4. **Action/live-update state is owned per-surface and reconciled purely** (P3). Read-only + surfaces are trivially clean; the moment toggles fire, route through `reconcile(state, update)` + with explicit ownership. +5. **The agnostic invariant is enforced** (§4.3): no styling/framework token may appear in the + ui-contract. Lint/review rule. +6. **Subscriptions are explicitly owned + disposed; specs built lazily.** The backend never + eagerly materializes all surfaces; the FE owns its subscription set as explicit state and + tears it down on unmount. No ambient subscription registry. + +### 4.6 Declarative-first, bespoke as escape hatch +- **Tier 1 (the path):** declarative semantic surfaces over the fixed catalog — settings, + toggles, progress, info, the future "views" (§9). Zero FE code per extension. +- **Tier 2 (escape hatch):** (a) *prettier rendering than the generic one* = purely client-local + (no contract impact, agnostic intact); (b) *data fits no primitive* = the `custom` kind, opt-in + per client, graceful-skip when a client has no renderer for that `rendererId`. + +### 4.7 Catalog growth (P4) +Slice 1 builds the surface *contract + interpreter + WS* and proves it against **one real first +surface** (TBD — §10 "To start"). Grow the catalog (`toggle, progress, selector, stat, button` + +`custom`) from real demand, never speculatively. + +--- + +## 5. Transport — agnostic op-protocol + WebSocket, carrying BOTH (SETTLED) + +Define the protocol as **logical ops with a pure core**, then the carrier is an injected adapter +(swappable/testable): +``` +ops (the contract): getCatalog · getSurface(id) · subscribe(id) · unsubscribe(id) + · onUpdate(id, patch) · invokeAction(id, actionId, payload) + · sendMessage(...) · onDelta(AgentEvent) +pure core (P2): reduce(intent, incoming) → { viewModel, outgoingCommands } +injected shell: the WS client (web) OR REST+stream (one-shot clients) +``` +- **Carrier = WebSocket, up front, for BOTH** live turn-deltas AND surface updates over ONE + persistent connection (+ a small reconnect/router). The connection IS the subscription session + → subscriptions die with the socket (clean lifecycle, guardrail 6). Bun's WS is first-class; the + old app proved a reconnecting WS client here. +- **REST `POST /chat` (NDJSON) is retained for one-shot clients (the CLI)** — no WS needed; plus + discovery, actions, and incremental history sync (§6). **Same `AgentEvent`s, different + carriers** — exactly "inject the transport." +- Chosen over SSE+POST: the subscription model wants a session whose lifetime = the connection + (SSE needs a server-side per-stream registry + GC); Bun ergonomics + precedent; bidirectional + sub/unsub without a correlation id. (SSE+POST remains a documented alternative behind the same + ops if proxy/CDN/curl-debuggability ever dominate.) + +--- + +## 6. Chats — caching + delta streaming + +### 6.1 The enabler +The backend §3.4 durability gives us the hard part: history is an **append-only chunk log** (past +turns never mutate) with a **monotonic per-conversation cursor**, and `reconcile(rows)` yields a +valid history on load. Therefore: +> **The client cache is a pure performance optimization over an authoritative, incrementally- +> syncable backend log. Wiping it is correctness-neutral — worst case is a re-fetch.** +That is what makes reliable caching + aggressive purging *safe*. + +### 6.2 Two data lifecycles — they cache OPPOSITELY +| | Chats (history) | Surfaces | +|---|---|---| +| Nature | append-only, immutable below the seal | live current-state | +| Client caching | **yes** — durable, incrementally synced, purgeable | **no durable cache** (stale = wrong; "show stale, update" at most) | +| Sync | "give me chunks after seq N" | subscribe → push, current-only | + +### 6.3 Delta streaming fits via the seal boundary + one reducer +Live `AgentEvent` deltas are the **in-flight turn** — ephemeral. They fold into the canonical +`Chunk[]` via the one pure reducer (`appendEventToChunks` pattern). **`turn-sealed` = the +cache-commit signal:** below the last seal is immutable + cacheable; the in-flight turn is +provisional (in-memory) until it seals. **Sync granularity (per-chunk `seq`) ≠ commit granularity +(per-turn seal)** — finer sync, turn-atomic caching. +``` + IndexedDB cache (committed chunks) ─┐ + REST sync: chunks since seq N ─┼─► reduce → Chunk[] ─► render + WS: live deltas (active turn) ─┘ ▲ turn-sealed ⇒ commit provisional turn to cache +``` +Three sources, ONE reducer, ONE shape — the one-render-model decision paying off. + +### 6.4 Cache design (mapped to principles) +- **P2/P3:** pure `reconcileCache(cached, incoming, seq) → { nextCache, whatToFetch }` and + `selectEvictions(index, budget) → toEvict`; storage (**IndexedDB**) is the injected shell. + The mirror of the backend's `reconcile`. +- **Isolation/P1:** a self-contained `conversation-cache` feature; depends only on the wire + contract (chunks + `seq` + `turn-sealed`). +- **Symmetry:** the FE cache = the backend's durability discipline applied client-side + (append-only, seq-keyed, reconcile-on-load, derived status). +- **"Don't pass all data constantly" — satisfied:** the wire only ever carries (a) live deltas + for the active turn and (b) the incremental tail since the client's `seq`. Cached chunks are + served locally, never re-fetched. + +### 6.5 Purging (safe, simple-first — P4) +Eviction is re-syncable, so start simple: byte/turn budget; LRU by conversation + evict oldest +sealed turns when over budget; **never evict the active conversation**. Defer per-chunk windowing +/ scroll-back rehydration until a conversation is big enough to need it. + +### 6.6 Honest subtleties +- **Interrupted-turn tail vs `reconcile`:** commit to cache only on `turn-sealed`; a provisional + tail is always replaceable by the next sync. No stale-tail risk. +- **Multi-tab / CLI+web convergence:** append-only + `seq` ⇒ a monotonic merge (each client + pulls chunks after its own `seq`). Only breaks if we ever allow editing/deleting history — we + don't (append-only). +- **Storage medium is an injected detail** — IndexedDB is the likely choice; the pure core + doesn't care. + +--- + +## 7. Backend contract changes for FE-friendliness + +**Shapes are right — don't churn them** (`ChatRequest`/`ModelsResponse`/`AgentEvent` proved live +via the CLI). The changes are about **packaging**, **read-side coverage**, and the **surface + +WS** seam, because a FE is long-lived, reloadable, multi-conversation, and surfaces extensions. + +- **`transport-contract` self-containment — DECIDED: split a types-only kernel sub-package.** The + pure wire/event types move to a types-only package that both `@dispatch/kernel` and + `transport-contract` re-export → `AgentEvent` stays single-source and the FE repo depends on no + runtime. +- **New shared `@dispatch/ui-contract`** (types-only): the semantic field-kind catalog + `region` + vocabulary + action protocol + surface-catalog types (§4). Consumed by the backend (to declare), + web, and CLI — **not** anything Svelte. +- **Surface + WS seam:** the surface-contribution mechanism (kernel/host carries it generically; + extensions declare surfaces), `GET /surfaces` (catalog) + `GET /surfaces/:id` + `POST + /surfaces/:id/actions/:actionId`, and the **WS channel** multiplexing turn-deltas + surface + ops (§5). +- **Read-side endpoints:** + | FE need | Today | Proposed | + |---|---|---| + | Reload a transcript | history only as the turn's own stream | `GET /conversations/:id?sinceSeq=<seq>` → reconciled `ChatMessage[]`/`Chunk[]` (incremental) | + | Conversation list / sidebar | none | `GET /conversations` → `[{ conversationId, title?, updatedAt, status }]` (later slice) | + | "Stop generating" | old `/chat/cancel` never rebuilt | `POST /conversations/:id/cancel` (later slice) | +- **Monotonic cursor on the wire — DECIDED: per-chunk `seq`.** `Chunk` carries no cursor today; + add a per-chunk `seq` (finer than turn-granular; allows mid-turn sync). Cache still commits at + the turn seal (§6.3). +- **Render-model alignment:** history returns the same `Chunk[]`/`ChatMessage[]` the live stream + folds into → ONE FE reducer. (Proven: old `chunks/append.ts` + DB-free `transform.ts`.) +- **Separate-repo consequence:** `lsp references` no longer spans the FE↔BE seam → the dormant + **§2.9 semver discipline wakes up** (the FE pins a contract version; a `major` bump is the + fan-out signal). A thin "contract conformance" type-test in the FE catches shape drift the + cross-repo compiler can't. + +--- + +## 8. The harness to set up (repo-scoped — P7) + +Because it's a separate repo, the harness travels with it. The new repo needs its own: +- **`AGENTS.md`** — FE constitution: pure view-models, inject browser effects, no ambient + cross-component state, Svelte-thin, one-owner, asymmetric testing. +- **`.dispatch/rules/frontend-*.md`** — 3–5 line reflexes in the existing format, e.g. *"Logic + modules import no Svelte and no `fetch`/DOM — effects are injected"*; *"State is owned + per-conversation and passed in; no module-global mutable store"*; *"The NDJSON/WS parser is + pure (bytes→events); inject the socket"*; *"The surface interpreter knows field kinds, never + surface ids."* +- **`.dispatch/package-agent.md`** — owner-agent brief adapted so "unit" = feature module; verify + = `vitest` (pure) + component tests. +- **`ORCHESTRATOR.md`** — FE summon manual. **DECIDED: per-repo harness** — FE summons run with + cwd = FE repo root + its own TS language server. **Cross-repo bridge:** an owner-agent or the + orchestrator may **ask the USER to look at the other (back/front) repo** when a change spans the + seam — the user is the cross-repo courier (since `lsp` can't span repos). +- **`GLOSSARY.md`** — adopt the backend's canonical terms **verbatim** (no drift): + `conversation`/`conversationId`, `turn`, `step`, `chunk`, `tool call`, `model name`, + `model catalog`, `AgentEvent`. Duplicating these is the intended trade (isolation-over-DRY: + share knowledge, not runtime code). + +**In THIS repo, when slice 1 starts** (per `tasks.md` ROADMAP §2): retire the AGENTS.md +"Backend only for now (no frontend)" line; update `ORCHESTRATOR.md` §7 (geography) + §3 +(rule-scoping map). `FRONTEND_PORT=24204` reserved. + +--- + +## 9. Vocabulary (§5.6 — human-gated; SETTLED 2026-06-06) + +- **surface** — a backend-declared, **frontend-agnostic** semantic data contribution (a "data + transportation surface"): fields + values + bound actions; structure + semantics + actions, + **never styling**. The backend *exposes* surfaces; any client renders them in its own idiom. +- **view** — an **old-Dispatch FE term, DEFERRED/RESERVED.** A sidebar element the user could + open; it took a spot in the sidebar and displayed a **settings view** or a **feature-specific + view** (e.g. cache reheating). A FE rendering affordance — conceptually the place a surface (or + settings) gets shown. The user liked the old interface and will **revisit "views" later**; the + term must not be reused meanwhile. (NB: avoid a `side-view` region name — it overlaps; leave + region names open until views are revisited.) +- **region** — *where* a surface mounts (the coarse placement). Chosen over "slot" to avoid + clashing with Svelte's `<slot>`. +- **field kind** — the semantic type of a field (`toggle`/`progress`/`selector`/`stat`/`button`/ + `custom`); the discriminant the interpreter switches on. +- **action / action ref** — the FE term for a backend-invokable action; a field carries an + **action ref** the client posts back. **Backend keeps `command` for now** (its existing + contribution point); a future review to unify `command` → `action` is logged in + `notes/restructure-plan.md` §8 (deferred). Do NOT rename `command` in the backend yet. +- **surface catalog** — the list of available surfaces (metadata) the FE fetches to discover them + (`GET /surfaces`). Parallels the existing **model catalog**. ("capability manifest" was + considered and **dropped** — it overloaded "manifest" and was redundant with this.) + +Relationship: a *surface* is backend data; a *view* (future) is a FE rendering slot that displays +a surface. (Promote "surface" + this vocab to the backend GLOSSARY + the FE GLOSSARY when slice 1 +starts.) + +--- + +## 10. Decisions + +### Settled +- **Slice order: surface system + WS FIRST** (front-load the architecture), then cache/reload, + then chat polish / conversation list / theming. +- Methodology = port principles, not the heavy host machinery; thin shell + pure feature + libraries + a lightweight surface-discovery seam (§1). +- No mandatory spine: a composition of feature modules + a surface host; "core" is per-frontend; + chat is a (decomposable) feature, not a surface (§1). +- Surface model = backend-declared, frontend-agnostic "data transportation surfaces"; semantic + field kinds; client owns 100% of presentation; isolation guardrails 1–6 (§4). +- Discovery (surface catalog) + opt-in per-surface subscription; no firehose; lazy spec build (§4.4). +- Transport = agnostic op-protocol with **WebSocket carrying BOTH** turn-deltas + surfaces; REST + `/chat` retained for one-shot/CLI (§5). +- Caching/streaming = append-only + **per-chunk `seq`** source of truth; `turn-sealed` = + cache-commit; three-source → one-reducer → one `Chunk[]`; pure `reconcileCache`/ + `selectEvictions` + injected IndexedDB; safe aggressive purging (§6). +- Stack = **Vite + Svelte 5 SPA** (not SvelteKit); testing = vitest + `@testing-library/svelte` + (Playwright later) (§3). +- `transport-contract` self-containment = **split a types-only kernel sub-package** (§7). +- Orchestration = **per-repo harness** + user-as-cross-repo-courier bridge (§8). +- Surface internals = recommended defaults, finalized as slice 1 details land: catalog = + `toggle/progress/selector/stat/button` (+`custom`); on/off = config+restart for now (runtime + enable/disable endpoint = a future backend pass); v1 interactivity = read-only + simple + toggles/buttons (defer rich forms/validation). +- Vocabulary (§9): `surface`, `view` (reserved), `region`, `field kind`, `action`/`action ref` + (backend stays `command`, future review), `surface catalog`. + +### Slice 1 — BUILT + verified live (2026-06-06) ✅ +The surface system, end-to-end: `ui-contract` (surface ABI + WS protocol), `surface-registry` +(typed service), `transport-ws` (Bun WS server on :24205, path-agnostic upgrade), +`surface-loaded-extensions` (first real surface), kernel `getExtensions`; FE `core/protocol` + +`features/surface-host` (interpreter + field components) + `adapters/ws` + `app` composition root. +**Live WS probe: catalog → subscribe → surface rendered the 10 loaded extensions** as stat fields. +Backend 460 vitest + 77 bun, FE 76 tests; both repos typecheck + biome clean. Deferred: F-app CR-1 +(vitest `browser` resolve condition, for component-render tests); B2 kernel wire-types split (chat +slice); DaisyUI styling (F4 follow-up). The slice-1 input decisions were: +1. **The first real surface** to build + prove the system against (the §4.7 demo). Needs a + concrete feature — e.g. a read-only server/model **stat** surface first (proves discovery → + subscribe → render with no action round-trip), then add a **toggle** to prove the action path. + Or pick a different first surface. +2. **WS-transport boundary:** a NEW `transport-ws` extension vs. augment the existing + `transport-http`. (Boundary call.) +3. **Repo scaffold go-ahead:** create `../dispatch-web` (git init + Vite/Svelte/biome/vitest + + the §8 harness). Orchestrator can scaffold config + harness; feature code = summoned agents. + +### Slice-1 findings + open items (for the next, clean-context agent) +- **Bug found + FIXED — transport-ws WebSocket upgrade.** The pure router unit-tested green, but + the live `Bun.serve` shell gated `server.upgrade()` behind a path (`/ws/surfaces`), so a client + hitting `ws://host:24205/` got "Expected 101" instead of an upgrade. **Only the live WS probe + caught it** — pure unit tests can't. Fixed (path-agnostic upgrade; `426` for non-WS) + a + `bun:test` server test (`packages/transport-ws/src/server.bun.test.ts`). **LESSON (reinforces + `restructure-plan.md` §3.6): a transport/server SHELL needs a thin LIVE integration test — the + effectful shell is exactly where integration bugs hide.** Apply to every future transport unit. +- **"10 vs 11 extensions" was a PROBE ARTIFACT, not a bug.** A boot-probe that exported an EMPTY + `DISPATCH_API_KEY` (it grepped a var absent from `.env`) made `provider-openai-compat` skip + activation → the surface showed 10. Booted normally (`bin/up` / `.env`'s real key) all **11** + activate (incl. "OpenAI-Compatible Provider") — verified live. Scar tissue: run live probes with + the real env, never an overridden empty key. +- **§8 reinforced (it cost me twice this session).** Boot-probes LEAK servers → the next boot hits + `EADDRINUSE`; ALWAYS sweep with the bracket-trick `pkill` afterward. And a single + boot+probe+cleanup bash command HANGS the tool — boot it, read the probe's `RESULT` + the + surface from the LOG FILE, then run a SEPARATE cleanup command. (See `ORCHESTRATOR.md` §8.) +- **OPEN — FE component-render tests (CR-1).** `dispatch-web/vite.config.ts` needs Svelte's + `browser` resolve condition (or the `@testing-library/svelte/vite` `svelteTesting()` plugin) for + `@testing-library/svelte` `render()` under vitest/jsdom. The 84 logic/store/resolver tests pass + without it; it only gates DOM component-render tests. Apply when those get written. + +--- + +## 11. References (do NOT copy blindly — keep our methodology) +- Old Dispatch FE: `/home/tradam/projects/dispatch/dispatch-source` (Svelte + DaisyUI) — UX/tech + reference only, NOT structure (esp. the "views" sidebar UX the user wants to revisit, §9). +- Backend seam: `packages/transport-contract/src/index.ts` (`ChatRequest`/`ModelsResponse` + + re-exported `AgentEvent`), `packages/kernel/src/contracts/events.ts` (`AgentEvent`), + `packages/kernel/src/contracts/conversation.ts` (`Chunk`/`ChatMessage`). +- Durability/cache basis: `notes/restructure-plan.md` §3.4 (append-only + `reconcile`). +- Methodology: `notes/restructure-plan.md` §1 (P1–P8), §2.9 (versioning), §5.4 (typed coupling), + §5.6 (glossary), `AGENTS.md`, `.dispatch/rules/`. +``` diff --git a/notes/restructure-plan.md b/notes/restructure-plan.md index 1c9e5e6..e6565c2 100644 --- a/notes/restructure-plan.md +++ b/notes/restructure-plan.md @@ -1387,6 +1387,12 @@ and permissions. Two consequences: active" sync (§5.1) — start with fresh-summoned agents. - TypeScript language server wired into `dispatch.toml` is a **prerequisite** for §5.3's `lsp references` workflow (today only Luau is configured). + - **Vocabulary unification — `command` → `action` (P8; raised during the frontend design, + `notes/frontend-design.md` §9):** the frontend names a backend-invokable action + `action` / `action ref`; the backend's existing contribution point is `command`. Review + renaming `command` → `action` so both sides share ONE term. Until this review the backend + keeps `command` and the frontend uses `action`. Cheap today (the `command` contribution is + design-stage, lightly built); if pursued, fan out via `lsp references`. - **Decided so far:** - ~~Tool-dispatch default policy~~ — **DECIDED** (§3.3): default `{ maxConcurrent: 1, eager: true }`. diff --git a/package.json b/package.json index c1e0de1..c2cfbd8 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,10 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc -b --pretty", - "test:bun": "bun test packages/storage-sqlite/src packages/trace-store/src packages/observability-collector/src", + "test:bun": "bun test packages/storage-sqlite/src packages/trace-store/src packages/observability-collector/src packages/transport-ws/src/server.bun.test.ts", "test:all": "bun run test && bun run test:bun", "dev": "bun packages/host-bin/src/main.ts", + "dev:all": "./bin/up", "dispatch": "bun packages/cli/src/main.ts" }, "devDependencies": { diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json index 4ce63a9..65e986d 100644 --- a/packages/host-bin/package.json +++ b/packages/host-bin/package.json @@ -13,6 +13,9 @@ "@dispatch/session-orchestrator": "workspace:*", "@dispatch/transport-http": "workspace:*", "@dispatch/tool-read-file": "workspace:*", - "@dispatch/journal-sink": "workspace:*" + "@dispatch/journal-sink": "workspace:*", + "@dispatch/surface-loaded-extensions": "workspace:*", + "@dispatch/surface-registry": "workspace:*", + "@dispatch/transport-ws": "workspace:*" } } diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index d766e46..68cbbdd 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -21,8 +21,11 @@ import { import { extension as providerOpenaiCompatExt } from "@dispatch/provider-openai-compat"; import { extension as sessionOrchestratorExt } from "@dispatch/session-orchestrator"; import { createSqliteStorage, extension as storageSqliteExt } from "@dispatch/storage-sqlite"; +import { createLoadedExtensionsExtension } from "@dispatch/surface-loaded-extensions"; +import { createSurfaceRegistryExtension } from "@dispatch/surface-registry"; import { extension as toolReadFileExt } from "@dispatch/tool-read-file"; import { createServer, extension as transportHttpExt } from "@dispatch/transport-http"; +import { createTransportWsExtension } from "@dispatch/transport-ws"; import type { ChildHandle } from "./collector-supervisor.js"; import { createCollectorSupervisor } from "./collector-supervisor.js"; import { configMapToAccess, envToConfigMap } from "./config.js"; @@ -61,6 +64,10 @@ const CORE_EXTENSIONS: readonly Extension[] = [ }), sessionOrchestratorExt, transportHttpExt, + // Surface extensions — dependency order: surface-registry first, then consumers. + createSurfaceRegistryExtension(), + createTransportWsExtension(), + createLoadedExtensionsExtension(), ]; async function boot(): Promise<void> { diff --git a/packages/host-bin/tsconfig.json b/packages/host-bin/tsconfig.json index 70ff95c..3630394 100644 --- a/packages/host-bin/tsconfig.json +++ b/packages/host-bin/tsconfig.json @@ -5,11 +5,10 @@ "references": [ { "path": "../kernel" }, { "path": "../storage-sqlite" }, - { "path": "../conversation-store" }, - { "path": "../auth-apikey" }, - { "path": "../credential-store" }, - { "path": "../provider-openai-compat" }, - { "path": "../session-orchestrator" }, - { "path": "../transport-http" } + { "path": "../surface-loaded-extensions" }, + { "path": "../surface-registry" }, + { "path": "../tool-read-file" }, + { "path": "../transport-http" }, + { "path": "../transport-ws" } ] } diff --git a/packages/kernel/package.json b/packages/kernel/package.json index f613cac..d8d55a7 100644 --- a/packages/kernel/package.json +++ b/packages/kernel/package.json @@ -4,5 +4,8 @@ "type": "module", "private": true, "main": "dist/index.js", - "types": "dist/index.d.ts" + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/wire": "workspace:*" + } } diff --git a/packages/kernel/src/contracts/conversation.ts b/packages/kernel/src/contracts/conversation.ts index c9ad0eb..ec9a389 100644 --- a/packages/kernel/src/contracts/conversation.ts +++ b/packages/kernel/src/contracts/conversation.ts @@ -1,91 +1,20 @@ /** * Conversation model — the kernel's representation of a dialogue. * - * The kernel owns only the types and pure transforms. Persistence is a core - * extension (conversation-store). A turn is one user→assistant cycle; a step - * is one LLM round-trip within a turn. Chunks are append-only. + * Re-exported from @dispatch/wire so the kernel barrel surface stays + * byte-identical. The canonical definitions live in @dispatch/wire. */ -/** Who produced a message. */ -export type Role = "system" | "user" | "assistant" | "tool"; - -/** Opaque identifier for a turn (one user→assistant cycle). */ -export type TurnId = string & { readonly __brand: "TurnId" }; - -/** Opaque identifier for a step (one LLM round-trip within a turn). */ -export type StepId = string & { readonly __brand: "StepId" }; - -/** - * A chunk is one ordered piece of a message — the atomic unit of the - * append-only conversation log. Discriminated by `type`. - */ -export type Chunk = - | TextChunk - | ThinkingChunk - | ToolCallChunk - | ToolResultChunk - | ErrorChunk - | SystemChunk; - -/** A piece of plain text content from the assistant or user. */ -export interface TextChunk { - readonly type: "text"; - readonly text: string; -} - -/** A piece of model reasoning / thinking content (e.g. extended thinking). */ -export interface ThinkingChunk { - readonly type: "thinking"; - readonly text: string; -} - -/** - * A model's request to run a tool. The kernel routes by `name`; the tool - * implementation never sees this directly — it receives parsed `input` via - * `ToolContract.execute`. - */ -export interface ToolCallChunk { - readonly type: "tool-call"; - readonly toolCallId: string; - readonly toolName: string; - readonly input: unknown; -} - -/** - * The result of a tool execution, attributed to the originating tool-call id. - * The kernel guarantees every tool-call chunk gets exactly one result chunk - * (synthesized if interrupted — see reconcile). - */ -export interface ToolResultChunk { - readonly type: "tool-result"; - readonly toolCallId: string; - readonly toolName: string; - readonly content: string; - readonly isError: boolean; -} - -/** An error that occurred during generation or tool dispatch. */ -export interface ErrorChunk { - readonly type: "error"; - readonly message: string; - readonly code?: string; -} - -/** - * A system-injected message (e.g. system prompt, context assembly output). - * Kept distinct from text so the log records provenance. - */ -export interface SystemChunk { - readonly type: "system"; - readonly text: string; -} - -/** - * A chat message: a role plus an ordered sequence of chunks. Messages are the - * unit passed to and from the provider; chunks are the unit persisted and - * rendered. - */ -export interface ChatMessage { - readonly role: Role; - readonly chunks: readonly Chunk[]; -} +export type { + ChatMessage, + Chunk, + ErrorChunk, + Role, + StepId, + SystemChunk, + TextChunk, + ThinkingChunk, + ToolCallChunk, + ToolResultChunk, + TurnId, +} from "@dispatch/wire"; diff --git a/packages/kernel/src/contracts/events.ts b/packages/kernel/src/contracts/events.ts index 74e23fd..8737b02 100644 --- a/packages/kernel/src/contracts/events.ts +++ b/packages/kernel/src/contracts/events.ts @@ -1,122 +1,21 @@ /** * Outward events — the event type the runtime emits to the outside world. * - * These are the events transport extensions push to clients, notification - * extensions react to, and conversation-store uses for persistence. - * Discriminated by `type`. + * Re-exported from @dispatch/wire so the kernel barrel surface stays + * byte-identical. The canonical definitions live in @dispatch/wire. */ -import type { Usage } from "./provider.js"; - -/** - * The union of all events the runtime emits outward during a turn. - * Consumers (transport, persistence, notifications) pattern-match on `type`. - */ -export type AgentEvent = - | StatusEvent - | TurnStartEvent - | TurnTextDeltaEvent - | TurnReasoningDeltaEvent - | TurnToolCallEvent - | TurnToolResultEvent - | TurnToolOutputEvent - | TurnUsageEvent - | TurnErrorEvent - | TurnDoneEvent - | TurnSealedEvent; - -/** Status change for a conversation (e.g. idle → running). */ -export interface StatusEvent { - readonly type: "status"; - readonly conversationId: string; - readonly status: string; -} - -/** A turn has begun. */ -export interface TurnStartEvent { - readonly type: "turn-start"; - readonly conversationId: string; - readonly turnId: string; -} - -/** Incremental text content from the model during a turn. */ -export interface TurnTextDeltaEvent { - readonly type: "text-delta"; - readonly conversationId: string; - readonly turnId: string; - readonly delta: string; -} - -/** Incremental reasoning / thinking content during a turn. */ -export interface TurnReasoningDeltaEvent { - readonly type: "reasoning-delta"; - readonly conversationId: string; - readonly turnId: string; - readonly delta: string; -} - -/** The model has requested a tool to be run. */ -export interface TurnToolCallEvent { - readonly type: "tool-call"; - readonly conversationId: string; - readonly turnId: string; - readonly toolCallId: string; - readonly toolName: string; - readonly input: unknown; -} - -/** A tool has completed execution. */ -export interface TurnToolResultEvent { - readonly type: "tool-result"; - readonly conversationId: string; - readonly turnId: string; - readonly toolCallId: string; - readonly toolName: string; - readonly content: string; - readonly isError: boolean; -} - -/** Streaming output from a tool execution (e.g. shell stdout/stderr). */ -export interface TurnToolOutputEvent { - readonly type: "tool-output"; - readonly conversationId: string; - readonly turnId: string; - readonly toolCallId: string; - readonly data: string; - readonly stream: "stdout" | "stderr"; -} - -/** Token usage for the current step or turn. */ -export interface TurnUsageEvent { - readonly type: "usage"; - readonly conversationId: string; - readonly turnId: string; - readonly usage: Usage; -} - -/** An error occurred during the turn. */ -export interface TurnErrorEvent { - readonly type: "error"; - readonly conversationId: string; - readonly turnId: string; - readonly message: string; - readonly code?: string; -} - -/** The turn has completed (model finished generating). */ -export interface TurnDoneEvent { - readonly type: "done"; - readonly conversationId: string; - readonly turnId: string; - readonly reason: string; -} - -/** - * The turn has been sealed — all chunks persisted, history is final. - * This is the hook point for post-turn extensions (compaction, cache-warm). - */ -export interface TurnSealedEvent { - readonly type: "turn-sealed"; - readonly conversationId: string; - readonly turnId: string; -} +export type { + AgentEvent, + StatusEvent, + TurnDoneEvent, + TurnErrorEvent, + TurnReasoningDeltaEvent, + TurnSealedEvent, + TurnStartEvent, + TurnTextDeltaEvent, + TurnToolCallEvent, + TurnToolOutputEvent, + TurnToolResultEvent, + TurnUsageEvent, +} from "@dispatch/wire"; diff --git a/packages/kernel/src/contracts/extension.ts b/packages/kernel/src/contracts/extension.ts index 00b41f1..1760cf9 100644 --- a/packages/kernel/src/contracts/extension.ts +++ b/packages/kernel/src/contracts/extension.ts @@ -232,6 +232,9 @@ export interface HostAPI { /** Look up a single auth provider by id. */ readonly getAuthProvider: (id: string) => AuthContract | undefined; + /** Read-only view of all activated extensions' manifests (what is loaded). */ + readonly getExtensions: () => readonly Manifest[]; + /** Register a scheduled job with the host's scheduler. */ readonly scheduler: { readonly register: (job: ScheduledJob) => void; diff --git a/packages/kernel/src/contracts/provider.ts b/packages/kernel/src/contracts/provider.ts index ee58c1d..0686c19 100644 --- a/packages/kernel/src/contracts/provider.ts +++ b/packages/kernel/src/contracts/provider.ts @@ -6,20 +6,12 @@ * translates its responses into `ProviderEvent`s. */ +import type { Usage } from "@dispatch/wire"; import type { ChatMessage } from "./conversation.js"; import type { Logger } from "./logging.js"; import type { ToolContract } from "./tool.js"; -/** - * Token usage counters for a single step. All fields are counts of tokens. - * Cache fields are optional because not all providers expose cache metrics. - */ -export interface Usage { - readonly inputTokens: number; - readonly outputTokens: number; - readonly cacheReadTokens?: number; - readonly cacheWriteTokens?: number; -} +export type { Usage } from "@dispatch/wire"; /** * Events a provider yields during a single `stream` call. The kernel consumes diff --git a/packages/kernel/src/host/host.test.ts b/packages/kernel/src/host/host.test.ts index 430447c..106dd56 100644 --- a/packages/kernel/src/host/host.test.ts +++ b/packages/kernel/src/host/host.test.ts @@ -726,6 +726,117 @@ describe("createHost", () => { }); }); + describe("getExtensions", () => { + it("returns empty array when no extensions are activated", async () => { + const host = createHost([], deps); + await host.activate(); + + expect(host.getExtensions()).toEqual([]); + }); + + it("returns manifests of all activated extensions", async () => { + const a = createExtension("ext-a"); + const b = createExtension("ext-b"); + + const host = createHost([a, b], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(2); + expect(exts.map((e) => e.id)).toContain("ext-a"); + expect(exts.map((e) => e.id)).toContain("ext-b"); + }); + + it("returns manifests in activation order", async () => { + const a = createExtension("a"); + const b = createExtension("b", { dependsOn: ["a"] }); + const c = createExtension("c", { dependsOn: ["b"] }); + + const host = createHost([c, b, a], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts.map((e) => e.id)).toEqual(["a", "b", "c"]); + }); + + it("excludes extensions that failed to activate", async () => { + const a = createExtension("good"); + const b = createExtension("bad", { + activate: () => { + throw new Error("boom"); + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(1); + expect(exts[0]?.id).toBe("good"); + }); + + it("excludes extensions disabled by apiVersion incompatibility", async () => { + const good = createExtension("good"); + const bad = createExtension("bad", { apiVersion: "^99.0.0" }); + + const host = createHost([good, bad], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(1); + expect(exts[0]?.id).toBe("good"); + }); + + it("returns a frozen array", async () => { + const ext = createExtension("ext"); + const host = createHost([ext], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(Object.isFrozen(exts)).toBe(true); + }); + + it("HostAPI getExtensions reflects activated extensions after full activation", async () => { + const a = createExtension("ext-a"); + const b = createExtension("ext-b", { + dependsOn: ["ext-a"], + activate: () => {}, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + // Use getHostAPI() to verify the post-activation view + const api = host.getHostAPI(); + const capturedExtsAfter = api.getExtensions(); + + expect(capturedExtsAfter).toHaveLength(2); + expect(capturedExtsAfter.map((e) => e.id)).toEqual(["ext-a", "ext-b"]); + }); + + it("HostAPI getExtensions during activation sees only previously activated", async () => { + const seenDuringActivation: string[][] = []; + + const a = createExtension("a", { + activate: (host) => { + seenDuringActivation.push(host.getExtensions().map((e) => e.id)); + }, + }); + const b = createExtension("b", { + activate: (host) => { + seenDuringActivation.push(host.getExtensions().map((e) => e.id)); + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + // When a activates, activated[] is empty (a hasn't been pushed yet) + // When b activates, activated[] has [a] (b hasn't been pushed yet) + expect(seenDuringActivation).toEqual([[], ["a"]]); + }); + }); + describe("DAG errors", () => { it("throws on missing dependency", () => { const ext = createExtension("a", { dependsOn: ["missing"] }); diff --git a/packages/kernel/src/host/host.ts b/packages/kernel/src/host/host.ts index 2331625..8aa4f78 100644 --- a/packages/kernel/src/host/host.ts +++ b/packages/kernel/src/host/host.ts @@ -57,6 +57,7 @@ export interface Host { readonly getScheduledJobs: () => readonly ScheduledJob[]; readonly getMigrations: () => readonly string[]; readonly getDisabled: () => readonly DisabledExtension[]; + readonly getExtensions: () => readonly Manifest[]; readonly getHostAPI: () => HostAPI; } @@ -150,6 +151,9 @@ export function createHost(extensions: readonly Extension[], deps: HostDeps): Ho getAuthProvider(id: string) { return authProviders.get(id); }, + getExtensions() { + return Object.freeze(activated.map((e) => e.manifest)); + }, scheduler: { register(job: ScheduledJob) { scheduledJobs.push(job); @@ -213,6 +217,9 @@ export function createHost(extensions: readonly Extension[], deps: HostDeps): Ho getDisabled() { return disabled; }, + getExtensions() { + return Object.freeze(activated.map((e) => e.manifest)); + }, getHostAPI() { return buildHostAPI("__host__", { registrationClosed: true }); }, diff --git a/packages/kernel/tsconfig.json b/packages/kernel/tsconfig.json index 2a3be7e..a882987 100644 --- a/packages/kernel/tsconfig.json +++ b/packages/kernel/tsconfig.json @@ -1,5 +1,6 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "references": [{ "path": "../wire" }] } diff --git a/packages/surface-loaded-extensions/package.json b/packages/surface-loaded-extensions/package.json new file mode 100644 index 0000000..66e2e69 --- /dev/null +++ b/packages/surface-loaded-extensions/package.json @@ -0,0 +1,13 @@ +{ + "name": "@dispatch/surface-loaded-extensions", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/ui-contract": "workspace:*", + "@dispatch/surface-registry": "workspace:*" + } +} diff --git a/packages/surface-loaded-extensions/src/extension.ts b/packages/surface-loaded-extensions/src/extension.ts new file mode 100644 index 0000000..abef4b6 --- /dev/null +++ b/packages/surface-loaded-extensions/src/extension.ts @@ -0,0 +1,43 @@ +import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; +import type { SurfaceProvider } from "@dispatch/surface-registry"; +import { surfaceRegistryHandle } from "@dispatch/surface-registry"; +import { buildLoadedExtensionsSpec } from "./spec.js"; + +export const manifest: Manifest = { + id: "surface-loaded-extensions", + name: "Loaded Extensions Surface", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + dependsOn: ["surface-registry"], + contributes: { services: [] }, +}; + +export function createLoadedExtensionsExtension(): Extension { + let dispose: (() => void) | undefined; + + return { + manifest, + activate(host: HostAPI) { + const registry = host.getService(surfaceRegistryHandle); + + const provider: SurfaceProvider = { + catalogEntry: { + id: "loaded-extensions", + region: "side", + title: "Loaded Extensions", + }, + getSpec() { + return buildLoadedExtensionsSpec(host.getExtensions()); + }, + invoke() {}, + }; + + dispose = registry.register(provider); + }, + deactivate() { + dispose?.(); + }, + }; +} diff --git a/packages/surface-loaded-extensions/src/index.ts b/packages/surface-loaded-extensions/src/index.ts new file mode 100644 index 0000000..bc10dc5 --- /dev/null +++ b/packages/surface-loaded-extensions/src/index.ts @@ -0,0 +1,2 @@ +export { createLoadedExtensionsExtension, manifest } from "./extension.js"; +export { buildLoadedExtensionsSpec } from "./spec.js"; diff --git a/packages/surface-loaded-extensions/src/spec.test.ts b/packages/surface-loaded-extensions/src/spec.test.ts new file mode 100644 index 0000000..9c1aa6a --- /dev/null +++ b/packages/surface-loaded-extensions/src/spec.test.ts @@ -0,0 +1,94 @@ +import type { Manifest } from "@dispatch/kernel"; +import type { StatField } from "@dispatch/ui-contract"; +import { describe, expect, it } from "vitest"; +import { buildLoadedExtensionsSpec } from "./spec.js"; + +function fakeManifest(id: string, name: string, version: string): Manifest { + return { + id, + name, + version, + apiVersion: "^0.1.0", + trust: "bundled", + }; +} + +describe("buildLoadedExtensionsSpec", () => { + it("returns a count stat of '0' and no extension stats for empty manifests", () => { + const spec = buildLoadedExtensionsSpec([]); + + expect(spec.id).toBe("loaded-extensions"); + expect(spec.region).toBe("side"); + expect(spec.title).toBe("Loaded Extensions"); + expect(spec.fields).toHaveLength(1); + expect(spec.fields[0]).toEqual({ + kind: "stat", + label: "Loaded", + value: "0", + }); + }); + + it("returns a count stat plus one stat per manifest in order", () => { + const manifests = [ + fakeManifest("alpha", "Alpha", "1.0.0"), + fakeManifest("beta", "Beta", "2.3.1"), + fakeManifest("gamma", "Gamma", "0.5.0"), + ]; + + const spec = buildLoadedExtensionsSpec(manifests); + + expect(spec.fields).toHaveLength(4); + expect(spec.fields[0]).toEqual({ + kind: "stat", + label: "Loaded", + value: "3", + }); + expect(spec.fields[1]).toEqual({ + kind: "stat", + label: "Alpha", + value: "1.0.0", + }); + expect(spec.fields[2]).toEqual({ + kind: "stat", + label: "Beta", + value: "2.3.1", + }); + expect(spec.fields[3]).toEqual({ + kind: "stat", + label: "Gamma", + value: "0.5.0", + }); + }); + + it("preserves input order of manifests", () => { + const manifests = [ + fakeManifest("z-last", "Z Last", "1.0.0"), + fakeManifest("a-first", "A First", "2.0.0"), + ]; + + const spec = buildLoadedExtensionsSpec(manifests); + + expect((spec.fields[1] as StatField).label).toBe("Z Last"); + expect((spec.fields[2] as StatField).label).toBe("A First"); + }); + + it("sets the surface id, region, and title correctly", () => { + const spec = buildLoadedExtensionsSpec([]); + + expect(spec.id).toBe("loaded-extensions"); + expect(spec.region).toBe("side"); + expect(spec.title).toBe("Loaded Extensions"); + }); + + it("uses manifest.name as label and manifest.version as value", () => { + const manifests = [fakeManifest("my-ext", "My Extension", "3.2.1")]; + + const spec = buildLoadedExtensionsSpec(manifests); + + expect(spec.fields[1]).toEqual({ + kind: "stat", + label: "My Extension", + value: "3.2.1", + }); + }); +}); diff --git a/packages/surface-loaded-extensions/src/spec.ts b/packages/surface-loaded-extensions/src/spec.ts new file mode 100644 index 0000000..bd3dd56 --- /dev/null +++ b/packages/surface-loaded-extensions/src/spec.ts @@ -0,0 +1,25 @@ +import type { Manifest } from "@dispatch/kernel"; +import type { StatField, SurfaceSpec } from "@dispatch/ui-contract"; + +/** + * Pure core — builds the SurfaceSpec for the loaded-extensions surface. + * Zero I/O, zero ambient state. Decision logic only: input → output. + */ +export function buildLoadedExtensionsSpec(manifests: readonly Manifest[]): SurfaceSpec { + const fields: StatField[] = [{ kind: "stat", label: "Loaded", value: String(manifests.length) }]; + + for (const manifest of manifests) { + fields.push({ + kind: "stat", + label: manifest.name, + value: manifest.version, + }); + } + + return { + id: "loaded-extensions", + region: "side", + title: "Loaded Extensions", + fields, + }; +} diff --git a/packages/surface-loaded-extensions/tsconfig.json b/packages/surface-loaded-extensions/tsconfig.json new file mode 100644 index 0000000..db257d0 --- /dev/null +++ b/packages/surface-loaded-extensions/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../kernel" }, + { "path": "../ui-contract" }, + { "path": "../surface-registry" } + ] +} diff --git a/packages/surface-registry/package.json b/packages/surface-registry/package.json new file mode 100644 index 0000000..16b0c4c --- /dev/null +++ b/packages/surface-registry/package.json @@ -0,0 +1,12 @@ +{ + "name": "@dispatch/surface-registry", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/ui-contract": "workspace:*" + } +} diff --git a/packages/surface-registry/src/extension.ts b/packages/surface-registry/src/extension.ts new file mode 100644 index 0000000..6d0ce22 --- /dev/null +++ b/packages/surface-registry/src/extension.ts @@ -0,0 +1,23 @@ +import type { Extension, Manifest } from "@dispatch/kernel"; +import { createSurfaceRegistry } from "./registry.js"; +import { surfaceRegistryHandle } from "./service.js"; + +export const manifest: Manifest = { + id: "surface-registry", + name: "Surface Registry", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + contributes: { services: ["surface-registry/registry"] }, +}; + +export function createSurfaceRegistryExtension(): Extension { + return { + manifest, + activate(host) { + const registry = createSurfaceRegistry(); + host.provideService(surfaceRegistryHandle, registry); + }, + }; +} diff --git a/packages/surface-registry/src/index.ts b/packages/surface-registry/src/index.ts new file mode 100644 index 0000000..cdfcf7e --- /dev/null +++ b/packages/surface-registry/src/index.ts @@ -0,0 +1,4 @@ +export { createSurfaceRegistryExtension, manifest } from "./extension.js"; +export type { SurfaceProvider, SurfaceRegistry } from "./registry.js"; +export { createSurfaceRegistry } from "./registry.js"; +export { surfaceRegistryHandle } from "./service.js"; diff --git a/packages/surface-registry/src/registry.test.ts b/packages/surface-registry/src/registry.test.ts new file mode 100644 index 0000000..c47c979 --- /dev/null +++ b/packages/surface-registry/src/registry.test.ts @@ -0,0 +1,122 @@ +import type { SurfaceCatalogEntry, SurfaceSpec } from "@dispatch/ui-contract"; +import { describe, expect, it } from "vitest"; +import type { SurfaceProvider } from "./registry.js"; +import { createSurfaceRegistry } from "./registry.js"; + +function fakeProvider(id: string, title?: string): SurfaceProvider { + const catalogEntry: SurfaceCatalogEntry = { + id, + region: "default", + title: title ?? `Surface ${id}`, + }; + return { + catalogEntry, + getSpec(): SurfaceSpec { + return { + id, + region: "default", + title: catalogEntry.title, + fields: [], + }; + }, + invoke() {}, + }; +} + +describe("createSurfaceRegistry", () => { + describe("register + getCatalog", () => { + it("returns the entry after registration", () => { + const registry = createSurfaceRegistry(); + registry.register(fakeProvider("a", "Surface A")); + + const catalog = registry.getCatalog(); + expect(catalog).toHaveLength(1); + expect(catalog[0]).toEqual({ + id: "a", + region: "default", + title: "Surface A", + }); + }); + + it("returns entries for multiple providers", () => { + const registry = createSurfaceRegistry(); + registry.register(fakeProvider("a")); + registry.register(fakeProvider("b")); + + const catalog = registry.getCatalog(); + expect(catalog).toHaveLength(2); + expect(catalog.map((e) => e.id)).toEqual(["a", "b"]); + }); + }); + + describe("getSurface", () => { + it("returns the provider for a known id", () => { + const registry = createSurfaceRegistry(); + const provider = fakeProvider("x"); + registry.register(provider); + + expect(registry.getSurface("x")).toBe(provider); + }); + + it("returns undefined for an unknown id", () => { + const registry = createSurfaceRegistry(); + expect(registry.getSurface("nonexistent")).toBeUndefined(); + }); + }); + + describe("disposer", () => { + it("removes the provider from catalog and lookup", () => { + const registry = createSurfaceRegistry(); + const dispose = registry.register(fakeProvider("a")); + + expect(registry.getCatalog()).toHaveLength(1); + expect(registry.getSurface("a")).toBeDefined(); + + dispose(); + + expect(registry.getCatalog()).toHaveLength(0); + expect(registry.getSurface("a")).toBeUndefined(); + }); + + it("is idempotent — calling dispose twice is safe", () => { + const registry = createSurfaceRegistry(); + const dispose = registry.register(fakeProvider("a")); + + dispose(); + dispose(); + + expect(registry.getCatalog()).toHaveLength(0); + }); + + it("does not remove a replacement provider with the same id", () => { + const registry = createSurfaceRegistry(); + const first = fakeProvider("a", "First"); + const second = fakeProvider("a", "Second"); + + const disposeFirst = registry.register(first); + registry.register(second); + + disposeFirst(); + + // The second provider should still be registered + expect(registry.getSurface("a")).toBe(second); + expect(registry.getCatalog()).toHaveLength(1); + expect(registry.getCatalog()[0]?.title).toBe("Second"); + }); + }); + + describe("duplicate-id behavior (last-wins)", () => { + it("replaces an existing provider when registering the same id", () => { + const registry = createSurfaceRegistry(); + const first = fakeProvider("a", "First"); + const second = fakeProvider("a", "Second"); + + registry.register(first); + registry.register(second); + + expect(registry.getSurface("a")).toBe(second); + expect(registry.getCatalog()).toHaveLength(1); + expect(registry.getCatalog()[0]?.title).toBe("Second"); + }); + }); +}); diff --git a/packages/surface-registry/src/registry.ts b/packages/surface-registry/src/registry.ts new file mode 100644 index 0000000..b1c8116 --- /dev/null +++ b/packages/surface-registry/src/registry.ts @@ -0,0 +1,80 @@ +import type { SurfaceCatalog, SurfaceCatalogEntry, SurfaceSpec } from "@dispatch/ui-contract"; + +/** + * What a surface-contributing extension registers with the surface registry. + * Each provider owns one surface identified by its catalog entry id. + */ +export interface SurfaceProvider { + /** Discovery metadata for the surface catalog. */ + readonly catalogEntry: SurfaceCatalogEntry; + + /** Build the current surface spec (may be async for dynamic surfaces). */ + getSpec(): SurfaceSpec | Promise<SurfaceSpec>; + + /** Run a backend action by id with an optional payload. */ + invoke(actionId: string, payload?: unknown): void | Promise<void>; + + /** + * Optional: subscribe to spec changes. Returns an unsubscribe disposer. + * When the spec changes, the caller should re-fetch via getSpec() and push. + */ + subscribe?(onChange: () => void): () => void; +} + +/** + * The surface registry service — the interface other extensions obtain via + * `host.getService(surfaceRegistryHandle)`. + */ +export interface SurfaceRegistry { + /** + * Register a surface provider. Returns an unregister disposer. + * If a provider with the same id is already registered, the new one + * replaces it (last-wins semantics). + */ + register(provider: SurfaceProvider): () => void; + + /** Return discovery metadata for all currently registered providers. */ + getCatalog(): SurfaceCatalog; + + /** Look up a provider by its surface id. */ + getSurface(id: string): SurfaceProvider | undefined; +} + +/** + * Create a pure in-memory surface registry. No I/O, no ambient state — + * the decision logic is a plain Map behind the SurfaceRegistry interface. + */ +export function createSurfaceRegistry(): SurfaceRegistry { + const providers = new Map<string, SurfaceProvider>(); + + return { + register(provider: SurfaceProvider): () => void { + const id = provider.catalogEntry.id; + providers.set(id, provider); + + let disposed = false; + return () => { + if (!disposed) { + disposed = true; + // Only delete if the current entry is still this provider + // (another register with the same id may have replaced it). + if (providers.get(id) === provider) { + providers.delete(id); + } + } + }; + }, + + getCatalog(): SurfaceCatalog { + const entries: SurfaceCatalogEntry[] = []; + for (const provider of providers.values()) { + entries.push(provider.catalogEntry); + } + return entries; + }, + + getSurface(id: string): SurfaceProvider | undefined { + return providers.get(id); + }, + }; +} diff --git a/packages/surface-registry/src/service.ts b/packages/surface-registry/src/service.ts new file mode 100644 index 0000000..a43c155 --- /dev/null +++ b/packages/surface-registry/src/service.ts @@ -0,0 +1,4 @@ +import { defineService } from "@dispatch/kernel"; +import type { SurfaceRegistry } from "./registry.js"; + +export const surfaceRegistryHandle = defineService<SurfaceRegistry>("surface-registry/registry"); diff --git a/packages/surface-registry/tsconfig.json b/packages/surface-registry/tsconfig.json new file mode 100644 index 0000000..e430ba9 --- /dev/null +++ b/packages/surface-registry/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../ui-contract" }] +} diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json index 83c8a71..2af6a73 100644 --- a/packages/transport-contract/package.json +++ b/packages/transport-contract/package.json @@ -6,6 +6,6 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "dependencies": { - "@dispatch/kernel": "workspace:*" + "@dispatch/wire": "workspace:*" } } diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index 5f16d8a..7d3996a 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -12,7 +12,7 @@ * union, re-exported here so a client has one import for the whole wire. */ -export type { AgentEvent } from "@dispatch/kernel"; +export type { AgentEvent } from "@dispatch/wire"; /** * Request body for `POST /chat` (sent as JSON). diff --git a/packages/transport-contract/tsconfig.json b/packages/transport-contract/tsconfig.json index ff99a43..a882987 100644 --- a/packages/transport-contract/tsconfig.json +++ b/packages/transport-contract/tsconfig.json @@ -2,5 +2,5 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "references": [{ "path": "../wire" }] } diff --git a/packages/transport-ws/package.json b/packages/transport-ws/package.json new file mode 100644 index 0000000..dab8ebc --- /dev/null +++ b/packages/transport-ws/package.json @@ -0,0 +1,13 @@ +{ + "name": "@dispatch/transport-ws", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/surface-registry": "workspace:*", + "@dispatch/ui-contract": "workspace:*" + } +} diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts new file mode 100644 index 0000000..a18aefa --- /dev/null +++ b/packages/transport-ws/src/extension.ts @@ -0,0 +1,161 @@ +/** + * Shell — thin imperative layer that owns the Bun.serve WebSocket server. + * + * All decision logic lives in router.ts (pure, unit-tested). + * This file handles I/O only: WS accept, JSON parse/stringify, + * provider.subscribe wiring, server lifecycle. + */ + +import type { Extension, HostAPI } from "@dispatch/kernel"; +import type { SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry"; +import { surfaceRegistryHandle } from "@dispatch/surface-registry"; +import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; +import { manifest } from "./manifest.js"; +import { catalogMessage, routeClientMessage } from "./router.js"; + +/** Active provider subscriptions for a single WS connection. */ +interface ConnectionState { + readonly subs: Set<string>; + readonly providerDisposers: Map<string, () => void>; +} + +type Ws = Bun.ServerWebSocket<ConnectionState>; + +export function createTransportWsExtension(): Extension { + let server: ReturnType<typeof Bun.serve<ConnectionState>> | undefined; + + return { + manifest, + async activate(host: HostAPI) { + const registry: SurfaceRegistry = host.getService(surfaceRegistryHandle); + const logger = host.logger; + const port = host.config.get<number>("surfaceWsPort") ?? 24205; + + function send(ws: Ws, msg: SurfaceServerMessage): void { + try { + ws.send(JSON.stringify(msg)); + } catch { + // Connection may have been dropped; swallow. + } + } + + function subscribeToProvider( + ws: Ws, + provider: SurfaceProvider, + surfaceId: string, + state: ConnectionState, + ): void { + if (!provider.subscribe || state.providerDisposers.has(surfaceId)) { + return; + } + const dispose = provider.subscribe(() => { + try { + const spec = provider.getSpec(); + if (spec instanceof Promise) { + spec + .then((s) => send(ws, { type: "update", update: { surfaceId, spec: s } })) + .catch(() => {}); + } else { + send(ws, { type: "update", update: { surfaceId, spec } }); + } + } catch { + // Provider threw — log but don't kill the connection. + } + }); + state.providerDisposers.set(surfaceId, dispose); + } + + function unsubscribeFromProvider(state: ConnectionState, surfaceId: string): void { + const dispose = state.providerDisposers.get(surfaceId); + if (dispose) { + dispose(); + state.providerDisposers.delete(surfaceId); + } + } + + server = Bun.serve<ConnectionState>({ + port, + fetch(req, srv) { + const initial: ConnectionState = { + subs: new Set(), + providerDisposers: new Map(), + }; + if (srv.upgrade(req, { data: initial })) return; + return new Response("expected websocket", { status: 426 }); + }, + websocket: { + open(ws) { + send(ws, catalogMessage(registry)); + }, + + message(ws, message) { + const state = ws.data; + if (!state) return; + + let parsed: SurfaceClientMessage; + try { + parsed = JSON.parse(String(message)) as SurfaceClientMessage; + } catch { + send(ws, { type: "error", message: "Invalid JSON" }); + return; + } + + const result = routeClientMessage(registry, state.subs, parsed); + + // Apply sub change. + if (result.subChange) { + if (result.subChange.op === "add") { + state.subs.add(result.subChange.surfaceId); + const provider = registry.getSurface(result.subChange.surfaceId); + if (provider) { + subscribeToProvider(ws, provider, result.subChange.surfaceId, state); + } + } else { + state.subs.delete(result.subChange.surfaceId); + unsubscribeFromProvider(state, result.subChange.surfaceId); + } + } + + // Send replies. + for (const reply of result.replies) { + send(ws, reply); + } + + // Perform invoke if signalled. + if (result.invoke) { + const provider = registry.getSurface(result.invoke.surfaceId); + if (provider) { + try { + const r = provider.invoke(result.invoke.actionId, result.invoke.payload); + if (r instanceof Promise) { + r.catch(() => {}); + } + } catch { + // Provider threw on invoke — log but don't kill the connection. + } + } + } + }, + + close(ws) { + const state = ws.data; + if (state) { + for (const dispose of state.providerDisposers.values()) { + dispose(); + } + } + }, + }, + }); + + logger.info?.("transport-ws: surface WebSocket listening", { port }); + }, + + deactivate() { + if (server) { + server.stop(); + server = undefined; + } + }, + }; +} diff --git a/packages/transport-ws/src/index.ts b/packages/transport-ws/src/index.ts new file mode 100644 index 0000000..a93611f --- /dev/null +++ b/packages/transport-ws/src/index.ts @@ -0,0 +1,4 @@ +export { createTransportWsExtension } from "./extension.js"; +export { manifest } from "./manifest.js"; +export type { RouteResult } from "./router.js"; +export { catalogMessage, routeClientMessage } from "./router.js"; diff --git a/packages/transport-ws/src/manifest.ts b/packages/transport-ws/src/manifest.ts new file mode 100644 index 0000000..b0612e2 --- /dev/null +++ b/packages/transport-ws/src/manifest.ts @@ -0,0 +1,13 @@ +import type { Manifest } from "@dispatch/kernel"; + +export const manifest: Manifest = { + id: "transport-ws", + name: "Transport WebSocket", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + dependsOn: ["surface-registry"], + capabilities: { network: true }, + contributes: { routes: ["/ws/surfaces"] }, + activation: "eager", +}; diff --git a/packages/transport-ws/src/router.test.ts b/packages/transport-ws/src/router.test.ts new file mode 100644 index 0000000..83496f3 --- /dev/null +++ b/packages/transport-ws/src/router.test.ts @@ -0,0 +1,203 @@ +import type { SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry"; +import type { SurfaceCatalogEntry, SurfaceSpec } from "@dispatch/ui-contract"; +import { describe, expect, it } from "vitest"; +import { catalogMessage, routeClientMessage } from "./router.js"; + +// ── Fake in-memory registry (no mocks — just a plain implementation) ──────── + +function fakeProvider(id: string, title?: string, actions?: readonly string[]): SurfaceProvider { + const catalogEntry: SurfaceCatalogEntry = { + id, + region: "default", + title: title ?? `Surface ${id}`, + }; + return { + catalogEntry, + getSpec(): SurfaceSpec { + return { + id, + region: "default", + title: catalogEntry.title, + fields: + actions?.map((a) => ({ + kind: "button" as const, + label: a, + action: { actionId: a }, + })) ?? [], + }; + }, + invoke(_actionId: string, _payload?: unknown) {}, + }; +} + +function fakeRegistry(providers: readonly SurfaceProvider[]): SurfaceRegistry { + const map = new Map(providers.map((p) => [p.catalogEntry.id, p])); + return { + register(_provider: SurfaceProvider) { + return () => {}; + }, + getCatalog() { + return [...map.values()].map((p) => p.catalogEntry); + }, + getSurface(id: string) { + return map.get(id); + }, + }; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("routeClientMessage", () => { + describe("subscribe", () => { + it("replies with `surface` and tracks the subscription", () => { + const provider = fakeProvider("a", "Surface A"); + const registry = fakeRegistry([provider]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "subscribe", + surfaceId: "a", + }); + + expect(result.replies).toHaveLength(1); + expect(result.replies[0]).toEqual({ + type: "surface", + spec: { + id: "a", + region: "default", + title: "Surface A", + fields: [], + }, + }); + expect(result.subChange).toEqual({ op: "add", surfaceId: "a" }); + }); + + it("is idempotent — subscribing twice does not duplicate the subChange", () => { + const provider = fakeProvider("a"); + const registry = fakeRegistry([provider]); + const connSubs = new Set<string>(["a"]); // already subscribed + + const result = routeClientMessage(registry, connSubs, { + type: "subscribe", + surfaceId: "a", + }); + + expect(result.replies).toHaveLength(1); + expect(result.replies[0]?.type).toBe("surface"); + expect(result.subChange).toBeUndefined(); + }); + + it("returns `error` for an unknown surface id", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "subscribe", + surfaceId: "nonexistent", + }); + + expect(result.replies).toHaveLength(1); + expect(result.replies[0]).toEqual({ + type: "error", + surfaceId: "nonexistent", + message: "Unknown surface: nonexistent", + }); + expect(result.subChange).toBeUndefined(); + }); + }); + + describe("unsubscribe", () => { + it("emits a remove subChange and no replies", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(["a"]); + + const result = routeClientMessage(registry, connSubs, { + type: "unsubscribe", + surfaceId: "a", + }); + + expect(result.replies).toHaveLength(0); + expect(result.subChange).toEqual({ op: "remove", surfaceId: "a" }); + }); + + it("emits remove even if not currently subscribed (idempotent)", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "unsubscribe", + surfaceId: "a", + }); + + expect(result.replies).toHaveLength(0); + expect(result.subChange).toEqual({ op: "remove", surfaceId: "a" }); + }); + }); + + describe("invoke", () => { + it("signals the invoke effect for a known surface", () => { + const provider = fakeProvider("a", "Surface A", ["toggle"]); + const registry = fakeRegistry([provider]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "invoke", + surfaceId: "a", + actionId: "toggle", + payload: true, + }); + + expect(result.replies).toHaveLength(0); + expect(result.invoke).toEqual({ + surfaceId: "a", + actionId: "toggle", + payload: true, + }); + }); + + it("returns `error` for an unknown surface id", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "invoke", + surfaceId: "nonexistent", + actionId: "toggle", + }); + + expect(result.replies).toHaveLength(1); + expect(result.replies[0]).toEqual({ + type: "error", + surfaceId: "nonexistent", + message: "Unknown surface: nonexistent", + }); + expect(result.invoke).toBeUndefined(); + }); + }); +}); + +describe("catalogMessage", () => { + it("returns the catalog from the registry", () => { + const providerA = fakeProvider("a", "Surface A"); + const providerB = fakeProvider("b", "Surface B"); + const registry = fakeRegistry([providerA, providerB]); + + const msg = catalogMessage(registry); + + expect(msg).toEqual({ + type: "catalog", + catalog: [ + { id: "a", region: "default", title: "Surface A" }, + { id: "b", region: "default", title: "Surface B" }, + ], + }); + }); + + it("returns an empty catalog when no providers are registered", () => { + const registry = fakeRegistry([]); + + const msg = catalogMessage(registry); + + expect(msg).toEqual({ type: "catalog", catalog: [] }); + }); +}); diff --git a/packages/transport-ws/src/router.ts b/packages/transport-ws/src/router.ts new file mode 100644 index 0000000..f9a7a82 --- /dev/null +++ b/packages/transport-ws/src/router.ts @@ -0,0 +1,116 @@ +/** + * Pure core — routes a client WS message into an effect description. + * + * Zero I/O, zero ambient state. Every function is `input → output`: + * it decides what to do but does NOT do it. The shell (extension.ts) + * interprets the result: sends WS messages, mutates connSubs, calls + * provider.invoke. + */ + +import type { SurfaceRegistry } from "@dispatch/surface-registry"; +import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; + +// ── Result types ──────────────────────────────────────────────────────────── + +/** The effect a single client message should produce. */ +export interface RouteResult { + /** Server messages to send back to this connection. */ + readonly replies: readonly SurfaceServerMessage[]; + /** Whether to add or remove the surface id from connSubs. */ + readonly subChange?: { readonly op: "add" | "remove"; readonly surfaceId: string }; + /** If set, the shell must call `provider.invoke(actionId, payload)`. */ + readonly invoke?: { + readonly surfaceId: string; + readonly actionId: string; + readonly payload?: unknown; + }; +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** Build the catalog `SurfaceServerMessage` from the registry. */ +export function catalogMessage(registry: SurfaceRegistry): SurfaceServerMessage { + return { type: "catalog", catalog: registry.getCatalog() }; +} + +// ── Router ────────────────────────────────────────────────────────────────── + +/** + * Route a single client message into a pure effect description. + * + * @param registry The surface registry (looked up once, injected). + * @param connSubs This connection's current subscribed surface ids. + * @param msg The parsed client message. + */ +export function routeClientMessage( + registry: SurfaceRegistry, + connSubs: ReadonlySet<string>, + msg: SurfaceClientMessage, +): RouteResult { + switch (msg.type) { + case "subscribe": + return handleSubscribe(registry, connSubs, msg.surfaceId); + case "unsubscribe": + return handleUnsubscribe(msg.surfaceId); + case "invoke": + return handleInvoke(registry, msg.surfaceId, msg.actionId, msg.payload); + } +} + +// ── Per-message handlers ──────────────────────────────────────────────────── + +function handleSubscribe( + registry: SurfaceRegistry, + connSubs: ReadonlySet<string>, + surfaceId: string, +): RouteResult { + const provider = registry.getSurface(surfaceId); + if (!provider) { + return { + replies: [{ type: "error", surfaceId, message: `Unknown surface: ${surfaceId}` }], + }; + } + + const spec = provider.getSpec(); + + // getSpec may be sync or async — the pure core treats it as a value the + // shell will resolve. We return the spec directly (it's a SurfaceSpec). + // If it's a Promise the shell awaits it; if it's sync it's already the value. + // For the pure core we just pass it through — the shell handles the resolution. + const specValue = spec as import("@dispatch/ui-contract").SurfaceSpec; + + const replies: import("@dispatch/ui-contract").SurfaceServerMessage[] = [ + { type: "surface", spec: specValue }, + ]; + + // Idempotent: only emit subChange if not already subscribed. + if (!connSubs.has(surfaceId)) { + return { replies, subChange: { op: "add", surfaceId } }; + } + return { replies }; +} + +function handleUnsubscribe(surfaceId: string): RouteResult { + return { + replies: [], + subChange: { op: "remove", surfaceId }, + }; +} + +function handleInvoke( + registry: SurfaceRegistry, + surfaceId: string, + actionId: string, + payload?: unknown, +): RouteResult { + const provider = registry.getSurface(surfaceId); + if (!provider) { + return { + replies: [{ type: "error", surfaceId, message: `Unknown surface: ${surfaceId}` }], + }; + } + return { + replies: [], + invoke: { surfaceId, actionId, payload }, + }; +} diff --git a/packages/transport-ws/src/server.bun.test.ts b/packages/transport-ws/src/server.bun.test.ts new file mode 100644 index 0000000..d51eb72 --- /dev/null +++ b/packages/transport-ws/src/server.bun.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry"; +import type { + SurfaceCatalogEntry, + SurfaceClientMessage, + SurfaceServerMessage, + SurfaceSpec, +} from "@dispatch/ui-contract"; +import { catalogMessage, routeClientMessage } from "./router.js"; + +// ── Fake registry (same pattern as router.test.ts) ────────────────────────── + +function fakeProvider(id: string, title?: string): SurfaceProvider { + const catalogEntry: SurfaceCatalogEntry = { + id, + region: "default", + title: title ?? `Surface ${id}`, + }; + return { + catalogEntry, + getSpec(): SurfaceSpec { + return { + id, + region: "default", + title: catalogEntry.title, + fields: [], + }; + }, + invoke(_actionId: string, _payload?: unknown) {}, + }; +} + +function fakeRegistry(providers: readonly SurfaceProvider[]): SurfaceRegistry { + const map = new Map(providers.map((p) => [p.catalogEntry.id, p])); + return { + register(_provider: SurfaceProvider) { + return () => {}; + }, + getCatalog() { + return [...map.values()].map((p) => p.catalogEntry); + }, + getSurface(id: string) { + return map.get(id); + }, + }; +} + +// ── Per-connection state (mirrors extension.ts) ───────────────────────────── + +interface ConnectionState { + readonly subs: Set<string>; + readonly providerDisposers: Map<string, () => void>; +} + +// ── Server helper ─────────────────────────────────────────────────────────── + +function startServer(registry: SurfaceRegistry, port = 0) { + return Bun.serve<ConnectionState>({ + port, + fetch(req, srv) { + const initial: ConnectionState = { + subs: new Set(), + providerDisposers: new Map(), + }; + if (srv.upgrade(req, { data: initial })) return; + return new Response("expected websocket", { status: 426 }); + }, + websocket: { + open(ws) { + ws.send(JSON.stringify(catalogMessage(registry))); + }, + + message(ws, raw) { + const state = ws.data; + if (!state) return; + + let parsed: SurfaceClientMessage; + try { + parsed = JSON.parse(String(raw)) as SurfaceClientMessage; + } catch { + ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })); + return; + } + + const result = routeClientMessage(registry, state.subs, parsed); + + if (result.subChange) { + if (result.subChange.op === "add") { + state.subs.add(result.subChange.surfaceId); + } else { + state.subs.delete(result.subChange.surfaceId); + } + } + + for (const reply of result.replies) { + ws.send(JSON.stringify(reply)); + } + }, + + close(ws) { + const state = ws.data; + if (state) { + for (const dispose of state.providerDisposers.values()) { + dispose(); + } + } + }, + }, + }); +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function waitForMessage(ws: WebSocket): Promise<SurfaceServerMessage> { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("timed out waiting for message")), 5000); + function handler(ev: MessageEvent) { + clearTimeout(timeout); + ws.removeEventListener("message", handler); + resolve(JSON.parse(ev.data as string) as SurfaceServerMessage); + } + ws.addEventListener("message", handler); + }); +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("Bun.serve WebSocket server", () => { + let server: ReturnType<typeof Bun.serve>; + let port: number; + + beforeEach(() => { + const provider = fakeProvider("demo", "Demo Surface"); + const registry = fakeRegistry([provider]); + server = startServer(registry); + port = server.port as number; + }); + + afterEach(() => { + server.stop(); + }); + + test("performs WebSocket upgrade (returns 101)", async () => { + const ws = new WebSocket(`ws://localhost:${port}`); + const msg = await waitForMessage(ws); + expect(msg.type).toBe("catalog"); + ws.close(); + }); + + test("sends catalog on open", async () => { + const ws = new WebSocket(`ws://localhost:${port}`); + const msg = await waitForMessage(ws); + expect(msg).toEqual({ + type: "catalog", + catalog: [{ id: "demo", region: "default", title: "Demo Surface" }], + }); + ws.close(); + }); + + test("subscribe returns surface spec", async () => { + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send(JSON.stringify({ type: "subscribe", surfaceId: "demo" })); + const msg = await waitForMessage(ws); + + expect(msg.type).toBe("surface"); + if (msg.type === "surface") { + expect(msg.spec.id).toBe("demo"); + expect(msg.spec.title).toBe("Demo Surface"); + } + ws.close(); + }); + + test("subscribe to unknown surface returns error", async () => { + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send(JSON.stringify({ type: "subscribe", surfaceId: "nope" })); + const msg = await waitForMessage(ws); + + expect(msg).toEqual({ + type: "error", + surfaceId: "nope", + message: "Unknown surface: nope", + }); + ws.close(); + }); + + test("non-WebSocket request returns 426", async () => { + const res = await fetch(`http://localhost:${port}/`); + expect(res.status).toBe(426); + expect(await res.text()).toBe("expected websocket"); + }); +}); diff --git a/packages/transport-ws/tsconfig.json b/packages/transport-ws/tsconfig.json new file mode 100644 index 0000000..102c8f0 --- /dev/null +++ b/packages/transport-ws/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../kernel" }, + { "path": "../surface-registry" }, + { "path": "../ui-contract" } + ] +} diff --git a/packages/ui-contract/package.json b/packages/ui-contract/package.json new file mode 100644 index 0000000..e1f4c35 --- /dev/null +++ b/packages/ui-contract/package.json @@ -0,0 +1,8 @@ +{ + "name": "@dispatch/ui-contract", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts" +} diff --git a/packages/ui-contract/src/index.ts b/packages/ui-contract/src/index.ts new file mode 100644 index 0000000..ea0fc26 --- /dev/null +++ b/packages/ui-contract/src/index.ts @@ -0,0 +1,198 @@ +/** + * UI contract — the frontend-agnostic vocabulary for backend-declared "surfaces". + * + * A SURFACE is a "data transportation surface": a typed description of what data an + * extension exposes, its semantics, and the actions that can act on it — NOT UI. It + * carries STRUCTURE + SEMANTICS + ACTIONS, never styling and never a rendering- + * framework token. Any client (web/Svelte, CLI, future TUI/mobile) renders a surface + * in its own idiom, so swapping or adding a client is a zero-backend-change event. + * See `notes/frontend-design.md` §4. + * + * This package is types-only (zero runtime) and has ZERO `@dispatch/*` dependencies, + * so a separate client repo can depend on JUST this contract. + */ + +/** + * Where a surface mounts — a coarse, semantic placement hint, NOT a layout/CSS + * instruction. A client maps a region to its own idiom; an unknown region falls back + * to the client's default placement. Deliberately left open (a `string`): region + * names are not finalized (the old-Dispatch "view" sidebar UX will be revisited). + */ +export type Region = string; + +/** + * A typed reference to a backend action a field can invoke. The client posts it back + * (with a payload) to `POST /surfaces/:surfaceId/actions/:actionId`; the surface id + * comes from context. (Backend-side this maps to a `command` today — a future review + * may unify `command` → `action`; see `notes/restructure-plan.md` §8.) + */ +export interface ActionRef { + readonly actionId: string; +} + +/** One selectable option in a `selector` field. */ +export interface SurfaceOption { + readonly value: string; + readonly label: string; +} + +/** + * A field within a surface — a SEMANTIC value, not a widget. `kind` is the + * discriminant a client switches on to pick a renderer. Names are training-baked + * hints; the contract is the data shape. + */ +export type SurfaceField = + | ToggleField + | ProgressField + | SelectorField + | StatField + | ButtonField + | CustomField; + +/** A boolean setting plus the action that flips it. */ +export interface ToggleField { + readonly kind: "toggle"; + readonly label: string; + readonly value: boolean; + readonly action: ActionRef; +} + +/** A bounded ratio in [0, 1] with a label (e.g. a cache-hit rate). Read-only. */ +export interface ProgressField { + readonly kind: "progress"; + readonly label: string; + readonly value: number; +} + +/** An enum choice: the current value, the options, and the action that sets it. */ +export interface SelectorField { + readonly kind: "selector"; + readonly label: string; + readonly value: string; + readonly options: readonly SurfaceOption[]; + readonly action: ActionRef; +} + +/** A read-only labelled scalar readout. */ +export interface StatField { + readonly kind: "stat"; + readonly label: string; + readonly value: string; +} + +/** A labelled action trigger. */ +export interface ButtonField { + readonly kind: "button"; + readonly label: string; + readonly action: ActionRef; +} + +/** + * The escape hatch (isolation guardrail 2): data that fits no semantic field kind. + * Carries an opaque `payload` + a `rendererId`; clients WITH a renderer for that id + * show it, others GRACEFULLY SKIP. Keep rare — and the owning extension should export + * a typed payload type so its bespoke renderer narrows `payload` via a typed symbol + * (not a blind `unknown`). + */ +export interface CustomField { + readonly kind: "custom"; + readonly rendererId: string; + readonly payload: unknown; +} + +/** + * A surface: an ordered set of fields mounted in a region, with a title. The atomic + * unit a backend extension contributes and a client renders. + */ +export interface SurfaceSpec { + readonly id: string; + readonly region: Region; + readonly title: string; + readonly fields: readonly SurfaceField[]; +} + +/** + * A surface-catalog entry — discovery metadata only (no field data). Returned by + * `GET /surfaces`; parallels the model catalog. The full spec + live values come from + * `GET /surfaces/:id`. + */ +export interface SurfaceCatalogEntry { + readonly id: string; + readonly region: Region; + readonly title: string; +} + +/** The surface catalog: the list of available surfaces a client can choose to show. */ +export type SurfaceCatalog = readonly SurfaceCatalogEntry[]; + +/** + * A live update for a subscribed surface (pushed over the WS channel — §5). v1 + * carries the full new spec (the simplest "patch"); granular field-level patches are + * deferred until a real surface needs them (P4). + */ +export interface SurfaceUpdate { + readonly surfaceId: string; + readonly spec: SurfaceSpec; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Surface WebSocket protocol — the typed message envelopes the surface channel +// carries. The carrier (a WebSocket) is INJECTED; these are the payloads both the +// server (transport-ws) and any client serialize/deserialize. Slice 1 is +// surfaces-only; chat deltas join this channel in a later slice (a separate union). +// ───────────────────────────────────────────────────────────────────────────── + +/** A client → server message on the surface channel. */ +export type SurfaceClientMessage = SubscribeMessage | UnsubscribeMessage | InvokeMessage; + +/** Begin receiving live updates for a surface (server replies with `surface`, then `update`s). */ +export interface SubscribeMessage { + readonly type: "subscribe"; + readonly surfaceId: string; +} + +/** Stop receiving updates for a surface. */ +export interface UnsubscribeMessage { + readonly type: "unsubscribe"; + readonly surfaceId: string; +} + +/** Invoke a field's action; `payload` is the new value (e.g. a toggle's boolean). */ +export interface InvokeMessage { + readonly type: "invoke"; + readonly surfaceId: string; + readonly actionId: string; + readonly payload?: unknown; +} + +/** A server → client message on the surface channel. */ +export type SurfaceServerMessage = + | CatalogMessage + | SurfaceMessage + | SurfaceUpdateMessage + | SurfaceErrorMessage; + +/** The current surface catalog (sent on connect and whenever it changes). */ +export interface CatalogMessage { + readonly type: "catalog"; + readonly catalog: SurfaceCatalog; +} + +/** The full current spec for a surface the client just subscribed to. */ +export interface SurfaceMessage { + readonly type: "surface"; + readonly spec: SurfaceSpec; +} + +/** A live update for a subscribed surface. */ +export interface SurfaceUpdateMessage { + readonly type: "update"; + readonly update: SurfaceUpdate; +} + +/** A surface-scoped error (e.g. unknown surface id, invoke failed). */ +export interface SurfaceErrorMessage { + readonly type: "error"; + readonly surfaceId?: string; + readonly message: string; +} diff --git a/packages/ui-contract/tsconfig.json b/packages/ui-contract/tsconfig.json new file mode 100644 index 0000000..2a3be7e --- /dev/null +++ b/packages/ui-contract/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"] +} diff --git a/packages/wire/package.json b/packages/wire/package.json new file mode 100644 index 0000000..2893e79 --- /dev/null +++ b/packages/wire/package.json @@ -0,0 +1,8 @@ +{ + "name": "@dispatch/wire", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts" +} diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts new file mode 100644 index 0000000..d2ea341 --- /dev/null +++ b/packages/wire/src/index.ts @@ -0,0 +1,221 @@ +/** + * @dispatch/wire — pure wire types shared by the kernel, the transport + * contract, and out-of-repo clients (the web frontend). + * + * Types ONLY: zero runtime, zero `@dispatch/*` dependencies, so a client can + * depend on the wire without pulling the kernel runtime. + */ + +// ─── Conversation model ───────────────────────────────────────────────────── + +/** Who produced a message. */ +export type Role = "system" | "user" | "assistant" | "tool"; + +/** Opaque identifier for a turn (one user→assistant cycle). */ +export type TurnId = string & { readonly __brand: "TurnId" }; + +/** Opaque identifier for a step (one LLM round-trip within a turn). */ +export type StepId = string & { readonly __brand: "StepId" }; + +/** + * A chunk is one ordered piece of a message — the atomic unit of the + * append-only conversation log. Discriminated by `type`. + */ +export type Chunk = + | TextChunk + | ThinkingChunk + | ToolCallChunk + | ToolResultChunk + | ErrorChunk + | SystemChunk; + +/** A piece of plain text content from the assistant or user. */ +export interface TextChunk { + readonly type: "text"; + readonly text: string; +} + +/** A piece of model reasoning / thinking content (e.g. extended thinking). */ +export interface ThinkingChunk { + readonly type: "thinking"; + readonly text: string; +} + +/** + * A model's request to run a tool. The kernel routes by `name`; the tool + * implementation never sees this directly — it receives parsed `input` via + * `ToolContract.execute`. + */ +export interface ToolCallChunk { + readonly type: "tool-call"; + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; +} + +/** + * The result of a tool execution, attributed to the originating tool-call id. + * The kernel guarantees every tool-call chunk gets exactly one result chunk + * (synthesized if interrupted — see reconcile). + */ +export interface ToolResultChunk { + readonly type: "tool-result"; + readonly toolCallId: string; + readonly toolName: string; + readonly content: string; + readonly isError: boolean; +} + +/** An error that occurred during generation or tool dispatch. */ +export interface ErrorChunk { + readonly type: "error"; + readonly message: string; + readonly code?: string; +} + +/** + * A system-injected message (e.g. system prompt, context assembly output). + * Kept distinct from text so the log records provenance. + */ +export interface SystemChunk { + readonly type: "system"; + readonly text: string; +} + +/** + * A chat message: a role plus an ordered sequence of chunks. Messages are the + * unit passed to and from the provider; chunks are the unit persisted and + * rendered. + */ +export interface ChatMessage { + readonly role: Role; + readonly chunks: readonly Chunk[]; +} + +// ─── Usage ────────────────────────────────────────────────────────────────── + +/** + * Token usage counters for a single step. All fields are counts of tokens. + * Cache fields are optional because not all providers expose cache metrics. + */ +export interface Usage { + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens?: number; + readonly cacheWriteTokens?: number; +} + +// ─── Outward events ───────────────────────────────────────────────────────── + +/** + * The union of all events the runtime emits outward during a turn. + * Consumers (transport, persistence, notifications) pattern-match on `type`. + */ +export type AgentEvent = + | StatusEvent + | TurnStartEvent + | TurnTextDeltaEvent + | TurnReasoningDeltaEvent + | TurnToolCallEvent + | TurnToolResultEvent + | TurnToolOutputEvent + | TurnUsageEvent + | TurnErrorEvent + | TurnDoneEvent + | TurnSealedEvent; + +/** Status change for a conversation (e.g. idle → running). */ +export interface StatusEvent { + readonly type: "status"; + readonly conversationId: string; + readonly status: string; +} + +/** A turn has begun. */ +export interface TurnStartEvent { + readonly type: "turn-start"; + readonly conversationId: string; + readonly turnId: string; +} + +/** Incremental text content from the model during a turn. */ +export interface TurnTextDeltaEvent { + readonly type: "text-delta"; + readonly conversationId: string; + readonly turnId: string; + readonly delta: string; +} + +/** Incremental reasoning / thinking content during a turn. */ +export interface TurnReasoningDeltaEvent { + readonly type: "reasoning-delta"; + readonly conversationId: string; + readonly turnId: string; + readonly delta: string; +} + +/** The model has requested a tool to be run. */ +export interface TurnToolCallEvent { + readonly type: "tool-call"; + readonly conversationId: string; + readonly turnId: string; + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; +} + +/** A tool has completed execution. */ +export interface TurnToolResultEvent { + readonly type: "tool-result"; + readonly conversationId: string; + readonly turnId: string; + readonly toolCallId: string; + readonly toolName: string; + readonly content: string; + readonly isError: boolean; +} + +/** Streaming output from a tool execution (e.g. shell stdout/stderr). */ +export interface TurnToolOutputEvent { + readonly type: "tool-output"; + readonly conversationId: string; + readonly turnId: string; + readonly toolCallId: string; + readonly data: string; + readonly stream: "stdout" | "stderr"; +} + +/** Token usage for the current step or turn. */ +export interface TurnUsageEvent { + readonly type: "usage"; + readonly conversationId: string; + readonly turnId: string; + readonly usage: Usage; +} + +/** An error occurred during the turn. */ +export interface TurnErrorEvent { + readonly type: "error"; + readonly conversationId: string; + readonly turnId: string; + readonly message: string; + readonly code?: string; +} + +/** The turn has completed (model finished generating). */ +export interface TurnDoneEvent { + readonly type: "done"; + readonly conversationId: string; + readonly turnId: string; + readonly reason: string; +} + +/** + * The turn has been sealed — all chunks persisted, history is final. + * This is the hook point for post-turn extensions (compaction, cache-warm). + */ +export interface TurnSealedEvent { + readonly type: "turn-sealed"; + readonly conversationId: string; + readonly turnId: string; +} diff --git a/packages/wire/tsconfig.json b/packages/wire/tsconfig.json new file mode 100644 index 0000000..2a3be7e --- /dev/null +++ b/packages/wire/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"] +} @@ -333,6 +333,51 @@ reserved in `.env`. When FE build begins: retire the AGENTS.md "Backend only for frontend)" line, author new scoped `.dispatch/rules/frontend-*.md`, and update ORCHESTRATOR.md §7 (repo geography) + §3 (rule scoping map). +**STATUS — slice 1 STARTED (user front-loaded the architecture: surface system + WS FIRST, +not chat-first).** Done: `notes/frontend-design.md` LOCKED (no-mandatory-spine model; +chat is a decomposable feature, NOT a surface); authored `packages/ui-contract` (types-only +surface ABI — `SurfaceSpec`/field kinds/`region`/`ActionRef`/catalog; verified green); +scaffolded the SEPARATE repo `../dispatch-web` (Vite + Svelte 5 + vitest + biome; +svelte-check/biome/`vite build` all green; `@dispatch/ui-contract` linked via `file:` dep); +authored the FE harness (AGENTS/ORCHESTRATOR/GLOSSARY/.dispatch rules). Retired the AGENTS.md +"Backend only" line; updated ORCHESTRATOR §7/§3. Vocab locked (surface/view/region/field +kind/action+action ref/surface catalog); backend `command`→`action` unification logged as a +future review (restructure-plan §8). NEXT (summons): backend B2 kernel wire-types split, B3 +surface-contribution mechanism, B4 `transport-ws`, B5 loaded-extensions surface; FE F1–F5. + +**FE SLICE 1 — DONE + verified live (2026-06-06): the surface system.** Built across both repos +(orchestrated, mimo-v2.5-pro owner-agents): NEW backend pkgs `ui-contract` (surface ABI + WS +protocol), `surface-registry` (typed service handle), `transport-ws` (Bun WS server :24205), +`surface-loaded-extensions` (first surface); kernel `HostAPI.getExtensions`; NEW repo +`../dispatch-web` (Vite+Svelte5) with `core/protocol` · `features/surface-host` · `adapters/ws` · +`app`. **Live WS probe: catalog → subscribe → surface rendered the 10 loaded extensions.** Backend +460 vitest + 77 bun (typecheck+biome clean); FE 76 vitest + build (svelte-check+biome clean). +Scar tissue captured: headless cross-repo read hang (in-repo `ui-contract.reference.md` + brief +guards); live boot-probe tool-timeout (ORCHESTRATOR §8). Deferred: F-app CR-1 (vitest browser +condition), DaisyUI styling, B2 kernel wire-types split (chat slice). Full plan + status: +`notes/frontend-design.md` §10. + +### FE SLICE 2 — chat slice (browser chat MVP): backend prerequisites — IN PROGRESS +The product MVP: send a message, render the streamed multi-turn response, with §6 caching/delta +streaming. Spans both repos; the backend prereqs live HERE (FE work runs in `../dispatch-web`). +- [x] **B2 — wire-types split** (`@dispatch/wire`, NEW types-only pkg). The pure wire ABI — + `AgentEvent` (+11 variants), the conversation model (`Chunk`/`ChatMessage`/`Role`/`TurnId`/ + `StepId` + 6 chunk variants), and `Usage` — moved out of the kernel into `@dispatch/wire` + (zero `@dispatch/*` deps, zero runtime). `@dispatch/kernel` re-exports them via shim files → + its public surface is **byte-identical** (zero consumer blast radius). `transport-contract` + now re-exports `AgentEvent` from `@dispatch/wire` and **dropped its `@dispatch/kernel` + dependency** → the FE can consume the wire contract without pulling the kernel runtime (the + whole point of B2). Coordinated multi-file owner-agent (mimo-v2.5-pro, ORCHESTRATOR §5.5) + + orchestrator build wiring (new pkg scaffold, project refs, deps). typecheck + biome clean; + **460 vitest + 77 bun** (unchanged — pure type move). Summon: prompts/b2-wire-split.md, + report: reports/b2-wire-split.md. +- [ ] **per-chunk `seq`** on `Chunk` (wire) — monotonic cursor for mid-turn incremental sync. +- [ ] **read-side endpoint** `GET /conversations/:id?sinceSeq=` → reconciled `Chunk[]`/ + `ChatMessage[]` (transport-http + conversation-store). +- [ ] **WS turn-deltas** — `transport-ws` multiplexes `sendMessage`/`onDelta(AgentEvent)` + alongside surface ops (one connection carries both; frontend-design §5). +Then FE (`../dispatch-web`): `core/transcript` reducer + `conversation-cache` + `chat` feature. + ### 3. dedup / storage growth (after frontend) The deferred trace-body de-duplication + rotation/compression (D5 volume-control + `prefix.fingerprint` + §6 retention strategy) — already designed in diff --git a/tsconfig.json b/tsconfig.json index 2935e09..58ec820 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,13 @@ { "files": [], "references": [ + { "path": "./packages/wire" }, { "path": "./packages/kernel" }, { "path": "./packages/transport-contract" }, + { "path": "./packages/ui-contract" }, + { "path": "./packages/surface-registry" }, + { "path": "./packages/transport-ws" }, + { "path": "./packages/surface-loaded-extensions" }, { "path": "./packages/storage-sqlite" }, { "path": "./packages/auth-apikey" }, { "path": "./packages/provider-openai-compat" }, diff --git a/vitest.config.ts b/vitest.config.ts index e04946d..33b8337 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,15 +1,17 @@ -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ test: { - // Packages whose code imports Bun-only modules (e.g. `bun:sqlite`) can't run - // under Vite's Node transform — they test via `bun test` (see `test:bun`). - // Everything else runs here under vitest. - projects: [ - "packages/*", - "!packages/storage-sqlite", - "!packages/trace-store", - "!packages/observability-collector", + // Everything runs here under vitest EXCEPT bun-only tests, which run via + // `test:bun` (they use real `Bun.serve` / `bun:sqlite` / `bun:test`): + // - `*.bun.test.ts` files (e.g. transport-ws's live WebSocket server test) + // - packages whose code imports Bun-only modules (`bun:sqlite`) + exclude: [ + ...configDefaults.exclude, + "**/*.bun.test.ts", + "packages/storage-sqlite/**", + "packages/trace-store/**", + "packages/observability-collector/**", ], }, }); |
