Lot F3 du chantier server/client mode : le client web branche xterm.js sur le terminal distant via WebSocket, en face de l'endpoint PTY B5. - wsLiveClient.ts : transport WebSocket du terminal avec reconnexion. - streamGateways.ts : gateway terminal (open/attach/data/resize/close). - frames.ts : frames PTY alignées sur le contrat serveur B5. - Tests : terminalGateway.test.ts, wsLiveClientReconnect.test.ts. Validé : frontend 743 tests verts (F3 ciblé 12), cohérence des frames B5↔F3 confirmée, desktop non régressé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
4.3 KiB
TypeScript
142 lines
4.3 KiB
TypeScript
/**
|
|
* WebSocket frame contract for the web (client/server) transport — ticket #13,
|
|
* lot F1. Mirrors the first-draft frames in
|
|
* `docs/ticket13-b0-backend-transport-inventory.md` (§ "WebSocket first draft").
|
|
*
|
|
* F1 delivers the *types + skeleton* only. The full PTY wiring runs on top of
|
|
* these in F3/B5 once a server exists (B3/B4). Keeping the frame shapes here — in
|
|
* the adapters layer — lets the terminal/agent/chat gateways be structurally
|
|
* correct against the contract today without a live backend.
|
|
*
|
|
* Envelope (B0): every frame carries an `id` (client→server) or optional
|
|
* `replyTo` (server→client, present for acknowledgements, absent for unsolicited
|
|
* output/events). PTY bytes travel base64-encoded (`bytesBase64`) so binary
|
|
* survives JSON.
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Base64 helpers (binary PTY bytes over JSON frames)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Encodes raw bytes to a base64 string for a `bytesBase64` frame field. */
|
|
export function bytesToBase64(bytes: Uint8Array): string {
|
|
let binary = "";
|
|
for (let i = 0; i < bytes.length; i += 1) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
// `btoa` exists in browsers and jsdom.
|
|
return btoa(binary);
|
|
}
|
|
|
|
/** Decodes a base64 `bytesBase64` frame field back to raw bytes. */
|
|
export function base64ToBytes(base64: string): Uint8Array {
|
|
const binary = atob(base64);
|
|
const out = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
out[i] = binary.charCodeAt(i);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Client → server frames
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Client→server frame kinds (B0 § PTY + structured chat + ping). */
|
|
export type ClientFrameKind =
|
|
| "terminal.attach"
|
|
| "terminal.open"
|
|
| "agent.launch"
|
|
| "terminal.input"
|
|
| "terminal.resize"
|
|
| "terminal.detach"
|
|
| "terminal.close"
|
|
| "chat.attach"
|
|
| "chat.send"
|
|
| "chat.detach"
|
|
| "ping";
|
|
|
|
/** A client→server frame. `id` correlates the eventual `replyTo`. */
|
|
export interface ClientFrame {
|
|
id: string;
|
|
kind: ClientFrameKind;
|
|
payload: Record<string, unknown>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server → client frames
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Server→client frame kinds. */
|
|
export type ServerFrameKind =
|
|
| "terminal.attached"
|
|
| "terminal.output"
|
|
| "terminal.status"
|
|
| "chat.attached"
|
|
| "chat.output"
|
|
| "event.domain"
|
|
| "error"
|
|
| "pong";
|
|
|
|
/** Suggested lifecycle statuses (B0). */
|
|
export type TerminalStatus =
|
|
| "starting"
|
|
| "running"
|
|
| "exited"
|
|
| "closed"
|
|
| "detached"
|
|
| "reattached";
|
|
|
|
/** A server→client frame. `replyTo` is present only for acknowledgements. */
|
|
export interface ServerFrame {
|
|
kind: ServerFrameKind;
|
|
replyTo?: string;
|
|
payload: Record<string, unknown>;
|
|
}
|
|
|
|
/** Payload of a `terminal.attached` acknowledgement (B5, `server.rs`). */
|
|
export interface AttachedPayload {
|
|
session: {
|
|
sessionId: string;
|
|
projectId?: string;
|
|
nodeId?: string | null;
|
|
rows: number;
|
|
cols: number;
|
|
};
|
|
nextSeq: number;
|
|
/**
|
|
* Bounded scrollback replayed at (re)attach. B5 sends a single entry (`seq:0`)
|
|
* carrying all retained bytes, or an empty array when there is nothing to
|
|
* replay.
|
|
*/
|
|
scrollback: { seq: number; bytesBase64: string }[];
|
|
gap: boolean;
|
|
/** Lifecycle status carried on the ack (B5 sends `"running"`). */
|
|
status?: string;
|
|
/** Conversation id minted by an `agent.launch` (mirrors `assignedConversationId`). */
|
|
assignedConversationId?: string;
|
|
}
|
|
|
|
/** Payload of a `terminal.output` frame. */
|
|
export interface OutputPayload {
|
|
sessionId: string;
|
|
seq: number;
|
|
bytesBase64: string;
|
|
}
|
|
|
|
/**
|
|
* Payload of a `terminal.status` frame (B5). `status` is a lifecycle transition
|
|
* (`"exited"` on close); `exitCode` is present for `"exited"` (may be `null`).
|
|
*/
|
|
export interface StatusPayload {
|
|
sessionId: string;
|
|
status: string;
|
|
exitCode?: number | null;
|
|
}
|
|
|
|
/** Payload of a `chat.output` frame (structured assistant stream). */
|
|
export interface ChatOutputPayload {
|
|
sessionId: string;
|
|
chunk: unknown; // ReplyChunk-shaped (kind-tagged); normalized by the caller.
|
|
}
|