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