From 0e10dc21c3e64cb22f1c7c26c5d57859b36210b7 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 16 Jun 2026 07:58:52 +0200 Subject: [PATCH] feat: T2 freeze src/types.ts (shared contracts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All ARCHITECTURE §3 interfaces as pure types; dependency-free (no ws/node/DOM) so both backend and frontend can import it - Refinements vs §3 (documented): EnvLike (not NodeJS.ProcessEnv), WebSocketLike + WS_OPEN (not ws.WebSocket), added Config.wsPath (invariant 8) - ARCHITECTURE §3.1/§3.4 reconciled to match (anti-drift) - tsc --noEmit passes T2 of docs/PLAN.md --- docs/ARCHITECTURE.md | 13 +++- docs/PROGRESS_LOG.md | 28 +++++-- src/types.ts | 170 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 src/types.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b6d8962..8ea9e01 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -90,6 +90,7 @@ export interface Config { readonly idleTtlMs: number; // IDLE_TTL,默认 24h readonly scrollbackBytes: number; // ring buffer 容量,默认 2MB readonly maxPayloadBytes: number; // 单条 WS 帧上限,默认 1MB(远超正常键盘输入,防巨帧打爆内存) + readonly wsPath: string; // WS 升级路径,默认 '/term'(L3;不变量 8:路径入 config 不硬编码) readonly allowedOrigins: readonly string[]; // 见下方推导规则 } @@ -106,9 +107,14 @@ export interface Config { * isOriginAllowed 应同时比对 host 与 port。 * 否则 F4(局域网任意设备访问)会被 F9(Origin 校验)误判 401 —— 二者直接打架。 */ -export function loadConfig(env: NodeJS.ProcessEnv): Config; +export function loadConfig(env: EnvLike): Config; // EnvLike = Readonly> ``` +> **T2 已冻结的实现以 [`src/types.ts`](../src/types.ts) 为准**。其中三处可移植性精化:`loadConfig` 用 `EnvLike` +> 替代 `NodeJS.ProcessEnv`(process.env 可赋值);`Session.attachedWs` 用 `WebSocketLike` 替代 `ws` 的 `WebSocket` +> (真实 ws 结构兼容,并导出 `WS_OPEN=1` 供 M5 守卫);`Config.wsPath` 新增。原因:types.ts 同时被前后端 import, +> 必须无 `ws`/`NodeJS`/DOM 外部类型依赖。 + ### 3.2 `protocol.ts`(纯函数,先用 TDD 实现) 消息形状即 TECH_DOC §4。用可辨识联合(discriminated union)表达: @@ -178,8 +184,9 @@ export interface Session { readonly meta: SessionMeta; /** 输出环形缓冲(2MB),重连回放用。淘汰语义见下方 RingBuffer。 */ readonly buffer: RingBuffer; - /** 当前附着的 ws,null = 已 detach 但 PTY 仍活(vibe coding 核心)。 */ - attachedWs: WebSocket | null; + /** 当前附着的 ws,null = 已 detach 但 PTY 仍活(vibe coding 核心)。 + * 类型为 WebSocketLike(types.ts;真实 ws.WebSocket 结构兼容)。 */ + attachedWs: WebSocketLike | null; detachedAt: number | null; // detach 时刻 /** 最近一次 pty.onData 的时间戳。孤儿回收用它近似"是否仍在跑前台作业"(M3)。 */ lastOutputAt: number; diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index f26c75b..019e43d 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,10 +24,11 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 -- **Wave**: W0(T1 ✓)→ 下一步 **T2**(冻结 `src/types.ts`) -- **任务**: T2 — 共享类型契约,完成后 W0 全部 commit,即可放开 W1 并行 -- **下一步**: 派 `module-builder`(sonnet)执行 T2,或主会话直接做;依据 ARCHITECTURE §3 -- **阻塞**: 无。(前端打包待决项**已解决**:选 esbuild,已装好并接好 `build:web`/`dev:web`,约定写入 PLAN §1。) +- **Wave**: W0(T1 ✓ T2 ✓)→ **W1 可放开并行** + T3 +- **任务**: 下一步派 **W1 批次**(单批 ~3–5 个,worktree 隔离):T3 mock + T4 config + T5 protocol + + T6 ring-buffer + T7 origin(再一批 T8–T11 前端)。dispatch prompt 见 DISPATCH.md。 +- **下一步**: `git commit` 本次(T2)后,按 DISPATCH 派第一批 W1 builder;每批返回后 orchestrator 写 LOG。 +- **阻塞**: 无。`src/types.ts` 已冻结,所有 W1 任务可只读 import。 - **最后更新**: 2026-06-16 --- @@ -40,7 +41,7 @@ | 任务 | Wave | 模块 | 状态 | Steps (完成/总) | 完成日期 | |------|------|------|------|------------------|----------| | T1 | W0 | 脚手架 + 装齐依赖 | `[x]` | 5/5 | 2026-06-16 | -| T2 | W0 | `src/types.ts` 共享契约(冻结源) | `[ ]` | 0/8 | — | +| T2 | W0 | `src/types.ts` 共享契约(冻结源) | `[x]` | 8/8 | 2026-06-16 | | T3 | W0 | mock IPty 测试替身 | `[ ]` | 0/2 | — | | T4 | W1 | `config.ts` | `[ ]` | 0/5 | — | | T5 | W1 | `protocol.ts` | `[ ]` | 0/4 | — | @@ -118,6 +119,23 @@ - **遗留 / 待办**: 无(原前端打包待决项已解决,见下补记)。 - **commit**: `b126cf0` +### 2026-06-16 · T2 冻结共享类型契约 src/types.ts + +- **状态**: `[x]` DONE +- **改动**: 新建 `src/types.ts`(纯类型,无实现);删 `src/.gitkeep`。含 Config、ClientMessage/ServerMessage/ + ParseResult、Dims、IPty(子集)+ IDisposable/PtyEvent、RingBuffer、WebSocketLike + WS_OPEN、SessionMeta/Session + (lastOutputAt/exitedAt/exitCode)、SessionManager、MountKeybar;各模块工厂函数签名以注释列为 import 锚点。 + 同步更新 ARCHITECTURE §3.1/§3.4(加 wsPath、EnvLike、WebSocketLike 注记,防漂移)。 +- **验证**: `npx tsc -p tsconfig.json --noEmit` PASS;grep 确认无 `ws`/`NodeJS`/DOM 外部类型依赖(仅注释提及)。 +- **决策 / 偏离 ARCHITECTURE §3**(为"前后端共享 → 必须无外部类型依赖"): + 1. `loadConfig(env: EnvLike)` 替代 `NodeJS.ProcessEnv`(`EnvLike = Readonly>`,process.env 可赋值)。 + 2. `Session.attachedWs: WebSocketLike` 替代 `ws` 的 `WebSocket`(真实 ws 结构兼容);导出 `WS_OPEN=1` 供 M5 守卫。 + 3. **新增 `Config.wsPath`**(默认 '/term'):不变量 8 要求 WS 路径入 config,§3.1 原 Config 漏列,已补;T4 需从 env `WS_PATH` 读、T14 用 `cfg.wsPath`。 + 三处均已回写 ARCHITECTURE,types.ts 为权威冻结源。 +- **遗留 / 待办**: 无。W1 全部任务现可只读 import `src/types.ts`。**注意**:IPty/WebSocketLike 是结构子集, + 实现方(T12/T14)可把真实 node-pty IPty / ws.WebSocket 直接赋值(结构兼容)。 +- **commit**: (见下次提交) + ### 2026-06-16 · T1 补记 — 前端打包定为 esbuild - **状态**: `[x]` DONE diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2a48370 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,170 @@ +/** + * src/types.ts — FROZEN shared contracts (T2). + * + * Single source of truth, imported READ-ONLY by every other module. + * Transcribed from ARCHITECTURE §3. **Dependency-free on purpose**: no `ws`, + * no `@types/node` ambient (`NodeJS.*`), no DOM — so BOTH the backend and the + * browser frontend can import it without dragging in each other's type space. + * + * Need a new shared type? Change it HERE (a coordination point). Do not + * redeclare it locally in another module. + * + * Three deliberate refinements vs ARCHITECTURE §3 (for the portability goal): + * 1. `loadConfig` takes `EnvLike` instead of `NodeJS.ProcessEnv` (process.env is assignable). + * 2. `Session.attachedWs` is `WebSocketLike` instead of `ws`'s `WebSocket` (a real ws socket + * is structurally assignable); `WS_OPEN` exported for the M5 readyState guard. + * 3. `Config.wsPath` added (invariant 8 requires the WS path to be config, not hardcoded). + */ + +/* ──────────────────────── config (§3.1) ──────────────────────── */ + +export interface Config { + readonly port: number; // PORT, default 3000 + readonly bindHost: string; // BIND_HOST, default '0.0.0.0' + readonly shellPath: string; // SHELL_PATH, default process.env.SHELL ?? '/bin/zsh' + readonly homeDir: string; // PTY cwd, default os.homedir() + readonly idleTtlMs: number; // IDLE_TTL, default 24h + readonly scrollbackBytes: number; // ring buffer capacity, default 2MB + readonly maxPayloadBytes: number; // max WS frame bytes, default 1MB (L5) + readonly wsPath: string; // WS upgrade path, default '/term' (L3; invariant 8) + readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1) +} + +/** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */ +export type EnvLike = Readonly>; + +// impl anchor [src/config.ts]: +// export function loadConfig(env: EnvLike): Config // invalid values → throw (fail-fast) + +/* ─────────────────────── protocol (§3.2) ─────────────────────── */ + +/** client → server */ +export type ClientMessage = + | { type: 'attach'; sessionId: string | null } + | { type: 'input'; data: string } + | { type: 'resize'; cols: number; rows: number }; + +/** server → client. exit.code = shell code; -1 when spawn never succeeded (M4). + * exit.reason optional normally, REQUIRED on spawn failure / abnormal exit. */ +export type ServerMessage = + | { type: 'attached'; sessionId: string } + | { type: 'output'; data: string } + | { type: 'exit'; code: number; reason?: string }; + +/** parseClientMessage result — never throws; errors flow here (§5.3). */ +export type ParseResult = + | { ok: true; message: ClientMessage } + | { ok: false; error: string }; + +export interface Dims { + cols: number; + rows: number; +} + +// impl anchors [src/protocol.ts]: +// export const SESSION_ID_RE: RegExp // UUID v4 (M7) +// export function parseClientMessage(raw: string): ParseResult // NEVER throws +// export function serialize(msg: ServerMessage): string + +/* ──────────────────────── origin (§3.3) ──────────────────────── */ + +// impl anchor [src/http/origin.ts]: +// export function isOriginAllowed(origin: string | undefined, allowed: readonly string[]): boolean +// host+port must both match; undefined Origin rejected by default. + +/* ─────────────────── PTY abstraction (§3.4) ──────────────────── */ + +export interface IDisposable { + dispose(): void; +} + +/** node-pty's IEvent shape: subscribe a listener, get a disposable back. */ +export type PtyEvent = (listener: (e: T) => void) => IDisposable; + +/** Subset of node-pty's IPty that we use. A real node-pty IPty is assignable to + * this; test/helpers/mock-pty.ts (T3) implements exactly this. */ +export interface IPty { + readonly pid: number; + readonly cols: number; + readonly rows: number; + onData: PtyEvent; + onExit: PtyEvent<{ exitCode: number; signal?: number }>; + write(data: string): void; + resize(cols: number, rows: number): void; + kill(signal?: string): void; +} + +/* ────────────────────── ring buffer (§3.4) ───────────────────── */ + +export interface RingBuffer { + append(chunk: string): void; + /** full buffer for replay; prepend `\x1b[0m` soft-reset as a safety net (M2). */ + snapshot(): string; +} + +// impl anchor [src/session/ring-buffer.ts]: +// export function createRingBuffer(maxBytes: number): RingBuffer +// M2: measure REAL bytes (Buffer), evict the whole oldest chunk — never split a +// multi-byte UTF-8 codepoint or an ANSI escape sequence mid-way. + +/* ──────────────────────── session (§3.4) ─────────────────────── */ + +/** The minimal WebSocket the server holds per session. A real `ws` WebSocket is + * structurally assignable; keeping this local makes types.ts portable. */ +export interface WebSocketLike { + readonly readyState: number; + send(data: string): void; + close(): void; +} + +/** WebSocket.OPEN. Guard every send with `ws.readyState === WS_OPEN` (M5). */ +export const WS_OPEN = 1; + +/** Immutable snapshot, fixed at creation (§5.2). */ +export interface SessionMeta { + readonly id: string; // crypto.randomUUID() — UUID v4 + readonly createdAt: number; // caller-supplied timestamp (injectable for tests) + readonly shellPath: string; +} + +export interface Session { + readonly meta: SessionMeta; + readonly buffer: RingBuffer; + /** attached ws, or null = detached but PTY still alive (vibe-coding core). */ + attachedWs: WebSocketLike | null; + detachedAt: number | null; // detach time + /** last pty.onData timestamp; reapIdle liveness proxy (M3). */ + lastOutputAt: number; + /** PTY exit time; null = alive. Once set: writeInput/resize are ignored (L4), + * and attach replays the buffer then re-sends `exit` (L1). */ + exitedAt: number | null; + exitCode: number | null; + readonly pty: IPty; +} + +// impl anchors [src/session/session.ts]: +// createSession(cfg: Config, dims: Dims, now: number, onExit: (s: Session) => void): Session +// // spawn failure THROWS, not swallowed (M4) +// attachWs(session: Session, ws: WebSocketLike): WebSocketLike | null // returns kicked old ws +// detachWs(session: Session, now: number): void // never kills PTY +// writeInput(session: Session, data: string): void // no-op after exit (L4) +// resize(session: Session, cols: number, rows: number): void // no-op after exit; idempotent +// kill(session: Session): void + +/* ──────────────────────── manager (§3.5) ─────────────────────── */ + +export interface SessionManager { + handleAttach(ws: WebSocketLike, sessionId: string | null, dims: Dims, now: number): Session; + get(id: string): Session | undefined; + /** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */ + reapIdle(now: number): number; + shutdown(): void; +} + +// impl anchor [src/session/manager.ts]: +// export function createSessionManager(cfg: Config): SessionManager + +/* ─────────────────────── frontend (§5/§6.3) ──────────────────── */ + +/** keybar.ts (T10) exports a mount fn of this shape; main.ts (T11) calls it. */ +export type MountKeybar = (onSend: (data: string) => void) => void;