docs: add terminal websocket design spec
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
|||||||
|
# Terminal WebSocket Design
|
||||||
|
|
||||||
|
Date: 2026-07-03
|
||||||
|
Branch: feat/terminal-websocket
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Connect the existing `TerminalPanel` in the web IDE to the running OpenCode
|
||||||
|
process for the current workspace, so the user can see real stdout/stderr and
|
||||||
|
type input that is sent to the process's stdin. Stop the terminal from being
|
||||||
|
a static mock.
|
||||||
|
|
||||||
|
The feature must keep the existing HTTP process controls (start/stop/restart)
|
||||||
|
working, must not break `internal/api/router.go` shape for non-WebSocket
|
||||||
|
clients, and must not couple the WebSocket lifecycle to the workspace CRUD
|
||||||
|
path.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- One WebSocket per workspace, keyed by workspace ID. Multiple browser tabs on
|
||||||
|
the same workspace open multiple sockets; each gets a copy of the stream.
|
||||||
|
History replay is **not** required for v1: a socket that connects after
|
||||||
|
output is produced does not see the past output.
|
||||||
|
- Bidirectional: server streams stdout+stderr, client streams stdin. Both
|
||||||
|
directions are text frames carrying raw bytes (UTF-8). We do not use a
|
||||||
|
custom envelope or JSON framing in v1.
|
||||||
|
- Backend process model gets two new responsibilities: capture child
|
||||||
|
stdout/stderr into an in-memory ring buffer plus a list of subscriber
|
||||||
|
channels, and accept stdin writes. This stays inside `internal/process`;
|
||||||
|
the API layer is a thin WebSocket adapter.
|
||||||
|
- The existing `Manager` interface grows one method: `Subscribe(workspaceID)
|
||||||
|
(Subscription, error)`. We also add an `Input` accessor on the service
|
||||||
|
layer for stdin. The surface stays small.
|
||||||
|
- Workspace root deletion protection and path-escape protection are unchanged.
|
||||||
|
The WebSocket path is `/api/workspaces/:id/process/ws`. It does not accept
|
||||||
|
a `path` query, so there is nothing to validate.
|
||||||
|
- Backward compatibility: the existing `/process/start|stop|restart|status`
|
||||||
|
endpoints keep their signatures and 204 / 200 responses. We only add one
|
||||||
|
new endpoint.
|
||||||
|
- Frontend: `TerminalPanel` now takes `workspaceId: string | null`. If
|
||||||
|
`workspaceId` is null, show "select a workspace" empty state. If the
|
||||||
|
workspace's process is not running, the panel still opens the socket;
|
||||||
|
the server closes it with a code that the client surfaces as "process not
|
||||||
|
running, start it from the toolbar".
|
||||||
|
- Process exit on the server side: server sends a single text frame
|
||||||
|
containing the literal string `\r\n[process exited with code N]\r\n`
|
||||||
|
(or "signal: name" for signal exits) and then closes the WebSocket with
|
||||||
|
close code 1000. This is the only framed message — all other bytes are
|
||||||
|
raw stream output.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend additions
|
||||||
|
|
||||||
|
`internal/process/manager.go`
|
||||||
|
|
||||||
|
- Add `Subscribe(workspaceID string) (Subscription, error)` to the `Manager`
|
||||||
|
interface. `Subscription` is a new type defined in `internal/process`
|
||||||
|
with two methods:
|
||||||
|
- `Output() <-chan []byte` — receives chunks of stdout+stderr merged.
|
||||||
|
Buffered to a small constant (e.g. 32) to avoid blocking the producer.
|
||||||
|
- `Close() error` — drops the subscription. Idempotent.
|
||||||
|
- `LocalManager` keeps per-session:
|
||||||
|
- a `bytes.Buffer` of recent output (cap, e.g. 64 KiB) for any future
|
||||||
|
replay — implemented but **not exposed in v1**.
|
||||||
|
- a `[]chan []byte` of subscribers, guarded by `sync.Mutex`.
|
||||||
|
- a `io.WriteCloser` for stdin (created via `cmd.Stdin = ...`).
|
||||||
|
- On `Start`, set `cmd.Stdout` and `cmd.Stderr` to a pipe. A new goroutine
|
||||||
|
per session reads from the pipe, writes to the ring buffer, and fans out
|
||||||
|
to every subscriber channel. A non-blocking send (`select` with
|
||||||
|
`default`) drops output for slow clients. A 4 KiB read buffer is fine.
|
||||||
|
- `Wait()` is called in a separate goroutine; on exit, the manager closes
|
||||||
|
all subscriber channels and stores the exit status on the session.
|
||||||
|
|
||||||
|
`internal/service/process_service.go`
|
||||||
|
|
||||||
|
- New method `Subscribe(workspaceID string) (process.Subscription, error)`
|
||||||
|
that first calls `workspaces.Get` (so an unknown workspace returns
|
||||||
|
`CodeNotFound` — same as Status) and then delegates to `processes.Subscribe`.
|
||||||
|
- New method `Input(workspaceID string) (io.WriteCloser, error)` that
|
||||||
|
returns the session's stdin writer, or `CodeNotFound` if the process is
|
||||||
|
not running, or `CodeUnavailable` if the session is shutting down.
|
||||||
|
- New method `ExitStatus(workspaceID string) (process.ExitInfo, error)`
|
||||||
|
for the handler to retrieve the exit reason after the process terminates.
|
||||||
|
|
||||||
|
`internal/api/process_handler.go`
|
||||||
|
|
||||||
|
- New handler `ws` that:
|
||||||
|
1. Looks up the workspace; 404 if missing.
|
||||||
|
2. Looks up the session; if not running, writes 409 Conflict with a JSON
|
||||||
|
`{"error":"process not running"}` and returns.
|
||||||
|
3. Upgrades the connection to a WebSocket using `gorilla/websocket`
|
||||||
|
(single new dep — see Open Questions).
|
||||||
|
4. Spawns two goroutines: a read pump that copies WS text frames to the
|
||||||
|
stdin writer, and a write pump that copies the `Subscription.Output()`
|
||||||
|
channel to the WS. A `done` channel cancels both.
|
||||||
|
5. On the read pump seeing a `Close` from the client, it calls
|
||||||
|
`stdin.Close()` and cancels the writer.
|
||||||
|
6. On the write pump seeing the subscription channel close, it reads
|
||||||
|
`ExitStatus` and writes the exit banner text frame, then closes the
|
||||||
|
socket with code 1000.
|
||||||
|
|
||||||
|
`go.mod`
|
||||||
|
|
||||||
|
- Add `github.com/gorilla/websocket v1.5.x` (one new dependency, Apache-2.0,
|
||||||
|
widely used, small).
|
||||||
|
|
||||||
|
`internal/api/router.go`
|
||||||
|
|
||||||
|
- Register `api.GET("/workspaces/:id/process/ws", procHandler.ws)`.
|
||||||
|
|
||||||
|
### Frontend additions
|
||||||
|
|
||||||
|
`web/src/lib/api/process.ts`
|
||||||
|
|
||||||
|
- Add `useProcessWebSocket(workspaceId, opts?)` hook that:
|
||||||
|
- Opens `ws[s]://<host>/api/workspaces/:id/process/ws` when `workspaceId`
|
||||||
|
is set and the workspace is selected.
|
||||||
|
- Exposes `{ status, send, close }` where:
|
||||||
|
- `status: "connecting" | "open" | "closed" | "error"`
|
||||||
|
- `send(data: string)` — no-op when not open.
|
||||||
|
- `close()` — graceful close.
|
||||||
|
- Reconnects with exponential backoff (max 3 attempts) only if the close
|
||||||
|
was unexpected. We do **not** reconnect if the server sent the exit
|
||||||
|
banner (recognizable by the literal prefix `[process exited`).
|
||||||
|
- Hook returns an `onData(cb)` subscription mechanism via a ref that
|
||||||
|
`TerminalPanel` registers into.
|
||||||
|
|
||||||
|
`web/src/components/terminal/TerminalPanel.tsx`
|
||||||
|
|
||||||
|
- Replace the mock welcome banner and `writeln` calls with a subscription
|
||||||
|
to the live WebSocket.
|
||||||
|
- Forward user keystrokes (already captured by xterm's `onData`) to
|
||||||
|
`send(...)`.
|
||||||
|
- On `status === "closed"` with exit banner, print the banner and stop
|
||||||
|
accepting input (set `isDead` flag).
|
||||||
|
- On `status === "error"`, render an inline "terminal error" message but
|
||||||
|
keep xterm mounted.
|
||||||
|
- Keep the ResizeObserver fix from the previous task; it stays untouched.
|
||||||
|
|
||||||
|
`web/src/components/layout/WorkspaceShell.tsx`
|
||||||
|
|
||||||
|
- Pass `workspaceId` down to `TerminalPanel`.
|
||||||
|
|
||||||
|
## State and Data Flow
|
||||||
|
|
||||||
|
- The `process.Manager` is the **single source of truth** for whether a
|
||||||
|
process is running and what its output is. The WebSocket handler is a
|
||||||
|
read-only consumer of `Subscription` plus a writer to stdin; it never
|
||||||
|
mutates the process state directly.
|
||||||
|
- The WebSocket handler does **not** call `processes.Start`. If the process
|
||||||
|
is not running, the socket is refused with 409. The user must use the
|
||||||
|
existing toolbar button.
|
||||||
|
- The existing `Status` polling and the WebSocket are independent. They
|
||||||
|
can disagree transiently (process just exited, status query not yet
|
||||||
|
refetched). The exit banner inside the WS is the authoritative "this
|
||||||
|
process is gone" signal for the terminal panel.
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
- `internal/api/router.go` adds one route. The four existing routes are
|
||||||
|
unchanged.
|
||||||
|
- `internal/process.Manager` interface gains one method. The concrete
|
||||||
|
`LocalManager` implements it. **There are no other implementations** in
|
||||||
|
this repo (verified by grep), so this is not a breaking change for
|
||||||
|
downstream code.
|
||||||
|
- Existing tests in `internal/process/manager_test.go` continue to compile
|
||||||
|
and pass — the new method is additive, and tests that don't call
|
||||||
|
`Subscribe` are unaffected.
|
||||||
|
- `ProcessStatusResponse` is unchanged. No wire-format break for any
|
||||||
|
client that calls `/process/status`.
|
||||||
|
- Frontend: the four existing API hooks for workspaces/files/process are
|
||||||
|
unchanged. The terminal panel's mock data is removed, but the panel is
|
||||||
|
not yet part of any contract that other components rely on.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **WebSocket library**: `gorilla/websocket` is the obvious choice and is
|
||||||
|
already used in countless Go services. We could also use the
|
||||||
|
newer `coder/websocket` (formerly `nhooyr.io/websocket`). The spec
|
||||||
|
picks `gorilla/websocket` for stability. Confirm in plan.
|
||||||
|
2. **Backpressure / client-slow policy**: v1 silently drops output for
|
||||||
|
slow clients via non-blocking sends. This is acceptable for an
|
||||||
|
interactive terminal because xterm will show whatever it has and the
|
||||||
|
user can resize. The plan should pick a specific drop policy.
|
||||||
|
3. **Reconnect**: spec says reconnect on transport error, not on exit
|
||||||
|
banner. Should we add a manual "reconnect" button in the terminal
|
||||||
|
panel header? Out of scope for v1.
|
||||||
|
4. **Authentication**: the existing HTTP API has none. The WebSocket
|
||||||
|
follows suit. Out of scope.
|
||||||
|
5. **Workspace deletion during a live socket**: today `DELETE /workspaces/:id`
|
||||||
|
stops the process. The plan should verify this also closes any open
|
||||||
|
WebSockets for that workspace.
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- The `Subscription` type and the `Input` writer are the two new
|
||||||
|
capabilities the manager must grow. They are small and orthogonal.
|
||||||
|
- The ring buffer for replay is implemented but unused in v1. This is
|
||||||
|
dead weight. **Defer**: the spec keeps it for symmetry with future
|
||||||
|
replay work. Plan may drop it if not needed.
|
||||||
|
- The exit banner format (`\r\n[process exited with code N]\r\n`) is a
|
||||||
|
simple, debuggable string. We are not building a structured protocol.
|
||||||
|
- All v1 design decisions aim to keep the backend diff small, the
|
||||||
|
contract clean, and the frontend's existing terminal lifecycle
|
||||||
|
untouched (same ResizeObserver pattern, same component name).
|
||||||
Reference in New Issue
Block a user