diff --git a/.gitleaks.toml b/.gitleaks.toml index eb0ce63c..c4a3d7bb 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -81,3 +81,18 @@ condition = "AND" paths = ['''(^|/)packages/webui/test/trajectory/store\.test\.mjs$'''] regexTarget = "match" regexes = ['''^api_key=abcdef123456'$'''] + +[[rules.allowlists]] +description = "RFC 6455 sample WebSocket key (the sample nonce) in the documented handshake example, not a credential" +condition = "AND" +paths = ['''(^|/)packages/webui/docs/HTTPS-REVERSE-PROXY(\.zh-CN)?\.md$'''] +regexTarget = "match" +regexes = ['''Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=='''] + +[[rules.allowlists]] +description = "RFC 6455 sample WebSocket key (the sample nonce) constant in the handshake test, not a credential" +condition = "AND" +paths = ['''(^|/)packages/webui/test/lib-ws-server\.test\.js$'''] +regexTarget = "match" +regexes = ['''CLIENT_KEY = "dGhlIHNhbXBsZSBub25jZQ=="'''] + diff --git a/README.md b/README.md index 6cbc8b58..b119313d 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ From a source checkout use `pnpm mcode-web`. Without `--port` the server starts on 18090 and moves to the next free port when 18090 is taken, printing the URL it bound; an explicit `--port` is pinned and never moves. -The Web UI streams chat over SSE, renders tool calls and permission prompts, manages sessions and workspaces (with a modal directory picker over `/api/fs/*`, confined to allowed workspace roots), shows the current model in the selector, and mounts a read-only session **trajectory studio** at `/trajectory/`. It binds loopback by default; LAN exposure is explicit opt-in and token-gated. See [packages/webui](packages/webui/README.md) and [docs/webui.md](docs/webui.md). +The Web UI streams chat over WebSocket, renders tool calls and permission prompts, manages sessions and workspaces (with a modal directory picker over `/api/fs/*`, confined to allowed workspace roots), shows the current model in the selector, and mounts a read-only session **trajectory studio** at `/trajectory/`. It binds loopback by default; LAN exposure is explicit opt-in and token-gated. See [packages/webui](packages/webui/README.md) and [docs/webui.md](docs/webui.md). The Web UI originated as the community **mcode-webui** plugin and was migrated into this repository as a first-class package — the people and history behind it are recorded in [co-builders.md](co-builders.md). diff --git a/README_ZH.md b/README_ZH.md index 5b497c2f..e03f3803 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -265,7 +265,7 @@ mcode webui --port 8123 # 自定义端口,启动后打印访问地址 18090 被占用就换下一个空闲端口,并打印实际绑定的地址;显式传入的 `--port` 会被钉住,不会自动后移。 -Web UI 通过 SSE 流式输出对话,渲染工具调用与权限确认,管理会话与工作区(含模态目录选择器,接口限制在允许的工作区根内),模型选择器常显当前模型,并在 `/trajectory/` 挂载只读的会话**轨迹工作室**。默认仅绑定回环地址;局域网暴露需显式开启并通过令牌鉴权。详见 [packages/webui](packages/webui/README.md) 与 [docs/webui.md](docs/webui.md)。 +Web UI 通过 WebSocket 流式输出对话,渲染工具调用与权限确认,管理会话与工作区(含模态目录选择器,接口限制在允许的工作区根内),模型选择器常显当前模型,并在 `/trajectory/` 挂载只读的会话**轨迹工作室**。默认仅绑定回环地址;局域网暴露需显式开启并通过令牌鉴权。详见 [packages/webui](packages/webui/README.md) 与 [docs/webui.md](docs/webui.md)。 Web UI 源自社区的 **mcode-webui** 插件,现已作为一等公民包迁入本仓库——其人员与历史脉络记录于 [co-builders.md](co-builders.md)。 diff --git a/co-builders.md b/co-builders.md index 164e993e..75b01d2d 100644 --- a/co-builders.md +++ b/co-builders.md @@ -27,7 +27,7 @@ This file is the memory of that journey. | Handle | Role | |---|---| -| **Wzdhehe** | Original author and lead of mcode-webui. Designed and built the entire v0.5 → v1.1.1 line (~100+ commits): the zero-dependency HTTP/SSE server, the ACP-over-stdio engine wiring, the SPA frontend, the token/LAN security surface, and the bilingual UI. Submitted [PR #16]. | +| **Wzdhehe** | Original author and lead of mcode-webui. Designed and built the entire v0.5 → v1.1.1 line (~100+ commits): the zero-dependency HTTP server and streaming push transport, the ACP-over-stdio engine wiring, the SPA frontend, the token/LAN security surface, and the bilingual UI. Submitted [PR #16]. | | **modacker** (commits as **moc**, `moc@sgmov.com`) | External reviewer turned co-author. Tested PR #16 on macOS and fixed the db-path blocker (round 5, [PR #23]); authored the round-8 CORS tightening and cross-origin token-leak fix ([PR #31]); authored **v2.0.0 — 工业化** ([PR #55]), the industrial rewrite that closed the v1 line; synced v2.0.0 back to the standalone mirror. | | **Hahaha** (`Mjc39672@gmail.com`) | Maintainer of the standalone mirror Wzdhehe/Mcode-webui with merge rights; merged the round-8 security fix (mirror PR #6). | | **weekbin** | Author of **mcode-trajectory-studio** ([PR #56]): the read-only session-trajectory inspector (7 MCP tools + studio panel) over the runtime SQLite projection, including its redaction and containment design. | @@ -71,13 +71,13 @@ Trajectory 视图的能力,落到一个 MCode Agent Plugin 上". queue panel, ACP model picker, cross-workspace session search; four ACP surface probe rounds documented; layout consolidated to a single root tree. - **2026-09-19** — **v1.1.1**: live-test fixes (activity-aware prompt idle - timeout, throttled per-chunk SSE push). + timeout, throttled per-chunk streaming push). ### Plugin era — v2 line - **2026-09-20** — **v2.0.0 “工业化”** ([PR #55], author modacker, closes #16): append-only event stream with SHA-256 hash chain, per-request `authorize()` - gate (fail-closed audit), independent anomaly SSE channel, write-ahead + gate (fail-closed audit), independent anomaly REST snapshot channel, write-ahead intent/outcome events, interaction/feedback subsystem split, rate limiting, virtual chat list, session export (Markdown/JSON), quota forecast, token onboarding modal, local SBOM + CVE gates, loopback-default bind with diff --git a/docs/webui.md b/docs/webui.md index dadc0969..74d422f8 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -78,7 +78,7 @@ The canonical disclosure is [`packages/webui/references/SECURITY-NOTES.md`](../p ## Architecture -See [`packages/webui/docs/ARCHITECTURE.md`](../packages/webui/docs/ARCHITECTURE.md) for the runtime topology, request lifecycle, and SSE contract. In short: `server.js` bootstraps an HTTP server; `server/router.js` applies the gate chain (CORS → origin/CSRF → LAN → token → rate limit → read-only) and dispatches to `server/routes/*`; `server/lib/*` holds one-concern modules; `acp.mjs` is the ACP client spawning the engine; `public/` is the SPA. +See [`packages/webui/docs/ARCHITECTURE.md`](../packages/webui/docs/ARCHITECTURE.md) for the runtime topology, request lifecycle, and WebSocket event-stream contract. In short: `server.js` bootstraps an HTTP server; `server/router.js` applies the gate chain (CORS → origin/CSRF → LAN → token → rate limit → read-only) and dispatches to `server/routes/*`; `server/lib/*` holds one-concern modules; `acp.mjs` is the ACP client spawning the engine; `public/` is the SPA. ## Trajectory studio diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index ad8ea652..a1a73f33 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -77,7 +77,7 @@ node dist/cli.js webui --host 0.0.0.0 --no-open # PORT defaults to 18080 ## 架构 -运行时拓扑、请求生命周期和 SSE 契约见 [`packages/webui/docs/ARCHITECTURE.md`](../packages/webui/docs/ARCHITECTURE.md)。简言之:`server.js` 引导一个 HTTP 服务器;`server/router.js` 应用门禁链(CORS → origin/CSRF → LAN → token → rate limit → read-only)并分发到 `server/routes/*`;`server/lib/*` 存放单一职责模块;`acp.mjs` 是生成引擎的 ACP 客户端;`public/` 是 SPA。 +运行时拓扑、请求生命周期和 WebSocket 事件流契约见 [`packages/webui/docs/ARCHITECTURE.md`](../packages/webui/docs/ARCHITECTURE.md)。简言之:`server.js` 引导一个 HTTP 服务器;`server/router.js` 应用门禁链(CORS → origin/CSRF → LAN → token → rate limit → read-only)并分发到 `server/routes/*`;`server/lib/*` 存放单一职责模块;`acp.mjs` 是生成引擎的 ACP 客户端;`public/` 是 SPA。 ## 轨迹工作室 diff --git a/packages/webui/README.md b/packages/webui/README.md index 876c7d2d..9a84b2a5 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -3,7 +3,7 @@ **English** | [简体中文](README.zh-CN.md) > **Browser frontend for the MiniMax Code agent runtime.** -> Streams `mcode acp` / `mcode exec` sessions over HTTP/SSE. Zero npm +> Streams `mcode acp` / `mcode exec` sessions over HTTP/WebSocket. Zero npm > dependencies; runs on Node 22.19+. The Web UI is a first-class part of this repository — the same engine that @@ -29,7 +29,7 @@ node packages/webui/server.js The server binds loopback by default. LAN exposure is explicit opt-in (`--host` / `HOST` env, or the persisted `lanBind` setting). On first start a -token is generated and delivered to the browser over SSE; non-local requests +token is generated and delivered to the browser over the event stream; non-local requests must carry `?token=` or `Authorization: Bearer `. Recommended on non-loopback networks: @@ -44,7 +44,7 @@ mcode webui --host 0.0.0.0 | File | What | |------|------| -| `server.js` | HTTP + SSE server bootstrap | +| `server.js` | HTTP + WebSocket server bootstrap | | `server/` | Router, route modules, and pure libs (`server/lib/`) | | `acp.mjs` | `mcode acp` JSON-RPC client (spawns the engine over stdio) | | `public/` | Static frontend SPA | @@ -64,7 +64,7 @@ Real captures taken against a running v2.0.0 server — see | # | What it shows | |---|---| | 1 | **Startup** — empty chat view on first launch | -| 2 | **Mid-stream chat** — history restored, SSE deltas in flight, tok/s meter | +| 2 | **Mid-stream chat** — history restored, stream deltas in flight, tok/s meter | | 3 | **Settings panel** — Appearance / Language / LAN Access toggles | | 4 | **Chat input** — prompt typed, send/stop affordances, `/` and `@file` hints | | 5 | **Post-send + tool call** — assistant streaming, tool-call block auto-collapse | @@ -79,7 +79,7 @@ IDE integrations match on these strings. | Capability | One-line | |---|---| -| `chat-streaming` | SSE deltas from `mcode acp` rendered token-by-token | +| `chat-streaming` | stream deltas from `mcode acp` rendered token-by-token | | `tool-execution` | Bash / Read / Write / Edit forwarded from acp `tool_call` events | | `plan-mode` | Plan review modal with `agree` / `skip` / `add context` options | | `ask-user-tool` | 2-4 option question modal with `Other` free-text fallback | @@ -125,7 +125,7 @@ degrades to sequential scans. - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — runtime topology, request lifecycle, module contracts - [`docs/DESKTOP-ARCHITECTURE.md`](docs/DESKTOP-ARCHITECTURE.md) — measured desktop and TUI architecture reference - [`docs/DESIGN.md`](docs/DESIGN.md) — design system of record (tokens, theme protocol, layout) and the desktop alignment contract -- [`docs/API.md`](docs/API.md) — HTTP/SSE surface +- [`docs/API.md`](docs/API.md) — HTTP/WebSocket surface - [`docs/CAPABILITIES.md`](docs/CAPABILITIES.md) — capability deep-dive - [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) — dev workflow, tests - [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) — common failures @@ -137,7 +137,7 @@ degrades to sequential scans. Read [`references/SECURITY-NOTES.md`](references/SECURITY-NOTES.md) before binding to anything other than loopback. Highlights: loopback bind by default, trusted-origin CORS, per-request `authorize()` gate (fail-closed audit), -independent anomaly SSE channel, workspace containment, bounded uploads, +independent anomaly REST snapshot channel, workspace containment, bounded uploads, rate limiting, no telemetry. ## License diff --git a/packages/webui/README.zh-CN.md b/packages/webui/README.zh-CN.md index 6a0853f0..de491d5a 100644 --- a/packages/webui/README.zh-CN.md +++ b/packages/webui/README.zh-CN.md @@ -3,7 +3,7 @@ > 简体中文 | [English](README.md) > **MiniMax Code 智能体运行时的浏览器前端。** -> 通过 HTTP/SSE 流式传输 `mcode acp` / `mcode exec` 会话。零 npm +> 通过 HTTP/WebSocket 流式传输 `mcode acp` / `mcode exec` 会话。零 npm > 依赖;运行于 Node 22.19+。 Web UI 是本仓库的一等组成部分 —— 驱动 TUI(`mcode acp`,基于 stdio 的 @@ -29,7 +29,7 @@ node packages/webui/server.js 服务器默认绑定回环地址。局域网暴露需显式选择开启 (`--host` / `HOST` 环境变量,或持久化的 `lanBind` 设置)。首次 -启动时会生成一个令牌并通过 SSE 发送给浏览器;非本地请求必须携带 +启动时会生成一个令牌并通过事件流发送给浏览器;非本地请求必须携带 `?token=` 或 `Authorization: Bearer `。 在非回环网络上推荐的做法: @@ -44,7 +44,7 @@ mcode webui --host 0.0.0.0 | 文件 | 说明 | |------|------| -| `server.js` | HTTP + SSE 服务器引导 | +| `server.js` | HTTP + WebSocket 服务器引导 | | `server/` | 路由器、路由模块与纯函数库(`server/lib/`) | | `acp.mjs` | `mcode acp` JSON-RPC 客户端(通过 stdio 派生引擎) | | `public/` | 静态前端 SPA | @@ -64,7 +64,7 @@ mcode webui --host 0.0.0.0 | # | 展示内容 | |---|---| | 1 | **启动** —— 首次启动时的空聊天视图 | -| 2 | **流式聊天中** —— 历史已恢复,SSE 增量正在传输,tok/s 仪表 | +| 2 | **流式聊天中** —— 历史已恢复,流式增量正在传输,tok/s 仪表 | | 3 | **设置面板** —— 外观 / 语言 / 局域网访问开关 | | 4 | **聊天输入** —— 已输入提示词,发送/停止控件,`/` 与 `@file` 提示 | | 5 | **发送后 + 工具调用** —— 助手流式输出,工具调用块自动折叠 | @@ -78,7 +78,7 @@ mcode webui --host 0.0.0.0 | 能力 | 一句话说明 | |---|---| -| `chat-streaming` | 来自 `mcode acp` 的 SSE 增量逐令牌渲染 | +| `chat-streaming` | 来自 `mcode acp` 的流式增量逐令牌渲染 | | `tool-execution` | 从 acp `tool_call` 事件转发的 Bash / Read / Write / Edit | | `plan-mode` | 计划审阅模态框,含 `agree` / `skip` / `add context` 选项 | | `ask-user-tool` | 2–4 个选项的提问模态框,带 `Other` 自由文本回退 | @@ -122,7 +122,7 @@ FTS5)为 **>=22.19 <23 || >=24 <27**,与运行时自身的引擎范围一致 ## 文档 - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) —— 运行时拓扑、请求生命周期、模块契约 -- [`docs/API.md`](docs/API.md) —— HTTP/SSE 接口面 +- [`docs/API.md`](docs/API.md) —— HTTP/WebSocket 接口面 - [`docs/CAPABILITIES.md`](docs/CAPABILITIES.md) —— 能力深入解析 - [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) —— 开发工作流、测试 - [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) —— 常见故障 @@ -134,7 +134,7 @@ FTS5)为 **>=22.19 <23 || >=24 <27**,与运行时自身的引擎范围一致 在绑定到回环之外的任何地址之前,请阅读 [`references/SECURITY-NOTES.md`](references/SECURITY-NOTES.md)。要点: 默认绑定回环、可信来源 CORS、逐请求 `authorize()` 门禁(失败关闭的 -审计)、独立的异常 SSE 通道、工作区围栏、受限上传、速率限制、无遥测。 +审计)、独立的异常 REST 快照通道、工作区围栏、受限上传、速率限制、无遥测。 ## 许可证 diff --git a/packages/webui/checks/alerts-theme.check.mjs b/packages/webui/checks/alerts-theme.check.mjs index ddd342f4..42b6c2fd 100644 --- a/packages/webui/checks/alerts-theme.check.mjs +++ b/packages/webui/checks/alerts-theme.check.mjs @@ -4,10 +4,12 @@ // // D1 — anomaly-channel (alerts) surface. server/lib/alerts.js pushes // system signals (failed chat send / spawn ENOENT, sqlite -// failure, …) on the INDEPENDENT /api/alerts SSE channel; the -// frontend had ZERO surface, so errors were invisible (P1). -// Covers: the state.js alerts store driven by fake SSE frames -// (snapshot / append / update / replay dedup / malformed), the +// failure, …) on the INDEPENDENT alerts channel: the +// GET /api/alerts REST ring snapshot plus alerts.append / +// alerts.update control frames on /api/stream. The frontend +// had ZERO surface, so errors were invisible (P1). +// Covers: the state.js alerts store driven by fake stream +// frames (snapshot / append / update / replay dedup / malformed), the // badge count + 99+ cap, mark-read-on-open, clear semantics, // and the render.js renderAlerts DOM construction (level class, // msg via textContent, src/session/×count/time meta line). @@ -104,37 +106,45 @@ const RICH_IDS = [ "appearance-icon", ]; -class FakeEventSource { +// FakeWebSocket — state.js opens ONE /api/stream socket per connect(); +// every server→client frame is a JSON text frame, so tests inject them +// through sws.onmessage({data: JSON.stringify(frame)}) exactly as the +// browser would deliver them. close() is silent on purpose: a +// deliberate close must NOT schedule the 3s reconnect retry. +class FakeWebSocket { constructor(url) { - this.url = url; - this.listeners = new Map(); + this.url = String(url); + this.onopen = null; + this.onmessage = null; + this.onclose = null; this.closed = false; + this.sent = []; } - addEventListener(name, fn) { - if (!this.listeners.has(name)) this.listeners.set(name, []); - this.listeners.get(name).push(fn); - } + send(data) { this.sent.push(data) } close() { this.closed = true } - fire(name, data) { - for (const fn of this.listeners.get(name) || []) fn({ data }); - } } const _store = new Map(); +const _fetchCalls = []; function installGlobals() { + // connect() reads the bare `location` global (browser semantics): + // protocol/host pick the ws:// URL; search/pathname/hash back the + // module-load token handling. + const loc = { search: "", pathname: "/", hash: "", protocol: "http:", host: "localhost:3000" }; globalThis.window = { - location: { search: "", pathname: "/", hash: "" }, + location: loc, history: { replaceState() {} }, matchMedia: () => ({ matches: false }), // → default theme "light" addEventListener() {}, }; + globalThis.location = loc; globalThis.localStorage = { getItem: (k) => (_store.has(k) ? _store.get(k) : null), setItem: (k, v) => _store.set(k, String(v)), removeItem: (k) => _store.delete(k), }; - globalThis.EventSource = FakeEventSource; + globalThis.WebSocket = FakeWebSocket; globalThis.document = { documentElement: makeEl("html"), body: makeEl("body"), @@ -151,18 +161,60 @@ function installGlobals() { configurable: true, writable: true, }); } catch {} - globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }); + globalThis.fetch = async (url) => { + const u = String(url); + _fetchCalls.push({ url: u }); + if (u.startsWith("/api/alerts")) { + // The hello handler's ring-snapshot fetch: fire() queues the frame + // just before it triggers hello; capture it at call time. + const snap = _pendingAlertsSnapshot; + _pendingAlertsSnapshot = null; + return { ok: true, status: 200, json: async () => snap || { kind: "snapshot", alerts: [] } }; + } + return { ok: true, status: 200, json: async () => ({ ok: true }) }; + }; } // ---------- SUT handles ---------- -let stateMod, renderMod, eventsMod, i18nMod, aes; +let stateMod, renderMod, eventsMod, i18nMod, sws; + +let _seq = 0; // control-frame seq counter +let _pendingAlertsSnapshot = null; // consumed by the fake /api/alerts fetch + +function pushHello() { + // The first server→client frame on /api/stream; its handler fetches + // the /api/alerts ring snapshot (and, on a first connect, the + // /api/state REST baseline). + sws.onmessage({ data: JSON.stringify({ + v: 1, type: "hello", + payload: { resumeSupported: true, latestSeq: 0, heartbeatMs: 30000, ringCapacity: 100, cid: "check" }, + }) }); +} + +// Drain the hello→fetch('/api/alerts')→_handleAlertFrame microtask +// chain before asserting; callers that fire a snapshot must await it. +function flush() { return new Promise((r) => setImmediate(r)); } function fire(frame) { - // The alerts channel uses default `message` events (the server's 30s - // heartbeat is a NAMED event and never reaches onmessage). - assert.ok(aes && typeof aes.onmessage === "function", "alerts EventSource must be connected"); - aes.onmessage({ data: typeof frame === "string" ? frame : JSON.stringify(frame) }); + // Live alert frames arrive as /api/stream control frames + // (alerts.append / alerts.update). Ring snapshots are the one + // exception — production fetches them from GET /api/alerts on every + // hello — so route those through the exact same path. + assert.ok(sws && typeof sws.onmessage === "function", "state stream WebSocket must be connected"); + if (frame && typeof frame === "object" && frame.kind === "snapshot") { + _pendingAlertsSnapshot = frame; + pushHello(); + return; + } + // Non-snapshot input degrades to an alerts.append control frame — + // malformed strings exercise the same JSON.parse failure path the + // production dispatch does. + const kind = frame && typeof frame === "object" && frame.kind ? frame.kind : "append"; + sws.onmessage({ data: JSON.stringify({ + v: 1, seq: ++_seq, ts: Date.now(), type: "control", + payload: { name: `alerts.${kind}`, data: typeof frame === "string" ? frame : JSON.stringify(frame) }, + }) }); } function click(id, target) { @@ -195,7 +247,9 @@ before(async (t) => { } finally { t.mock.timers.reset(); } - aes = stateMod.alertsEs; + sws = stateMod.es; + assert.ok(sws instanceof FakeWebSocket, "connect() must open our fake WebSocket on /api/stream"); + assert.ok(String(sws.url).includes("/api/stream"), `unexpected state-stream url ${sws.url}`); }); beforeEach(() => { @@ -212,22 +266,26 @@ after(() => { }); // ============================================================ -// D1 — state.js alerts store driven by SSE frames +// D1 — state.js alerts store driven by stream frames // ============================================================ describe("D1 alerts channel — connection + queue", () => { - test("connect() opens an independent /api/alerts EventSource (token+cid suffix)", () => { - assert.ok(aes instanceof FakeEventSource, "alerts must ride its own EventSource"); - assert.ok(aes.url.startsWith("/api/alerts"), `unexpected url ${aes.url}`); - assert.ok(/cid=/.test(aes.url), "cid query must be present (per-client routing)"); - assert.notEqual(aes, stateMod.es, "must NOT reuse the state-stream EventSource"); + test("connect() rides /api/stream (token+cid suffix) and fetches /api/alerts on hello", () => { + assert.ok(sws instanceof FakeWebSocket, "state stream must be our fake WebSocket"); + assert.ok(String(sws.url).includes("/api/stream"), `unexpected url ${sws.url}`); + assert.ok(/cid=/.test(sws.url), "cid query must be present (per-client routing)"); + const before = _fetchCalls.filter((c) => c.url.startsWith("/api/alerts")).length; + pushHello(); + const after = _fetchCalls.filter((c) => c.url.startsWith("/api/alerts")).length; + assert.equal(after, before + 1, "hello must fetch the /api/alerts ring snapshot (REST)"); }); - test("snapshot frame replaces the list (newest first) and counts unseen ids as unread", () => { + test("snapshot frame replaces the list (newest first) and counts unseen ids as unread", async () => { fire({ kind: "snapshot", alerts: [ { id: "a-old", ts: 1, level: "error", msg: "old failure", src: "chat:send", count: 1 }, { id: "a-new", ts: 2, level: "warn", msg: "new warning", src: "db", count: 1 }, ] }); + await flush(); const list = stateMod.getAlerts(); assert.equal(list.length, 2); assert.equal(list[0].id, "a-new", "server sends oldest→newest; store must be newest-first"); @@ -240,15 +298,16 @@ describe("D1 alerts channel — connection + queue", () => { assert.equal(stateMod.getAlertsUnread(), 1); assert.equal(stateMod.getAlerts()[0].id, "b1"); fire({ kind: "append", alert: { id: "b1", ts: 3, level: "error", msg: "spawn mcode ENOENT", src: "chat:send", count: 1 } }); - assert.equal(stateMod.getAlertsUnread(), 1, "SSE reconnect replay must not double-count"); + assert.equal(stateMod.getAlertsUnread(), 1, "stream reconnect replay must not double-count"); assert.equal(stateMod.getAlerts().length, 1); }); - test("snapshot replay after reconnect does not re-inflate the unread count", () => { + test("snapshot replay after reconnect does not re-inflate the unread count", async () => { fire({ kind: "append", alert: { id: "c1", ts: 4, level: "info", msg: "x", src: "system", count: 1 } }); fire({ kind: "snapshot", alerts: [ { id: "c1", ts: 4, level: "info", msg: "x", src: "system", count: 1 }, ] }); + await flush(); assert.equal(stateMod.getAlertsUnread(), 1, "already-seen id → fresh=0"); assert.equal(stateMod.getAlerts().length, 1, "but the list is still the server truth"); }); @@ -346,7 +405,7 @@ describe("D1 alerts surface — badge, popover, mark-read, clear", () => { assert.equal(body.children[0].textContent, "No alerts"); // en default dictionary }); - test("clear empties the list; the badge only returns for genuinely new alerts", () => { + test("clear empties the list; the badge only returns for genuinely new alerts", async () => { fire({ kind: "append", alert: { id: "i1", ts: 12, level: "info", msg: "x", src: "system", count: 1 } }); click("btn-alerts"); // open + read click("alerts-clear"); @@ -358,6 +417,7 @@ describe("D1 alerts surface — badge, popover, mark-read, clear", () => { fire({ kind: "snapshot", alerts: [ { id: "i1", ts: 12, level: "info", msg: "x", src: "system", count: 1 }, ] }); + await flush(); assert.equal(stateMod.getAlertsUnread(), 0, "cleared id is still 'seen'"); fire({ kind: "append", alert: { id: "i2", ts: 13, level: "error", msg: "y", src: "chat:send", count: 1 } }); assert.equal(stateMod.getAlertsUnread(), 1, "a genuinely new alert re-arms the badge"); @@ -519,7 +579,7 @@ describe("static source guards", () => { "// v2 (2026-09-20 webui-manual-audit D1): alerts surface — end alerts-surface", ); assert.equal(/\.innerHTML\s*=/.test(block), false, - "alert msg/src/sessionId are untrusted SSE wire data (server relays raw subprocess stderr)"); + "alert msg/src/sessionId are untrusted stream wire data (server relays raw subprocess stderr)"); assert.match(block, /createElement\(\s*['"]div['"]\s*\)/); assert.match(block, /createElement\(\s*['"]span['"]\s*\)/); assert.match(block, /\.textContent\s*=/); @@ -542,10 +602,10 @@ describe("static source guards", () => { assert.match(INDEX_SRC, /id="alerts-popover-body"/); }); - test("the alerts channel is a SEPARATE EventSource, not the state stream", () => { - const block = slice(STATE_SRC, "if (!alertsEs) {", "// v0.5.ak: user footer"); - assert.ok(block.includes("new EventSource('/api/alerts'"), "must subscribe to /api/alerts"); - assert.ok(STATE_SRC.includes("const url = '/api/events'"), "state stream unchanged"); + test("the frontend subscribes to BOTH the /api/alerts snapshot and the /api/stream state endpoint", () => { + const block = slice(STATE_SRC, "fetch('/api/alerts'", "// v0.5.ak: user footer"); + assert.ok(block.includes("fetch('/api/alerts'"), "must subscribe to /api/alerts (REST snapshot)"); + assert.ok(STATE_SRC.includes("'/api/stream'"), "state stream rides /api/stream"); }); test("i18n completeness — alerts_* keys exist in BOTH zh and en", () => { diff --git a/packages/webui/checks/authorize-modal.check.mjs b/packages/webui/checks/authorize-modal.check.mjs index fe9a0e66..ba464670 100644 --- a/packages/webui/checks/authorize-modal.check.mjs +++ b/packages/webui/checks/authorize-modal.check.mjs @@ -4,7 +4,7 @@ // Why this test exists (2026-09-20 webui-manual-audit): the server half // of the authorize gate was complete — server/lib/authorize.js blocks // gated actions on a 5-minute fail-closed promise, state-bus.js pushes -// `needs_authorization` / `authorization_decided` SSE frames, and +// `needs_authorization` / `authorization_decided` control frames, and // POST /api/auth/decision is routed — but public/ had ZERO wiring. Every // gated action (delete / export / cross-workspace search / /clear / /new / // token reset) hung silently for 5 minutes and then declined. A @@ -12,7 +12,7 @@ // accidental destruction (an approve firing without a click). // // Coverage: -// • state.js SSE listeners: needs_authorization enqueues (dedup on +// • state.js stream handlers: needs_authorization enqueues (dedup on // reconnect replay; malformed frames ignored); authorization_decided // removes exactly that requestId (unknown ids no-op). // • submitAuthDecision POST shape: {requestId, approve} with strict @@ -31,7 +31,8 @@ // // How the real modules load under node:test: public/app/state.js pulls // the whole render/events/util/i18n cluster, which touches DOM globals -// at module-eval time (window / localStorage / document / EventSource). +// at module-eval time (window / localStorage / document / location / +// WebSocket). // We install minimal fakes BEFORE the dynamic import — the same globals // the browser provides for free. No t.mock.module needed: the modules // under test are the REAL frontend files, imported unmocked. @@ -82,25 +83,51 @@ const _els = {}; const _fetchCalls = []; let _fetchImpl = null; // per-test override; default 200 {ok:true} -class FakeEventSource { +// Fake WebSocket (decision 20: state.js switched the SSE stream → +// WebSocket /api/stream). Node 24 ships a REAL global WebSocket — +// installGlobals overrides globalThis.WebSocket with this BEFORE +// state.js is imported so connect() never opens a live socket. The +// frontend drives the stream through the onmessage/onclose properties. +class FakeWebSocket { constructor(url) { - this.url = url; - this.listeners = new Map(); + this.url = String(url); + this.readyState = 0; // CONNECTING + this.onopen = null; + this.onmessage = null; + this.onclose = null; + this.onerror = null; + this.sent = []; this.closed = false; } - addEventListener(name, fn) { - if (!this.listeners.has(name)) this.listeners.set(name, []); - this.listeners.get(name).push(fn); - } - close() { this.closed = true } - fire(name, data) { - for (const fn of this.listeners.get(name) || []) fn({ data }); + send(data) { this.sent.push(String(data)); } + close() { + this.closed = true; + this.readyState = 3; // CLOSED + if (typeof this.onclose === "function") this.onclose({ code: 1000, reason: "" }); } + // state.js drives the stream via onmessage; keep addEventListener as + // a harmless recorder for forwards-compatibility. + addEventListener() {} } function installGlobals() { + // Bare browser globals the frontend reads directly: connect() builds + // the ws:// URL from `location` (protocol/host) — Node has no + // `location`, so provide a minimal one (shared with window.location). + const loc = { + protocol: "http:", + host: "127.0.0.1:18090", + hostname: "127.0.0.1", + port: "18090", + origin: "http://127.0.0.1:18090", + search: "", + pathname: "/", + hash: "", + href: "http://127.0.0.1:18090/", + }; + globalThis.location = loc; globalThis.window = { - location: { search: "", pathname: "/", hash: "" }, + location: loc, history: { replaceState() {} }, matchMedia: () => ({ matches: false }), addEventListener() {}, @@ -113,7 +140,7 @@ function installGlobals() { removeItem: (k) => m.delete(k), }; })(); - globalThis.EventSource = FakeEventSource; + globalThis.WebSocket = FakeWebSocket; // 覆盖 Node 自带的真 WebSocket globalThis.document = { getElementById: (id) => _els[id] || null, createElement: (tag) => makeEl(tag), @@ -162,7 +189,8 @@ before(async (t) => { t.mock.timers.reset(); } es = stateMod.es; - assert.ok(es instanceof FakeEventSource, "connect() must use our fake EventSource"); + assert.ok(es instanceof FakeWebSocket, "connect() must open our fake WebSocket"); + assert.match(es.url, /\/api\/stream/, `stream must target /api/stream, got: ${es.url}`); }); beforeEach(() => { @@ -194,18 +222,43 @@ after(() => { // ---------- helpers ---------- +let _frameSeq = 0; +// Inject one protocol TEXT frame through the WebSocket's onmessage — +// {v:1, seq, ts, type:"control", payload:{name, data}} where data is +// the event payload as a JSON STRING (the same bytes the old SSE +// `data:` line carried). fireRaw takes the string as-is so malformed +// payloads stay malformed. +function fireRaw(name, dataStr) { + assert.equal( + typeof es.onmessage, + "function", + "state.js must register ws.onmessage for control-frame dispatch", + ); + es.onmessage({ + data: JSON.stringify({ + v: 1, + seq: ++_frameSeq, + ts: Date.now(), + type: "control", + payload: { name, data: dataStr }, + }), + }); +} +function fireControl(name, data) { + fireRaw(name, JSON.stringify(data)); +} function pushAuthFrame({ requestId, action = "slash.clear", ctx = {}, expiresAt = Date.now() + 300_000 }) { - es.fire("needs_authorization", JSON.stringify({ requestId, action, ctx, expiresAt })); + fireControl("needs_authorization", { requestId, action, ctx, expiresAt }); } function fireDecided(requestId, approved = true, decidedBy = "user") { - es.fire("authorization_decided", JSON.stringify({ requestId, approved, decidedBy })); + fireControl("authorization_decided", { requestId, approved, decidedBy }); } // ============================================================ -// state.js SSE listeners — queue add/remove +// state.js stream handlers — queue add/remove // ============================================================ -describe("state.js SSE listeners — pending queue", () => { +describe("state.js stream handlers — pending queue", () => { test("needs_authorization enqueues the parsed frame and opens the modal", () => { pushAuthFrame({ requestId: "rid-1", @@ -226,7 +279,7 @@ describe("state.js SSE listeners — pending queue", () => { assert.equal(_els["auth-modal-position"].textContent, ""); }); - test("duplicate frame (SSE reconnect replay) does not double-queue", () => { + test("duplicate frame (resume replay) does not double-queue", () => { pushAuthFrame({ requestId: "rid-dup", action: "token.reset" }); pushAuthFrame({ requestId: "rid-dup", action: "token.reset" }); assert.equal(stateMod.getPendingAuthRequests().length, 1); @@ -272,9 +325,9 @@ describe("state.js SSE listeners — pending queue", () => { test("malformed frames are ignored without throwing", () => { pushAuthFrame({ requestId: "rid-m" }); - es.fire("needs_authorization", "not json at all"); - es.fire("authorization_decided", ""); - es.fire("needs_authorization", JSON.stringify({ noRequestId: true })); + fireRaw("needs_authorization", "not json at all"); + fireRaw("authorization_decided", ""); + fireRaw("needs_authorization", JSON.stringify({ noRequestId: true })); const q = stateMod.getPendingAuthRequests(); assert.equal(q.length, 1); assert.equal(q[0].requestId, "rid-m"); diff --git a/packages/webui/checks/chat-failed-send.check.mjs b/packages/webui/checks/chat-failed-send.check.mjs index 839d62d5..c26949a5 100644 --- a/packages/webui/checks/chat-failed-send.check.mjs +++ b/packages/webui/checks/chat-failed-send.check.mjs @@ -44,6 +44,10 @@ import { registerSessionsStore, registerMcodeAcpMock, } from "../test/_setup.js"; +import { + subscribeEvents, + resetEventBusForTests, +} from "../server/lib/event-bus.js"; // alerts.js fire-and-forget audit-writes to events.ndjson via the REAL // lib/events.js — redirect to a per-file tmp dir so the check never @@ -89,19 +93,19 @@ let handleStop; let sb; // state-bus handle let alerts; // real lib/alerts.js (not mocked by _setup.js) -// Capturing SSE fake — pushStateFor writes `data: ` frames. -function makeFakeSse() { - return { - frames: [], - write(s) { - this.frames.push(String(s)); - }, - }; +// 事件总线捕获(原 fakeSse 模式的等价替换,decision 20 SSE 移除)—— +// pushStateFor 发布 type==="state.snapshot" 事件到订阅 cid。 +function captureCid(cid) { + const box = []; + const unsub = subscribeEvents(cid, (item) => box.push(item)); + return { box, unsub }; } -function lastStateFrame(sse) { - const dataFrames = sse.frames.filter((f) => f.startsWith("data: ")); - assert.ok(dataFrames.length > 0, "expected at least one state frame"); - return JSON.parse(dataFrames[dataFrames.length - 1].replace(/^data: /, "")); +function lastStateFrame(box) { + for (let i = box.length - 1; i >= 0; i--) { + const e = box[i] && box[i].event; + if (e && e.type === "state.snapshot") return e.snapshot; + } + assert.fail("expected at least one state.snapshot event"); } before(async (t) => { @@ -127,7 +131,7 @@ before(async (t) => { beforeEach(() => { sb.clients.clear(); - sb.resetCoalesceState(); + resetEventBusForTests(); registerSessionsStore({ initial: [] }); alerts._resetForTests(); }); @@ -166,8 +170,7 @@ describe("handleSend — failed send (§AP3) resets the thinking claim", () => { cs.workspace = { dir: "/ws-X", branch: null, tree: null }; seedStaleThinkingClaim(cs); sb.clients.set(cid, cs); - const sse = makeFakeSse(); - sb.setSseClient(cid, sse); + const cap = captureCid(cid); await handleSend(fakeReq({ content: "hello" }), fakeRes(), { cs, cid }); @@ -194,7 +197,7 @@ describe("handleSend — failed send (§AP3) resets the thinking claim", () => { assert.equal(hit.cid, cid); // 6. The TERMINAL pushed frame (after the reset) is at-rest — this // is the frame the browser renders 思考中 from. - const frame = lastStateFrame(sse); + const frame = lastStateFrame(cap.box); assert.equal(frame.running.active, false); assert.equal(frame.context.thinkingStatus, "Idle"); assert.equal(frame.context.thinkingDuration, null); @@ -276,8 +279,7 @@ describe("handleStop — zombie run claim", () => { cs.mcodeSessionId = null; // skips the gentle-cancel RPC path seedStaleThinkingClaim(cs); sb.clients.set(cid, cs); - const sse = makeFakeSse(); - sb.setSseClient(cid, sse); + const cap = captureCid(cid); const res = fakeRes(); await handleStop(null, res, { cs, cid }); @@ -290,7 +292,7 @@ describe("handleStop — zombie run claim", () => { assert.equal(cs.context.thinkingStatus, "Idle"); assert.equal(cs.context.thinkingDuration, null); // ...and the at-rest state actually went out on the wire. - const frame = lastStateFrame(sse); + const frame = lastStateFrame(cap.box); assert.equal(frame.running.active, false); assert.equal(frame.context.thinkingStatus, "Idle"); }); diff --git a/packages/webui/checks/lib-alerts.check.mjs b/packages/webui/checks/lib-alerts.check.mjs index e6c303be..f21fb0ef 100644 --- a/packages/webui/checks/lib-alerts.check.mjs +++ b/packages/webui/checks/lib-alerts.check.mjs @@ -1,6 +1,7 @@ // webui/test/lib-alerts.test.js // Unit tests for server/lib/alerts.js — pushAlert / dedup / ring -// buffer / SSE broadcast. +// buffer / subscriber broadcast (decision 20: sinks are plain +// callbacks that receive the frame object itself). // // Why this test exists: lease B02 introduces the independent anomaly // channel (front-end bell icon data source). pushAlert is the only @@ -22,16 +23,6 @@ const absPath = (rel) => const alerts = await import(absPath("lib/alerts.js")); -function fakeSse() { - const writes = []; - return { - writes, - write(chunk) { - writes.push(chunk); - }, - }; -} - describe("pushAlert — basic", () => { beforeEach(() => { alerts._resetForTests(); @@ -203,67 +194,60 @@ describe("pushAlert — dedup", () => { }); }); -describe("pushAlert — SSE broadcast", () => { +describe("pushAlert — subscriber broadcast (sink callbacks)", () => { beforeEach(() => { alerts._resetForTests(); }); - test("subscriber receives an `append` frame on pushAlert", () => { - const res = fakeSse(); - const unsubscribe = alerts.subscribeAlerts(res); - alerts.pushAlert({ level: "info", msg: "hi", src: "s", cid: "cid-1" }); - assert.equal(res.writes.length, 1); - const frame = JSON.parse(res.writes[0].slice(6)); - assert.equal(frame.kind, "append"); - assert.equal(frame.alert.msg, "hi"); + test("subscriber sink receives an `append` frame on pushAlert", () => { + const got = []; + const unsubscribe = alerts.subscribeAlerts((f) => got.push(f)); + const a = alerts.pushAlert({ level: "info", msg: "hi", src: "s", cid: "cid-1" }); + assert.equal(got.length, 1); + assert.deepEqual(got[0], { kind: "append", alert: a }); unsubscribe(); }); - test("multiple subscribers all receive frames", () => { - const a = fakeSse(); - const b = fakeSse(); - alerts.subscribeAlerts(a); - alerts.subscribeAlerts(b); + test("multiple subscriber sinks all receive frames", () => { + const a = []; + const b = []; + alerts.subscribeAlerts((f) => a.push(f)); + alerts.subscribeAlerts((f) => b.push(f)); alerts.pushAlert({ level: "info", msg: "fan-out", src: "s" }); - assert.equal(a.writes.length, 1); - assert.equal(b.writes.length, 1); - assert.equal(a.writes[0], b.writes[0]); + assert.equal(a.length, 1); + assert.equal(b.length, 1); + assert.deepEqual(a[0], b[0]); }); test("deduped push sends an `update` frame (not a new entry)", () => { - const res = fakeSse(); - alerts.subscribeAlerts(res); - alerts.pushAlert({ level: "warn", msg: "dup", src: "s" }); + const got = []; + alerts.subscribeAlerts((f) => got.push(f)); alerts.pushAlert({ level: "warn", msg: "dup", src: "s" }); - assert.equal(res.writes.length, 2); - const first = JSON.parse(res.writes[0].slice(6)); - const second = JSON.parse(res.writes[1].slice(6)); - assert.equal(first.kind, "append"); - assert.equal(second.kind, "update"); - assert.equal(second.alert.count, 2); + const b = alerts.pushAlert({ level: "warn", msg: "dup", src: "s" }); + assert.equal(got.length, 2); + assert.equal(got[0].kind, "append"); + assert.deepEqual(got[1], { kind: "update", alert: b }); + assert.equal(got[1].alert.count, 2); }); - test("unsubscribe stops further writes", () => { - const res = fakeSse(); - const unsub = alerts.subscribeAlerts(res); + test("unsubscribe stops further frames", () => { + const got = []; + const unsub = alerts.subscribeAlerts((f) => got.push(f)); alerts.pushAlert({ level: "info", msg: "1", src: "s" }); unsub(); alerts.pushAlert({ level: "info", msg: "2", src: "s" }); - assert.equal(res.writes.length, 1); + assert.equal(got.length, 1); assert.equal(alerts.getSubscriberCount(), 0); }); - test("a subscriber that throws on write does not break others", () => { - const throwing = { - write() { - throw new Error("broken pipe"); - }, - }; - const good = fakeSse(); - alerts.subscribeAlerts(throwing); - alerts.subscribeAlerts(good); + test("a first sink that throws does not break the second", () => { + const got = []; + alerts.subscribeAlerts(() => { + throw new Error("broken pipe"); + }); + alerts.subscribeAlerts((f) => got.push(f)); alerts.pushAlert({ level: "info", msg: "x", src: "s" }); - assert.equal(good.writes.length, 1); + assert.equal(got.length, 1, "second sink must still receive the frame"); }); }); diff --git a/packages/webui/checks/lib-authorize.check.mjs b/packages/webui/checks/lib-authorize.check.mjs index a60a52ea..b02f38dd 100644 --- a/packages/webui/checks/lib-authorize.check.mjs +++ b/packages/webui/checks/lib-authorize.check.mjs @@ -24,7 +24,9 @@ // // We mock state-bus.js so pushAuthRequest / pushAuthDecision push // into a captured `_sseFrames` array, letting tests assert what was -// emitted to the client. +// emitted to the client. (SSE is gone — decision 20: the real module +// publishes control frames through event-bus; the mock keeps its own +// record so assertions stay independent of the transport.) import { test, describe, before, beforeEach } from "node:test"; import assert from "node:assert/strict"; @@ -38,7 +40,6 @@ const absPath = (rel) => pathToFileURL(resolve(SERVER_DIR, rel)).href; // ----- state-bus mock state (read by the registered module mock) ----- let _sseFrames = []; -let _subscribers = new Map(); // cid -> Set function _installStateBusMock(t) { t.mock.module(absPath("lib/state-bus.js"), { @@ -51,9 +52,7 @@ function _installStateBusMock(t) { pushStateFor: () => {}, pushOnlineCount: () => {}, clients: new Map(), - sseByCid: _subscribers, activeChildByCid: new Map(), - SSE_HEADERS: {}, mcodeSessionsSnapshotFields: () => ({ mcodeSessions: [], mcodeSessionsPending: false, @@ -62,18 +61,6 @@ function _installStateBusMock(t) { getActiveChild: () => null, clearActiveChild: () => {}, getCidsByMcodeSession: () => [], - getSseClient: (cid) => _subscribers.get(cid) || null, - setSseClient: (cid, res) => { - if (!_subscribers.has(cid)) _subscribers.set(cid, new Set()); - _subscribers.get(cid).add(res); - }, - endSseClient: (cid, res) => { - const set = _subscribers.get(cid); - if (set) { - set.delete(res); - if (set.size === 0) _subscribers.delete(cid); - } - }, broadcastTokenRotated: () => {}, // ---- the B03 helpers under test ---- pushAuthRequest: ({ requestId, action, ctx, expiresAt }) => { @@ -136,7 +123,6 @@ before(async (t) => { beforeEach(() => { _sseFrames = []; - _subscribers = new Map(); _resetForTests(); }); @@ -167,7 +153,7 @@ describe("authorize — happy paths", () => { assert.equal(getPendingCount(), 1); const [requestId] = getPendingRequestIds(); assert.ok(requestId); - // SSE event was emitted on push + // control event was emitted on push assert.equal(_sseFrames.length, 1); assert.equal(_sseFrames[0].event, "needs_authorization"); assert.equal(_sseFrames[0].action, "slash.clear"); @@ -201,7 +187,7 @@ describe("authorize — happy paths", () => { assert.equal(r.approved, true); assert.equal(r.decidedBy, "bypass"); assert.equal(getPendingCount(), 0); - assert.equal(_sseFrames.length, 0, "bypass must NOT push needs_authorization SSE"); + assert.equal(_sseFrames.length, 0, "bypass must NOT push a needs_authorization control frame"); }); }); @@ -250,11 +236,11 @@ describe("authorize — 5-minute default timeout (fail-closed)", () => { } assert.equal(r.approved, false); assert.equal(r.decidedBy, "timeout"); - // SSE mirror for other tabs + // control-frame mirror for other tabs assert.ok( _sseFrames.some((f) => f.event === "authorization_decided" && f.approved === false && f.decidedBy === "timeout"), - "timeout must broadcast authorization_decided SSE", + "timeout must broadcast the authorization_decided control frame", ); }); @@ -402,9 +388,9 @@ describe("authorize — no test-mode auto-approve (static guard)", () => { }); // ============================================================ -// SSE emission contract +// control-event emission contract // ============================================================ -describe("pushAuthRequest / pushAuthDecision — SSE contracts", () => { +describe("pushAuthRequest / pushAuthDecision — control-frame contracts", () => { test("authorize emits exactly one needs_authorization frame per call", () => { authorize("slash.clear", { cid: "tab-s1" }, {}); assert.equal(_sseFrames.length, 1); diff --git a/packages/webui/checks/lib-state-bus.check.mjs b/packages/webui/checks/lib-state-bus.check.mjs deleted file mode 100644 index 90e4669e..00000000 --- a/packages/webui/checks/lib-state-bus.check.mjs +++ /dev/null @@ -1,432 +0,0 @@ -// webui/test/lib-state-bus.test.js -// Unit tests for the Lease C04 SSE coalescing + diff gate added to -// server/lib/state-bus.js. Verifies: -// - STATE_PUSH_THROTTLE_MS env var controls the throttle window -// - Diff gate: identical payloads after a write are suppressed (no -// redundant full-state replaces hit the client) -// - Coalescing window: within STATE_PUSH_THROTTLE_MS, multiple -// pushes with DIFFERENT payloads coalesce to one write (last-call- -// wins), so the per-cid write rate is capped at ~1/throttle Hz -// - Fresh-client detection: when a cid gets a different SSE res -// (re-connect / test reset), the next push writes immediately -// regardless of diff or throttle state -// - setSseClient + endSseClient reset coalesce state for that cid -// - resetCoalesceState() / flushPendingPushes() / peekLastPushed() -// are usable as test escape hatches -// -// Why this file is separate from test/state-bus.test.js: that file -// was written for the pre-coalescer sync-write contract. Adding 150+ -// lines of throttle-specific tests would crowd an already-large file; -// the C04 tests live here so the existing file's contract remains -// visible at a glance. - -import { test, describe, before, beforeEach, after } from "node:test"; -import assert from "node:assert/strict"; -import { - setupMocks, - absPath, - registerAcpMock, - registerSessionsStore, -} from "../test/_setup.js"; - -let pushStateFor, pushOnlineCount; -let clients, sseByCid, makeClientState; -let STATE_PUSH_THROTTLE_MS; -let resetCoalesceState, flushPendingPushes; -let peekLastPushed, peekLastWriteTs; -let setSseClient, endSseClient; - -before(async (t) => { - await setupMocks(t); - const mod = await import(absPath("lib/state-bus.js")); - pushStateFor = mod.pushStateFor; - pushOnlineCount = mod.pushOnlineCount; - clients = mod.clients; - sseByCid = mod.sseByCid; - makeClientState = mod.makeClientState; - STATE_PUSH_THROTTLE_MS = mod.STATE_PUSH_THROTTLE_MS; - resetCoalesceState = mod.resetCoalesceState; - flushPendingPushes = mod.flushPendingPushes; - peekLastPushed = mod.peekLastPushed; - peekLastWriteTs = mod.peekLastWriteTs; - setSseClient = mod.setSseClient; - endSseClient = mod.endSseClient; -}); - -beforeEach(() => { - // Reset coalesce state between tests — production throttles stay - // live, but each test starts from a clean slate so diff cache / - // throttle timestamps from the previous test don't bleed in. - resetCoalesceState(); - clients.clear(); - sseByCid.clear(); - registerAcpMock({ - getMcodeSessionsForWorkspace: async () => [], - getMcodeSessionsCacheSync: () => null, - getMcodeSessionsStaleSync: () => null, - }); - registerSessionsStore({ - initial: [ - { - id: "sess-1", - title: "old", - workspace: "/w", - createdAt: 1, - updatedAt: 1, - chat: [], - }, - ], - }); -}); - -function fakeSse() { - const writes = []; - return { - writes, - write: (chunk) => { - writes.push(chunk); - }, - }; -} - -function sleep(ms) { - return new Promise((r) => setTimeout(r, ms)); -} - -// ============================================================ -// Throttle knob: STATE_PUSH_THROTTLE_MS is exported, env-controlled, -// and defaults to 0 (no time-based throttle — only the diff gate is -// always active). -// ============================================================ -describe("STATE_PUSH_THROTTLE_MS — env-driven throttle knob", () => { - test("default value is a non-negative integer (env-unset → 0)", () => { - assert.equal(typeof STATE_PUSH_THROTTLE_MS, "number"); - assert.ok(STATE_PUSH_THROTTLE_MS >= 0); - }); - - test("resetCoalesceState clears pending timers + diff cache", () => { - const cid = "cid-rst-1"; - const res = fakeSse(); - clients.set(cid, makeClientState()); - sseByCid.set(cid, res); - pushStateFor(cid, { mcodeSessions: [{ id: "x" }] }); - assert.ok(peekLastPushed(cid), "first push writes + caches payload"); - resetCoalesceState(); - assert.equal(peekLastPushed(cid), undefined, - "resetCoalesceState clears _lastPushedByCid"); - assert.equal(peekLastWriteTs(cid), undefined, - "resetCoalesceState clears _lastWriteTsByCid"); - }); - - test("flushPendingPushes returns count of cids flushed", () => { - // With default throttle=0, no pending builds up — 0 flushed - const a = fakeSse(), - b = fakeSse(); - clients.set("a", makeClientState()); - clients.set("b", makeClientState()); - sseByCid.set("a", a); - sseByCid.set("b", b); - pushStateFor("a", { mcodeSessions: [] }); - pushStateFor("b", { mcodeSessions: [] }); - // Both pushed synchronously (throttle=0); nothing pending - assert.equal(flushPendingPushes(), 0); - }); -}); - -// ============================================================ -// Diff gate: identical payloads after the last write are suppressed. -// Always-on regardless of STATE_PUSH_THROTTLE_MS value. -// ============================================================ -describe("diff gate — identical payloads suppressed (always on)", () => { - test("second push with identical bytes writes nothing", () => { - const cid = "cid-diff-1"; - const res = fakeSse(); - clients.set(cid, makeClientState()); - sseByCid.set(cid, res); - pushStateFor(cid, { mcodeSessions: [{ id: "v1" }] }); - assert.equal(res.writes.length, 1, "first push writes"); - pushStateFor(cid, { mcodeSessions: [{ id: "v1" }] }); - assert.equal(res.writes.length, 1, - "second push with identical bytes is dropped — diff gate"); - }); - - test("third push with identical bytes still suppressed", () => { - const cid = "cid-diff-2"; - const res = fakeSse(); - clients.set(cid, makeClientState()); - sseByCid.set(cid, res); - pushStateFor(cid, { mcodeSessions: [{ id: "v1" }] }); - pushStateFor(cid, { mcodeSessions: [{ id: "v1" }] }); - pushStateFor(cid, { mcodeSessions: [{ id: "v1" }] }); - assert.equal(res.writes.length, 1, - "all three identical pushes coalesce to one wire frame"); - }); - - test("different payload after a write goes through", () => { - const cid = "cid-diff-3"; - const res = fakeSse(); - clients.set(cid, makeClientState()); - sseByCid.set(cid, res); - pushStateFor(cid, { mcodeSessions: [{ id: "v1" }] }); - pushStateFor(cid, { mcodeSessions: [{ id: "v2" }] }); - assert.equal(res.writes.length, 2, - "different bytes pass the diff gate"); - }); - - test("peekLastPushed returns last successful wire payload", () => { - const cid = "cid-diff-4"; - const res = fakeSse(); - clients.set(cid, makeClientState()); - sseByCid.set(cid, res); - assert.equal(peekLastPushed(cid), undefined, - "no record before first push"); - pushStateFor(cid, { mcodeSessions: [{ id: "x" }] }); - const cached = peekLastPushed(cid); - assert.ok(cached, "peekLastPushed returns the cached JSON"); - assert.match(cached, /"x"/, "cached payload contains the data"); - }); - - test("peekLastWriteTs returns ms timestamp of last successful write", () => { - const cid = "cid-diff-5"; - const res = fakeSse(); - clients.set(cid, makeClientState()); - sseByCid.set(cid, res); - assert.equal(peekLastWriteTs(cid), undefined); - const before = Date.now(); - pushStateFor(cid, { mcodeSessions: [{ id: "t" }] }); - const after = Date.now(); - const ts = peekLastWriteTs(cid); - assert.ok(ts >= before && ts <= after, - "timestamp is between before/after Date.now() bounds"); - }); -}); - -// ============================================================ -// Fresh-client detection: when a cid's res changes, all coalesce -// state for that cid is cleared and the next push writes immediately. -// ============================================================ -describe("fresh-client detection — new res resets coalesce state", () => { - test("first pushStateFor writes unconditionally (no prior state)", () => { - const cid = "cid-fresh-1"; - const res = fakeSse(); - clients.set(cid, makeClientState()); - sseByCid.set(cid, res); - pushStateFor(cid, { mcodeSessions: [{ id: "fresh" }] }); - assert.equal(res.writes.length, 1, - "first push to a never-seen cid writes synchronously"); - }); - - test("re-binding via setSseClient clears prior coalesce state", () => { - const cid = "cid-fresh-2"; - const oldRes = fakeSse(); - clients.set(cid, makeClientState()); - sseByCid.set(cid, oldRes); - pushStateFor(cid, { mcodeSessions: [{ id: "old" }] }); - assert.equal(oldRes.writes.length, 1); - // New client connects (simulated by setSseClient with new res) - const newRes = fakeSse(); - sseByCid.set(cid, newRes); - setSseClient(cid, newRes); - // Diff cache is fresh — push with identical payload should - // still write (the new client hasn't seen any prior frame). - pushStateFor(cid, { mcodeSessions: [{ id: "old" }] }); - assert.equal(newRes.writes.length, 1, - "after setSseClient, identical payload writes to new res"); - assert.equal(oldRes.writes.length, 1, - "old res not re-touched (different res object)"); - }); - - test("endSseClient drops coalesce state for the disconnected cid", () => { - const cid = "cid-fresh-3"; - const res = fakeSse(); - clients.set(cid, makeClientState()); - sseByCid.set(cid, res); - pushStateFor(cid, { mcodeSessions: [{ id: "v1" }] }); - assert.equal(peekLastPushed(cid).length > 0, true); - endSseClient(cid, res); - assert.equal(peekLastPushed(cid), undefined, - "endSseClient clears _lastPushedByCid for that cid"); - assert.equal(peekLastWriteTs(cid), undefined, - "endSseClient clears _lastWriteTsByCid for that cid"); - }); -}); - -// ============================================================ -// Coalescing under explicit throttle: with the throttle set to a -// short window via a fresh import, multiple pushes within the window -// coalesce to one write. -// ============================================================ -describe("coalescing window — explicit throttle via env", () => { - // Re-import with env var set to a tight window. We can't mutate - // STATE_PUSH_THROTTLE_MS at runtime (it's a module-level const), - // but the import resolves the env at load time, so an isolated - // dynamic import with env set gives us a fresh module instance - // with the throttle knob turned up. - let throttledMod; - let originalEnv; - before(() => { - originalEnv = process.env.STATE_PUSH_THROTTLE_MS; - process.env.STATE_PUSH_THROTTLE_MS = "20"; - // Cache-bust the module so the new env is read on import - return import(absPath("lib/state-bus.js") + "?bust=" + Date.now()) - .then((m) => { - throttledMod = m; - }); - }); - after(() => { - if (originalEnv === undefined) delete process.env.STATE_PUSH_THROTTLE_MS; - else process.env.STATE_PUSH_THROTTLE_MS = originalEnv; - }); - - test("first push in window writes synchronously", async () => { - const cid = "cid-coal-1"; - const res = fakeSse(); - throttledMod.clients.set(cid, throttledMod.makeClientState()); - throttledMod.sseByCid.set(cid, res); - throttledMod.pushStateFor(cid, { mcodeSessions: [{ id: "first" }] }); - assert.equal(res.writes.length, 1, - "first push in window writes immediately (sync contract)"); - }); - - test("subsequent pushes in window are deferred (not written sync)", () => { - const cid = "cid-coal-2"; - const res = fakeSse(); - throttledMod.clients.set(cid, throttledMod.makeClientState()); - throttledMod.sseByCid.set(cid, res); - throttledMod.pushStateFor(cid, { mcodeSessions: [{ id: "v1" }] }); - // Three more pushes within microseconds — all within window - throttledMod.pushStateFor(cid, { mcodeSessions: [{ id: "v2" }] }); - throttledMod.pushStateFor(cid, { mcodeSessions: [{ id: "v3" }] }); - throttledMod.pushStateFor(cid, { mcodeSessions: [{ id: "v4" }] }); - assert.equal(res.writes.length, 1, - "4 pushes within window → 1 wire frame so far (3 deferred)"); - }); - - test("after window expires, last pending flushes to wire", async () => { - const cid = "cid-coal-3"; - const res = fakeSse(); - throttledMod.clients.set(cid, throttledMod.makeClientState()); - throttledMod.sseByCid.set(cid, res); - throttledMod.pushStateFor(cid, { mcodeSessions: [{ id: "v1" }] }); - throttledMod.pushStateFor(cid, { mcodeSessions: [{ id: "v4" }] }); - // Wait past the 20ms window - await sleep(40); - assert.equal(res.writes.length, 2, - "after window: pending flushes (last-call-wins v4)"); - const lastWrite = JSON.parse(res.writes[1].slice(6)); - assert.deepEqual(lastWrite.mcodeSessions, [{ id: "v4" }], - "flushed payload is the latest (last-call-wins)"); - }); - - test("flushPendingPushes forces immediate flush", () => { - const cid = "cid-coal-4"; - const res = fakeSse(); - throttledMod.clients.set(cid, throttledMod.makeClientState()); - throttledMod.sseByCid.set(cid, res); - throttledMod.pushStateFor(cid, { mcodeSessions: [{ id: "v1" }] }); - throttledMod.pushStateFor(cid, { mcodeSessions: [{ id: "v2" }] }); - const flushed = throttledMod.flushPendingPushes(); - assert.ok(flushed >= 1, "flushPendingPushes reports cids flushed"); - assert.equal(res.writes.length, 2, - "immediate flush emits the pending wire frame"); - }); - - test("pushes outside window each write (60Hz cap is a soft ceiling)", async () => { - const cid = "cid-coal-5"; - const res = fakeSse(); - throttledMod.clients.set(cid, throttledMod.makeClientState()); - throttledMod.sseByCid.set(cid, res); - throttledMod.pushStateFor(cid, { mcodeSessions: [{ id: "a" }] }); - await sleep(25); // > 20ms window - throttledMod.pushStateFor(cid, { mcodeSessions: [{ id: "b" }] }); - await sleep(25); - throttledMod.pushStateFor(cid, { mcodeSessions: [{ id: "c" }] }); - await sleep(25); - assert.equal(res.writes.length, 3, - "three pushes spaced >window each write"); - }); -}); - -// ============================================================ -// pushOnlineCount + broadcast go through the same gate -// ============================================================ -describe("pushOnlineCount + broadcast — same diff gate", () => { - test("pushOnlineCount identical burst is coalesced to 1 write", () => { - const cid = "cid-oc-1"; - const res = fakeSse(); - clients.set(cid, makeClientState()); - sseByCid.set(cid, res); - pushOnlineCount(false); - pushOnlineCount(false); - pushOnlineCount(false); - assert.equal(res.writes.length, 1, - "3 identical pushOnlineCount calls → 1 wire frame"); - }); - - test("broadcast with two clients coalesces per-client", () => { - const a = fakeSse(), - b = fakeSse(); - clients.set("a", makeClientState()); - clients.set("b", makeClientState()); - sseByCid.set("a", a); - sseByCid.set("b", b); - pushStateFor("__broadcast__"); - pushStateFor("__broadcast__"); - assert.equal(a.writes.length, 1, - "client a receives 1 wire frame (diff gate)"); - assert.equal(b.writes.length, 1, - "client b receives 1 wire frame (diff gate)"); - }); -}); - -// ============================================================ -// qa (OOM hardening): write backpressure + dead-socket guard -// ============================================================ -describe("_writeNow — backpressure / dead-socket guard", () => { - test("skips the write when the socket is backed up (writableNeedDrain)", async () => { - const cid = "cid-bp-1"; - const res = fakeSse(); - res.writableNeedDrain = true; - clients.set(cid, makeClientState()); - sseByCid.set(cid, res); - pushStateFor(cid, { mcodeSessions: [{ id: "x" }] }); - assert.equal(res.writes.length, 0, "backed-up socket gets no frame"); - // skip must NOT update the diff cache — after the socket drains, - // re-pushing the same payload must actually reach the wire - res.writableNeedDrain = false; - pushStateFor(cid, { mcodeSessions: [{ id: "x" }] }); - assert.equal(res.writes.length, 1, - "same payload delivered once the socket drains"); - }); - - test("skips writes to destroyed / ended responses", () => { - const destroyed = fakeSse(); - destroyed.destroyed = true; - clients.set("cid-bp-2a", makeClientState()); - sseByCid.set("cid-bp-2a", destroyed); - pushStateFor("cid-bp-2a", { mcodeSessions: [] }); - assert.equal(destroyed.writes.length, 0, "destroyed res receives nothing"); - - const ended = fakeSse(); - ended.writableEnded = true; - clients.set("cid-bp-2b", makeClientState()); - sseByCid.set("cid-bp-2b", ended); - pushStateFor("cid-bp-2b", { mcodeSessions: [] }); - assert.equal(ended.writes.length, 0, "ended res receives nothing"); - }); - - test("endSseClient drops the dead res reference (no retention)", async () => { - const cid = "cid-bp-3"; - const res = fakeSse(); - clients.set(cid, makeClientState()); - setSseClient(cid, res); - pushStateFor(cid, { mcodeSessions: [] }); - assert.equal(res.writes.length, 1); - const mod = await import(absPath("lib/state-bus.js")); - assert.equal(mod.peekLastPushedRes(cid), res, - "res reference cached while connected"); - endSseClient(cid, res); - assert.equal(mod.peekLastPushedRes(cid), undefined, - "endSseClient drops the res reference (dead socket not retained)"); - }); -}); \ No newline at end of file diff --git a/packages/webui/checks/routes-alerts.check.mjs b/packages/webui/checks/routes-alerts.check.mjs index 1e4002b4..a8d7f510 100644 --- a/packages/webui/checks/routes-alerts.check.mjs +++ b/packages/webui/checks/routes-alerts.check.mjs @@ -1,20 +1,25 @@ // webui/test/routes-alerts.test.js -// Unit tests for server/routes/alerts.js — handleAlerts SSE endpoint. +// Unit tests for server/routes/alerts.js — handleAlerts REST snapshot endpoint. // -// Why this test exists: lease B02 wires /api/alerts (anomaly SSE). -// The route must: -// 1. Reply with text/event-stream + correct headers -// 2. Replay the ring buffer as a `snapshot` frame -// 3. Forward subsequent pushAlert() frames to the subscriber -// 4. Tear down heartbeat + subscriber on `req.on("close")` +// Why this test exists: lease B02 wires /api/alerts (bell-icon data +// source). With SSE removed (decision 20 — WebSocket event stream + +// REST only) the route collapsed to a plain REST snapshot: +// GET /api/alerts → 200 application/json +// → body {"kind":"snapshot","alerts":[...]} (the ring +// buffer from getRecentAlerts()). +// Live append/update traffic rides the /api/stream WebSocket as the +// alerts.append / alerts.update control frames (state-bus' +// attachAlertBridge) — so there is no streaming header, heartbeat, or +// subscriber bookkeeping left to assert on this route. // -// Test strategy: NO setupMocks — alerts.js is the only dep and is -// pure. We synthesize a fake req/res with a write hook and exercise -// the live broadcast path. +// Test strategy: NO setupMocks — the route's only dependency is the +// pure alerts.js. pushAlert is THE single write point and state-bus +// re-exports the exact same function as its chokepoint alias, so +// injecting via alertsLib.pushAlert below is semantically identical to +// calling state-bus.pushAlert(). import { test, describe, beforeEach } from "node:test"; import assert from "node:assert/strict"; -import { EventEmitter } from "node:events"; import { pathToFileURL } from "node:url"; import { join } from "node:path"; @@ -24,113 +29,78 @@ const absPath = (rel) => const alertsRoute = await import(absPath("routes/alerts.js")); const alertsLib = await import(absPath("lib/alerts.js")); +// Fake res covering both writeHead-style and setHeader-style JSON +// responses: status + headers land in _headers, body accumulates from +// write()/end() chunks. function fakeRes() { const res = { _status: null, - _headers: null, - writes: [], + _headers: {}, + _body: "", writeHead(s, h) { this._status = s; - this._headers = h; + if (h) this._headers = h; + }, + setHeader(k, v) { + this._headers[k] = v; }, write(chunk) { - this.writes.push(chunk); + this._body += String(chunk); return true; }, + end(chunk) { + if (chunk !== undefined) this._body += String(chunk); + }, + headerOf(name) { + for (const [k, v] of Object.entries(this._headers)) { + if (k.toLowerCase() === String(name).toLowerCase()) return String(v); + } + return ""; + }, }; return res; } -function fakeReq() { - const req = new EventEmitter(); - return req; -} - -describe("handleAlerts — /api/alerts", () => { +describe("handleAlerts — GET /api/alerts (REST)", () => { beforeEach(() => { alertsLib._resetForTests(); }); - test("returns 200 with SSE headers", async () => { - const req = fakeReq(); + test("returns 200 application/json with kind=snapshot + alerts array", async () => { const res = fakeRes(); - const handled = await alertsRoute.handleAlerts(req, res, { cid: "c" }); - assert.equal(handled, true); + await alertsRoute.handleAlerts({}, res, {}); assert.equal(res._status, 200); - assert.match(res._headers["Content-Type"], /text\/event-stream/); - assert.match(res._headers["Cache-Control"], /no-cache/); - assert.equal(res._headers["X-Accel-Buffering"], "no"); - // prevent the heartbeat interval from keeping the test loop alive - req.emit("close"); - }); - - test("first frame is a snapshot of the ring buffer", async () => { - alertsLib.pushAlert({ level: "info", msg: "first", src: "s" }); - alertsLib.pushAlert({ level: "error", msg: "second", src: "s" }); - const req = fakeReq(); - const res = fakeRes(); - await alertsRoute.handleAlerts(req, res, {}); - const snapFrame = JSON.parse(res.writes[0].slice(6)); - assert.equal(snapFrame.kind, "snapshot"); - assert.equal(snapFrame.alerts.length, 2); - assert.equal(snapFrame.alerts[0].msg, "first"); - assert.equal(snapFrame.alerts[1].msg, "second"); - req.emit("close"); + assert.ok( + res.headerOf("Content-Type").includes("application/json"), + `Content-Type must be application/json, got: ${res.headerOf("Content-Type")}`, + ); + const body = JSON.parse(res._body); + assert.equal(body.kind, "snapshot"); + assert.ok(Array.isArray(body.alerts), "body.alerts must be an array"); + // Fresh ring buffer (beforeEach reset) → empty snapshot. + assert.equal(body.alerts.length, 0); }); - test("live pushAlert after subscribe is forwarded as an `append` frame", async () => { - const req = fakeReq(); - const res = fakeRes(); - await alertsRoute.handleAlerts(req, res, {}); - const writesBefore = res.writes.length; - - alertsLib.pushAlert({ - level: "warn", - msg: "live", + test("snapshot carries an injected alert from the pushAlert chokepoint", async () => { + // state-bus.pushAlert re-exports this exact function — injecting + // here IS the state-bus.pushAlert path. + const injected = alertsLib.pushAlert({ + level: "error", + msg: "boom", src: "s", - cid: "cid-x", + cid: "cid-rest", }); - - assert.equal(res.writes.length, writesBefore + 1); - const liveFrame = JSON.parse( - res.writes[res.writes.length - 1].slice(6), - ); - assert.equal(liveFrame.kind, "append"); - assert.equal(liveFrame.alert.msg, "live"); - assert.equal(liveFrame.alert.cid, "cid-x"); - req.emit("close"); - }); - - test("req close → subscriber is removed", async () => { - const req = fakeReq(); - const res = fakeRes(); - await alertsRoute.handleAlerts(req, res, {}); - assert.equal(alertsLib.getSubscriberCount(), 1); - req.emit("close"); - assert.equal(alertsLib.getSubscriberCount(), 0); - }); - - test("req close → no further frames after unsubscribe", async () => { - const req = fakeReq(); const res = fakeRes(); - await alertsRoute.handleAlerts(req, res, {}); - const writesBefore = res.writes.length; - req.emit("close"); - alertsLib.pushAlert({ level: "info", msg: "post-close", src: "s" }); - assert.equal(res.writes.length, writesBefore); - }); - - test("returns true on client already gone (write throws during replay)", async () => { - const req = fakeReq(); - const res = { - writeHead() {}, - write() { - throw new Error("socket hang up"); - }, - }; - const handled = await alertsRoute.handleAlerts(req, res, {}); - assert.equal(handled, true); - // cleanup - req.emit("close"); + await alertsRoute.handleAlerts({}, res, {}); + const body = JSON.parse(res._body); + assert.equal(body.kind, "snapshot"); + const hit = body.alerts.find((a) => a.id === injected.id); + assert.ok( + hit, + `injected alert must appear in the snapshot, got: ${res._body.slice(0, 300)}`, + ); + assert.equal(hit.msg, "boom"); + assert.equal(hit.level, "error"); + assert.equal(hit.cid, "cid-rest"); }); -}); \ No newline at end of file +}); diff --git a/packages/webui/checks/state-bus.check.mjs b/packages/webui/checks/state-bus.check.mjs index d45d898b..82fd3566 100644 --- a/packages/webui/checks/state-bus.check.mjs +++ b/packages/webui/checks/state-bus.check.mjs @@ -1,14 +1,27 @@ // webui/test/state-bus.test.js // Unit tests for server/lib/state-bus.js — pushStateFor + ensureMcodeSessionsFetchedAndPush // -// Why this test exists: v0.5.bx-31 broadcast bug — when the first SSE -// connection is established and mcodeSessions cache is empty, the -// SUT must fire-and-forget fetch the sessions and then push to all -// connected SSE clients. The dedup test ensures a second call with -// the same workspace is a no-op while the first is in flight. +// Why this test exists: v0.5.bx-31 broadcast bug — when the first event +// stream (/api/stream) subscriber is attached and the mcodeSessions +// cache is empty, the SUT must fire-and-forget fetch the sessions and +// then publish the authoritative state to all subscribed cids. The +// dedup test ensures a second call with the same workspace is a no-op +// while the first is in flight. +// +// [decision 20] SSE 已移除:下行唯一通道是 event-bus(WebSocket +// /api/stream 订阅面),旧 SSE 适配器与 per-cid 连接映射一并删除。本文件的捕获模式: +// const cap = capture(cid); // 触发推送“之前”订阅 +// <触发推送>; +// lastSnapshot(cap.box); // 从尾部反向找 type==="state.snapshot" 的快照 +// snapshotAt(cap.box, n); // 多次推送时取第 N 条快照(原 writes[0] 清空语义) +// onlineCount 语义 = getSubscribedCids().length —— capture 即“在线”。 import { test, describe, before, beforeEach } from "node:test"; import assert from "node:assert/strict"; +import { + subscribeEvents, + resetEventBusForTests, +} from "../server/lib/event-bus.js"; import { setupMocks, absPath, @@ -25,7 +38,9 @@ import { } from "../test/_setup.js"; let pushStateFor, mcodeSessionsSnapshotFields; -let clients, sseByCid, makeClientState; +let clients, makeClientState; +let pushOnlineCount, setActiveChild, getActiveChild, clearActiveChild; +let getCidsByMcodeSession, getClient, getCidFromReq; let acpFetchCalls, cachedByWs; before(async (t) => { @@ -34,8 +49,14 @@ before(async (t) => { pushStateFor = mod.pushStateFor; mcodeSessionsSnapshotFields = mod.mcodeSessionsSnapshotFields; clients = mod.clients; - sseByCid = mod.sseByCid; makeClientState = mod.makeClientState; + pushOnlineCount = mod.pushOnlineCount; + setActiveChild = mod.setActiveChild; + getActiveChild = mod.getActiveChild; + clearActiveChild = mod.clearActiveChild; + getCidsByMcodeSession = mod.getCidsByMcodeSession; + getClient = mod.getClient; + getCidFromReq = mod.getCidFromReq; }); // Mock acp-client to track fetch calls and serve cache from in-memory map @@ -51,9 +72,10 @@ beforeEach(async () => { cachedByWs.has(ws) ? cachedByWs.get(ws) : null, getMcodeSessionsStaleSync: () => null, }); - // Clear clients / sseByCid between tests + // 清 client 状态 + 重置事件总线。注意:reset 之后要重新 capture + // (“先 capture 后触发”),所以下面每个用例都在触发推送前订阅。 clients.clear(); - sseByCid.clear(); + resetEventBusForTests(); registerSessionsStore({ initial: [ { @@ -68,14 +90,36 @@ beforeEach(async () => { }); }); -function fakeSse() { - const writes = []; - return { - writes, - write: (chunk) => { - writes.push(chunk); - }, - }; +// --------------------------------------------------------------------------- +// 事件总线捕获(原 fakeSse + SSE 连接映射模式的等价替换) +// --------------------------------------------------------------------------- +function capture(cid) { + const box = []; + const unsub = subscribeEvents(cid, (item) => box.push(item)); + return { box, unsub }; +} + +function snapshotsOf(box) { + return box + .filter((i) => i && i.event && i.event.type === "state.snapshot") + .map((i) => i.event.snapshot); +} + +// 从尾部反向找最后一条 state.snapshot —— 等价于旧的“读最后一帧写入”。 +function lastSnapshot(box) { + const snaps = snapshotsOf(box); + assert.ok(snaps.length > 0, "expected at least one state.snapshot event"); + return snaps[snaps.length - 1]; +} + +// 多次推送序列里的第 N 条快照(0-based)—— 等价于旧的“清空 writes[0] 再读”。 +function snapshotAt(box, n) { + const snaps = snapshotsOf(box); + assert.ok( + snaps.length > n, + `expected snapshot #${n}, only got ${snaps.length}`, + ); + return snaps[n]; } describe("pushStateFor", () => { @@ -84,10 +128,10 @@ describe("pushStateFor", () => { const cs = makeClientState(); cs.workspace.dir = "/w"; clients.set(cid, cs); - sseByCid.set(cid, fakeSse()); + const cap = capture(cid); const sessions = [{ id: "direct-1" }]; pushStateFor(cid, { mcodeSessions: sessions }); - const payload = JSON.parse(sseByCid.get(cid).writes[0].slice(6)); + const payload = lastSnapshot(cap.box); assert.deepEqual(payload.mcodeSessions, sessions); }); @@ -96,10 +140,10 @@ describe("pushStateFor", () => { const cs = makeClientState(); cs.workspace.dir = "/cached-ws"; clients.set(cid, cs); - sseByCid.set(cid, fakeSse()); + const cap = capture(cid); cachedByWs.set("/cached-ws", [{ id: "cached-1" }]); pushStateFor(cid); - const payload = JSON.parse(sseByCid.get(cid).writes[0].slice(6)); + const payload = lastSnapshot(cap.box); assert.deepEqual(payload.mcodeSessions, [{ id: "cached-1" }]); // No fetch should have been triggered assert.equal(acpFetchCalls.length, 0); @@ -110,72 +154,50 @@ describe("pushStateFor", () => { const cs = makeClientState(); cs.workspace.dir = "/uncached-ws"; clients.set(cid, cs); - sseByCid.set(cid, fakeSse()); + const cap = capture(cid); pushStateFor(cid); - const payload = JSON.parse(sseByCid.get(cid).writes[0].slice(6)); + // 断言第一条(同步)快照:cache miss → 空占位立即发布。异步的 + // authoritative 推送走 microtask,不会落在这个同步断言里。 + const payload = snapshotAt(cap.box, 0); // Immediately sees [] (cache miss → empty placeholder) assert.deepEqual(payload.mcodeSessions, []); }); }); describe('pushStateFor "__broadcast__"', () => { - test("iterates all connected SSE clients", () => { - const a = fakeSse(), - b = fakeSse(); + test("publishes one snapshot to every subscribed cid", () => { clients.set("a", makeClientState()); - sseByCid.set("a", a); clients.set("b", makeClientState()); - sseByCid.set("b", b); + const ca = capture("a"); + const cb = capture("b"); pushStateFor("__broadcast__", { mcodeSessions: [{ id: "bcast" }] }); - assert.equal(a.writes.length, 1); - assert.equal(b.writes.length, 1); - const pa = JSON.parse(a.writes[0].slice(6)); - const pb = JSON.parse(b.writes[0].slice(6)); - assert.deepEqual(pa.mcodeSessions, [{ id: "bcast" }]); - assert.deepEqual(pb.mcodeSessions, [{ id: "bcast" }]); + assert.equal(snapshotsOf(ca.box).length, 1); + assert.equal(snapshotsOf(cb.box).length, 1); + assert.deepEqual(lastSnapshot(ca.box).mcodeSessions, [{ id: "bcast" }]); + assert.deepEqual(lastSnapshot(cb.box).mcodeSessions, [{ id: "bcast" }]); }); }); // ============================================================ -// 批次 D 扩展: 覆盖 SSE 频道管理 + active child 管理 +// 批次 D 扩展: 覆盖事件流订阅面(在线数)+ active child 管理 // ============================================================ -let pushOnlineCount, setActiveChild, getActiveChild, clearActiveChild; -let getCidsByMcodeSession, getSseClient, setSseClient, endSseClient; -let getClient, getCidFromReq; - -before(async () => { - const sb = await import(absPath("lib/state-bus.js")); - pushOnlineCount = sb.pushOnlineCount; - setActiveChild = sb.setActiveChild; - getActiveChild = sb.getActiveChild; - clearActiveChild = sb.clearActiveChild; - getCidsByMcodeSession = sb.getCidsByMcodeSession; - getSseClient = sb.getSseClient; - setSseClient = sb.setSseClient; - endSseClient = sb.endSseClient; - getClient = sb.getClient; - getCidFromReq = sb.getCidFromReq; -}); - describe("pushOnlineCount", () => { - test("broadcasts online count to all connected SSE clients", () => { - const a = fakeSse(), - b = fakeSse(); + test("publishes online count to every subscribed stream client", () => { clients.set("a", makeClientState()); - sseByCid.set("a", a); clients.set("b", makeClientState()); - sseByCid.set("b", b); + const ca = capture("a"); + const cb = capture("b"); pushOnlineCount(false); - assert.equal(a.writes.length, 1); - assert.equal(b.writes.length, 1); - // payload should have onlineCount=2 - const pa = JSON.parse(a.writes[0].slice(6)); + assert.equal(snapshotsOf(ca.box).length, 1); + assert.equal(snapshotsOf(cb.box).length, 1); + // onlineCount 语义 = getSubscribedCids().length → 两个订阅 cid = 2 + const pa = lastSnapshot(ca.box); assert.equal(pa.onlineCount, 2); }); - test("does not throw when no SSE clients connected", () => { - sseByCid.clear(); + test("does not throw when no stream clients are subscribed", () => { + resetEventBusForTests(); assert.doesNotThrow(() => pushOnlineCount(false)); }); }); @@ -243,46 +265,6 @@ describe("getCidsByMcodeSession", () => { }); }); -describe("SSE channel helpers", () => { - test("getSseClient returns null for unregistered cid", () => { - assert.equal(getSseClient("never-set"), null); - }); - - test("setSseClient + getSseClient round-trip", () => { - const cid = "cid-sse-1"; - const res = fakeSse(); - setSseClient(cid, res); - assert.strictEqual(getSseClient(cid), res); - }); - - test("setSseClient for same cid overwrites previous", () => { - const cid = "cid-sse-2"; - const a = fakeSse(); - const b = fakeSse(); - setSseClient(cid, a); - setSseClient(cid, b); - assert.strictEqual(getSseClient(cid), b, "should overwrite"); - }); - - test("endSseClient clears the map entry", () => { - const cid = "cid-sse-3"; - const res = fakeSse(); - setSseClient(cid, res); - endSseClient(cid, res); - assert.equal(getSseClient(cid), null); - }); - - test("endSseClient with mismatched res does NOT clear (race-safe)", () => { - const cid = "cid-sse-4"; - const a = fakeSse(); - const b = fakeSse(); - setSseClient(cid, a); - // Caller passes a different res (stale) - endSseClient(cid, b); - assert.strictEqual(getSseClient(cid), a, "should still be a, not cleared"); - }); -}); - describe("getClient + getCidFromReq", () => { test("getClient creates a fresh state for unknown cid", () => { const cid = "cid-fresh-1"; @@ -323,7 +305,7 @@ describe("getClient + getCidFromReq", () => { // ============================================================ // v1.0 推送字段回归 — 侧栏闪跌三连修: -// 1) pushOnlineCount / SSE 首推曾不带 mcodeSessions → 客户端 undefined 闪跌 +// 1) pushOnlineCount / 首推曾不带 mcodeSessions → 客户端 undefined 闪跌 // 2) 缓存过期时曾推空占位 → 闪跌后弹回 // 3) 统一走 mcodeSessionsSnapshotFields, 过期推旧值 (pending=true) // ============================================================ @@ -334,9 +316,9 @@ describe("v1.0 push fields — mcodeSessions 永不缺失、永不空占位", () const cs = makeClientState(); cs.workspace.dir = "/w"; clients.set(cid, cs); - sseByCid.set(cid, fakeSse()); + const cap = capture(cid); pushOnlineCount(true); - const payload = JSON.parse(sseByCid.get(cid).writes[0].slice(6)); + const payload = lastSnapshot(cap.box); assert.ok(Array.isArray(payload.mcodeSessions), "回归: pushOnlineCount 曾不带该字段, 客户端整包替换后 undefined → 侧栏闪跌"); assert.equal(payload.mcodeSessions.length, 1); @@ -350,9 +332,9 @@ describe("v1.0 push fields — mcodeSessions 永不缺失、永不空占位", () const cs = makeClientState(); cs.workspace.dir = "/w"; clients.set(cid, cs); - sseByCid.set(cid, fakeSse()); + const cap = capture(cid); pushOnlineCount(true); - const payload = JSON.parse(sseByCid.get(cid).writes[0].slice(6)); + const payload = lastSnapshot(cap.box); assert.ok(Array.isArray(payload.mcodeSessions)); assert.equal(payload.mcodeSessions.length, 2, "过期值好过空值 — 不允许闪跌到空列表"); assert.equal(payload.mcodeSessionsPending, true); @@ -382,9 +364,9 @@ describe("v1.0 push fields — mcodeSessions 永不缺失、永不空占位", () const cs = makeClientState(); cs.workspace.dir = "/w"; clients.set(cid, cs); - sseByCid.set(cid, fakeSse()); + const cap = capture(cid); pushStateFor(cid); - const payload = JSON.parse(sseByCid.get(cid).writes[0].slice(6)); + const payload = lastSnapshot(cap.box); assert.ok(Array.isArray(payload.mcodeSessions)); assert.equal(payload.mcodeSessions.length, 1); assert.equal(payload.mcodeSessionsPending, true); @@ -396,10 +378,10 @@ describe("v1.0 push fields — mcodeSessions 永不缺失、永不空占位", () // pushStateFor / pushOnlineCount / ensureMcodeSessionsFetchedAndPush // 三个推送点的 snapshot 都必须带 quotaEnabled / hasTokenPlanKey / // tokenPlanApiKeyMasked. 之前只走 settings.snapshot (one-shot -// loadLanInfo), SSE 整包替换 state 后 quotaEnabled 被冲掉, +// loadLanInfo), 整包替换 state 后 quotaEnabled 被冲掉, // "启用套餐用量" toggle 视觉上无反应 — btn-usage 一直 hidden. // ============================================================ -describe("v2026-08-28 modacker: Token Plan fields — 每次 SSE 推送必须带 quota 三件", () => { +describe("v2026-08-28 modacker: Token Plan fields — 每次推送必须带 quota 三件", () => { test("pushStateFor (单播) 带 quotaEnabled / hasTokenPlanKey / tokenPlanApiKeyMasked", () => { setQuotaEnabled(true); setTokenPlanApiKey("eyJhbGciOiJIUzI1NiJ9.payload.signature"); @@ -407,18 +389,18 @@ describe("v2026-08-28 modacker: Token Plan fields — 每次 SSE 推送必须带 const cs = makeClientState(); cs.workspace.dir = "/w"; clients.set(cid, cs); - sseByCid.set(cid, fakeSse()); + const cap = capture(cid); pushStateFor(cid); - const payload = JSON.parse(sseByCid.get(cid).writes[0].slice(6)); - assert.equal(payload.quotaEnabled, true, "回归: 该字段缺失则 SSE 替换 state 后 toggle 失效"); + const payload = lastSnapshot(cap.box); + assert.equal(payload.quotaEnabled, true, "回归: 该字段缺失则 替换 state 后 toggle 失效"); assert.equal(payload.hasTokenPlanKey, true); assert.equal(payload.tokenPlanApiKeyMasked, "sk-cp-...ture", "masked 形如 sk-cp-...XXXX, 不能回传原始 key"); - // 反向: 关掉时三件同步更新 + // 反向: 关掉时三件同步更新(第二条快照) setQuotaEnabled(false); - sseByCid.get(cid).writes.length = 0; pushStateFor(cid); - const p2 = JSON.parse(sseByCid.get(cid).writes[0].slice(6)); + const p2 = lastSnapshot(cap.box); + assert.notStrictEqual(p2, payload, "第二次推送必须发布新的快照事件"); assert.equal(p2.quotaEnabled, false); assert.equal(p2.hasTokenPlanKey, false, "setQuotaEnabled(false) 应当清空 key, hasTokenPlanKey 必须反映"); @@ -434,11 +416,11 @@ describe("v2026-08-28 modacker: Token Plan fields — 每次 SSE 推送必须带 const cidA = "q-A", cidB = "q-B"; clients.set(cidA, makeClientState()); clients.set(cidB, makeClientState()); - sseByCid.set(cidA, fakeSse()); - sseByCid.set(cidB, fakeSse()); + const capA = capture(cidA); + const capB = capture(cidB); pushStateFor("__broadcast__"); - for (const cid of [cidA, cidB]) { - const payload = JSON.parse(sseByCid.get(cid).writes[0].slice(6)); + for (const [cid, cap] of [[cidA, capA], [cidB, capB]]) { + const payload = lastSnapshot(cap.box); assert.equal(payload.quotaEnabled, true, `client ${cid} 收到 broadcast 必须带 quotaEnabled`); assert.equal(payload.hasTokenPlanKey, true); assert.equal(payload.tokenPlanApiKeyMasked, "sk-cp-...ghij"); @@ -454,9 +436,9 @@ describe("v2026-08-28 modacker: Token Plan fields — 每次 SSE 推送必须带 const cs = makeClientState(); cs.workspace.dir = "/w"; clients.set(cid, cs); - sseByCid.set(cid, fakeSse()); + const cap = capture(cid); pushOnlineCount(true); - const payload = JSON.parse(sseByCid.get(cid).writes[0].slice(6)); + const payload = lastSnapshot(cap.box); assert.equal(payload.quotaEnabled, true, "回归: 之前 pushOnlineCount 不带此字段, 多 tab 打开/关闭时 toggle 被打回"); assert.equal(payload.hasTokenPlanKey, true); @@ -469,23 +451,19 @@ describe("v2026-08-28 modacker: Token Plan fields — 每次 SSE 推送必须带 // ============================================================ // v2026-08-28 modacker (A+C): 外部 key 源优先级链 + source 字段 // getTokenPlanApiKey() 优先级: env > file > settings.json -// 每次 SSE 推送必须带 tokenPlanApiKeySource + tokenPlanApiKeyFilePath +// 每次推送必须带 tokenPlanApiKeySource + tokenPlanApiKeyFilePath // 这两个新字段, webui 据此隐藏 "delete" 按钮 + 显示来源标签。 // 这些测试同时也是 _setup.js mock 跟真实实现行为一致的契约。 // ============================================================ -describe("v2026-08-28 modacker (A+C): external key source 优先级 + SSE 字段", () => { - function snapshotOf(cid) { - return JSON.parse(sseByCid.get(cid).writes[0].slice(6)); - } - +describe("v2026-08-28 modacker (A+C): external key source 优先级 + 推送字段", () => { test("settings.json 路径: source = 'settings', 无 file path", () => { setQuotaEnabled(true); setTokenPlanApiKey("settings-key-1234"); const cid = "src-1"; clients.set(cid, makeClientState()); - sseByCid.set(cid, fakeSse()); + const cap = capture(cid); pushStateFor(cid); - const p = snapshotOf(cid); + const p = lastSnapshot(cap.box); assert.equal(p.tokenPlanApiKeySource, "settings"); assert.equal(p.tokenPlanApiKeyFilePath, ""); assert.equal(p.hasTokenPlanKey, true); @@ -500,9 +478,9 @@ describe("v2026-08-28 modacker (A+C): external key source 优先级 + SSE 字段 setFileTokenPlanKey("file-key-5678", "/tmp/token-plan.json"); const cid = "src-2"; clients.set(cid, makeClientState()); - sseByCid.set(cid, fakeSse()); + const cap = capture(cid); pushStateFor(cid); - const p = snapshotOf(cid); + const p = lastSnapshot(cap.box); assert.equal(p.tokenPlanApiKeySource, "file", "file 路径应当胜过 settings.json (env 缺席时)"); assert.equal(p.tokenPlanApiKeyFilePath, "/tmp/token-plan.json"); @@ -522,9 +500,9 @@ describe("v2026-08-28 modacker (A+C): external key source 优先级 + SSE 字段 setEnvTokenPlanKey("env-key-9999"); const cid = "src-3"; clients.set(cid, makeClientState()); - sseByCid.set(cid, fakeSse()); + const cap = capture(cid); pushStateFor(cid); - const p = snapshotOf(cid); + const p = lastSnapshot(cap.box); assert.equal(p.tokenPlanApiKeySource, "env", "env 应当胜过 file + settings.json, 是最高优先级"); assert.equal(p.hasTokenPlanKey, true); @@ -544,21 +522,19 @@ describe("v2026-08-28 modacker (A+C): external key source 优先级 + SSE 字段 setEnvTokenPlanKey("env-key-9999"); const cid = "src-4"; clients.set(cid, makeClientState()); - sseByCid.set(cid, fakeSse()); - // initial: env + const cap = capture(cid); + // initial: env(第 1 条快照) pushStateFor(cid); - assert.equal(snapshotOf(cid).tokenPlanApiKeySource, "env"); - // 清 env — file 顶上 - sseByCid.get(cid).writes.length = 0; + assert.equal(snapshotAt(cap.box, 0).tokenPlanApiKeySource, "env"); + // 清 env — file 顶上(第 2 条快照) setEnvTokenPlanKey(""); pushStateFor(cid); - assert.equal(snapshotOf(cid).tokenPlanApiKeySource, "file", + assert.equal(snapshotAt(cap.box, 1).tokenPlanApiKeySource, "file", "env 取消后, file 自动顶上 — 优先级链实时"); - // 清 file — settings 顶上 - sseByCid.get(cid).writes.length = 0; + // 清 file — settings 顶上(第 3 条快照) setFileTokenPlanKey("", ""); pushStateFor(cid); - assert.equal(snapshotOf(cid).tokenPlanApiKeySource, "settings", + assert.equal(snapshotAt(cap.box, 2).tokenPlanApiKeySource, "settings", "file 也取消后, settings.json 顶上"); // reset setTokenPlanApiKey(""); @@ -571,11 +547,11 @@ describe("v2026-08-28 modacker (A+C): external key source 优先级 + SSE 字段 const cidA = "src-A", cidB = "src-B"; clients.set(cidA, makeClientState()); clients.set(cidB, makeClientState()); - sseByCid.set(cidA, fakeSse()); - sseByCid.set(cidB, fakeSse()); + const capA = capture(cidA); + const capB = capture(cidB); pushStateFor("__broadcast__"); - for (const cid of [cidA, cidB]) { - const p = snapshotOf(cid); + for (const cap of [capA, capB]) { + const p = lastSnapshot(cap.box); assert.equal(p.tokenPlanApiKeySource, "file"); assert.equal(p.tokenPlanApiKeyFilePath, "/etc/webui/key.json"); } @@ -591,9 +567,9 @@ describe("v2026-08-28 modacker (A+C): external key source 优先级 + SSE 字段 setQuotaEnabled(false); const cid = "src-empty"; clients.set(cid, makeClientState()); - sseByCid.set(cid, fakeSse()); + const cap = capture(cid); pushStateFor(cid); - const p = snapshotOf(cid); + const p = lastSnapshot(cap.box); assert.equal(p.tokenPlanApiKeySource, ""); assert.equal(p.hasTokenPlanKey, false); assert.equal(p.tokenPlanApiKeyMasked, ""); diff --git a/packages/webui/checks/usage.check.mjs b/packages/webui/checks/usage.check.mjs index 384c6839..37baa989 100644 --- a/packages/webui/checks/usage.check.mjs +++ b/packages/webui/checks/usage.check.mjs @@ -35,7 +35,6 @@ let makeClientState; let getTokenPlanApiKey; let getQuotaEnabled; let pushStateFor; -let sseByCid; let clients; before(async (t) => { @@ -47,7 +46,6 @@ before(async (t) => { // the cs fixture. const sbMod = await import(absPath("lib/state-bus.js")); makeClientState = sbMod.makeClientState; - sseByCid = sbMod.sseByCid; clients = sbMod.clients; // settings mock — re-imported in usage.js const settingsMod = await import(absPath("lib/settings.js")); @@ -58,8 +56,9 @@ before(async (t) => { beforeEach(async () => { // We don't run runUsageQuery (which has network side effects), - // so we don't need a real SSE client registered. But if a test - // ever switches to runUsageQuery, leave this here for the day. + // so we don't need a real event-stream subscriber registered. But + // if a test ever switches to runUsageQuery, leave this here for + // the day. }); // Real Token Plan API response captured 2026-08-28 via: diff --git a/packages/webui/docs/API.md b/packages/webui/docs/API.md index f8a47f0c..a4d2cbcc 100644 --- a/packages/webui/docs/API.md +++ b/packages/webui/docs/API.md @@ -2,8 +2,9 @@ **English** | [简体中文](API.zh-CN.md) -> Complete enumeration of every endpoint. REST is JSON unless noted; the -> only SSE endpoint is `/api/events`. +> Complete enumeration of every endpoint. REST is JSON unless noted. +> There is no SSE: real-time downstream delivery is the WebSocket event +> stream (`GET /api/stream`, always enabled) plus REST snapshots. All non-API routes return static files (`server.js` → `serveStatic` / `serveIndex`). @@ -47,7 +48,7 @@ Returns server status. No auth required, no CID required. --- -## State & SSE +## State & event stream ### `GET /api/state` @@ -59,27 +60,27 @@ Returns the current `state` object for this CID. See { "ok": true, "version": "0.1.3", "running": {"active": false}, … } ``` -### `GET /api/events` +### `GET /api/alerts` -Server-Sent Events stream for this CID. The connection stays open -indefinitely. Events are listed in -[ARCHITECTURE.md §5](ARCHITECTURE.md). +REST snapshot of the anomaly / system-signal ring buffer (at most 100 +entries, oldest first). Live updates are not delivered here — they +arrive as `alerts.append` / `alerts.update` control frames on the +WebSocket event stream (`GET /api/stream`); clients merge those frames +into this snapshot and de-duplicate by `alert.id`. -**Response 200** (`Content-Type: text/event-stream`) +**Response 200** +```json +{ + "kind": "snapshot", + "alerts": [ + { "id": "uuid", "ts": 1724259600000, "level": "warn", "msg": "…", "src": "…", "cid": "uuid", "sessionId": "mvs_…", "data": {}, "count": 1 } + ] +} ``` -event: state -data: {"version":"0.1.3","running":{"active":false},…} - -event: delta -data: {"text":"hello","isPartial":true} -event: exec -data: {"status":"ok","durationMs":12345} -``` +### `GET /api/stream` -The connection is held open until the client closes it (`EventSource.close()`) -or the server shuts down. No automatic reconnect from the server side; -the webui handles reconnection with exponential backoff. +WebSocket event stream endpoint (design doc `docs/drafts/arch_net_solution_0922.md` §7.2). The endpoint is always enabled — there is no transport switch — and the upgrade executes the same gate chain (origin / LAN / token) as every other `/api/*` route; a plain `GET` without an `Upgrade` header answers 426, and a successful RFC 6455 handshake establishes the connection. Server-to-client frames are WS text JSON: `hello` (`{v:1, type:"hello", payload:{cid, resumeSupported, latestSeq, heartbeatMs, ringCapacity}}` — `cid` echoes the client id this stream is bound to), `state.snapshot` and `control` event frames carrying `seq`/`ts`, and `error` frames. The shipped SPA consumes this endpoint: it receives state snapshots and control events here, takes its first-connect baseline from `GET /api/state`, and its alert snapshot from `GET /api/alerts`. The client may send only JSON text frames (`resume`/`ping`/`pong`/`close`); binary frames close the connection with 1002. Resume: `{v:1, type:"resume", payload:{lastSeq}}` replays buffered events in strictly increasing `seq` order; when the ring buffer has underrun, the most recent `state.snapshot` is sent as the baseline. Heartbeats are WS ping control frames (default 30s; two missed pongs close with 1001). The inbound token-bucket quota is 20 frames/s sustained with a burst of 40; exceeding it closes with 1013. --- @@ -88,7 +89,7 @@ the webui handles reconnection with exponential backoff. ### `POST /api/send` Send a user message. Spawns (or reuses) the mcode subprocess for this CID -and streams the result via SSE. +and streams the result over the WebSocket event stream (`GET /api/stream`). **Request** ```json @@ -108,8 +109,8 @@ and streams the result via SSE. answer to an active `ask_user` question. Set by the ask modal automatically. -**Response 200** `{ok: true}` immediately. The actual response is -streamed via `/api/events`. +**Response 200** `{ok: true}` immediately. The actual response streams +over the WebSocket event stream (`/api/stream`). **Errors** - 409 if `state.running.active === true` (already running) @@ -362,8 +363,9 @@ boundary. Returns the full settings snapshot. **This endpoint is exempt from the LAN guard** — it's how a remote user toggles LAN back on after -locking themselves out. The same snapshot is also pushed via SSE on -state changes (see [ARCHITECTURE.md §5 SSE state push](./ARCHITECTURE.md#5-sse-state-push)). +locking themselves out. The same snapshot is also pushed over the +WebSocket event stream on state changes (see +[ARCHITECTURE.md §5 event schema](./ARCHITECTURE.md#5-event-schema-websocket-event-stream)). **Response 200** (🆕 v1.0.1, 🔒 v2 security — PR #55 review) ```json @@ -427,7 +429,7 @@ admin can always toggle things remotely, even in read-only mode). "trustedOrigins": ["https://webui.example.com"], // 🔒 v2 — explicit CORS allowlist; replaces the stored list wholesale "readOnly": true, // 🆕 v1.0.1 — toggle read-only mode "tokenEnabled": false, // 🆕 v1.0.1 — toggle token auth master switch - "resetToken": true, // 🆕 v1.0.1 — generate new token + broadcast auth.token_rotated SSE + "resetToken": true, // 🆕 v1.0.1 — generate new token + broadcast auth.token_rotated over the event stream "acknowledgeToken": true // 🆕 v1.0.1 — operator confirms they saved the token; server stops sending it } ``` @@ -675,7 +677,7 @@ enable. ### `POST /api/debug/inject` -Inject a fake event into the SSE channel for a CID. Used for testing +Inject a fake event into the event stream for a CID. Used for testing the UI without a real mcode subprocess. **Request** diff --git a/packages/webui/docs/API.zh-CN.md b/packages/webui/docs/API.zh-CN.md index 2d11f592..6f664729 100644 --- a/packages/webui/docs/API.zh-CN.md +++ b/packages/webui/docs/API.zh-CN.md @@ -2,8 +2,9 @@ > 简体中文 | [English](API.md) -> 完整枚举所有端点。除非另有说明,REST 均为 JSON;唯一的 -> 服务端推送事件(SSE)端点是 `/api/events`。 +> 完整枚举所有端点。除非另有说明,REST 均为 JSON。本服务没有 +> SSE:实时下行通道是 WebSocket 事件流(`GET /api/stream`, +> 始终启用)加 REST 快照。 所有非 API 路由返回静态文件(`server.js` → `serveStatic` / `serveIndex`)。 @@ -47,7 +48,7 @@ --- -## 状态与 SSE +## 状态与事件流 ### `GET /api/state` @@ -59,27 +60,26 @@ { "ok": true, "version": "0.1.3", "running": {"active": false}, … } ``` -### `GET /api/events` +### `GET /api/alerts` -此 CID 的服务端推送事件(Server-Sent Events)流。连接会无限期保持 -打开。事件列表见 -[ARCHITECTURE.md §5](ARCHITECTURE.md)。 +异常 / 系统信号环形缓冲的 REST 快照(至多 100 条,旧 → 新)。 +实时更新不由本端点下发 —— 它们以 `alerts.append` / +`alerts.update` 控制帧经 WebSocket 事件流(`GET /api/stream`) +下发;客户端把这些帧并入本快照,并按 `alert.id` 去重。 -**响应 200**(`Content-Type: text/event-stream`) +**响应 200** +```json +{ + "kind": "snapshot", + "alerts": [ + { "id": "uuid", "ts": 1724259600000, "level": "warn", "msg": "…", "src": "…", "cid": "uuid", "sessionId": "mvs_…", "data": {}, "count": 1 } + ] +} ``` -event: state -data: {"version":"0.1.3","running":{"active":false},…} - -event: delta -data: {"text":"hello","isPartial":true} -event: exec -data: {"status":"ok","durationMs":12345} -``` +### `GET /api/stream` -连接会一直持有,直到客户端关闭(`EventSource.close()`) -或服务器关闭。服务器端不会自动重连; -webui 会以指数退避方式处理重连。 +WebSocket 事件流端点(技术方案 `docs/drafts/arch_net_solution_0922.md` §7.2)。本端点始终启用 —— 传输开关已删除 —— 升级执行与全部 `/api/*` 路由相同的门链(origin / LAN / token);不带 `Upgrade` 头的普通 `GET` 返回 426,成功的 RFC 6455 握手建立连接。服务端 → 客户端帧为 WS text JSON:`hello`(`{v:1, type:"hello", payload:{cid, resumeSupported, latestSeq, heartbeatMs, ringCapacity}}` —— `cid` 回显该流绑定的客户端 id)、带 `seq`/`ts` 的 `state.snapshot` 与 `control` 事件帧、`error` 帧。发行版 SPA 即通过本端点接收状态快照与控制事件:首连基线经 `GET /api/state` 获取,告警快照经 `GET /api/alerts` 获取。客户端 → 服务端仅接受 JSON text 帧(`resume`/`ping`/`pong`/`close`),二进制帧以 1002 关闭。断线恢复:`{v:1, type:"resume", payload:{lastSeq}}` 按 seq 严格递增重放缓冲事件;环形缓冲欠载时以最近 `state.snapshot` 为基线。心跳为 WS ping 控制帧(默认 30 秒,连续 2 次无 pong 以 1001 关闭);入站帧令牌桶配额为稳态 20 帧/秒、突发 40,超出以 1013 关闭。 --- @@ -88,7 +88,7 @@ webui 会以指数退避方式处理重连。 ### `POST /api/send` 发送一条用户消息。为此 CID 启动(或复用)mcode 子进程, -并通过 SSE 流式返回结果。 +并通过 WebSocket 事件流(`GET /api/stream`)流式返回结果。 **请求体** ```json @@ -107,8 +107,8 @@ webui 会以指数退避方式处理重连。 - `isAskAnswer`(布尔值,可选)—— 为 `true` 时,内容是对一个 进行中的 `ask_user` 提问的回答。由询问弹窗自动设置。 -**响应 200** 立即返回 `{ok: true}`。实际响应通过 -`/api/events` 流式推送。 +**响应 200** 立即返回 `{ok: true}`。实际响应经 +WebSocket 事件流(`/api/stream`)流式下发。 **错误** - 若 `state.running.active === true`(已在运行)返回 409 @@ -359,8 +359,8 @@ Linux 上为 `/`) 返回完整的设置快照。**此端点豁免于局域网防护** —— 它是远程 用户在把自己锁在门外之后重新开启局域网访问的途径。同一快照 -也会在状态变化时通过 SSE 推送 -(见 [ARCHITECTURE.md §5 SSE state push](./ARCHITECTURE.md#5-sse-state-push))。 +也会在状态变化时经 WebSocket 事件流推送 +(见 [ARCHITECTURE.md §5 事件模式](./ARCHITECTURE.md#5-event-schema-websocket-event-stream))。 **响应 200**(🆕 v1.0.1,🔒 v2 安全 —— PR #55 评审) ```json @@ -424,7 +424,7 @@ Linux 上为 `/`) "trustedOrigins": ["https://webui.example.com"], // 🔒 v2 — explicit CORS allowlist; replaces the stored list wholesale "readOnly": true, // 🆕 v1.0.1 — toggle read-only mode "tokenEnabled": false, // 🆕 v1.0.1 — toggle token auth master switch - "resetToken": true, // 🆕 v1.0.1 — generate new token + broadcast auth.token_rotated SSE + "resetToken": true, // 🆕 v1.0.1 — generate new token + broadcast auth.token_rotated over the event stream "acknowledgeToken": true // 🆕 v1.0.1 — operator confirms they saved the token; server stops sending it } ``` @@ -670,7 +670,7 @@ mcode TUI 进行模型配置。 ### `POST /api/debug/inject` -向某个 CID 的 SSE 通道注入一个伪造事件。用于在没有真实 +向某个 CID 的事件流注入一个伪造事件。用于在没有真实 mcode 子进程的情况下测试 UI。 **请求体** diff --git a/packages/webui/docs/ARCHITECTURE.md b/packages/webui/docs/ARCHITECTURE.md index edfed134..38b0c695 100644 --- a/packages/webui/docs/ARCHITECTURE.md +++ b/packages/webui/docs/ARCHITECTURE.md @@ -4,8 +4,8 @@ > Companion to [README.md](../README.md). This document is for people > modifying the webui or integrating with it. It describes the runtime -> topology, the module boundaries, the request lifecycle, and the SSE -> payload contract. +> topology, the module boundaries, the request lifecycle, and the +> WebSocket event-stream payload contract. > > **Scope boundary.** This document is the single source of truth for how the > webui is built. The companion [DESKTOP-ARCHITECTURE.md](DESKTOP-ARCHITECTURE.md) @@ -23,7 +23,7 @@ │ • styles/main.css │ └─────────────────────────────────────────────┘ │ ▲ │ ▲ - fetch / JSON │ │ EventSource / SSE │ │ + fetch / JSON │ │ WebSocket /api/stream │ │ ▼ │ ▼ │ ┌──────────────────────────────────────────────────────────────────────┐ │ server.js — bootstrap only (≈ 100 lines) │ @@ -40,8 +40,8 @@ │ │ │ ┌─ static ┐ ┌─ /api/health ┐ ┌─ /api/state ┐ ┌─ /api/sessions ┐ │ │ │ index │ │ health.js │ │ state.js │ │ sessions.js │ │ - │ │ .html │ └───────────────┘ │ + /api/events│ │ + acp- │ │ - │ │ .css/js │ │ (SSE) │ │ sessions/* │ │ + │ │ .html │ └───────────────┘ │ + /api/stream│ │ + acp- │ │ + │ │ .css/js │ │ (WebSocket)│ │ sessions/* │ │ │ │ .png │ └──────────────┘ └────────────────┘ │ │ └─────────┘ │ │ ┌─ /api/send ┐ ┌─ /api/usage ┐ ┌─ /api/workspace ┐ │ @@ -99,14 +99,14 @@ browser server/router.js server/lib/* mcode │ │ │ ─── spawn / pipe stdin ───► │ │ │ │ │ state-bus: pushStateFor(cid) │ - │ ◄──────────── SSE event ────│ {type:'state', running:…} │ + │ ◄──────────── WS event ────│ {type:'state', running:…} │ │ {type:'chat', lines:[…]} │ │ - │ ◄──────────── SSE event ────│ ◄── line ◄─── stdout ────│ + │ ◄──────────── WS event ────│ ◄── line ◄─── stdout ────│ │ {type:'delta', text:'…'} │ │ │ … │ │ - │ ◄──────────── SSE event ────│ ◄── exec.result ──────────│ + │ ◄──────────── WS event ────│ ◄── exec.result ──────────│ │ {type:'exec', status:'ok'} │ │ - │ ◄──────────── SSE event ────│ │ + │ ◄──────────── WS event ────│ │ │ {type:'state', running:false}│ │ │ … │ │ │ connection closes / kept open │ │ @@ -118,9 +118,9 @@ Key invariants: client id, a UUID stored in `localStorage.webui_cid`). A new tab gets a new subprocess; a closed tab kills its subprocess. State is per-cid, not per-connection. -- **The SSE channel is the only source of state updates** for the client. - REST endpoints mutate server state but do not push to the client. The - client treats SSE as truth. +- **The WebSocket event stream (`GET /api/stream`) is the only source of + state updates** for the client. REST endpoints mutate server state but do + not push to the client. The client treats the event stream as truth. - **`pushStateFor(cid, opts)` is the only function that mutates per-cid state on the server.** Everything else is read-only. This is why `state-bus.js` is the size it is — it's the single chokepoint. @@ -162,11 +162,11 @@ sequenceDiagram E->>E: assemble system prompt:
AGENTS.md (system-reminder module),
skills, permission presets A->>E: session/prompt {prompt} E->>E: model call (provider / minimax_api key) - C-->>B: 200 {ok:true} (ack only — everything else is SSE) + C-->>B: 200 {ok:true} (ack only — everything else is the event stream) ``` The stream that follows — every engine event becomes a chat line, every -chat mutation becomes an SSE state snapshot: +chat mutation becomes a state snapshot on the event stream: ```mermaid sequenceDiagram @@ -182,7 +182,7 @@ sequenceDiagram A->>M: {kind:'thought', text} M->>M: streamUpdateLine(cs.chat, "▲", text) M->>S: pushStateFor(cid) (60Hz coalesced) - S-->>B: SSE {type:'state', chat:[...], running:{active:true,tps}} + S-->>B: WS {type:'state', chat:[...], running:{active:true,tps}} B->>B: thinking block (escaped text, collapsible) end loop per tool call (incl. MCP tools & skill-spawned tools) @@ -271,7 +271,7 @@ flowchart TD F{"clicked id is mvs_…?"} G["switch: find-or-create overlay
(id = mvs_…, idempotent)"] H["transcript backfill from
runtime SQLite (≤400 lines / ≤200KB)"] - I["bind cs: sessionId / mcodeSessionId / chat
→ pushStateFor (SSE)"] + I["bind cs: sessionId / mcodeSessionId / chat
→ pushStateFor (event stream)"] end subgraph STORES["stores"] @@ -345,13 +345,14 @@ The chokepoint. Exports: | Function | Purpose | |---|---| -| `getClient(cid)` | Returns the `clientState` object: `state`, `sse`, `activeChild`, `chatHistory`, `requestSeq`. Lazily creates on first call. | -| `pushStateFor(cid, opts)` | Build a normalized `state` object and write it to `clientState.state`. Broadcasts to the SSE channel unless `opts.silent`. | -| `pushOnlineCount(lanBroadcast)` | Count `sseByCid.size` and broadcast to all clients. Called on connect/disconnect. | -| `SSE_HEADERS` | Standard headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`, `X-Accel-Buffering: no`. | +| `getClient(cid)` | Returns the per-cid `clientState` built by `makeClientState()` (`version`, `workspace`, `model`, `sessionId`, `mcodeSessionId`, `chat`, `sessions`, `context`, `usage`, `permissions`, `running`, `plan`, `ask`, `todo`, `goal` …). Lazily creates on first call; there is no `sse` field — the per-cid live channel is the `/api/stream` subscription on the event bus. | +| `pushStateFor(cid, opts)` | Build a full snapshot for the cid — the `clientState` fields plus injected `sessions`, settings and quota fields (`opts` carries `lanBroadcast` / `mcodeSessions` overrides) — and publish it straight to the event bus as a `state.snapshot` event; `cid === "__broadcast__"` fans out to every subscribed cid. | +| `pushOnlineCount(lanBroadcast)` | Set `onlineCount` to the number of event-stream subscribers (`getSubscribedCids().length`) and emit a `state.snapshot` to every subscribed cid. Called on `/api/stream` connect/disconnect. | -The `state` payload is documented in § 5 below. The `clientState.state` -object is the **only** thing the rest of the codebase reads from. +The snapshot payload shape is documented in § 4 below. Snapshots are +built on the fly by `pushStateFor` (`clientState` fields + injected +sessions / settings / quota fields); the rest of the codebase reads +the `clientState` fields themselves. ### `acp-client.js` Wraps mcode's JSON-RPC-over-stdio protocol. Exports: @@ -398,10 +399,10 @@ Both expose: `state`, `chat`, `delta`, `tool`, `permission`, `plan`, `ask`, `exec`, `usage`. See § 5. -## 4. The `clientState.state` payload +## 4. The state snapshot payload -This is the shape every SSE `state` event contains. The webui mirrors -it 1:1 into the `state` JS variable. +This is the shape every state snapshot on the event stream contains. +The webui mirrors it 1:1 into the `state` JS variable. ```ts { @@ -438,7 +439,7 @@ it 1:1 into the `state` JS variable. cwd: string, updatedAt: number }>, mcodeSessionId?: string, // currently-active mcode session - context?: { // updated by SSE delta accumulation + context?: { // updated by delta accumulation used: number, // tokens used (per-turn) percent: number, // 0..100 cacheRead: number, // per-turn cache reads @@ -461,7 +462,7 @@ it 1:1 into the `state` JS variable. todo?: Array<{ content: string, status: 'pending'|'in_progress'|'done' }>, lanBroadcast: boolean, // mirrors /api/settings onlineCount: number, // from pushOnlineCount - // 🆕 v1.0.1 — settings surface pushed over SSE state updates + // 🆕 v1.0.1 — settings surface pushed over event-stream state updates readOnly: boolean, // read-only mode (server gate blocks remote POST/DELETE on /api/*) tokenEnabled: boolean, // token auth master switch (default true) currentToken: string, // 32-hex auto-generated token; "" after tokenAcknowledged=true @@ -474,12 +475,19 @@ The webui **does not** hold additional state outside this object. Any UI panel that needs data reads it from `state` and reacts to `state` changes via `render()`. -## 5. SSE event schema +## 5. Event schema (WebSocket event stream) Two event types — `state` (the standard state push) and a 🆕 v1.0.1 named event `auth.token_rotated` that fires only when the token changes. +> **Channel note (decision 20).** SSE is removed. These events ride +> `GET /api/stream` as `state.snapshot` frames (payload = the §4 state +> object) and `control` frames (`{v:1, seq, ts, type:"control", +> payload:{name, data}}`) — see [API.md `GET /api/stream`](API.md). +> The `event:` / `data:` lines below are the pre-decision-20 encoding, +> retained as the canonical event-name → payload map. + ``` event: state data: {"version":"0.1.3","running":{"active":true,…},…} @@ -527,15 +535,16 @@ and the live `HEADERS.Authorization` object **in place** — subsequent Clients that were offline when the event fired will get `401` on their next request; they need to be re-sent the new URL manually. -The body is **raw text**, not JSON-encoded — it's obvious in devtools -that this is sensitive material, and `JSON.stringify` would not add -any value (and would obscure the token when copy-pasted from -network logs). +The payload's `data` field is the **raw token string**, not a +JSON-encoded string — it's obvious in devtools that this is sensitive +material, and double-encoding would not add any value (and would +obscure the token when copy-pasted from network logs). The webui treats each event as an idempotent update; replaying the -same event is safe. The server uses an at-most-once delivery model -(SSE drops on disconnect → no retry), which the client handles by -fetching `/api/state` on reconnect. +same event is safe. The event stream keeps a per-cid ring buffer: on +reconnect the client resumes from `lastSeq`, and when the buffer has +underrun the server replays the latest `state.snapshot` as the +baseline (the first-connect baseline comes from `GET /api/state`). ## 6. Frontend topology @@ -557,7 +566,7 @@ Internal structure (top to bottom): 2. **I18N tables** — `zh`, `en` objects; `t(key)` lookup; `applyI18n()` walk 3. **DOM cache** — `els = {...}` populated on `init()` 4. **Render functions** — `render()`, `renderChat()`, `renderSessions()`, `renderUsage()`, `renderRight()`, `renderGoal()`, `renderTodo()`, `renderContext()` -5. **State synchronization** — `connect()` (SSE), `pushStateFor` mirror +5. **State synchronization** — `connect()` (WebSocket event stream), `pushStateFor` mirror 6. **Event handlers** — `attachEvents()` (delegation + per-element), `attachModalEvents()` 7. **Action functions** — `send()`, `stopExec()`, `setMode()`, `setModel()`, `submitWorkspaceChange()`, `cancelConfirm()`, `refreshSessions()`, `refreshUsage()` 8. **Helpers** — `parseChatLines()`, `parseMarkdown()`, `renderMessage()`, `escapeHtml()` @@ -569,8 +578,9 @@ The webui is intentionally dependency-free. Reasons: - `mcode.cmd` is already a toolchain that pulls its own deps - A webui that needs `npm install` to start is one more thing that can break -- All required functionality (HTTP server, EventSource, JSON, multipart - parsing) is in Node stdlib +- All required functionality (HTTP server, JSON, multipart parsing) is + in Node stdlib; the WebSocket event stream is a hand-written RFC 6455 + subset, not a dependency The `package.json` exists for the `name`/`version`/`scripts` fields and for editor tooling (Node type detection). `npm start` is a one-liner @@ -585,7 +595,7 @@ keep the dep optional where possible (try/catch + fallback). |---|---|---| | mcode acp subprocess crashes | `child.on('exit')` listener | pushStateFor with `running.active=false`; client shows "agent stopped" toast | | mcode acp returns "Method not found" | `mcode-rpc.js` whitelist | returns `{ok:false, code:'unsupported'}` synchronously; route handler returns 501 Not Implemented; client shows toast | -| SSE connection drops | `EventSource.onerror` | auto-reconnect with backoff; on reconnect, fetch `/api/state` and resync | +| Event stream drops | WebSocket `onclose` | reconnect with backoff + `resume {lastSeq}`; on ring-buffer underrun the server replays the latest `state.snapshot` (first connect fetches `/api/state`) | | LAN request from a non-whitelisted IP | `router.js` L120 | 403 + friendly HTML page (or JSON for /api/*) | | Server out of file descriptors | `installGlobalErrorHandlers` EMFILE sink | written to `.server.err`; user sees an empty page; reload usually fixes it | | mcode exec encoding is GBK (Windows) | Node defaults to UTF-8 in `spawn`; no fix needed | documented in README as a pitfall for future Python ports | @@ -601,7 +611,7 @@ The pattern (see `docs/DEVELOPMENT.md` for the full walk-through): { method: 'POST', match: (p) => p === '/api/foo', handler: fooRoute.handleFoo } ``` 4. If the new endpoint mutates state, call `pushStateFor(cid, {...})` from - the handler. Never write to `clientState.state` directly. + the handler. Never write to the `clientState` fields directly. 5. If the endpoint is invoked by the webui, add it to the fetch helper in `public/app/main.js` (`API_SUFFIX` is automatically appended). @@ -612,10 +622,12 @@ The pattern (see `docs/DEVELOPMENT.md` for the full walk-through): `mcode-rpc.js` shrinks; the corresponding `/api/protocol/*` endpoints become functional. The `protocol/capabilities` endpoint already advertises this. -- **WebSocket transport**: SSE is fine for unidirectional push. If - bidirectional low-latency control becomes a need (e.g. live - cursor tracking in a shared session), replace the EventSource - with a WebSocket and keep the same message schema. +- **Structured uplink frames**: the push channel is the WebSocket event + stream (`GET /api/stream`) — SSE was removed (decision 20, + `docs/drafts/arch_net_solution_0922.md` §10). Client frames are + currently limited to `resume` / `ping` / `pong` / `close`; structured + uplink messages (answers, cancels) over the same connection remain + future work. - **Multi-user session sharing**: per-cid state can be replaced with per-session state and a session-id routing key. The architecture already separates per-cid state from per-session data; the diff --git a/packages/webui/docs/ARCHITECTURE.zh-CN.md b/packages/webui/docs/ARCHITECTURE.zh-CN.md index a3ad69e5..b8f658a4 100644 --- a/packages/webui/docs/ARCHITECTURE.zh-CN.md +++ b/packages/webui/docs/ARCHITECTURE.zh-CN.md @@ -4,8 +4,8 @@ > 本文档是 [README.md](../README.md) 的配套文档,面向需要 > 修改 webui 或与其集成的人员。它描述了运行时 -> 拓扑、模块边界、请求生命周期以及 SSE -> 载荷契约。 +> 拓扑、模块边界、请求生命周期以及 WebSocket +> 事件流载荷契约。 ## 1. 高层拓扑 @@ -17,7 +17,7 @@ │ • styles/main.css │ └─────────────────────────────────────────────┘ │ ▲ │ ▲ - fetch / JSON │ │ EventSource / SSE │ │ + fetch / JSON │ │ WebSocket /api/stream │ │ ▼ │ ▼ │ ┌──────────────────────────────────────────────────────────────────────┐ │ server.js — bootstrap only (≈ 100 lines) │ @@ -34,8 +34,8 @@ │ │ │ ┌─ static ┐ ┌─ /api/health ┐ ┌─ /api/state ┐ ┌─ /api/sessions ┐ │ │ │ index │ │ health.js │ │ state.js │ │ sessions.js │ │ - │ │ .html │ └───────────────┘ │ + /api/events│ │ + acp- │ │ - │ │ .css/js │ │ (SSE) │ │ sessions/* │ │ + │ │ .html │ └───────────────┘ │ + /api/stream│ │ + acp- │ │ + │ │ .css/js │ │ (WebSocket)│ │ sessions/* │ │ │ │ .png │ └──────────────┘ └────────────────┘ │ │ └─────────┘ │ │ ┌─ /api/send ┐ ┌─ /api/usage ┐ ┌─ /api/workspace ┐ │ @@ -93,14 +93,14 @@ browser server/router.js server/lib/* mcode │ │ │ ─── spawn / pipe stdin ───► │ │ │ │ │ state-bus: pushStateFor(cid) │ - │ ◄──────────── SSE event ────│ {type:'state', running:…} │ + │ ◄──────────── WS event ────│ {type:'state', running:…} │ │ {type:'chat', lines:[…]} │ │ - │ ◄──────────── SSE event ────│ ◄── line ◄─── stdout ────│ + │ ◄──────────── WS event ────│ ◄── line ◄─── stdout ────│ │ {type:'delta', text:'…'} │ │ │ … │ │ - │ ◄──────────── SSE event ────│ ◄── exec.result ──────────│ + │ ◄──────────── WS event ────│ ◄── exec.result ──────────│ │ {type:'exec', status:'ok'} │ │ - │ ◄──────────── SSE event ────│ │ + │ ◄──────────── WS event ────│ │ │ {type:'state', running:false}│ │ │ … │ │ │ connection closes / kept open │ │ @@ -112,9 +112,10 @@ browser server/router.js server/lib/* mcode 客户端 id 为键,即存储在 `localStorage.webui_cid` 中的 UUID)。 新标签页会获得一个新子进程;关闭标签页会杀死其子进程。状态是按 cid 划分的,而不是按连接划分的。 -- **SSE 通道是客户端状态更新的唯一来源**。 +- **WebSocket 事件流(`GET /api/stream`)是客户端状态更新的 + 唯一来源**。 REST 端点会改变服务器状态,但不会推送给客户端。 - 客户端将 SSE 视为事实来源。 + 客户端将事件流视为事实来源。 - **`pushStateFor(cid, opts)` 是服务器上唯一会修改 按 cid 划分的状态的函数。** 其他一切都是只读的。这就是 `state-bus.js` 有如此体量的原因——它是唯一的收口点(chokepoint)。 @@ -156,11 +157,11 @@ sequenceDiagram E->>E: 组装系统提示词:
AGENTS.md(system-reminder 模块)、
技能、权限预设 A->>E: session/prompt {prompt} E->>E: 模型调用(provider / minimax_api 密钥) - C-->>B: 200 {ok:true}(仅是确认——其余一切走 SSE) + C-->>B: 200 {ok:true}(仅是确认——其余一切走事件流) ``` 随后的流式输出——每个引擎事件都变成一行聊天内容,每次 -聊天变更都变成一个 SSE 状态快照: +聊天变更都变成事件流上的状态快照: ```mermaid sequenceDiagram @@ -176,7 +177,7 @@ sequenceDiagram A->>M: {kind:'thought', text} M->>M: streamUpdateLine(cs.chat, "▲", text) M->>S: pushStateFor(cid)(60Hz 合并) - S-->>B: SSE {type:'state', chat:[...], running:{active:true,tps}} + S-->>B: WS {type:'state', chat:[...], running:{active:true,tps}} B->>B: 思维链块(转义文本,可折叠) end loop 每个工具调用(含 MCP 工具与技能派生的工具) @@ -262,7 +263,7 @@ flowchart TD F{"点击的 id 是 mvs_…
且没有记录?"} G["切换:查找或创建叠加记录
(id = mvs_…,幂等)"] H["从运行时 SQLite 进行
正文回填(≤400 行 / ≤200KB)"] - I["绑定 cs:sessionId / mcodeSessionId / chat
→ pushStateFor(SSE)"] + I["绑定 cs:sessionId / mcodeSessionId / chat
→ pushStateFor(事件流)"] end subgraph STORES["存储"] @@ -335,13 +336,13 @@ flowchart TD | 函数 | 用途 | |---|---| -| `getClient(cid)` | 返回 `clientState` 对象:`state`、`sse`、`activeChild`、`chatHistory`、`requestSeq`。首次调用时惰性创建。 | -| `pushStateFor(cid, opts)` | 构建规范化的 `state` 对象并写入 `clientState.state`。除非 `opts.silent`,否则向 SSE 通道广播。 | -| `pushOnlineCount(lanBroadcast)` | 统计 `sseByCid.size` 并广播给所有客户端。在连接/断开时调用。 | -| `SSE_HEADERS` | 标准头:`Content-Type: text/event-stream`、`Cache-Control: no-cache`、`Connection: keep-alive`、`X-Accel-Buffering: no`。 | +| `getClient(cid)` | 返回 `makeClientState()` 构建的按 cid 的 `clientState`(`version`、`workspace`、`model`、`sessionId`、`mcodeSessionId`、`chat`、`sessions`、`context`、`usage`、`permissions`、`running`、`plan`、`ask`、`todo`、`goal` 等)。首次调用时惰性创建;没有 `sse` 字段 —— 实时下行是事件总线上 `/api/stream` 的订阅。 | +| `pushStateFor(cid, opts)` | 为该 cid 现场构造完整快照 —— `clientState` 字段加注入的 `sessions`、settings 与 quota 字段(`opts` 承载 `lanBroadcast` / `mcodeSessions` 覆盖)—— 并直接向事件总线发布 `state.snapshot` 事件;`cid === "__broadcast__"` 时广播到每个已订阅 cid。 | +| `pushOnlineCount(lanBroadcast)` | 把 `onlineCount` 设为事件流订阅数(`getSubscribedCids().length`),并向每个已订阅 cid 发出 `state.snapshot`。在 `/api/stream` 连接/断开时调用。 | -`state` 载荷在下文 § 5 中说明。`clientState.state` -对象是代码库其余部分**唯一**读取的东西。 +快照载荷形状在下文 § 4 中说明。快照由 `pushStateFor` 现场构造 +(`clientState` 字段 + 注入的 sessions / settings / quota 字段); +代码库其余部分读取的是 `clientState` 字段本身。 ### `acp-client.js` 封装 mcode 的基于 stdio 的 JSON-RPC 协议。导出: @@ -387,9 +388,9 @@ const UNSUPPORTED = new Set([ `state`、`chat`、`delta`、`tool`、`permission`、`plan`、`ask`、 `exec`、`usage`。见 § 5。 -## 4. `clientState.state` 载荷 +## 4. 状态快照载荷 -这是每个 SSE `state` 事件所包含的形状。webui 将其 +这是事件流上每份状态快照所包含的形状。webui 将其 1:1 镜像到 `state` JS 变量中。 ```ts @@ -427,7 +428,7 @@ const UNSUPPORTED = new Set([ cwd: string, updatedAt: number }>, mcodeSessionId?: string, // currently-active mcode session - context?: { // updated by SSE delta accumulation + context?: { // updated by delta accumulation used: number, // tokens used (per-turn) percent: number, // 0..100 cacheRead: number, // per-turn cache reads @@ -450,7 +451,7 @@ const UNSUPPORTED = new Set([ todo?: Array<{ content: string, status: 'pending'|'in_progress'|'done' }>, lanBroadcast: boolean, // mirrors /api/settings onlineCount: number, // from pushOnlineCount - // 🆕 v1.0.1 — settings surface pushed over SSE state updates + // 🆕 v1.0.1 — settings surface pushed over event-stream state updates readOnly: boolean, // read-only mode (server gate blocks remote POST/DELETE on /api/*) tokenEnabled: boolean, // token auth master switch (default true) currentToken: string, // 32-hex auto-generated token; "" after tokenAcknowledged=true @@ -463,12 +464,18 @@ webui **不会**在此对象之外持有额外状态。任何需要数据的 UI 面板都从 `state` 读取,并通过 `render()` 响应 `state` 的变化。 -## 5. SSE 事件模式 +## 5. 事件模式(WebSocket 事件流) 两种事件类型——`state`(标准状态推送)和一个 🆕 v1.0.1 的命名事件 `auth.token_rotated`,仅在 令牌变更时触发。 +> **通道注记(决策 20)**:SSE 已移除。这些事件经 `GET /api/stream` +> 以 `state.snapshot` 帧(载荷 = §4 的 state 对象)与 `control` 帧 +> (`{v:1, seq, ts, type:"control", payload:{name, data}}`)下发 —— 见 +> [API.md `GET /api/stream`](API.md)。下方的 `event:` / `data:` 行是 +> 决策 20 之前的编码,保留作为 事件名 → 载荷 的权威映射。 + ``` event: state data: {"version":"0.1.3","running":{"active":true,…},…} @@ -516,14 +523,16 @@ data: // raw string, NOT JSON-wrapped 事件触发时离线的客户端将在下一次请求时收到 `401`; 需要手动把新 URL 重新发给它们。 -正文是**原始文本**,而非 JSON 编码——在 devtools 中 -一眼就能看出这是敏感材料,而 `JSON.stringify` 不会增加 -任何价值(并且在从网络日志复制粘贴时还会遮蔽令牌)。 +载荷的 `data` 字段是**原始令牌字符串**,而非 JSON 编码的 +字符串——在 devtools 中一眼就能看出这是敏感材料,而二次 +编码不会增加任何价值(并且在从网络日志复制粘贴时还会 +遮蔽令牌)。 webui 将每个事件视为幂等更新;重放同一 -事件是安全的。服务器采用至多一次投递模型 -(SSE 在断连时丢弃 → 不重试),客户端通过在 -重连时拉取 `/api/state` 来应对。 +事件是安全的。事件流按 cid 保留环形缓冲: +重连时客户端以 `lastSeq` 续传;缓冲欠载时 +服务器重放最近的 `state.snapshot` 作为基线 +(首连基线取自 `GET /api/state`)。 ## 6. 前端拓扑 @@ -545,7 +554,7 @@ public/lib/marked.min.js (third-party markdown) 2. **I18N 表**——`zh`、`en` 对象;`t(key)` 查找;`applyI18n()` 遍历 3. **DOM 缓存**——`els = {...}`,在 `init()` 时填充 4. **渲染函数**——`render()`、`renderChat()`、`renderSessions()`、`renderUsage()`、`renderRight()`、`renderGoal()`、`renderTodo()`、`renderContext()` -5. **状态同步**——`connect()`(SSE),`pushStateFor` 镜像 +5. **状态同步**——`connect()`(WebSocket 事件流),`pushStateFor` 镜像 6. **事件处理器**——`attachEvents()`(委托 + 逐元素),`attachModalEvents()` 7. **动作函数**——`send()`、`stopExec()`、`setMode()`、`setModel()`、`submitWorkspaceChange()`、`cancelConfirm()`、`refreshSessions()`、`refreshUsage()` 8. **辅助函数**——`parseChatLines()`、`parseMarkdown()`、`renderMessage()`、`escapeHtml()` @@ -557,8 +566,9 @@ webui 有意做到无依赖。原因: - `mcode.cmd` 本身就是一个自带依赖的工具链 - 一个需要 `npm install` 才能启动的 webui 是多一个可能出错的环节 -- 所有必需功能(HTTP 服务器、EventSource、JSON、multipart - 解析)都在 Node 标准库中 +- 所有必需功能(HTTP 服务器、JSON、multipart 解析)都在 + Node 标准库中;WebSocket 事件流是手写的 RFC 6455 子集, + 不是依赖 `package.json` 存在是为了 `name`/`version`/`scripts` 字段 以及编辑器工具链(Node 类型检测)。`npm start` 是 @@ -573,7 +583,7 @@ webui 有意做到无依赖。原因: |---|---|---| | mcode acp 子进程崩溃 | `child.on('exit')` 监听器 | 以 `running.active=false` 调用 pushStateFor;客户端显示「agent stopped」toast | | mcode acp 返回 "Method not found" | `mcode-rpc.js` 允许列表 | 同步返回 `{ok:false, code:'unsupported'}`;路由处理器返回 501 Not Implemented;客户端显示 toast | -| SSE 连接断开 | `EventSource.onerror` | 带退避的自动重连;重连后拉取 `/api/state` 并重新同步 | +| 事件流断开 | WebSocket `onclose` | 带退避的重连 + `resume {lastSeq}`;环形缓冲欠载时服务器重放最近的 `state.snapshot`(首连拉取 `/api/state`) | | 来自非白名单 IP 的 LAN 请求 | `router.js` L120 | 403 + 友好的 HTML 页面(/api/* 则返回 JSON) | | 服务器文件描述符耗尽 | `installGlobalErrorHandlers` 的 EMFILE 兜底 | 写入 `.server.err`;用户看到空白页;重新加载通常可修复 | | mcode exec 编码为 GBK(Windows) | Node 在 `spawn` 中默认使用 UTF-8;无需修复 | 已在 README 中记录为面向未来 Python 移植的坑 | @@ -589,7 +599,7 @@ webui 有意做到无依赖。原因: { method: 'POST', match: (p) => p === '/api/foo', handler: fooRoute.handleFoo } ``` 4. 如果新端点会修改状态,在处理器中调用 `pushStateFor(cid, {...})`。 - 绝不要直接写入 `clientState.state`。 + 绝不要直接写入 `clientState` 字段。 5. 如果该端点由 webui 调用,将其添加到 `public/app/main.js` 中的 fetch 辅助函数(`API_SUFFIX` 会自动附加)。 @@ -600,10 +610,11 @@ webui 有意做到无依赖。原因: `UNSUPPORTED` 集合就会缩小;相应的 `/api/protocol/*` 端点 将变得可用。`protocol/capabilities` 端点已经 对外宣告了这一点。 -- **WebSocket 传输**:SSE 对单向推送已经足够。如果 - 双向低延迟控制成为需求(例如在共享会话中实时 - 跟踪光标),可以用 WebSocket 替换 EventSource - 并保持相同的消息模式。 +- **结构化上行帧**:推送通道即 WebSocket 事件流 + (`GET /api/stream`)—— SSE 已移除(决策 20, + `docs/drafts/arch_net_solution_0922.md` §10)。客户端帧 + 目前仅限 `resume` / `ping` / `pong` / `close`;同一连接上的 + 结构化上行消息(应答、取消)仍属未来工作。 - **多用户会话共享**:按 cid 的状态可以替换为 按会话的状态加上会话 id 路由键。该架构 已经把按 cid 的状态与按会话的数据分离; diff --git a/packages/webui/docs/CAPABILITIES.md b/packages/webui/docs/CAPABILITIES.md index b01a02d1..94ecc119 100644 --- a/packages/webui/docs/CAPABILITIES.md +++ b/packages/webui/docs/CAPABILITIES.md @@ -9,7 +9,7 @@ The webui is bound by three constraints: 1. What mcode 0.1.5 acp exposes via JSON-RPC. 2. What the Node `http` / `child_process` APIs can do. -3. What the browser's `EventSource` and `fetch` can do. +3. What the browser's `WebSocket` and `fetch` can do. Anything outside these three is either ❌ blocked (no workaround) or ⚠ partial (workaround exists, with caveats). @@ -177,13 +177,13 @@ single index that satisfies the check. | LAN sharing with on/off toggle | ✅ | runtime state in `settings.lanBroadcastEnabled`; closed by default for non-local IPs | | Friendly 403 page when LAN is off | ✅ | `LAN_REJECT_HTML` template in `settings.js`; v1.0.1: single bilingual page (zh + en stacked), dynamic `PORT` (was hardcoded `7890` which broke at v0.5 default change) | | Token auth (`?token=` or `Authorization: Bearer`) | ✅ | `server.js` validates `req.url` and `req.headers.authorization`; if set, every request must include the token | -| **Token auth: default-on (v1.0.1)** | ✅ | First start with no `TOKEN` env auto-generates a 32-hex token, persists to `~/.mcode-webui/settings.json` (mode 0600, atomic write via `.tmp` + rename). **v2.0.0 (lease C08)**: token no longer printed to stdout in 14-line ASCII box; instead a `token.first_run` SSE event is broadcast to all connected tabs and a single neutral `token persisted to: ` line is printed to stdout (gated by `MCODE_WEBUI_TOKEN_STDOUT=1`). The settings card shows the token until the operator clicks "我已保存 / I have saved it". `MCODE_WEBUI_SETTINGS_PATH` env overrides the file location. `TOKEN` env still wins (escape hatch). | -| **Token auth: reset + live broadcast (v1.0.1)** | ✅ | "重置 token" button generates a new 32-hex value, persists it, and broadcasts an `auth.token_rotated` SSE event with the new token. Each connected client updates its `localStorage` and the live `HEADERS.Authorization` object **in place** — subsequent `fetch()` calls use the new token automatically, no reload required. Crash-safe: disk write first, in-memory state committed only on success. | -| **Token auth: acknowledged state machine (v1.0.1)** | ✅ | After "我已保存", the server records `tokenAcknowledged=true` and stops including `currentToken` in subsequent `GET /api/settings` responses and SSE state pushes. UI replaces the value/mask row with a `✓ 已保存 — 查看请点"重置" / Saved — click "Reset" to view again` placeholder. Resetting triggers a new rotation. Persisted across restarts. | +| **Token auth: default-on (v1.0.1)** | ✅ | First start with no `TOKEN` env auto-generates a 32-hex token, persists to `~/.mcode-webui/settings.json` (mode 0600, atomic write via `.tmp` + rename). **v2.0.0 (lease C08)**: token no longer printed to stdout in 14-line ASCII box; instead a `token.first_run` control event is broadcast over the WebSocket event stream (`/api/stream`) to all connected tabs and a single neutral `token persisted to: ` line is printed to stdout (gated by `MCODE_WEBUI_TOKEN_STDOUT=1`). The settings card shows the token until the operator clicks "我已保存 / I have saved it". `MCODE_WEBUI_SETTINGS_PATH` env overrides the file location. `TOKEN` env still wins (escape hatch). | +| **Token auth: reset + live broadcast (v1.0.1)** | ✅ | "重置 token" button generates a new 32-hex value, persists it, and broadcasts an `auth.token_rotated` control event over the WebSocket event stream (`/api/stream`) with the new token. Each connected client updates its `localStorage` and the live `HEADERS.Authorization` object **in place** — subsequent `fetch()` calls use the new token automatically, no reload required. Crash-safe: disk write first, in-memory state committed only on success. | +| **Token auth: acknowledged state machine (v1.0.1)** | ✅ | After "我已保存", the server records `tokenAcknowledged=true` and stops including `currentToken` in subsequent `GET /api/settings` responses and event-stream state pushes. UI replaces the value/mask row with a `✓ 已保存 — 查看请点"重置" / Saved — click "Reset" to view again` placeholder. Resetting triggers a new rotation. Persisted across restarts. | | **Token auth: settings persistence (v1.0.1)** | ✅ | Token + readOnly + tokenEnabled + tokenAcknowledged + tokenRotatedAt + allowedInterfaces (no-op stub) all persist to `~/.mcode-webui/settings.json`. `lanBroadcast` remains in-memory only (intentional — reboot re-enables LAN so admins don't get locked out). | | Read-only mode (v1.0.1) | ✅ | When on, non-local `POST` / `DELETE` to `/api/*` return `403 {"error": "read-only mode"}`. `GET` / `HEAD` / `OPTIONS` exempt. Local requests always exempt. `/api/settings` exempt (escape hatch). Persisted. Top-bar shows a red pulsing "只读 / READ ONLY" chip when on. | -| Per-cid SSE channel | ✅ | one EventSource per browser tab; one mcode subprocess per cid | -| HTTPS | ⚠ | v2.0.0 (lease C03) — HTTPS itself requires a reverse proxy; **fully documented** in `docs/HTTPS-REVERSE-PROXY.md` (387 lines, nginx / caddy / Traefik 2 configurations with SSE long-connection notes). No code change in webui. | +| Per-cid WebSocket event stream | ✅ | one `GET /api/stream` connection per browser tab; one mcode subprocess per cid | +| HTTPS | ⚠ | v2.0.0 (lease C03) — HTTPS itself requires a reverse proxy; **fully documented** in `docs/HTTPS-REVERSE-PROXY.md` (387 lines, nginx / caddy / Traefik 2 configurations with WebSocket upgrade and long-connection notes). No code change in webui. | | mTLS / client cert | ❌ | same as above; documentation in `docs/HTTPS-REVERSE-PROXY.md` | | Rate limiting | ✅ | v2.0.0 (lease C03): `server/lib/rate-limit.js` (252 lines) — token-bucket per-ip with 60/min default + 100 burst + 2× multiplier for token holders. Router gate 4 returns 429 when exceeded. `lib-rate-limit.test.js` (339 lines, 21 unit tests). | @@ -201,10 +201,10 @@ single index that satisfies the check. | Hot reload of code | ❌ | restart the server | | Health check endpoint | ✅ | `GET /api/health` returns `{ok:true, port, defaultModel, defaultWorkspace, mcodeCmd, mcodeVersion, maxConcurrent}` | | Append-only event audit log (events.ndjson) | ✅ | v2.0.0 (lease B01): `server/lib/events.js` (494 lines) — NDJSON append with SHA-256 hash chain, monotonic `seq`, 200ms write-behind. 7 write-points patched: settings.js / sessions.js / upload.js / slash.js / db.js / export.js / alerts.js (dynamic). `lib-events.test.js` + `lib-events-hash.test.js`. `~/.mcode-webui/events.ndjson` (overridable via `MCODE_WEBUI_EVENTS_PATH`). | -| Independent anomaly SSE channel | ✅ | v2.0.0 (lease B02): `server/lib/alerts.js` (203 lines) + `GET /api/alerts` SSE + frontend bell icon + unread count. 3 levels (info/warn/error), 100-entry ring buffer, 60s dedup window. `lib-alerts.test.js` (17) + `routes-alerts.test.js` (7). | +| Independent anomaly REST snapshot channel | ✅ | v2.0.0 (lease B02): `server/lib/alerts.js` (203 lines) + `GET /api/alerts` REST snapshot with live `alerts.append` / `alerts.update` control frames on `/api/stream` + frontend bell icon + unread count. 3 levels (info/warn/error), 100-entry ring buffer, 60s dedup window. `lib-alerts.test.js` (17) + `routes-alerts.test.js` (7). | | Per-request authorize gate | ✅ | v2.0.0 (lease B03): `server/lib/authorize.js` (354 lines) — `authorize(action, ctx, opts)` Promise with 5-minute default timeout (fail-closed), 8-action whitelist (`session.delete`, `sessions.cleanup-orphans`, `session.cleanup-all`, `session.export`, `session.search`, `token.reset`, `slash.clear`, `startup.cleanup`). 7 wrap-sites. `lib-authorize.test.js` (20 unit). | | SBOM + CVE gates (local; no plugin-owned CI) | ✅ | Amended 2026-09-20 (webui-rigor-fix): the plugin-level `.github/workflows/ci.yml` was deleted — GitHub only reads workflows from the repo root, so it never triggered; the only CI is the marketplace root `validate` job (ubuntu / Node 22, `npm ci` → `npm run check`, see `docs/CI.md`). SBOM + CVE run as **local** gates: `scripts/gen-sbom.mjs` CycloneDX 1.5 + `sbom.cdx.json` (115 components) + `npm audit --omit=dev` + `.cve-ignore.json`; cross-Node/OS coverage is a manual matrix recipe in `docs/CI.md`. | -| `token.first_run` SSE event | ✅ | v2.0.0 (lease C08): `server/lib/state-bus.js#pushTokenFirstRun` broadcasts `{event: "token.first_run", data: {token, persistPath}}` to all `sseByCid` on first boot. Replay-guarded by `auth.js#isFirstRun()` + persistent `tokenAcknowledged` flag. | +| `token.first_run` control event | ✅ | v2.0.0 (lease C08): `server/lib/state-bus.js#pushTokenFirstRun` emits a `control` frame (`{type: "control", name: "token.first_run", data: {token, persistPath}}`) over the WebSocket event stream (`/api/stream`) to every connected tab on first boot. Replay-guarded by `auth.js#isFirstRun()` + persistent `tokenAcknowledged` flag. | ## 13. What mcode would need to add to enable the ❌ rows diff --git a/packages/webui/docs/CAPABILITIES.zh-CN.md b/packages/webui/docs/CAPABILITIES.zh-CN.md index d86f3dc2..2faad4cc 100644 --- a/packages/webui/docs/CAPABILITIES.zh-CN.md +++ b/packages/webui/docs/CAPABILITIES.zh-CN.md @@ -9,7 +9,7 @@ webui 受三项约束限制: 1. mcode 0.1.5 acp 通过 JSON-RPC 暴露的能力。 2. Node `http` / `child_process` API 能做的事。 -3. 浏览器 `EventSource` 与 `fetch` 能做的事。 +3. 浏览器 `WebSocket` 与 `fetch` 能做的事。 超出这三者范围的功能要么是 ❌ 受阻(没有变通办法),要么是 ⚠ 部分可用(存在变通办法,但有注意事项)。 @@ -177,13 +177,13 @@ CI 会对上述每一个名称是否出现在本文档中进行断言 | 带开关的局域网共享 | ✅ | 运行时状态存于 `settings.lanBroadcastEnabled`;对非本地 IP 默认关闭 | | 局域网关闭时的友好 403 页面 | ✅ | `settings.js` 中的 `LAN_REJECT_HTML` 模板;v1.0.1:单个双语页面(zh + en 上下堆叠),动态 `PORT`(之前硬编码为 `7890`,在 v0.5 默认端口变更后失效) | | 令牌认证(`?token=` 或 `Authorization: Bearer`) | ✅ | `server.js` 校验 `req.url` 与 `req.headers.authorization`;一旦设置,每个请求都必须携带令牌 | -| **令牌认证:默认开启(v1.0.1)** | ✅ | 首次启动且未设置 `TOKEN` 环境变量时自动生成一个 32 位十六进制令牌,持久化到 `~/.mcode-webui/settings.json`(权限 0600,通过 `.tmp` + rename 原子写入)。**v2.0.0(lease C08)**:令牌不再以 14 行 ASCII 方框打印到 stdout;改为向所有已连接标签页广播一个 `token.first_run` SSE 事件,并向 stdout 打印一行中性的 `token persisted to: `(由 `MCODE_WEBUI_TOKEN_STDOUT=1` 门禁控制)。设置卡片会一直显示令牌,直到操作者点击 "我已保存 / I have saved it"。`MCODE_WEBUI_SETTINGS_PATH` 环境变量可覆盖文件位置。`TOKEN` 环境变量仍然优先(逃生通道)。 | -| **令牌认证:重置 + 实时广播(v1.0.1)** | ✅ | "重置 token" 按钮生成新的 32 位十六进制值,持久化,并广播携带新令牌的 `auth.token_rotated` SSE 事件。每个已连接客户端**就地**更新其 `localStorage` 和当前 `HEADERS.Authorization` 对象——后续 `fetch()` 调用自动使用新令牌,无需重新加载。崩溃安全:先写磁盘,仅在成功后才提交内存状态。 | -| **令牌认证:确认状态机(v1.0.1)** | ✅ | 点击 "我已保存" 后,服务器记录 `tokenAcknowledged=true`,并在后续的 `GET /api/settings` 响应与 SSE 状态推送中不再包含 `currentToken`。UI 将令牌值/掩码行替换为 `✓ 已保存 — 查看请点"重置" / Saved — click "Reset" to view again` 占位符。重置会触发新一轮轮换。跨重启持久化。 | +| **令牌认证:默认开启(v1.0.1)** | ✅ | 首次启动且未设置 `TOKEN` 环境变量时自动生成一个 32 位十六进制令牌,持久化到 `~/.mcode-webui/settings.json`(权限 0600,通过 `.tmp` + rename 原子写入)。**v2.0.0(lease C08)**:令牌不再以 14 行 ASCII 方框打印到 stdout;改为通过 WebSocket 事件流(`/api/stream`)向所有已连接标签页广播一个 `token.first_run` 控制事件,并向 stdout 打印一行中性的 `token persisted to: `(由 `MCODE_WEBUI_TOKEN_STDOUT=1` 门禁控制)。设置卡片会一直显示令牌,直到操作者点击 "我已保存 / I have saved it"。`MCODE_WEBUI_SETTINGS_PATH` 环境变量可覆盖文件位置。`TOKEN` 环境变量仍然优先(逃生通道)。 | +| **令牌认证:重置 + 实时广播(v1.0.1)** | ✅ | "重置 token" 按钮生成新的 32 位十六进制值,持久化,并通过 WebSocket 事件流(`/api/stream`)广播携带新令牌的 `auth.token_rotated` 控制事件。每个已连接客户端**就地**更新其 `localStorage` 和当前 `HEADERS.Authorization` 对象——后续 `fetch()` 调用自动使用新令牌,无需重新加载。崩溃安全:先写磁盘,仅在成功后才提交内存状态。 | +| **令牌认证:确认状态机(v1.0.1)** | ✅ | 点击 "我已保存" 后,服务器记录 `tokenAcknowledged=true`,并在后续的 `GET /api/settings` 响应与事件流状态快照中不再包含 `currentToken`。UI 将令牌值/掩码行替换为 `✓ 已保存 — 查看请点"重置" / Saved — click "Reset" to view again` 占位符。重置会触发新一轮轮换。跨重启持久化。 | | **令牌认证:设置持久化(v1.0.1)** | ✅ | 令牌 + readOnly + tokenEnabled + tokenAcknowledged + tokenRotatedAt + allowedInterfaces(空操作占位)全部持久化到 `~/.mcode-webui/settings.json`。`lanBroadcast` 仍只保存在内存中(有意为之——重启后重新启用局域网,避免管理员把自己锁在门外)。 | | 只读模式(v1.0.1) | ✅ | 开启后,非本地的对 `/api/*` 的 `POST` / `DELETE` 返回 `403 {"error": "read-only mode"}`。`GET` / `HEAD` / `OPTIONS` 豁免。本地请求始终豁免。`/api/settings` 豁免(逃生通道)。已持久化。开启时顶栏显示红色脉动的 "只读 / READ ONLY" 徽标。 | -| 按 cid 划分的 SSE 通道 | ✅ | 每个浏览器标签页一个 EventSource;每个 cid 一个 mcode 子进程 | -| HTTPS | ⚠ | v2.0.0(lease C03)——HTTPS 本身需要反向代理;已在 `docs/HTTPS-REVERSE-PROXY.md`(387 行,含 nginx / caddy / Traefik 2 配置及 SSE 长连接注意事项)中**完整记录**。webui 无代码改动。 | +| 按 cid 划分的 WebSocket 事件流 | ✅ | 每个浏览器标签页一个 `GET /api/stream` 连接;每个 cid 一个 mcode 子进程 | +| HTTPS | ⚠ | v2.0.0(lease C03)——HTTPS 本身需要反向代理;已在 `docs/HTTPS-REVERSE-PROXY.md`(387 行,含 nginx / caddy / Traefik 2 配置及 WebSocket 升级与长连接注意事项)中**完整记录**。webui 无代码改动。 | | mTLS / 客户端证书 | ❌ | 同上;文档见 `docs/HTTPS-REVERSE-PROXY.md` | | 速率限制 | ✅ | v2.0.0(lease C03):`server/lib/rate-limit.js`(252 行)——按 IP 的令牌桶,默认 60 次/分钟 + 100 突发容量 + 令牌持有者 2× 倍率。路由器门禁 4 在超限时返回 429。`lib-rate-limit.test.js`(339 行,21 个单元测试)。 | @@ -201,10 +201,10 @@ CI 会对上述每一个名称是否出现在本文档中进行断言 | 代码热重载 | ❌ | 重启服务器 | | 健康检查端点 | ✅ | `GET /api/health` 返回 `{ok:true, port, defaultModel, defaultWorkspace, mcodeCmd, mcodeVersion, maxConcurrent}` | | 只追加事件审计日志(events.ndjson) | ✅ | v2.0.0(lease B01):`server/lib/events.js`(494 行)——NDJSON 追加写入,带 SHA-256 哈希链、单调递增 `seq`、200ms 延迟写入。已接入 7 个写入点:settings.js / sessions.js / upload.js / slash.js / db.js / export.js / alerts.js(动态)。`lib-events.test.js` + `lib-events-hash.test.js`。`~/.mcode-webui/events.ndjson`(可通过 `MCODE_WEBUI_EVENTS_PATH` 覆盖)。 | -| 独立的异常告警 SSE 通道 | ✅ | v2.0.0(lease B02):`server/lib/alerts.js`(203 行)+ `GET /api/alerts` SSE + 前端铃铛图标 + 未读计数。3 个级别(info/warn/error),100 条环形缓冲,60 秒去重窗口。`lib-alerts.test.js`(17 个)+ `routes-alerts.test.js`(7 个)。 | +| 独立的异常告警 REST 快照通道 | ✅ | v2.0.0(lease B02):`server/lib/alerts.js`(203 行)+ `GET /api/alerts` REST 快照(实时 `alerts.append` / `alerts.update` 控制帧经 `/api/stream` 下发)+ 前端铃铛图标 + 未读计数。3 个级别(info/warn/error),100 条环形缓冲,60 秒去重窗口。`lib-alerts.test.js`(17 个)+ `routes-alerts.test.js`(7 个)。 | | 按请求的授权门禁 | ✅ | v2.0.0(lease B03):`server/lib/authorize.js`(354 行)——`authorize(action, ctx, opts)` Promise,默认 5 分钟超时(失败即拒绝),8 个动作的白名单(`session.delete`、`sessions.cleanup-orphans`、`session.cleanup-all`、`session.export`、`session.search`、`token.reset`、`slash.clear`、`startup.cleanup`)。7 个包装点。`lib-authorize.test.js`(20 个单元测试)。 | | SBOM + CVE 门禁(本地;插件无自有 CI) | ✅ | 2026-09-20 修订(webui-rigor-fix):插件级的 `.github/workflows/ci.yml` 已删除——GitHub 只从仓库根目录读取工作流,因此它从未触发过;唯一的 CI 是 marketplace 根目录的 `validate` 任务(ubuntu / Node 22,`npm ci` → `npm run check`,见 `docs/CI.md`)。SBOM + CVE 作为**本地**门禁运行:`scripts/gen-sbom.mjs` CycloneDX 1.5 + `sbom.cdx.json`(115 个组件)+ `npm audit --omit=dev` + `.cve-ignore.json`;跨 Node/操作系统覆盖是 `docs/CI.md` 中的手动矩阵操作步骤。 | -| `token.first_run` SSE 事件 | ✅ | v2.0.0(lease C08):`server/lib/state-bus.js#pushTokenFirstRun` 在首次启动时向所有 `sseByCid` 广播 `{event: "token.first_run", data: {token, persistPath}}`。由 `auth.js#isFirstRun()` + 持久化的 `tokenAcknowledged` 标志防重放。 | +| `token.first_run` 控制事件 | ✅ | v2.0.0(lease C08):`server/lib/state-bus.js#pushTokenFirstRun` 在首次启动时通过 WebSocket 事件流(`/api/stream`)向所有已连接标签页下发 `control` 帧(`{type: "control", name: "token.first_run", data: {token, persistPath}}`)。由 `auth.js#isFirstRun()` + 持久化的 `tokenAcknowledged` 标志防重放。 | ## 13. 要启用 ❌ 行,mcode 需要增加什么 diff --git a/packages/webui/docs/DEVELOPMENT.md b/packages/webui/docs/DEVELOPMENT.md index 3dc08bdd..7c8e190a 100644 --- a/packages/webui/docs/DEVELOPMENT.md +++ b/packages/webui/docs/DEVELOPMENT.md @@ -23,7 +23,7 @@ node server.js # → http://127.0.0.1:18090 ``` -If you want a debug session (verbose SSE, no cache, injectable events): +If you want a debug session (verbose event-stream logging, no cache, injectable events): ```powershell $env:DEBUG_INJECT = '1' node server.js @@ -61,7 +61,7 @@ node server.js // if it mutates state: pushStateFor(cid, { /* delta */ }) - // for one-off SSE messages, see `pushOnlineCount` / `broadcastTokenRotated` + // for one-off control events, see `pushOnlineCount` / `broadcastTokenRotated` return ok(res, { /* response */ }) } @@ -89,12 +89,12 @@ node server.js ``` 4. **Don't write to `clientState.state` directly** from a route handler. - Use `pushStateFor(cid, …)` so the change is broadcast on the SSE - channel and the client is the source of truth. + Use `pushStateFor(cid, …)` so the change is broadcast on the + event stream and the client is the source of truth. -## Adding a new SSE event type +## Adding a new event-stream event type -1. Define the event shape in `docs/ARCHITECTURE.md § 5` (SSE event schema). +1. Define the event shape in `docs/ARCHITECTURE.md § 5` (event schema). 2. In the transport layer (`mcode-acp.js` or `mcode-exec.js`), translate the raw mcode event to your normalized event: ```js @@ -102,9 +102,9 @@ node server.js ``` 3. The transport layer pushes events via `pushStateFor(cid, …)` (state snapshots) or `broadcastTokenRotated(token)` (one-off event) which - go onto the SSE channel. -4. In `public/app/main.js`, handle the event in the SSE message - handler in `connect()` and update `state.foo` accordingly. + go onto the WebSocket event stream (`/api/stream`). +4. In `public/app/main.js`, handle the event in the event-stream + message handler in `connect()` and update `state.foo` accordingly. 5. If the event needs UI, add a render function `renderFoo()` and call it from `render()`. @@ -155,7 +155,7 @@ command; the webui picks it up on connect. body: JSON.stringify({ type: 'delta', text: 'hello from test' }) }) ``` -4. The injected event appears in the right panel and in the SSE +4. The injected event appears in the right panel and in the event stream. You can also call `__DBG.log('whatever')` from the console — it shows diff --git a/packages/webui/docs/DEVELOPMENT.zh-CN.md b/packages/webui/docs/DEVELOPMENT.zh-CN.md index d498214d..0f7ab1bb 100644 --- a/packages/webui/docs/DEVELOPMENT.zh-CN.md +++ b/packages/webui/docs/DEVELOPMENT.zh-CN.md @@ -23,7 +23,7 @@ node server.js # → http://127.0.0.1:18090 ``` -如果想要调试会话(详细 SSE、无缓存、可注入事件): +如果想要调试会话(详细事件流日志、无缓存、可注入事件): ```powershell $env:DEBUG_INJECT = '1' node server.js @@ -61,7 +61,7 @@ node server.js // if it mutates state: pushStateFor(cid, { /* delta */ }) - // for one-off SSE messages, see `pushOnlineCount` / `broadcastTokenRotated` + // for one-off control events, see `pushOnlineCount` / `broadcastTokenRotated` return ok(res, { /* response */ }) } @@ -89,12 +89,12 @@ node server.js ``` 4. **不要在路由处理器中直接写入 `clientState.state`**。 - 使用 `pushStateFor(cid, …)`,这样变更会通过 SSE - 通道广播,客户端才是事实来源。 + 使用 `pushStateFor(cid, …)`,这样变更会通过 + 事件流广播,客户端才是事实来源。 -## 添加新的 SSE 事件类型 +## 添加新的事件流事件类型 -1. 在 `docs/ARCHITECTURE.md § 5`(SSE 事件模式)中定义事件结构。 +1. 在 `docs/ARCHITECTURE.md § 5`(事件模式)中定义事件结构。 2. 在传输层(`mcode-acp.js` 或 `mcode-exec.js`)中,将 原始 mcode 事件翻译为你的规范化事件: ```js @@ -102,8 +102,8 @@ node server.js ``` 3. 传输层通过 `pushStateFor(cid, …)`(状态快照)或 `broadcastTokenRotated(token)`(一次性事件)推送事件, - 它们会进入 SSE 通道。 -4. 在 `public/app/main.js` 中,在 `connect()` 的 SSE 消息 + 它们会进入 WebSocket 事件流(`/api/stream`)。 +4. 在 `public/app/main.js` 中,在 `connect()` 的事件流消息 处理器中处理该事件,并相应更新 `state.foo`。 5. 如果该事件需要 UI,添加一个 `renderFoo()` 渲染函数,并从 `render()` 中调用它。 @@ -156,8 +156,8 @@ webui 会在连接时获取它。 body: JSON.stringify({ type: 'delta', text: 'hello from test' }) }) ``` -4. 注入的事件会出现在右侧面板和 SSE - 流中。 +4. 注入的事件会出现在右侧面板和 + 事件流中。 你也可以在控制台调用 `__DBG.log('whatever')`——它会显示 在右下角的调试面板中。 diff --git a/packages/webui/docs/HTTPS-REVERSE-PROXY.md b/packages/webui/docs/HTTPS-REVERSE-PROXY.md index 75e1813a..efda831b 100644 --- a/packages/webui/docs/HTTPS-REVERSE-PROXY.md +++ b/packages/webui/docs/HTTPS-REVERSE-PROXY.md @@ -4,7 +4,7 @@ > **Why this doc exists.** The webui binds plain HTTP (Node `http.createServer`). > TLS termination is delegated to a reverse proxy in front of it. This page -> collects three ready-to-copy configs (nginx, caddy, Traefik 2) plus the SSE +> collects three ready-to-copy configs (nginx, caddy, Traefik 2) plus the WebSocket > foot-guns that bite people who don't know to look for them. ## Table of contents @@ -13,7 +13,7 @@ |---|---| | 1 | [Why a reverse proxy](#1-why-a-reverse-proxy) | | 2 | [How the webui and the proxy share auth](#2-how-the-webui-and-the-proxy-share-auth) | -| 3 | [Common pitfalls — Server-Sent Events (SSE)](#3-common-pitfalls--server-sent-events-sse) | +| 3 | [Common pitfalls — the WebSocket event stream](#3-common-pitfalls--the-websocket-event-stream) | | 4 | [nginx](#4-nginx) | | 5 | [caddy](#5-caddy) | | 6 | [Traefik 2](#6-traefik-2) | @@ -40,16 +40,16 @@ The webui accepts two token carriers (see [`server/lib/auth.js`](../server/lib/a | Carrier | Use case | |---|---| | `Authorization: Bearer ` | Browser `fetch`, programmatic clients. Preferred — never touches URL bar / referer / history. | -| `?token=` query string | Browser `EventSource` (SSE). The `EventSource` API cannot set custom headers, so the only way to authenticate an SSE connection from the browser is via the URL. | +| `?token=` query string | Browser `WebSocket` (`/api/stream`). The `WebSocket` API cannot set custom headers, so the only way to authenticate the WebSocket handshake from the browser is via the URL. | **Recommendation for proxy configs below**: set `MCODE_WEBUI_TOKEN=` on the webui process, and either: - (preferred) have the proxy rewrite the `Authorization` header to the webui's expected value (`proxy_set_header Authorization "Bearer "`), OR -- pass `?token=` straight through (the EventSource will see it). +- pass `?token=` straight through (the WebSocket handshake will see it). **Don't log the token.** Both nginx and caddy default to logging the request line including the query string; if you put `?token=` in the URL, that lands in the access log. Either: - strip the `token=` query param at the proxy (`proxy_set_header Authorization "Bearer $arg_token"`), OR -- set `access_log off` for the SSE / API location. +- set `access_log off` for the WebSocket / API location. ### 2.1 Browser origins behind a proxy — the `trustedOrigins` allowlist (v2) @@ -98,16 +98,15 @@ Proxy checklist for origins: unaffected by the gate — they keep working through the proxy as before. -## 3. Common pitfalls — Server-Sent Events (SSE) +## 3. Common pitfalls — the WebSocket event stream -SSE long connections (`/api/events`, `/api/alerts`) are the #1 source of "my proxy works for everything except the live feed" bug reports. The trap is buffering: reverse proxies default to **buffering upstream responses** to send them in one TCP write, which kills any stream that depends on incremental flushing. +The live feed (`GET /api/stream`, a WebSocket) is the #1 source of "my proxy works for everything except the live feed" bug reports. The trap is the Upgrade handshake: reverse proxies must forward the `Upgrade` / `Connection` headers and speak HTTP/1.1 upstream, or the handshake is refused before the connection ever opens. (`GET /api/alerts` is an ordinary REST snapshot and needs no special proxy handling.) | Pitfall | Symptom | Fix | |---|---|---| -| **Response buffering** | Events appear in batches every 30+ seconds instead of as they happen | `proxy_buffering off;` (nginx) / `flush_interval -1` or `buffer` not set (caddy) / `flushInterval: "100ms"` (Traefik 2 file provider) | -| **HTTP/1.0 downstream** | Some proxies default to HTTP/1.0 for upstream; SSE needs 1.1 for chunked transfer | `proxy_http_version 1.1;` (nginx) / `versions h1 h2` (Traefik 2.4+ default) | -| **Connection: close header injected by proxy** | EventSource closes every few minutes | `proxy_set_header Connection "";` (nginx) / default in caddy / default in Traefik 2 | -| **Read timeout shorter than event gap** | If the proxy's idle timeout < event gap, it kills the SSE | `proxy_read_timeout 1h;` (nginx) / `timeouts { read 1h }` (Traefik 2) | +| **`Upgrade` / `Connection` headers dropped** | `/api/stream` handshake fails (400/426), the SPA has no live feed | `proxy_set_header Upgrade $http_upgrade;` + `proxy_set_header Connection "upgrade";` (nginx, dedicated `/api/stream` location) / caddy forwards them by default / Traefik 2 forwards them by default | +| **HTTP/1.0 downstream** | The handshake needs HTTP/1.1 end to end | `proxy_http_version 1.1;` (nginx) / `versions h1 h2` (Traefik 2.4+ default) | +| **Read timeout shorter than the heartbeat gap** | The proxy kills the connection between the webui's 30s pings | `proxy_read_timeout 1h;` (nginx) / `timeouts { read 1h }` (Traefik 2) | | **`/api/health` throttled** | Liveness probe gets 429 under load | Exempt `/api/health` at the proxy level too (most do — but some rate-limiting middlewares don't) | ## 4. nginx @@ -159,25 +158,19 @@ server { access_log /var/log/nginx/mcode-webui.access.log; error_log /var/log/nginx/mcode-webui.error.log; - # --- SSE / streaming endpoints --------------------------------------- - # MUST come before the catch-all `/` location so the SSE-specific - # overrides win. - location ~ ^/api/(events|alerts)$ { + # --- WebSocket event stream (/api/stream) ---------------------------- + # MUST come before the catch-all `/` location so the upgrade + # headers and the long read timeout win. + location = /api/stream { proxy_pass http://mcode_webui_upstream; - # SSE: disable buffering so each `data:` line flushes immediately. - proxy_buffering off; - proxy_cache off; - - # SSE: HTTP/1.1 upstream so chunked transfer encoding works. + # WebSocket upgrade: forward the handshake headers upstream. proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; - # SSE: clear the upstream Connection header — some apps send - # "close" which makes EventSource disconnect mid-stream. - proxy_set_header Connection ""; - - # SSE: keep the connection open longer than the proxy's default - # 60s idle timeout. 1h matches webui's keepalive cadence. + # Keep the connection open longer than the proxy's default + # 60s idle timeout; the webui pings every 30s. proxy_read_timeout 1h; proxy_send_timeout 1h; @@ -200,7 +193,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Authorization $auth_bearer; - # Reasonable default for the JSON API. /api/events already has + # Reasonable default for the JSON API. /api/stream already has # its own block above. proxy_read_timeout 60s; } @@ -236,8 +229,8 @@ webui.example.com { # ----- Reverse proxy base config ----- reverse_proxy http://127.0.0.1:18090 { - # SSE: keep the upstream connection open. Caddy's default is - # 30s; bump to 1h to match the webui's keepalive. + # Keep the upstream connection open. Caddy's default is + # 30s; bump to 1h to comfortably outlive the 30s ping. transport http { # Caddy 2.7+ supports read_timeout per transport. read_timeout 1h @@ -254,30 +247,25 @@ webui.example.com { # query string). lib/auth.js#extractToken accepts both. header_up Authorization {http.reverse_proxy.header.Authorization} - # SSE: Caddy does NOT buffer streaming responses by default - # (unlike nginx), so no `flush_interval -1` is needed. If you - # see buffering in your version, add: - # flush_interval -1 - # ...to this block. Caddy 2.7+ already passes through SSE. + # WebSocket: Caddy forwards the Upgrade / Connection headers + # by default — no extra configuration is needed for /api/stream. } - # ----- SSE-specific route (must come BEFORE the catch-all) ----- - # The `events` and `alerts` endpoints are SSE streams. The base - # reverse_proxy above handles them correctly out of the box, but - # the explicit block lets reviewers see *why* these paths exist. - @sse_paths { - path /api/events /api/alerts + # ----- WebSocket route (must come BEFORE the catch-all) ----- + # `GET /api/stream` is the WebSocket event stream. The base + # reverse_proxy above proxies the handshake correctly out of the box + # (Caddy forwards Upgrade / Connection by default), but the explicit + # block lets reviewers see *why* this path exists. + @stream_paths { + path /api/stream } - handle @sse_paths { + handle @stream_paths { reverse_proxy http://127.0.0.1:18090 { transport http { read_timeout 1h - # SSE: do NOT buffer. Caddy 2.7+ default is fine. - # Older versions: uncomment to force passthrough. - # flush_interval -1 } - # Don't truncate the response at the upstream's idle timeout. - # Caddy 2.7+ also requires you to NOT set + # Don't truncate the connection at the upstream's idle + # timeout. Caddy 2.7+ also requires you to NOT set # `timeouts { read 30s }` at the server level — it's # transport-local. } @@ -337,16 +325,17 @@ http: loadBalancer: servers: - url: "http://127.0.0.1:18090" - # SSE: keep the connection alive longer than Traefik's 30s default. - # Traefik's `serversTransport` controls this. + # Keep the event-stream connection alive longer than Traefik's + # 30s default. Traefik's `serversTransport` controls this. serversTransport: mcode-webui-transport serversTransports: mcode-webui-transport: - # SSE: 1h read timeout. Default 30s would kill long-idle SSE. + # 1h read/idle timeout: the default 30s would drop the + # /api/stream WebSocket between the webui's 30s pings. forwardingTimeouts: dialTimeout: "30s" - responseHeaderTimeout: "0s" # no timeout on response headers — SSE + responseHeaderTimeout: "0s" # no timeout on response headers idleConnTimeout: "1h" # keepalive matches webui middlewares: @@ -384,13 +373,8 @@ http: headers: customRequestHeaders: X-Real-IP: "true" # placeholder; Traefik fills this automatically - # SSE: do NOT buffer. Traefik 2.10 passes streaming responses - # through by default. If you're on 2.4 or older and see SSE - # buffering, add a plugin: - # plugin: - # name: buffering - # config: - # flushInterval: "100ms" + # WebSocket: Traefik forwards the Upgrade / Connection headers + # by default — nothing else is needed for /api/stream. ``` ## 7. Verification checklist @@ -414,10 +398,18 @@ curl -i "https://${HOST}/api/state" | head -1 curl -i -H "Authorization: Bearer " "https://${HOST}/api/state" | head -1 # Expect: HTTP/2 200 -# 4. SSE — open a stream and confirm events arrive within 1s, not 30s. -timeout 5 curl -N -H "Authorization: Bearer " \ - "https://${HOST}/api/events" -# Expect: data: {...} lines arriving at near-realtime cadence. +# 4. WebSocket event stream — a plain GET must be refused with 426 +# (Upgrade Required); a handshake must return 101. +curl -i --http1.1 -H "Authorization: Bearer " \ + "https://${HOST}/api/stream" +# Expect: HTTP/1.1 426 Upgrade Required + +curl -i --http1.1 -H "Authorization: Bearer " \ + -H "Connection: Upgrade" -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Version: 13" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + "https://${HOST}/api/stream" +# Expect: HTTP/1.1 101 Switching Protocols # 5. Browser-origin gate (v2) — with the external origin registered in # trustedOrigins (§2.1), the preflight is answered and the origin is @@ -443,9 +435,9 @@ curl -s -i -X POST "https://${HOST}/api/settings" \ | Browser `POST`/`DELETE` returns 403 `cross-origin request rejected` | v2 Origin/CSRF gate: the external origin is not in `trustedOrigins` | `POST /api/settings {"trustedOrigins": ["https://webui.example.com"]}` — exact browser-visible origin, no trailing slash (§2.1). Check the proxy isn't rewriting/stripping the `Origin` header | | Browser can't read API responses (CORS errors in console) but curl works | External origin not allowlisted — untrusted origins get zero `Access-Control-*` headers by design | Same fix: register the origin in `trustedOrigins` (§2.1) | | `curl` returns 301 to HTTPS but the browser shows cert error | You're testing the redirect, not the TLS handshake | Test directly: `curl -v https://webui.example.com/api/health` | -| SSE events arrive in bursts every 30s | Proxy is buffering | See §3 — set `proxy_buffering off` (nginx) / add the `buffering` plugin (Traefik 2.4) / check Caddy version | -| SSE disconnects after a few minutes | Proxy idle timeout | Bump `proxy_read_timeout` / `read_timeout` / `forwardingTimeouts.idleConnTimeout` to 1h | -| `?token=` appears in nginx access log | Default nginx logs include query string | Either strip at proxy (recommended) or `access_log off` for the SSE location | +| `/api/stream` WebSocket handshake fails (400/426), no live feed | Proxy drops the `Upgrade` / `Connection` headers | See §3 — forward the upgrade headers (nginx `proxy_set_header Upgrade $http_upgrade;` + `proxy_set_header Connection "upgrade";`); caddy / Traefik forward them by default | +| Event stream disconnects after a few minutes | Proxy idle timeout shorter than the webui's 30s ping | Bump `proxy_read_timeout` / `read_timeout` / `forwardingTimeouts.idleConnTimeout` to 1h | +| `?token=` appears in nginx access log | Default nginx logs include query string | Either strip at proxy (recommended) or `access_log off` for the WebSocket / API location | | 401 even with token | Token lost during header rewrite | Check your `proxy_set_header Authorization` line; verify the webui process has `MCODE_WEBUI_TOKEN` matching | | Rate limit (429) on the health endpoint | Misconfigured middleware also throttling | `/api/health` is already exempted at the webui layer (router.js Gate 4). If your proxy middleware still throttles, exempt `/api/health` there too | | 502 from nginx after webui restart | Upstream down during restart | `proxy_next_upstream` + retry; or just reload nginx after the webui is back up | diff --git a/packages/webui/docs/HTTPS-REVERSE-PROXY.zh-CN.md b/packages/webui/docs/HTTPS-REVERSE-PROXY.zh-CN.md index 28cf55dc..d20212f3 100644 --- a/packages/webui/docs/HTTPS-REVERSE-PROXY.zh-CN.md +++ b/packages/webui/docs/HTTPS-REVERSE-PROXY.zh-CN.md @@ -5,7 +5,7 @@ > **本文档存在的理由。** webui 绑定的是纯 HTTP(Node `http.createServer`)。 > TLS 终端被委托给位于它前面的反向代理。本页收集了 > 三份可直接复制的配置(nginx、caddy、Traefik 2),外加那些 -> 不知道要留意就会被坑到的 SSE 陷阱。 +> 不知道要留意就会被坑到的 WebSocket 陷阱。 ## 目录 @@ -13,7 +13,7 @@ |---|---| | 1 | [为什么需要反向代理](#1-为什么需要反向代理) | | 2 | [webui 与代理如何共享认证](#2-webui-与代理如何共享认证) | -| 3 | [常见陷阱 —— Server-Sent Events (SSE)](#3-常见陷阱--server-sent-events-sse) | +| 3 | [常见陷阱 —— WebSocket 事件流](#3-常见陷阱--websocket-事件流) | | 4 | [nginx](#4-nginx) | | 5 | [caddy](#5-caddy) | | 6 | [Traefik 2](#6-traefik-2) | @@ -40,16 +40,16 @@ webui 接受两种令牌载体(见 [`server/lib/auth.js`](../server/lib/auth.j | 载体 | 使用场景 | |---|---| | `Authorization: Bearer ` | 浏览器 `fetch`、程序化客户端。首选 —— 永远不会接触 URL 栏 / referer / 历史记录。 | -| `?token=` 查询字符串 | 浏览器 `EventSource`(SSE)。`EventSource` API 无法设置自定义请求头,所以从浏览器认证 SSE 连接的唯一方式就是走 URL。 | +| `?token=` 查询字符串 | 浏览器 `WebSocket`(`/api/stream`)。`WebSocket` API 无法设置自定义请求头,所以从浏览器认证 WebSocket 握手的唯一方式就是走 URL。 | **对下面代理配置的建议**:在 webui 进程上设置 `MCODE_WEBUI_TOKEN=`,然后任选其一: - (首选)让代理把 `Authorization` 请求头改写为 webui 期望的值(`proxy_set_header Authorization "Bearer "`),或者 -- 直接透传 `?token=`(EventSource 会看到它)。 +- 直接透传 `?token=`(WebSocket 握手会看到它)。 **不要把令牌写进日志。** nginx 和 caddy 默认都会记录包含查询字符串的请求行;如果你把 `?token=` 放进 URL,它就会落进访问日志。要么: - 在代理处剥离 `token=` 查询参数(`proxy_set_header Authorization "Bearer $arg_token"`),要么 -- 对 SSE / API location 设置 `access_log off`。 +- 对 WebSocket / API location 设置 `access_log off`。 ### 2.1 代理之后的浏览器源 —— `trustedOrigins` 允许清单(v2) @@ -96,16 +96,15 @@ curl -X POST http://127.0.0.1:18090/api/settings \ - 不发送 `Origin` 的程序化客户端(curl、MCP、CLI)不受 该门禁影响 —— 它们照常通过代理工作。 -## 3. 常见陷阱 —— Server-Sent Events (SSE) +## 3. 常见陷阱 —— WebSocket 事件流 -SSE 长连接(`/api/events`、`/api/alerts`)是"我的代理除了实时推送之外一切正常"类 bug 报告的头号来源。陷阱在于缓冲:反向代理默认会**缓冲上游响应**以便在一次 TCP 写入中发送,这会杀死任何依赖增量刷新的流。 +实时下行通道(`GET /api/stream`,一个 WebSocket)是"我的代理除了实时推送之外一切正常"类 bug 报告的头号来源。陷阱在于 Upgrade 握手:反向代理必须转发 `Upgrade` / `Connection` 头并以 HTTP/1.1 对上游通信,否则握手在连接建立之前就会被拒绝。(`GET /api/alerts` 是普通 REST 快照,代理无需特殊处理。) | 陷阱 | 症状 | 修复 | |---|---|---| -| **响应缓冲** | 事件每隔 30 秒以上成批出现,而不是即时出现 | `proxy_buffering off;`(nginx)/ `flush_interval -1` 或不设置 `buffer`(caddy)/ `flushInterval: "100ms"`(Traefik 2 file provider) | -| **下游使用 HTTP/1.0** | 某些代理对上游默认使用 HTTP/1.0;SSE 需要 1.1 的分块传输 | `proxy_http_version 1.1;`(nginx)/ `versions h1 h2`(Traefik 2.4+ 默认) | -| **代理注入 Connection: close 头** | EventSource 每隔几分钟就关闭 | `proxy_set_header Connection "";`(nginx)/ caddy 默认即可 / Traefik 2 默认即可 | -| **读取超时短于事件间隔** | 如果代理的空闲超时 < 事件间隔,它会杀掉 SSE | `proxy_read_timeout 1h;`(nginx)/ `timeouts { read 1h }`(Traefik 2) | +| **`Upgrade` / `Connection` 头被丢弃** | `/api/stream` 握手失败(400/426),SPA 没有实时下行 | `proxy_set_header Upgrade $http_upgrade;` + `proxy_set_header Connection "upgrade";`(nginx,独立的 `/api/stream` location)/ caddy 默认转发 / Traefik 2 默认转发 | +| **下游使用 HTTP/1.0** | 握手全程需要 HTTP/1.1 | `proxy_http_version 1.1;`(nginx)/ `versions h1 h2`(Traefik 2.4+ 默认) | +| **读取超时短于心跳间隔** | 代理在 webui 每 30 秒一次的 ping 之间杀掉连接 | `proxy_read_timeout 1h;`(nginx)/ `timeouts { read 1h }`(Traefik 2) | | **`/api/health` 被限流** | 存活探针在负载下收到 429 | 也在代理层豁免 `/api/health`(大多数代理如此 —— 但某些限流中间件不会) | ## 4. nginx @@ -157,25 +156,19 @@ server { access_log /var/log/nginx/mcode-webui.access.log; error_log /var/log/nginx/mcode-webui.error.log; - # --- SSE / streaming endpoints --------------------------------------- - # MUST come before the catch-all `/` location so the SSE-specific - # overrides win. - location ~ ^/api/(events|alerts)$ { + # --- WebSocket event stream (/api/stream) ---------------------------- + # MUST come before the catch-all `/` location so the upgrade + # headers and the long read timeout win. + location = /api/stream { proxy_pass http://mcode_webui_upstream; - # SSE: disable buffering so each `data:` line flushes immediately. - proxy_buffering off; - proxy_cache off; - - # SSE: HTTP/1.1 upstream so chunked transfer encoding works. + # WebSocket upgrade: forward the handshake headers upstream. proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; - # SSE: clear the upstream Connection header — some apps send - # "close" which makes EventSource disconnect mid-stream. - proxy_set_header Connection ""; - - # SSE: keep the connection open longer than the proxy's default - # 60s idle timeout. 1h matches webui's keepalive cadence. + # Keep the connection open longer than the proxy's default + # 60s idle timeout; the webui pings every 30s. proxy_read_timeout 1h; proxy_send_timeout 1h; @@ -198,7 +191,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Authorization $auth_bearer; - # Reasonable default for the JSON API. /api/events already has + # Reasonable default for the JSON API. /api/stream already has # its own block above. proxy_read_timeout 60s; } @@ -234,8 +227,8 @@ webui.example.com { # ----- Reverse proxy base config ----- reverse_proxy http://127.0.0.1:18090 { - # SSE: keep the upstream connection open. Caddy's default is - # 30s; bump to 1h to match the webui's keepalive. + # Keep the upstream connection open. Caddy's default is + # 30s; bump to 1h to comfortably outlive the 30s ping. transport http { # Caddy 2.7+ supports read_timeout per transport. read_timeout 1h @@ -252,30 +245,25 @@ webui.example.com { # query string). lib/auth.js#extractToken accepts both. header_up Authorization {http.reverse_proxy.header.Authorization} - # SSE: Caddy does NOT buffer streaming responses by default - # (unlike nginx), so no `flush_interval -1` is needed. If you - # see buffering in your version, add: - # flush_interval -1 - # ...to this block. Caddy 2.7+ already passes through SSE. + # WebSocket: Caddy forwards the Upgrade / Connection headers + # by default — no extra configuration is needed for /api/stream. } - # ----- SSE-specific route (must come BEFORE the catch-all) ----- - # The `events` and `alerts` endpoints are SSE streams. The base - # reverse_proxy above handles them correctly out of the box, but - # the explicit block lets reviewers see *why* these paths exist. - @sse_paths { - path /api/events /api/alerts + # ----- WebSocket route (must come BEFORE the catch-all) ----- + # `GET /api/stream` is the WebSocket event stream. The base + # reverse_proxy above proxies the handshake correctly out of the box + # (Caddy forwards Upgrade / Connection by default), but the explicit + # block lets reviewers see *why* this path exists. + @stream_paths { + path /api/stream } - handle @sse_paths { + handle @stream_paths { reverse_proxy http://127.0.0.1:18090 { transport http { read_timeout 1h - # SSE: do NOT buffer. Caddy 2.7+ default is fine. - # Older versions: uncomment to force passthrough. - # flush_interval -1 } - # Don't truncate the response at the upstream's idle timeout. - # Caddy 2.7+ also requires you to NOT set + # Don't truncate the connection at the upstream's idle + # timeout. Caddy 2.7+ also requires you to NOT set # `timeouts { read 30s }` at the server level — it's # transport-local. } @@ -335,16 +323,17 @@ http: loadBalancer: servers: - url: "http://127.0.0.1:18090" - # SSE: keep the connection alive longer than Traefik's 30s default. - # Traefik's `serversTransport` controls this. + # Keep the event-stream connection alive longer than Traefik's + # 30s default. Traefik's `serversTransport` controls this. serversTransport: mcode-webui-transport serversTransports: mcode-webui-transport: - # SSE: 1h read timeout. Default 30s would kill long-idle SSE. + # 1h read/idle timeout: the default 30s would drop the + # /api/stream WebSocket between the webui's 30s pings. forwardingTimeouts: dialTimeout: "30s" - responseHeaderTimeout: "0s" # no timeout on response headers — SSE + responseHeaderTimeout: "0s" # no timeout on response headers idleConnTimeout: "1h" # keepalive matches webui middlewares: @@ -382,13 +371,8 @@ http: headers: customRequestHeaders: X-Real-IP: "true" # placeholder; Traefik fills this automatically - # SSE: do NOT buffer. Traefik 2.10 passes streaming responses - # through by default. If you're on 2.4 or older and see SSE - # buffering, add a plugin: - # plugin: - # name: buffering - # config: - # flushInterval: "100ms" + # WebSocket: Traefik forwards the Upgrade / Connection headers + # by default — nothing else is needed for /api/stream. ``` ## 7. 验证清单 @@ -412,10 +396,18 @@ curl -i "https://${HOST}/api/state" | head -1 curl -i -H "Authorization: Bearer " "https://${HOST}/api/state" | head -1 # Expect: HTTP/2 200 -# 4. SSE — open a stream and confirm events arrive within 1s, not 30s. -timeout 5 curl -N -H "Authorization: Bearer " \ - "https://${HOST}/api/events" -# Expect: data: {...} lines arriving at near-realtime cadence. +# 4. WebSocket event stream — a plain GET must be refused with 426 +# (Upgrade Required); a handshake must return 101. +curl -i --http1.1 -H "Authorization: Bearer " \ + "https://${HOST}/api/stream" +# Expect: HTTP/1.1 426 Upgrade Required + +curl -i --http1.1 -H "Authorization: Bearer " \ + -H "Connection: Upgrade" -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Version: 13" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + "https://${HOST}/api/stream" +# Expect: HTTP/1.1 101 Switching Protocols # 5. Browser-origin gate (v2) — with the external origin registered in # trustedOrigins (§2.1), the preflight is answered and the origin is @@ -441,9 +433,9 @@ curl -s -i -X POST "https://${HOST}/api/settings" \ | 浏览器 `POST`/`DELETE` 返回 403 `cross-origin request rejected` | v2 Origin/CSRF 门禁:外部源不在 `trustedOrigins` 中 | `POST /api/settings {"trustedOrigins": ["https://webui.example.com"]}` —— 使用浏览器可见的确切源,不带尾部斜杠(§2.1)。检查代理没有改写/剥离 `Origin` 请求头 | | 浏览器无法读取 API 响应(控制台出现 CORS 报错)但 curl 正常 | 外部源未列入允许清单 —— 不受信源按设计得不到任何 `Access-Control-*` 头 | 同样的修复:在 `trustedOrigins` 中注册该源(§2.1) | | `curl` 返回 301 跳转 HTTPS 但浏览器显示证书错误 | 你测试的是重定向,而不是 TLS 握手 | 直接测试:`curl -v https://webui.example.com/api/health` | -| SSE 事件每隔 30 秒成批到达 | 代理在缓冲 | 见 §3 —— 设置 `proxy_buffering off`(nginx)/ 添加 `buffering` 插件(Traefik 2.4)/ 检查 Caddy 版本 | -| SSE 几分钟后断开 | 代理空闲超时 | 把 `proxy_read_timeout` / `read_timeout` / `forwardingTimeouts.idleConnTimeout` 调大到 1h | -| `?token=` 出现在 nginx 访问日志中 | nginx 默认日志包含查询字符串 | 要么在代理处剥离(推荐),要么对 SSE location 设置 `access_log off` | +| `/api/stream` WebSocket 握手失败(400/426),没有实时下行 | 代理丢弃了 `Upgrade` / `Connection` 头 | 见 §3 —— 转发升级头(nginx `proxy_set_header Upgrade $http_upgrade;` + `proxy_set_header Connection "upgrade";`);caddy / Traefik 默认转发 | +| 事件流几分钟后断开 | 代理空闲超时短于 webui 的 30 秒 ping | 把 `proxy_read_timeout` / `read_timeout` / `forwardingTimeouts.idleConnTimeout` 调大到 1h | +| `?token=` 出现在 nginx 访问日志中 | nginx 默认日志包含查询字符串 | 要么在代理处剥离(推荐),要么对 WebSocket / API location 设置 `access_log off` | | 即使带令牌也返回 401 | 令牌在请求头改写中丢失 | 检查你的 `proxy_set_header Authorization` 行;验证 webui 进程的 `MCODE_WEBUI_TOKEN` 与之匹配 | | 健康端点被限流(429) | 配置错误的中间件也在限流 | `/api/health` 已在 webui 层被豁免(router.js Gate 4)。如果你的代理中间件仍在限流,也在那里豁免 `/api/health` | | webui 重启后 nginx 返回 502 | 重启期间上游不可用 | `proxy_next_upstream` + 重试;或者等 webui 恢复后直接 reload nginx | diff --git a/packages/webui/docs/TROUBLESHOOTING.md b/packages/webui/docs/TROUBLESHOOTING.md index 1db6c3cf..c83d1f9d 100644 --- a/packages/webui/docs/TROUBLESHOOTING.md +++ b/packages/webui/docs/TROUBLESHOOTING.md @@ -7,7 +7,7 @@ > verified fix. If the fix here doesn't work, enable the in-page debug log (bottom-right -of the webui) and check the right panel for SSE events. You can also +of the webui) and check the right panel for event-stream activity. You can also run `node --check public/app/main.js` to verify the file parses. --- @@ -17,17 +17,17 @@ run `node --check public/app/main.js` to verify the file parses. **Symptoms**: HTML loads, no red error block, but the sidebar is empty and the right panel shows "—" everywhere. -**Cause**: the init() promise chain failed silently, or the SSE -connection never opened. +**Cause**: the init() promise chain failed silently, or the +`/api/stream` WebSocket connection never opened. **Fix**: 1. Open devtools (F12) → Console → look for the `__DBG.log` entries at the bottom-right debug panel. 2. If you see `init: start` but not `init: state loaded`, the `/api/state` request is failing. Check the network tab. -3. If you see `init done` but the UI is still empty, the SSE - connection is failing. Reload the page; the webui will - reconnect. +3. If you see `init done` but the UI is still empty, the + `/api/stream` WebSocket connection is failing. Reload the page; the + webui will reconnect. ## `⚠ webui JS 初始化失败: TypeError: Cannot read properties of null (reading 'addEventListener')` @@ -51,8 +51,8 @@ of date and the browser is running an old main.js. ## `Failed to load resource: net::ERR_CONNECTION_REFUSED` to `127.0.0.1:18090` -**Symptoms**: devtools shows the SSE or `/api/state` request failing -with "connection refused". UI shows "init fail" or is stuck on +**Symptoms**: devtools shows the `/api/stream` WebSocket or +`/api/state` request failing with "connection refused". UI shows "init fail" or is stuck on "loading…". **Cause**: the server is not running, or it's running on a different @@ -71,7 +71,7 @@ port. webui's session list is empty. **Cause**: the webui cached an older empty list. This usually -resolves itself on the next SSE `state` event, but if it's +resolves itself on the next event-stream `state` snapshot, but if it's persistent: **Fix**: hard-reload the page. @@ -98,12 +98,12 @@ mcode sqlite via `GET /api/acp-sessions` on init. **Symptoms**: clicking "Skip" or pressing Esc doesn't close the plan modal. -**Cause**: the click handler is calling `hidePlan()` but the SSE -event from mcode hasn't arrived yet, so the next render re-opens -it. +**Cause**: the click handler is calling `hidePlan()` but the +event-stream update from mcode hasn't arrived yet, so the next render +re-opens it. **Fix**: -1. Wait 2-3 seconds for the SSE ack. +1. Wait 2-3 seconds for the event-stream ack. 2. If it still doesn't dismiss, click "Skip" again — sometimes the first click is consumed by the focus ring and the second click hits the button. @@ -137,17 +137,23 @@ functionality. **Fix**: this is cosmetic, ignore it. Or add a `public/favicon.ico`. -## SSE connection drops every 30-60 seconds +## Event-stream connection drops every 30-60 seconds **Symptoms**: the right panel freezes for a few seconds, then catches -up. devtools shows EventSource repeatedly closing and re-opening. +up. devtools shows the `/api/stream` WebSocket repeatedly closing and +re-opening (Network → WS tab). -**Cause**: an intermediate proxy (nginx, cloudflare) is closing -the SSE connection. SSE has no keep-alive in the protocol, so -proxies may decide to close idle connections. +**Cause**: an intermediate proxy (nginx, cloudflare) is closing the +connection between the webui's 30s pings, or it drops the +`Upgrade` / `Connection` headers so the handshake never holds. **Fix**: -- Set a longer proxy timeout: `proxy_read_timeout 3600s;` in nginx. +- Set a longer proxy timeout: `proxy_read_timeout 3600s;` in nginx, + and make sure the proxy forwards `Upgrade` / `Connection` + (see `docs/HTTPS-REVERSE-PROXY.md` §3). +- After a close the SPA reconnects ~3 seconds after `onclose` and + resumes from `lastSeq`, so a drop costs at most a few seconds + of catch-up. - Or deploy the webui behind a path that doesn't go through a proxy. For local dev, this is a non-issue. @@ -186,8 +192,9 @@ mcode subprocess" below. is waiting on stdin and we're not feeding it. **Fix**: -1. Open devtools → Network → find the `/api/events` EventSource. - If it's still open, the issue is on the mcode side. +1. Open devtools → Network → filter for `/api/stream` and find the + WebSocket connection. If it's still open (status 101), the issue is + on the mcode side. 2. Find the mcode subprocess: `Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | Where-Object { $_.CommandLine -like "*acp*" }` 3. Kill it: `Stop-Process -Id -Force` 4. The webui will spawn a fresh subprocess on the next message. @@ -211,13 +218,13 @@ optional fields, so this should be rare. or shows the wrong value. **Cause**: mcode's permission state isn't being reflected in -`state.permissions`. The webui reads this from the SSE -`state` events. +`state.permissions`. The webui reads this from the event-stream +`state` snapshots. **Fix**: 1. Check `curl 'http://127.0.0.1:18090/api/state?cid=' | jq .permissions` 2. If empty, mcode hasn't reported the current permission mode. - Send any message — the next SSE event will include it. + Send any message — the next event-stream snapshot will include it. 3. If the webui shows the wrong value, it's because mcode 0.1.5 acp doesn't implement `session/set_mode`. The displayed value is the user's selection, but the actual mcode mode hasn't diff --git a/packages/webui/docs/TROUBLESHOOTING.zh-CN.md b/packages/webui/docs/TROUBLESHOOTING.zh-CN.md index da6e1009..9d08ccba 100644 --- a/packages/webui/docs/TROUBLESHOOTING.zh-CN.md +++ b/packages/webui/docs/TROUBLESHOOTING.zh-CN.md @@ -6,7 +6,7 @@ > 报错信息、根因,以及经过验证的修复方法。 如果这里的修复方法不起作用,请启用页面内调试日志(webui 右下角) -并查看右侧面板中的 SSE 事件。你也可以运行 +并查看右侧面板中的事件流动态。你也可以运行 `node --check public/app/main.js` 来验证该文件能否正常解析。 --- @@ -16,16 +16,16 @@ **症状**:HTML 已加载,没有红色报错块,但侧边栏为空, 右侧面板处处显示 "—"。 -**根因**:init() 的 promise 链静默失败,或者 SSE -连接从未打开。 +**根因**:init() 的 promise 链静默失败,或者 +`/api/stream` WebSocket 连接从未打开。 **修复**: 1. 打开开发者工具(F12)→ Console → 查看右下角调试面板中的 `__DBG.log` 条目。 2. 如果你看到 `init: start` 但没有 `init: state loaded`,说明 `/api/state` 请求失败了。检查 network 标签页。 -3. 如果你看到 `init done` 但 UI 仍为空,说明 SSE - 连接失败了。重新加载页面,webui 会重新连接。 +3. 如果你看到 `init done` 但 UI 仍为空,说明 + `/api/stream` WebSocket 连接失败了。重新加载页面,webui 会重新连接。 ## `⚠ webui JS 初始化失败: TypeError: Cannot read properties of null (reading 'addEventListener')` @@ -49,8 +49,8 @@ HTML 元素;或者 `cache-bust?v=N` 查询参数已过期, ## `Failed to load resource: net::ERR_CONNECTION_REFUSED` 指向 `127.0.0.1:18090` -**症状**:开发者工具显示 SSE 或 `/api/state` 请求失败, -提示 "connection refused"。UI 显示 "init fail" 或卡在 +**症状**:开发者工具显示 `/api/stream` WebSocket 或 +`/api/state` 请求失败,提示 "connection refused"。UI 显示 "init fail" 或卡在 "loading…"。 **根因**:服务器没有运行,或者运行在不同的端口上。 @@ -66,8 +66,8 @@ HTML 元素;或者 `cache-bust?v=N` 查询参数已过期, **症状**:API 调用正常并返回会话,但 webui 的会话列表为空。 -**根因**:webui 缓存了一份较早的空列表。这通常会在下一个 -SSE `state` 事件时自行恢复,但如果一直存在: +**根因**:webui 缓存了一份较早的空列表。这通常会在下一份 +事件流 `state` 快照时自行恢复,但如果一直存在: **修复**:强制刷新页面。 @@ -92,11 +92,11 @@ webui 的会话列表中。 **症状**:点击 "Skip" 或按 Esc 无法关闭计划弹窗。 -**根因**:点击处理器调用了 `hidePlan()`,但来自 mcode 的 SSE -事件还没有到达,所以下一次渲染又把它打开了。 +**根因**:点击处理器调用了 `hidePlan()`,但来自 mcode 的 +事件流更新还没有到达,所以下一次渲染又把它打开了。 **修复**: -1. 等 2-3 秒让 SSE 确认到达。 +1. 等 2-3 秒让事件流确认到达。 2. 如果还是关不掉,再点一次 "Skip" —— 有时第一次点击被 焦点环消耗掉了,第二次点击才会命中按钮。 3. 如果弹窗真的卡死了,说明底层 mcode 状态卡住了。 @@ -128,17 +128,21 @@ localStorage 或使用了不同的 CID,关闭记录就会丢失。 **修复**:这只是外观问题,忽略即可。或者添加一个 `public/favicon.ico`。 -## SSE 连接每 30-60 秒断开一次 +## 事件流连接每 30-60 秒断开一次 **症状**:右侧面板冻结几秒,然后追上来。开发者工具显示 -EventSource 反复关闭并重新打开。 +`/api/stream` WebSocket 反复关闭并重新打开(Network → WS 标签页)。 -**根因**:中间代理(nginx、cloudflare)正在关闭 SSE -连接。SSE 协议中没有 keep-alive,所以代理可能会决定 -关闭空闲连接。 +**根因**:中间代理(nginx、cloudflare)在 webui 每 30 秒一次的 +ping 之间关闭了连接,或者它丢弃了 `Upgrade` / `Connection` 头, +导致握手根本无法保持。 **修复**: -- 设置更长的代理超时:nginx 中使用 `proxy_read_timeout 3600s;`。 +- 设置更长的代理超时:nginx 中使用 `proxy_read_timeout 3600s;`, + 并确认代理转发了 `Upgrade` / `Connection` + (见 `docs/HTTPS-REVERSE-PROXY.md` §3)。 +- 连接关闭后,SPA 会在 `onclose` 约 3 秒后重连并从 `lastSeq` + 续传,所以一次断开最多损失几秒的追赶。 - 或者把 webui 部署在不经代理的路径后面。本地开发时 这不是问题。 @@ -177,8 +181,8 @@ SIGTERM,但子进程需要一点时间才会退出,而进行中的 正在等待 stdin 而我们没有给它喂数据。 **修复**: -1. 打开开发者工具 → Network → 找到 `/api/events` EventSource。 - 如果它仍然打开着,问题在 mcode 一侧。 +1. 打开开发者工具 → Network → 过滤 `/api/stream`,找到该 + WebSocket 连接。如果它仍然开着(状态 101),问题在 mcode 一侧。 2. 找到 mcode 子进程:`Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | Where-Object { $_.CommandLine -like "*acp*" }` 3. 杀掉它:`Stop-Process -Id -Force` 4. webui 会在下一条消息时生成一个全新的子进程。 @@ -201,12 +205,12 @@ SIGTERM,但子进程需要一点时间才会退出,而进行中的 或显示错误的值。 **根因**:mcode 的权限状态没有反映到 -`state.permissions` 中。webui 从 SSE `state` 事件中读取它。 +`state.permissions` 中。webui 从事件流 `state` 快照中读取它。 **修复**: 1. 检查 `curl 'http://127.0.0.1:18090/api/state?cid=' | jq .permissions` 2. 如果为空,说明 mcode 还没有上报当前权限模式。 - 发送任意消息 —— 下一个 SSE 事件就会包含它。 + 发送任意消息 —— 下一份事件流快照就会包含它。 3. 如果 webui 显示错误的值,那是因为 mcode 0.1.5 的 acp 没有实现 `session/set_mode`。显示的值是用户的选择, 但实际的 mcode 模式并没有改变。等 mcode 实现该方法后 diff --git a/packages/webui/docs/drafts/README.md b/packages/webui/docs/drafts/README.md new file mode 100644 index 00000000..0daeb994 --- /dev/null +++ b/packages/webui/docs/drafts/README.md @@ -0,0 +1,14 @@ +# drafts/ 文档集索引 + +| 文件 | 性质 | 状态 | +|---|---|---| +| [arch_net_draft_0922.md](arch_net_draft_0922.md) | 网络层设计**提案**(493 行精简版) | 基准 | +| [arch_net_solution_0922.md](arch_net_solution_0922.md) | **技术方案**(可实施级:拓扑/协议/对齐义务/切片验收) | 现行 | + +阅读顺序:提案(问题与选型)→ 技术方案(实施规格)。 + +演进规则:**方案改动先落盘再改代码;实现偏差即时归档进技术方案 §10 决策记录。** + +术语约束:标准编程名词,禁自造缩写(历史讨论中已否决:自造编号体系、SQLite 消息队列、常驻进程监督循环、PTY 驱动 TUI)。 + +历史:2026-09-22 提案四稿(精简重组);2026-09-23 技术方案初版;2026-09-23 决策 20 移除 SSE —— 仅保留 WS 事件流 + REST(见技术方案 §10 决策记录 20)。 \ No newline at end of file diff --git a/packages/webui/docs/drafts/arch_net_draft_0922.md b/packages/webui/docs/drafts/arch_net_draft_0922.md new file mode 100644 index 00000000..f6bc740c --- /dev/null +++ b/packages/webui/docs/drafts/arch_net_draft_0922.md @@ -0,0 +1,495 @@ +# Web UI 网络层设计草案(2026-09-22) + +> **状态**:草案,未实现;所有内容均为提案,不改变当前发布行为。 +> **定位**:[ARCHITECTURE.md](../ARCHITECTURE.md) 的前瞻配套文档;方案接受后相关章节迁入正式文档,本文归档。 +> **范围**:浏览器 ↔ webui 后端(浏览器通信)、webui 后端 ↔ 引擎(引擎集成),及安全、迁移等横切关注点。 +> **修订**:四稿 —— 精简重组:去除自定义编号(目标/缺陷/边界改为具名引用),合并次要图表,保留关键设计与核心图表。 +> **变更**:2026-09-23 决策 20 移除 SSE(仅保留 WS 事件流 + REST),见 [arch_net_solution_0922.md](arch_net_solution_0922.md) 决策记录 20;本提案为快照,正文不重写。 + +## 1. 目标与非目标 + +**目标** + +1. 下行投递从「至多一次的快照流」升级为「至少一次的有序增量事件流」,支持断线续传。 +2. 下行负载从 O(状态总量) 降为 O(事件增量)。 +3. 上行交互(审批、问答、计划确认、取消)使用结构化消息,不再伪装成聊天内容。 +4. 引擎能力面完整可用:取消、模式切换、队列、会话中权限切换。 +5. 安全门链语义逐条映射到新传输,不新增攻击面;保留对接外部/旧版引擎的 ACP 通道。 +6. 引擎集成按角色分层,子 agent 由受评审的模板实例化,层间通信不引入消息代理。 + +**非目标**:多用户鉴权与多实例扩展;重设计 ACP 协议本身;PTY 驱动 TUI;更换前端渲染技术栈。 + +## 2. 术语 + +| 术语 | 英文 | 定义 | +|---|---|---| +| 控制面 / 数据面 | control plane / data plane | 指令上行通道 / 状态与事件下行通道 | +| 快照 / 增量 | snapshot / delta | 某时刻完整状态 / 相对前一状态的最小变更 | +| 事件溯源 | event sourcing | 以有序事件日志为事实来源,重放重建状态 | +| 单调序列号 | monotonic sequence number | 连接内连续递增、不回退的帧编号 | +| 环形缓冲区 | ring buffer | 固定容量、新帧覆盖最旧帧的内存重放窗口 | +| 断线续传 | resume | 携带最后序列号重连,服务端从该点续发 | +| 至少一次 | at-least-once | 不丢帧的投递保证(配合幂等消费) | +| 多路复用 | multiplexing | 单条连接承载多条逻辑流 | +| 能力协商 | capability negotiation | 握手期声明并探测双方支持的能力集 | +| 崩溃域 | crash domain | 一个故障波及的进程/线程边界 | +| 可重入性 | reentrancy | 同一模块被多个并发会话安全复用 | +| 传输抽象层 | transport abstraction | 同一接口契约下可互换的传输实现集合(`runMcode` → `NormalizedEvent`) | +| 模板 agent | agent template / blueprint | 以受评审文件定义方法论与验收标准,按需实例化为子 agent 进程 | +| 黑板 | blackboard | 多 agent 共享的异步协作介质(本方案为工作区文件) | + +## 3. 现状(as-is) + +### 3.1 拓扑 + +```mermaid +flowchart TD + subgraph B["浏览器 SPA"] + UI["render() 循环,镜像 state 对象"] + end + subgraph S["webui 后端"] + G["门链:CORS → Origin/CSRF → LAN → token → 限流 → 只读"] + R["REST 路由 /api/*"] + SB["state-bus.js:per-cid clientState
60Hz 节流合并 + 字节级 diff"] + E1["GET /api/events(SSE)"] + E2["GET /api/alerts(SSE)"] + N["mcode-acp.js / mcode-exec.js:NormalizedEvent 归一化"] + end + subgraph ENG["引擎子进程(每活动标签一个)"] + A["mcode acp:ndjson JSON-RPC 2.0 over stdio"] + X["mcode exec(回退传输)"] + end + DB[("runtime-state.sqlite
权威会话存储")] + UI -->|"POST /api/send 等,响应仅 ack"| G + G --> R + R --> SB + A -->|"session/update 通知"| N + N --> SB + SB -->|"匿名 data: 帧 = 全量状态快照"| E1 + E1 --> UI + SB --> E2 + SB -.->|"读:列表/回填;写:级联删除"| DB + A -.-> DB +``` + +三条事实链:**浏览器通信 = REST 控制面 + SSE 数据面**(`POST /api/send` 立即返回 `{ok:true}`,结果全部走 SSE);**引擎集成 = ACP 子进程**(`initialize` 握手后 `session/new|load|list|prompt|close`,事件以 `session/update` 回传归一化);**旁路 = 直连 SQLite**(列表、回填、级联删除不经 ACP)。 + +### 3.2 浏览器通信细节 + +- SSE 常规推送全部是**匿名 `data:` 帧,内容为全量 state 快照**;仅 4 个命名控制事件(`auth.token_rotated`、`token.first_run`、`needs_authorization`、`authorization_decided`)。 +- 推送优化:16ms 窗口节流合并(last-call-wins)+ 与上一帧逐字节相同则跳过——两者都是快照模型的补偿,单帧大小仍为 O(状态总量)。 + +### 3.3 引擎集成细节 + +- 传输:Agent Client Protocol(ACP),ndjson JSON-RPC 2.0 over stdio;每活动标签一个子进程 + 一个列表用单例(会话列表 30s 缓存、命令目录 24h 缓存)。 +- 能力探测是**静态黑名单**:`mcode-rpc.js` 的 `UNSUPPORTED` 集合硬编码自 mcode 0.1.5 实测;而本仓库引擎(`packages/tui/src/acp/agent.ts`)已实现 `setMode`、`setConfigOption`、`cancel`、`fork`、`resume`,并在 `initialize` 响应中发布能力与扩展方法清单——黑名单已与实际漂移。 +- 取消 = 杀子进程(SIGTERM → 2s 后 SIGKILL);权限模式仅能在 spawn 时以 `--permission` 传入。引擎侧自有并发上限(如并发 `session/new` ≤ 8、生命周期 64/会话、256 全局)。 + +### 3.4 限制清单(现状数值) + +| 限制 | 默认值 | 环境变量 | 出处 | +|---|---|---|---| +| 端口 | 18090,占用向后探测(≤20 次);显式指定则固定 | `PORT` / `--port` | `config.js`、`port.js` | +| 绑定 | `127.0.0.1`(loopback 默认) | `HOST` / `lanBind` | `config.js` | +| 限流 | 60 次/分钟、burst 100;持 token ×2;loopback 豁免 | `MCODE_WEBUI_RATE_LIMIT(_BURST)` | `rate-limit.js` | +| 上传 | 请求体 50 MiB / 单文件 25 MiB / 配额 200 MiB,中途超限即 413 | `MCODE_WEBUI_UPLOAD_*` | `upload.js` | +| 回合空闲看门狗 | 流静默 120s 判超时(事件续命,非墙钟) | `MCODE_WEBUI_PROMPT_IDLE_TIMEOUT` | `idle-watchdog.js` | +| 转录回填 | ≤400 行 / ≤200 KB | 无 | ARCHITECTURE.md §2.2 | +| token / trustedOrigins | token ≤256 字符;白名单 ≤16 条 × 1–200 字符 | `/api/settings` | `auth.js`、`settings.js` | +| 依赖 | 0 个 npm 运行时依赖;Node ≥22.19 | — | `docs/webui.md` | + +### 3.5 现状问题清单(按用户可感知的影响归纳) + +| 用户可感知的影响 | 技术根因 | 对策 | +|---|---|---| +| 会话越长,流式输出时界面越卡、流量消耗越大(弱网与手机上明显)——每帧推送都是全量状态快照,大小与聊天历史长度成正比 | 快照模型 | §5 方案 B | +| 网络闪断后页面整体重新加载(「转圈」);侧栏会话列表先闪空再弹回;断线期间的模型输出丢一段且无任何提示 | SSE 单向、无序列号,重连只能全量重同步 | §5 方案 B | +| 切回长会话只能看到最近约 400 行 / 200 KB 的历史,更早的记录在 webui 里看不到 | 快照模型装不下完整历史 | §5 方案 B | +| 点「停止」会杀掉整个引擎进程:进行中的输出直接丢弃、下一回合有秒级重启延迟,不是优雅取消;会话中途无法把权限从「询问」切成「自动」;部分功能按钮点击后提示「不支持」,即使新版引擎实际已支持 | 缺运行时能力协商(静态黑名单),取消与权限切换没有协议通道 | §8 阶段 0、§6 | +| 回合启动失败时界面残留「思考中」,要刷新页面才能恢复(该竞态目前靠补丁压制而非根治);弹窗应答与普通消息共用一条通道、靠特判区分,是同类竞态的温床 | 上行无结构化通道,请求与事件分属两条无序通道 | §5 方案 B | +| 偶发侧栏多出空会话条目(探测会话堆积,靠清理补丁压制);偶发引擎子进程卡死,表现为发送后长时间无响应 | 子进程生命周期管理固有成本 | §6 分层 | +| 短时间频繁刷新或切换会触发限流报错(429);管理员重置 token 后,其他已打开的设备全部掉线且需手动获取新地址;同一浏览器开约三个及以上标签页时请求可能排队变慢(浏览器同源连接数上限,每标签占用两条长连接) | 按请求认证、轮询消耗限流配额、SSE 每用途一条连接 | §5 方案 B | +| 列表/删除与会话协议走不同通道(直连数据库)——用户基本无感,属升级引擎版本时行为可能不一致的维护性风险 | ACP 能力与性能缺口 | §6 分层 | + +## 4. 同类方案 + +**DeepSeek Harness web**:web 服务与 agent 运行时同进程;单条多路复用 WebSocket(`/api/remote.mux`)承载全部逻辑流,类型化 RPC + 事件流,心跳保活,升级期认证与 Origin 校验;服务端可向浏览器发起带关联 ID 的请求(审批、提问天然双向)。 + +**Kimi Code Web**(`kimi web`):FastAPI + 每 session 一个 CLI 子进程;REST 承载 CRUD,每 session 一条 WebSocket;连接后先从 `wire.jsonl` 重放历史再转实时(断线零丢失);token 走 URL fragment,握手期校验 token/Origin/LAN。 + +| 维度 | mcode webui(现状) | dsh web | kimi web | +|---|---|---|---| +| 下行通道 | SSE ×2,快照流 | 单条多路复用 WebSocket,类型化事件流 | 每 session 一条 WebSocket,事件流 | +| 投递保证 | 至多一次 + 全量重同步 | 连接内有序 | 至少一次(日志重放) | +| 上行通道 | REST(交互伪装成消息) | WebSocket 双向 + 服务端主动请求 | WebSocket 双向 | +| 断线恢复 | `GET /api/state` 全量 | 重连 + 事件流恢复 | `wire.jsonl` 重放,零丢失 | +| 引擎关系 | ACP 子进程(公开标准协议) | 同进程直调 | 私有协议子进程 | +| 负载模型 | O(状态总量)/帧 | O(增量)/帧 | O(增量)/帧 | +| 认证 | 按请求 | 按连接(升级期) | 按连接 + fragment token | + +两者共同验证:「有序增量事件流」优于「无序快照流」,「按连接认证」优于「按请求认证」。 + +## 5. 浏览器通信选型 + +### 5.1 候选方案 + +- **方案 A(维持现状)**:REST + SSE 快照。已实现、可 curl 调试、客户端镜像即可;但 §3.5 前四类问题全部成立且互相耦合。适用:迁移期过渡。 +- **方案 B(选定)**:**WebSocket 事件流 + REST**。REST 保留为控制面(发送、会话、上传、设置),WebSocket 单连接承载全部下行事件流与结构化上行。 +- **方案 C(回退通道)**:SSE + `Last-Event-ID` 重放。改动最小、免费获得至少一次投递;但上行仍是 REST、仍两条长连接,事件化改造工作量与方案 B 相同却拿不到上行收益。定位:方案 B 的降级开关。 + +| 维度(权重) | A 现状 | B WebSocket 事件流 | C SSE + Last-Event-ID | +|---|---|---|---| +| 投递保证(高) | ✗ 至多一次 | ✓ 至少一次 + 精确续传 | ◐ 至少一次 | +| 上行结构化(高) | ✗ | ✓ | ✗ | +| 负载效率(高) | ✗ O(总量) | ✓ O(增量) | ◐ O(增量) | +| 连接数(中) | ✗ ×2/标签 | ✓ ×1 | ✗ ×2 | +| 代理兼容(中) | ◐ 需关缓冲 | ◐ 少数代理不支持 Upgrade | ◐ 需关缓冲 | +| 实现成本(中) | ✓ 零 | ✗ 高(协议 + reducer + RFC 6455 决策) | ◐ 中 | +| 客户端复杂度(中) | ✓ 镜像即可 | ◐ 需 reducer | ◐ 需 reducer | +| 可调试性(低) | ✓ curl | ◐ 需工具/日志 | ✓ curl | + +### 5.2 方案 B 设计 + +**核心原理:先快照、后增量(snapshot-then-delta)**。连接建立先发一次 `state.snapshot` 建立基线,此后只发增量事件;客户端用确定性归约函数(reducer)维护状态。 + +```mermaid +sequenceDiagram + autonumber + participant B as 浏览器 + participant S as webui 后端 + Note over B,S: 握手:HTTP Upgrade /api/stream(Origin 校验 + token,一次性) + S-->>B: {type:"hello", seq:0, resumeSupported:true} + B->>S: {type:"resume", lastSeq:412} + alt lastSeq 在环形缓冲区范围内 + S-->>B: 帧 413..427(按序重放) + else 落后太多 / 未知 + S-->>B: {type:"state.snapshot", seq:428}(全量基线) + end + S-->>B: 实时帧 seq=429…(单调递增,无空洞) + Note over B,S: 心跳:ping/pong 每 30s +``` + +**帧格式**: + +```ts +// 服务端 → 客户端(连接内 seq 单调递增、连续、不重置) +interface ServerFrame { + v: 1; + seq: number; // 从 1 开始 + ts: number; // epoch ms + type: ServerEventType; + payload: unknown; // 按 type 的 schema + ref?: string; // 对客户端请求的响应携带 +} + +// 客户端 → 服务端 +interface ClientFrame { + v: 1; + type: ClientEventType; + ref?: string; // 请求/响应关联 ID + payload: unknown; +} +``` + +**事件目录**(与 `NormalizedEvent` 一一对应): + +| 方向 | type | 说明 | +|---|---|---| +| ↓ | `state.snapshot` | 全量基线(与 ARCHITECTURE.md §4 同构) | +| ↓ | `chat.line_appended` / `chat.delta` | 行追加与流式增量 | +| ↓ | `tool.call_started` / `tool.call_updated` | 工具调用生命周期 | +| ↓ | `run.started` / `run.finished` / `run.failed` | 回合生命周期(stopReason、usage) | +| ↓ | `context.updated` / `usage.updated` / `commands.updated` / `session_list.updated` | 派生状态更新 | +| ↓ | `interaction.ask_raised` / `permission_raised` / `plan_raised` | 交互请求(弹窗) | +| ↓ | `auth.token_rotated` / `alert.raised` | 令牌轮转与告警(合并入单连接) | +| ↑ | `resume` | 携带 lastSeq 断线续传 | +| ↑ | `interaction.answer` / `permission.decision` / `plan.decision` | 结构化交互应答(替代 `isAskAnswer` 伪装) | +| ↑ | `run.cancel` | 取消(与 REST `/api/stop` 并存) | +| ↑ | `pong` | 心跳应答 | + +**负载对比**: + +``` +快照模型(现状) 增量模型(方案 B) +帧 1 ████████████████ ≈8 KiB ▎ ≈100 B chat.line_appended +帧 2 ████████████████ ≈8 KiB ▎ ≈110 B chat.delta +帧 N ████████████████ ≈8 KiB ▎ ≈120 B run.finished +每帧 = O(state 总量),含命令目录 每帧 = O(事件大小),与历史长度无关 +流式期间 ≈ 数百 KiB/s ≈ 数 KiB/s +``` + +**收益**:§3.5 前四类问题成批消失;回填上限可移除(历史 = 重放 + 快照基线);reducer 为纯函数可测;弱网体验从全量重同步变为精确续传。 +**成本**:前端需实现 reducer 与续传;WebSocket 不能 curl(缓解:事件落日志 + 开发期只读端点);依赖策略需决策(§7.5);少数代理不支持 Upgrade(缓解:保留 SSE 回退开关)。 + +## 6. 引擎集成选型 + +### 6.1 候选方案 + +- **方案 A(ACP 子进程,现状)**:进程隔离、版本解耦(`MCODE_CMD` 可指向任意安装)、公开标准协议;代价是能力黑名单、每事件序列化、子进程生命周期管理成本、单连接单活动会话。 +- **方案 B(进程内嵌入)**:直接 import `@mavis/local-runtime-v2` 应用服务(`./cli-service`、`./session-system` 等导出即为此设计),取消/模式切换/队列/会话中权限全部成为直接方法调用,类型编译期对齐、零序列化;代价是同崩溃域、可重入性未验证、进程级信号冲突风险。 +- **方案 C(Worker 线程嵌入)**:引擎跑在 `worker_threads`,经 `MessagePort`(结构化克隆)传请求与事件。兼顾 B 的类型化收益与接近进程级的隔离;`worker.terminate()` 是干净的强取消边界,未捕获异常不跨线程传播。 + +### 6.2 TUI(互补能力,非传输) + +| 子形态 | 结论 | +|---|---| +| 共享会话存储(SQLite + `session/load`) | 保留延续:webui 已能接管任意 TUI 会话 | +| `/web` 交接(TUI 内一键交给浏览器) | 建议采纳:上一行的产品化包装 | +| PTY 驱动 TUI 本体 | 否决:终端仿真层脆弱、不可测 | + +### 6.3 角色分层分配(选定方案) + +三个方案不互斥为「默认/回退」,而是按角色各就其位,隔离强度随信任梯度递增: + +| 角色 | 会话 origin | 传输 | 能力面 | 隔离强度 | 生命周期 | +|---|---|---|---|---|---| +| 主 agent(交互会话) | `main` | Worker 线程内嵌 | 完整:cancel/steer/queue/goal/会话中权限 | 线程级(`terminate()`) | 跟随标签页 | +| side-chat(侧边轻会话) | `side` | headless 子进程(`mcode exec`) | 刻意收窄:单回合、maxSteps 默认 6、spawn 时定权限 | 进程级 | 跟随会话,关闭即退出 | +| 子 agent(后台/并行任务,按模板实例化 §7.8) | `task` | ACP 子进程 | 会话化:多回合、流式、cancel、fork | 进程级 | 跟随任务,可独立终止 | + +机制性优点:**隔离梯度匹配信任梯度**(可重入性验证范围收窄到主会话——任意 side-chat 与子 agent 都在进程里,不触碰主 agent 的 Worker);**三条路径全部复用既有代码**(headless 与 ACP 传输已存在,新增仅 `mcode-embed`);**与方案 B 协同**(三个角色 = 三条逻辑事件流,复用在同一条 WebSocket 上)。 + +**四条硬边界(阶段 1 验收条件)**: + +| 边界 | 内容 | +|---|---| +| 编排边界 | 「子 agent → ACP」仅指 webui 发起的后台/并行代理;引擎内部 delegation 留在 Worker 内进程内完成,绝不外绕 webui ACP(否则形成循环编排) | +| 晋升路径 | side-chat 必须可晋升为主会话(等价于一次 `session/load` + origin 变更);无晋升则 headless 能力上限成为体验陷阱 | +| 可重入性范围 | 风险收窄为「主会话 × 主会话」;保守起点「每活动标签一个 Worker」,验证后再合并 | +| 静态分配 | 角色 → 传输是架构常量,不提供改派配置;主 agent 的 ACP 逃生门属部署级开关 | + +**场景覆盖**:产品 webui 按上表分层;`MCODE_CMD` 指向外部引擎时子进程层随之运行该版本(side-chat 天然充当金丝雀层);Docker 同产品默认;显式进程隔离需求时主 agent 降级 ACP;TUI 互通走存储共享 + `/web`。 + +## 7. 目标架构 + +### 7.1 拓扑 + +```mermaid +flowchart TD + subgraph B["浏览器 SPA"] + RD["事件归约器(reducer)
状态 = snapshot + Σ events"] + end + subgraph S["webui 后端进程"] + G["升级期门链:Origin + token + 入站配额"] + WS["WebSocket /api/stream
单连接多路复用:main / side / sub 流"] + RB["环形缓冲区(per-cid 重放窗口)"] + REST["REST /api/*(控制面)"] + TR["传输抽象层 runMcode() → NormalizedEvent"] + EMB["mcode-embed(主 agent)"] + HLT["mcode-exec(side-chat,既有)"] + ACPT["mcode-acp(子 agent,既有)"] + end + subgraph WK["工作线程"] + SVC["local-runtime-v2 应用服务(主 agent 会话)"] + end + SC["headless 子进程 ×N
side-chat"] + SUB["ACP 子进程 ×N
子 agent"] + DB[("runtime-state.sqlite
journal + 任务账本")] + B -->|"Upgrade + 一次性认证"| G + G --> WS + B -->|"REST(保留)"| REST + WS <-->|"有序帧 seq=1..n,流标识路由"| RD + WS --- RB + REST --> TR + EMB --> TR + HLT --> TR + ACPT --> TR + SVC <-->|"MessagePort"| EMB + SC <-->|"行分隔 stream-json"| HLT + SUB <-->|"JSON-RPC / stdio"| ACPT + SVC --- DB + SC --- DB + SUB --- DB + TR --> RB +``` + +关键点:传输抽象层(`runMcode → NormalizedEvent`)不变,`mcode-embed` 是实现该契约的第三个传输;两条通信链路(浏览器通信 / 引擎集成)独立演进、独立回退。 + +### 7.2 WebSocket 协议规格 + +| 项 | 规格 | +|---|---| +| 端点 | `GET /api/stream`(HTTP Upgrade),单连接承载全部逻辑流 | +| 流标识 | 下行帧携带 `stream` 字段(`main` / `side:` / `sub:`)做多路路由;`seq` 按连接全局递增(全序),流标识只做路由不做排序 | +| 认证 | 升级期 `?token=`(或首帧 `auth`),连接作用域;本机豁免规则同现状 | +| Origin | 升级期白名单校验(等价现状 CSRF 门,loopback 不豁免页面身份) | +| 序列号 | 连接内从 1 起单调递增、连续,全部帧共用同一序列空间 | +| 重放窗口 | per-cid 环形缓冲区(初值 4096 帧);`resume {lastSeq}` 续传;落后超窗降级为 `state.snapshot` 基线 | +| 心跳 | 服务端 ping 每 30s;连续 2 次未 pong 判死连接并释放资源 | +| 入站配额 | 每连接 20 帧/秒、burst 40;超限警告帧后关闭(等价限流门语义) | +| 帧约束 | 仅文本帧(JSON,UTF-8);单帧上限 1 MiB;非法 UTF-8 按规范关闭;相邻同目标 `chat.delta` 可保序合并 | +| 只读与错误 | 非本机连接的写类帧拒绝;错误帧复用现有消毒规则(截断 200 字符、去控制字符) | + +### 7.3 保留的 REST 面 + +| 端点组 | 处置 | +|---|---| +| `POST /api/send`、`/api/sessions*`、`/api/upload`(multipart)、`/api/settings`、`/api/models*`、`/api/protocol/*`、`/api/trajectory/*` | 保留(控制面;multipart 不适合 WebSocket) | +| `GET /api/state` | 保留(调试与降级基线) | +| `GET /api/events`、`GET /api/alerts`(SSE) | 迁移期保留为回退通道,阶段 3 起仅特性开关可启用 | +| `POST /api/stop` | 保留,同时提供 `run.cancel` 等价路径 | + +### 7.4 安全门映射(语义逐条对齐,不新增面) + +| 现状 HTTP 门 | WebSocket 等价物 | +|---|---| +| CORS 可信 Origin 反射 | 升级期 Origin 白名单校验,拒绝 403 | +| Origin/CSRF 门(含 loopback) | 同上,升级期一次性判定(连接即页面身份) | +| token(按请求) | 升级期一次,连接作用域 | +| 限流 60/min per {IP,token} | REST 面不变;WebSocket 改为每连接入站配额 | +| 只读门(非本机非 GET) | 非本机连接的写类帧直接拒绝 | +| loopback 默认绑定 | 不变 | + +### 7.5 依赖策略 + +手写 RFC 6455 服务端子集(握手、帧编解码、掩码校验、分片、close/ping/pong;不实现压缩扩展,约 300–400 行 + 一致性测试)可保持「零 npm 运行时依赖」的成文原则;引入 `ws` 库则边界用例久经考验但破例需书面理由。**推荐**:先手写并以一致性测试门护航,成本过高则降级引入 `ws`,决策门在阶段 2 原型完成时。 + +### 7.6 故障模式 + +| 故障 | 检测 | 恢复 | +|---|---|---| +| WebSocket 断开 | 心跳超时 / TCP 错误 | 退避重连 + `resume`;超窗则快照基线 | +| 环形缓冲区欠载 | 服务端比对窗口头 | 直接发 `state.snapshot` | +| 主 agent Worker 崩溃 | `worker.on(error/exit)` | 发 `run.failed` + 按策略重建(会话由 SQLite 恢复) | +| side-chat / 子 agent 进程崩溃 | 退出码 / ACP 子进程退出 | 上报失败并更新任务账本;side-chat 下回合自然重建;不影响主 agent Worker | +| 服务重启 | 客户端连接失败 | 重连 + 快照重建;按任务账本重挂接存活子 agent | +| 异常客户端 | 入站配额 + 帧校验 | 警告帧 → 关闭;REST 面仍受既有门链保护 | + +### 7.7 层间通信(三类 agent 之间) + +**原则:控制走活通道,状态进 SQLite。** 拓扑为星型(hub-and-spoke):webui 后端是唯一同时持有三类活句柄(MessagePort、headless stdio、ACP stdio)的组件,agent 之间无对等通信需求,不引入消息代理。 + +```mermaid +flowchart TD + BR["浏览器(WebSocket 多路复用:main / side / sub 流)"] + HUB["webui 后端 = 通信枢纽"] + W["主 agent(Worker 线程)"] + SC["side-chat ×N"] + SUB["子 agent ×N"] + DB[("SQLite:journal + 任务账本")] + BR <-->|"事件流↓ / 决策↑"| HUB + HUB <-->|"MessagePort"| W + HUB <-->|"stdio"| SC + HUB <-->|"ACP"| SUB + W -.->|"工具调用桥:delegation 工具结果回填"| HUB + W --- DB + SC --- DB + SUB --- DB +``` + +| | 主 agent(Worker) | side-chat(headless) | 子 agent(ACP) | +|---|---|---|---| +| **下达** | MessagePort 直接方法调用:`prompt` / `enqueueMessage` | spawn argv + stdin,prompt 即任务 | `session/new` → `session/prompt`;后续 `mcode/session/queue/enqueue` | +| **反馈**(中途介入) | `runtime.steer()` | 无 steer 对象(单回合):等待或终止 | `mcode/session/steer`、`queue/steer`、`session/cancel` | +| **上传** | MessagePort 事件流(`NormalizedEvent`) | stdout 行分隔 stream-json + 退出码 | `session/update` 通知 + prompt 响应(stopReason、usage) | +| **取消** | 语义 cancel 或 `worker.terminate()` | SIGTERM → SIGKILL | `session/cancel`(能力协商后),否则 kill | + +「队列」原语已在正确位置:引擎的 `mcode/session/queue/*` 扩展即「agent 忙时给它的下一条消息」,webui 只需接到 WebSocket 上行,无需自建队列。 + +跨层路由三条:① 三层上传事件按流标识汇入同一 WebSocket;② **主 agent ↔ 子 agent = 工具调用桥**——嵌入方向由 `host-contract.ts` 确立(宿主向引擎暴露能力),后台代理工具沿同一模式:主 agent 调用 delegation 工具 → 执行落在 webui → spawn ACP 子 agent → 结果作为工具结果回填 Worker;③ 审批/问答沿各层 interaction 事件 → 浏览器 → 原通道返回。 + +SQLite 角色 = **journal + 任务账本**,非消息队列:会话/事件日志(既有,三层同库,支撑侧栏/搜索/trajectory/晋升);任务账本(新增小表:task id、session id、status、owner)用于重启重挂接与审计。否决 SQLite 消息队列:星型枢纽已持有活管道,broker 是轮询中间人;流式/取消/steer 需要 push 与背压;跨进程写队列表需锁调优且重新耦合崩溃域。消息队列成立的条件(对等无共同父级、跨机分布、独立守护进程存活)当前均不成立。 + +### 7.8 会话可见性与多 agent 形态 + +#### origin 分类 + +side-chat 与子 agent 会话**必须持久化**(晋升与审计依赖同库),但默认不混排主列表。分类标记住 webui overlay(`sessions.json` 的 `kind` 字段扩展),不改引擎 schema: + +| origin | 主列表默认 | 可见位置 | 回收 | +|---|---|---|---| +| `main` | ✓ 显示 | 主列表 | 现有规则 | +| `side` | ✗ 隐藏 | 独立「侧边会话」分区/开关 | 未晋升超时 → 扩展启动清理 | +| `task` | ✗ 隐藏 | 任务面板(按 team 分组)+ trajectory | 任务终态后按账本清理 | +| `tui` / `exec` | ✓ 显示 | 主列表 | 现有规则 | + +晋升 = origin 从 `side` 改 `main`(一次 `session/load` + 一行 overlay 变更)。 + +#### team 编排 + +| 模式 | 编排者 | 适用 | +|---|---|---| +| fan-out / fan-in | webui 枢纽(或主 agent 经工具桥) | 并行侦察、多方案对比 | +| lead + workers | lead 子 agent 自己的引擎(进程内 delegation,编排边界递归适用);webui 经 `delegation/get` 只读观察 | 分层委托 | +| peer mesh | 否决——对等通信需要共享收件箱,与否决消息代理同理 | —— | + +跨成员共享状态用**黑板 = 工作区文件**(成员用既有文件工具读写,可 diff、可审计、零新增设施)。team 是任务账本一等对象;并发上限:每队 ≤8、全局 ≤16。 + +#### 模板 agent:定义常驻,进程按需 + +「常驻」的正确定义是**规格持久、进程按需**:方法论与验收标准写进受评审的模板文件,需要时复刻启动——运行时即兴编写提示词正是方法论疏漏的根源(编写时机在运行时、编写者是无评审的模型)。 + +| | 即兴提示词 | 模板实例化 | +|---|---|---| +| 规格编写 | 运行时每次重写 | 开发时一次编写 | +| 方法论/验收 | 靠模型发挥,常漂移 | 每次复刻一致,内置 checklist | +| 评审与版本 | 无 | 进仓库,可 diff、随产品发版 | +| 审计 | 无法回答 | 账本记录模板 id + 版本 | + +```markdown +--- +name: code-reviewer +version: 2 +model: minimax_api/MiniMax-M3 +permission: read # spawn 时权限(复用既有机制) +tools: [read, grep, glob] # 工具授权收窄 +acceptance: 必须产出 checklist 全绿的审查报告 +--- +# 方法论 / # 验收标准(正文) +``` + +```mermaid +flowchart LR + T["模板文件(受评审、可版本化)"] + L["任务账本:template id + version"] + A["ACP 子进程:spawn 时权限 + 模型配置"] + SR["system-reminder 注入模块(既有路径)"] + T -->|"复刻"| A + T -->|"登记版本"| L + A -->|"模板正文作为系统提示种子"| SR +``` + +实例化复用三处既有设施(system-reminder 注入路径、spawn 时权限、per-session 模型配置),新增仅模板文件 + 解析器 + 账本两个字段。模板与 skill 的区别:skill 是注入会话的说明书,模板是完整定义 agent 的规格(人格、工具授权、权限、模型、方法论、验收),模板可引用 skill。延伸收益:模板与产品同仓同版本,「同一模板在新旧引擎各跑一遍比对输出」即现成回归用例;side-chat 亦可由轻量 persona 模板实例化。 + +## 8. 迁移计划 + +| 阶段 | 内容 | 风险 | +|---|---|---| +| **阶段 0(偿还技术债,不动传输)** | 用 `initialize` 返回的 `agentCapabilities` + 扩展方法清单做运行时能力协商,替换静态黑名单;接通引擎已实现的 `session/cancel` | 低 | +| **阶段 1(引擎集成层,分层落地)** | 新增 `mcode-embed`(Worker 形态)承载主 agent;side-chat / 子 agent 沿用既有路径;实现晋升路径与任务账本;验收含四条边界与主会话可重入性;主 agent 保留 `MCODE_ENGINE=acp` 逃生门 | 中 | +| **阶段 2(浏览器通信层,双轨)** | WebSocket 服务端(含 RFC 6455 决策)、环形缓冲区、流标识与多路复用、事件目录与前端 reducer;模板实例化、origin 分类与侧栏分区随之落地;`MCODE_WEBUI_TRANSPORT=sse\|ws` 开关,默认 sse | 高 | +| **阶段 3(收敛)** | 默认翻转为 ws;`isAskAnswer` 伪装、stale-sync 补丁、状态残留补丁按事件目录重构删除;SSE 转为特性开关回退;(可选)持久化事件日志支持跨重启重放与回填上限移除 | 中 | + +测试策略:RFC 6455 一致性单测(握手、帧长边界、未掩码关闭、分片、跨帧 UTF-8、close/ping/pong);序列号性质测试(重放无空洞无重复、欠载降级、ref 唯一);三条固定泳道(主 agent 嵌入 / side-chat headless / 子 agent ACP)× 前端双轨(sse/ws)同一套行为断言。回退:阶段 1 = 主 agent 降级 ACP;阶段 2/3 = `MCODE_WEBUI_TRANSPORT=sse`;开关均在启动期读取。 + +## 9. 风险与待决问题 + +| 风险 | 等级 | 缓解 | +|---|---|---| +| RFC 6455 边界用例实现缺陷 | 中 | 一致性测试门先行;不达标降级引入 `ws` | +| 引擎应用服务不可重入 | 高(阶段 1) | 分层已收窄为主会话 × 主会话;验证失败则每标签一个 Worker 或主 agent 维持 ACP | +| Worker 环境不兼容引擎依赖 | 中 | 兼容性清单先行;不兼容模块走 ACP 兜底 | +| 前端 reducer 状态漂移 | 中 | reducer 纯函数 + 定期快照对账,漂移即重建 | +| 三传输并存维护成本 / side-chat 每回合 spawn 延迟 | 低–中 | 角色静态分配锁三条泳道;spawn 延迟第一版接受,必要时进程保活 | + +待决:环形缓冲区容量定值;持久化事件日志位置与保留;`/api/send` 是否迁移上行;多标签共享会话(状态键从 cid 迁到会话);`/api/alerts` 并轨时机;side-chat 回收时限与进程保活策略;任务账本表结构;Worker 拓扑合并时机;team 上限定值;模板存放位置(仓库 / workspace / 叠加)与是否引用 skill;验收失败的任务终态语义。 + +## 附录 A:证据索引 + +| 主题 | 出处 | +|---|---| +| SSE 快照推送、节流合并、diff 门、命名控制事件 | `server/lib/state-bus.js` | +| 发送即确认、`isAskAnswer` 伪装、状态残留补丁 | `server/routes/chat.js` | +| SQLite 旁路(列表/回填/级联删除)、≤400 行回填 | `server/lib/db.js`、`docs/ARCHITECTURE.md` §2.2 | +| 限流、上传三段限制、空闲看门狗、端口规则 | `server/lib/rate-limit.js`、`upload.js`、`idle-watchdog.js`、`config.js` | +| ACP 静态黑名单 vs 引擎实际能力、并发上限、queue/steer/delegation 扩展 | `server/lib/mcode-rpc.js`、`packages/tui/src/acp/agent.ts`、`extensions.ts` | +| 子进程生命周期补丁、空闲挂起、探测会话清理 | `acp.mjs`、`server/lib/acp-client.js` | +| 进程内入口、宿主能力契约(工具调用桥依据)、后台运行时 | `packages/local-runtime-v2`(`package.json`、`src/local/host-contract.ts`、`src/background-runtime.ts`) | +| 系统提示注入路径(模板实例化依据) | `packages/agent-modules`、`docs/ARCHITECTURE.md` §2.1 | +| dsh / kimi 同类方案 | `@deepseek-ai/dsh-api-gateway`(本机);`MoonshotAI/kimi-cli` `src/kimi_cli/web/` | + +## 附录 B:参考资料 + +- RFC 6455(WebSocket Protocol):;WHATWG HTML(EventSource): +- Kimi Code Web 文档: +- 本仓库:`docs/architecture.md`、`packages/webui/docs/ARCHITECTURE.md`、`docs/API.md`、`references/SECURITY-NOTES.md` \ No newline at end of file diff --git a/packages/webui/docs/drafts/arch_net_solution_0922.md b/packages/webui/docs/drafts/arch_net_solution_0922.md new file mode 100644 index 00000000..bb2ec79a --- /dev/null +++ b/packages/webui/docs/drafts/arch_net_solution_0922.md @@ -0,0 +1,270 @@ +# Web UI 网络层与进程/线程拓扑技术方案(2026-09-23) + +> **状态**:技术方案(可实施级)。依据 [arch_net_draft_0922.md](arch_net_draft_0922.md)(提案)展开;方案改动先落盘再改代码,实现偏差归档至 §10 决策记录。 +> **硬约束**:① 前端 SPA 零修改(`packages/webui/public/` 不动,REST 响应形状与 SSE 帧语义逐字节兼容)—— superseded by decision 20(发行版 SPA 改为消费 `/api/stream`,前端随之修改);② 暂时兼容原有方案(默认旧行为,新路径可开关)—— superseded by decision 20(SSE 移除,`/api/stream` 始终启用);③ 全量 webui 测试套件与 `check-docs-alignment` 为验收门。 + +## 1. 前端零修改不变量(golden 等价点) + +> Superseded by decision 20:发行版 SPA 改为消费 `/api/stream`,SSE 已移除;本节仅作历史基线保留。 + +SPA 依赖的每一种线上行为曾必须逐字节保持(以 `server/lib/state-bus.js` 实际代码为准): + +| 等价点 | 精确规格 | +|---|---| +| 状态快照帧 | 匿名默认事件:`data: ` + `JSON.stringify(snapshot)` + 两个换行;不带 `event:` 行 | +| 节流合并 | `STATE_PUSH_THROTTLE_MS`(默认 16ms,0 禁用);窗口内 last-call-wins,只写最后一份 payload | +| 字节级 diff 门 | 与上次成功写出的 payload 逐字节相同则跳过不写 | +| 命名控制事件 | `event: ` + `data: ` + 两个换行;`auth.token_rotated` 的 data 为原文(不包 JSON),`token.first_run` / `needs_authorization` / `authorization_decided` 的 data 为 JSON 字符串 | +| SSE 头 | `SSE_HEADERS` 四字段逐字保持(`text/event-stream`、`no-cache`、`keep-alive`、`X-Accel-Buffering: no`) | +| REST 形状 | 所有 `/api/*` 响应的 JSON 键序与错误消毒(截断 200 字符、去换行/控制字符)不变 | + +## 2. 进程/线程拓扑 + +### 2.1 现状 + +> Superseded by decision 20:下行通道已改为 `GET /api/stream`(WebSocket 事件流)+ REST,SSE ×2 已删除。 + +``` +webui 进程(1 主线程) +├─ HTTP 服务(router + 门链)/ SSE ×2 / state-bus +├─ mcode acp 子进程 ×N ← 每活动浏览器标签一个(stdio JSON-RPC 2.0) +├─ mcode acp 子进程 ×1 ← 列表/命令单例(30s / 24h 缓存) +└─ mcode acp 子进程 ×0..1 ← 命令探测临时进程(用完即删) +``` + +问题:标签数线性放大进程数;子进程生命周期补丁集合(挂起批量拒绝、探测会话清理、缓存绕行);取消 = 杀进程;能力面靠静态黑名单。 + +### 2.2 目标拓扑 + +```mermaid +flowchart TD + subgraph PROC["webui 进程"] + MT["主线程:HTTP 服务 + WS 事件流适配器 + 事件总线 + 传输抽象层 + 门链"] + WK["引擎宿主 Worker 线程 ×W
local-runtime-v2 应用服务(主 agent 会话)"] + end + SC["side-chat headless 子进程 ×N
(mcode exec,每回合一换)"] + SUB["子 agent ACP 子进程 ×M
(mcode acp,每任务一个)"] + LEG["兼容模式:主 agent ACP 子进程
(MCODE_ENGINE=acp,= 现状拓扑)"] + MT <-->|"MessagePort(结构化克隆)"| WK + MT <-->|"stdio 行分隔 stream-json"| SC + MT <-->|"stdio ndjson JSON-RPC 2.0"| SUB + MT <-->|"stdio JSON-RPC 2.0"| LEG +``` + +### 2.3 拓扑关系表 + +| 成员 | 职责 | 生命周期 | 崩溃域 | 数量模型 | +|---|---|---|---|---| +| 主线程 | 门链、REST、WS 事件流适配器、事件总线、传输选择 | 进程级 | 进程 | 1 | +| 引擎宿主 Worker | 承载主 agent 会话(嵌入模式) | 跟随标签/会话(可重入验证后合并) | 线程(`terminate()`) | W = 活动标签数(保守)→ 1(验证后) | +| side-chat 子进程 | 轻会话单回合 | 每回合一换 | 进程 | 0..N(并发 ≤4,规划值) | +| 子 agent 子进程 | 后台/并行任务 | 每任务一个 | 进程 | 0..M(并发 ≤8,规划值) | +| ACP 兼容子进程 | 兼容模式下的主 agent | 现状(每标签) | 进程 | 按开关 | + +### 2.4 引擎宿主 Worker 生命周期 + +```mermaid +stateDiagram-v2 + [*] --> Booting: 创建 Worker + Booting --> Ready: booted(引擎加载成功) + Booting --> Fallback: boot-failed(引擎加载失败) + Ready --> Degraded: fatal(引擎未捕获异常) + Degraded --> Fallback: 重建失败 + Degraded --> Ready: 重建成功 + Fallback --> [*]: 回退 ACP 传输(调用方决策) + Ready --> [*]: shutdown / terminate +``` + +回退触发:boot 动态 import 失败、Worker 未捕获异常(`fatal` 消息)、MessagePort 断开。回退语义:`runMcode` 传输选择层改走 `mcode-acp`(兼容路径),用户无感;当前仓库未构建引擎 dist 时全部走 Fallback 属预期。 + +## 3. 网络拓扑 + +### 3.1 链路图 + +```mermaid +flowchart LR + BR["浏览器 SPA
(消费 /api/stream —— decision 20)"] + subgraph SV["webui 主线程"] + EP["HTTP 端点:REST + GET /api/stream
(WebSocket 事件流,decision 20)"] + end + WK["引擎宿主 Worker"] + SC["side-chat 子进程"] + SUB["子 agent 子进程"] + BR -->|"REST(JSON)+ WebSocket(RFC 6455 子集)"| EP + EP <-->|"MessagePort:结构化克隆"| WK + EP <-->|"stdio:行分隔 stream-json"| SC + EP <-->|"stdio:ndjson JSON-RPC 2.0"| SUB +``` + +### 3.2 链路 × 进程边界映射 + +| 链路 | 协议 | A 端(进程/线程) | B 端(进程/线程) | +|---|---|---|---| +| REST 控制面 | HTTP/1.1 JSON | 浏览器主线程 | webui 主线程 | +| WebSocket 事件流数据面 | RFC 6455 子集 | 浏览器主线程 | webui 主线程 | +| 嵌入引擎 RPC | MessagePort 结构化克隆 | webui 主线程 | 引擎宿主 Worker | +| headless 传输 | stdio 行分隔 stream-json | webui 主线程 | side-chat 子进程 | +| ACP 传输 | stdio ndjson JSON-RPC 2.0 | webui 主线程 | 子 agent / ACP 兼容子进程 | + +## 4. 内部数据流:事件总线与适配器 + +```mermaid +flowchart LR + SRC["pushStateFor / 命名控制推送"] + BUS["事件总线 event-bus.js
cid 分区,seq 单调递增"] + WSA["WebSocket 适配器(decision 20 起唯一下行适配器)
事件流 + 环形缓冲区重放"] + SRC --> BUS + BUS --> WSA --> SPA["发行版 SPA / 调试客户端"] +``` + +接口签名(第一阶段载荷 = state 快照 + 控制事件;事件级增量随嵌入传输落地接入): + +```js +// event-bus.js +emitEvent(cid, event) // {type:"state.snapshot", snapshot} | {type:"control", name, data} +subscribeEvents(cid, sink) // sink({seq, ts, event}) → unsubscribe() +// state-bus.js(兼容 facade,调用点不变) +pushStateFor(cid, opts) // 构建快照 → emitEvent(opts.silent 不下发) +``` + +演进路径:阶段一总线承载快照 + 控制事件(~~SSE 兼容面零变化~~ —— superseded by decision 20:SSE 已移除,发行版 SPA 改为消费 WebSocket 适配器);`mcode-embed` 落地后 NormalizedEvent 级增量直接进总线,WebSocket 适配器按 `stream` 字段多路复用(`main`/`side:`/`sub:`)。 + +## 5. 引擎集成层 + +### 5.1 传输抽象层 + +接缝(按代码形态更正,2026-09-23):`runMcodeAcp(content, opts) → Promise<结果 r>`(内部经 streamAcpPrompt 写聊天行并 finalize);exec 传输为 spawn 描述符 + `collectExecResult` 收集(同样写聊天行);`runMcodeEmbed(content, opts) → AsyncGenerator`(生成器返回值 = 同形结果 r,聊天行由消费侧承接)。结果 r 形状:answer / thinking / status / error / usage / sessionId / durationMs / stopReason / tps。三实现:`mcode-acp`(既有)、`mcode-exec`(既有)、`mcode-embed`(新增,Worker RPC 桥,聊天行消费器随接线落地)。传输选择层按 `MCODE_ENGINE` 开关分发,回退链:embed 失败 → acp(默认即 acp)。 + +### 5.2 能力协商(三层策略) + +1. **声明清单**:`initialize` 响应的 `agentCapabilities.sessionCapabilities`(list/fork/resume/close)+ `_meta["minimax-code/extensions"].methods`(`session/activate`、`mcode/session/*`)→ 直接采信。 +2. **惰性探测**:未声明方法(如 `session/set_mode`、`session/set_config_option`)首次调用即探测——用故意非法但无副作用的参数(缺必填字段),按错误码分类:`-32601`(Method not found)= 不支持并缓存;`-32602`(invalidParams)/ `-32000`(resourceNotFound)/ 成功 = 支持。`session/cancel` 为 notification,恒视为支持(尝试无害)。 +3. **旧引擎回退**:`initialize` 无任何声明 → 沿用既有 `UNSUPPORTED` 静态表语义(前端 501 语义逐字节不变)。 + +### 5.3 Worker RPC 协议(MessagePort,v:1) + +| 方向 | 消息 | 字段 | +|---|---|---| +| 主→Worker | `boot` | `{workspace}` | +| 主→Worker | `prompt` | `{sessionId?, content, model, permission}` | +| 主→Worker | `steer` / `cancel` | `{sessionId, text}` / `{sessionId}` | +| 主→Worker | `shutdown` | — | +| Worker→主 | `booted` / `boot-failed` | `{engineVersion}` / `{error}` | +| Worker→主 | `event` | `payload: NormalizedEvent`(流式) | +| Worker→主 | `reply` | `{id, ok, payload|error}` | +| Worker→主 | `prompt-done` | `{stopReason, usage}` | +| Worker→主 | `fatal` | `{error}`(未捕获异常/引擎崩溃) | + +boot 动态 import 引擎应用服务(`@mavis/local-runtime-v2/cli-service` 或相对路径)置于 try/catch,失败发 `boot-failed` 由调用方回退。 + +## 6. 兼容性矩阵 + +| 子系统 | 现状 | 方案后 | 开关(默认) | +|---|---|---|---| +| SPA 静态资源 | 原样 | 改为消费 `/api/stream`(decision 20 解除“前端页面不动”约束) | — | +| REST 形状 | 见 §1 | 逐字节不变 | — | +| SSE 帧 | 见 §1 | 已删除(decision 20:SSE 移除) | — | +| 命名控制事件 | 4 个 | 不变 | — | +| ACP 线协议 | ndjson JSON-RPC 2.0 | 不变(保留为兼容/子 agent 传输) | — | +| 会话存储 | runtime-state.sqlite | 不变 | — | +| 门链 | CORS→Origin→LAN→token→限流→只读 | 不变 | — | +| 主 agent 引擎传输 | 每标签 ACP 子进程 | 嵌入 Worker(可回退) | `MCODE_ENGINE`(`acp`) | +| 浏览器下行通道 | SSE | WebSocket `/api/stream` + REST(SSE 已移除,decision 20) | 无(开关删除,`/api/stream` 始终启用) | + +新开关落地时在 `config.js` 声明 `export const` 并同步 §7 六处对齐面。 + +## 7. 对齐义务矩阵 + +`scripts/check-docs-alignment.mjs` 的 6 项交叉校验(已核实源码): + +| 检查 | 单一事实源关系 | 机制要点 | +|---|---|---| +| 1 | `package.json` capabilities → `README.md` + `docs/CAPABILITIES.md` | 每个 capability 名两处各出现一次 | +| 2 | `README.md` 行内反引号端点引用 → `router.js` | 解析 `METHOD /api/path` 反引号对 | +| 3 | `docs/API.md` 端点标题 → `router.js` | 标题须为 `` ### `METHOD /api/path` `` 反引号形式 | +| 4 | `SECURITY-NOTES.md` env 变量 → `config.js` | 仅校验命中 `KNOWN_ENV_VARS` 显式集合的词;新开关须加集合 + 同名 `export const` | +| 5 | `package.json` 回环解析 + capability 形状 | `description` ≥ 30 字符 | +| 6 | cleanup-orphans 端点一致性 | `docs/API.md` ↔ `router.js` | + +**每切片同步义务**(更新对齐代码要及时——同一变更内完成全部触点): + +| 切片 | 必须同改的对齐面 | +|---|---| +| WebSocket `/api/stream` 端点 | `router.js` + `package.json`(endpoints,若有 capability 还需 ≥30 字符描述)+ `docs/API.md` 标题 + `README.md`(若提及)—— decision 20 后无 env 开关(`MCODE_WEBUI_TRANSPORT` 已删除),不再牵动 `config.js` / `SECURITY-NOTES.md` / `KNOWN_ENV_VARS` | +| `MCODE_ENGINE` 开关 | `config.js` export const + `SECURITY-NOTES.md` + `KNOWN_ENV_VARS` 集合(`MCODE_WEBUI_TRANSPORT` 已随 decision 20 删除,不在对齐面内) | +| 纯内部模块(事件总线/帧库/能力协商/embed 骨架) | 无对齐面(不新增端点/env) | + +## 8. 实施切片与验收门 + +| 切片 | 文件 | 测试验收 | 回退 | +|---|---|---|---| +| 事件总线 + SSE 适配器 | `event-bus.js`、`sse-adapter.js`、`state-bus.js` | golden 帧等价 + SSE 契约守护 6 测试 | `git revert`(行为不变设计;`sse-adapter.js` 已随 decision 20 删除) | +| 能力协商 | `capability.js`、`mcode-rpc.js` | 声明/探测/回退三层单测 | 静态表回退 | +| WebSocket 帧库 + 环形缓冲 | `ws-frame.js`、`ring-buffer.js` | RFC 6455 一致性矩阵 | 纯新增,无回退需要 | +| 引擎宿主 Worker 骨架 | `mcode-embed.js`、`engine-host.worker.js` | RPC 往返 + NormalizedEvent 对齐 | boot-failed → ACP | +| WebSocket 端点集成 | `ws-server.js`、`router.js` 等 | 协议一致性 + 门链映射 | ~~`MCODE_WEBUI_TRANSPORT=sse`~~(开关已删除,decision 20:`/api/stream` 始终启用) | + +全局验收门:① 全量 `pnpm --filter @mavis/webui test` 绿(对照绿基线:契约面 36/36);② `node scripts/check-docs-alignment.mjs` PASS(6/6);③ `release/public-source.json` 清单登记(`node scripts/source-inventory.mjs --write` + check:source);④ `git diff packages/webui/public` 为空(前端零修改证明 —— superseded by decision 20:发行版 SPA 改为消费 `/api/stream`,前端随之修改)。 + +## 9. 风险与回退 + +| 风险 | 缓解 | +|---|---| +| RFC 6455 边界缺陷 | 一致性测试门先行;不达标暂缓端点集成(帧库为纯新增无害) | +| 引擎服务不可重入 | 分层收窄为主会话 × 主会话;验证失败维持 ACP | +| Worker 环境不兼容引擎 | boot-failed 兜底全量回退 ACP(已可测) | +| SSE 行为漂移 | ~~golden 等价为合并门~~(SSE 已随 decision 20 移除,golden 契约仅存于历史基线) | +| 对齐门破坏 | §7 矩阵同改纪律 + 每轮 check-docs-alignment 守卫 | + +## 10. 决策记录 + +(预留:实现与规格的偏差由主协调者在此归档——原因、替代方案、影响面。) + +### 2026-09-23 能力协商切片 + +1. **错误文案统一**:不支持方法的错误信息由「mcode 0.1.5 acp does not implement …」统一为「mcode acp does not implement … (server returns "Method not found")」。影响面:仅 toast 文案;前端契约 code:'unsupported' 与 501 语义不变。 +2. **cancel 能力位恒 true + cancelSession 改 notification**:`session/cancel` 在引擎侧是 notification(onNotification 承接),旧 request 路径会永挂;改为 `client.notify` 后尝试无害,能力位恒 true 属既定改进(旧实现不再依赖引擎支持该方法)。chat.js 的杀进程兜底保持不变。 +3. **注册表播种时序**:活动注册表在 acp-client 启动成功时按 initialize 结果播种;进程内首个 RPC 若发生在任何 client 启动之前,沿用旧回退语义(一次 501),client 就绪后自然生效。实际拓扑(列表单例先行)使该窗口趋近于零。 +4. **实现形态**:能力注册表归 capability.js 所有(零本地依赖),acp-client 播种、mcode-rpc 消费,避免循环依赖;UI 能力映射为可变对象,routes/protocol.js 零修改。 + +### 2026-09-23 WebSocket 帧库切片(成员三报告,防御性收紧 2 条) + +5. **分片重组消息总量受 maxFrameBytes 约束**(超出 → 1009):仅限单帧则 N×1MiB 分片可放大占满内存(不可信客户端 DoS 面)。替代方案(只限单帧)被否决;影响面:总量超 1 MiB 的分片消息被拒,与提案 §7.2「单帧上限 1 MiB」语义一致。 +6. **close 线上 code 校验与长度字段最小化编码校验**(非法 code / 1 字节 close 体 / 非最小编码长度 → 1002):RFC §7.4.1/§5.2 MUST 要求拒绝;影响面仅畸形输入路径,正常 close 行为不变。 + +### 2026-09-23 事件总线 + SSE 适配器切片 + +7. **STATE_PUSH_THROTTLE_MS 默认值更正**:实测代码默认为 0(节流禁用、每次推送同步写出),此前文档表述的「默认 16ms」来自过时注释。golden 以代码为准:默认行为不变,窗口仅在显式设置 env 时启用。§1 的 16ms 表述按此更正。 +8. **节流判定改为调用期读 env**(`currentThrottleMs()`),导出常量保留为导入期快照仅供内省:既有测试契约「cache-bust 重导入状态模块即可读到新 env」要求行为跟随 env 变化;生产环境 env 进程内恒定,线上行为不变。 +9. **总线并行落点 + SSE 兼容面直写**(superseded by decision 20:SSE 兼容面随 SSE 移除而删除):`pushStateFor` 与命名控制推送同时 emit 到事件总线(WebSocket 适配器订阅面)并按旧路径直写 SSE。原因:既有测试直接操纵 `sseByCid`(绕过 `setSseClient`),订阅驱动会破坏该契约;影响面:零线上差异,WebSocket 适配器接线时可切换为订阅驱动(届时同步调整测试装配方式)。 + +### 2026-09-23 引擎宿主 Worker 切片(成员五报告,偏差 7 条 + 发现 2 条) + +10. **NormalizedEvent 标签用 `kind`**(非文档的 `type`):以代码形态为准逐字段对齐 `mcode-acp.js` 流回调载荷。连带发现:`docs/ARCHITECTURE.md` §3 的传输契约描述与两实现不符(`runMcodeAcp` 实为 Promise、`runMcodeExec` 为 spawn 描述符 + `collectExecResult`)——§5.1 已按代码形态更正,ARCHITECTURE.md 的修订列为文档跟进项。 +11. **导出面扩展**:`steerEmbed`/`cancelEmbed`(RPC 往返验收入口)、`stopEmbed` 返回 `Promise`(确定性验证无悬挂句柄)、`bootEngineHost` 扩展 `workspace`/`workerData` 参数(单参调用兼容)。 +12. **引擎适配器缝(createEngineAdapter)**:prompt/steer/cancel 的引擎调用点为适配器契约;真实引擎会话编排(组合应用服务)超出传输骨架切片且 dist 未构建不可测,未接线时明确回复 `engine-adapter-missing`。嵌入传输全量点亮依赖该适配器落地(后续波次)。 +13. **语义收束优于强杀**:空闲超时以语义 cancel 收束(非 terminate);error 事件落定后不再回写(finalize 恰好一次,修正了 mcode-acp 的晚回写瑕疵);失败路径返回全九字段结果形状。 + +### 2026-09-23 embed 接线(chat.js 三路选择 + 聊天行归约器) + +14. **归约器范围取舍**(embed-consumer.js):收尾记账镜像 streamAcpPrompt 的真实值/估算两条分支;mavis 真值覆盖层与工具输出全文渲染留待共享归约器提取轮次(当前 embed 依赖的引擎适配器缝未接线,无真实回合可跑,先保契约正确)。工具行最小化为标记行(→ title)。 +15. **回退语义定案**:boot 失败自动回退旧传输(ACP/exec);**回合级失败不回退**(如 engine-adapter-missing 显示为失败告警)——避免部分事件已落聊天行后的重复回合。`MCODE_ENGINE=embed` 在引擎适配器落地前的预期行为即失败告警 + 显式可选,不影响默认路径。 +16. **/api/stop 的 embed 路径**(cancelEmbed 语义取消)留待下一波;当前 embed 回合的停止走 host shutdown 兜底。`MCODE_USE_ACP=0` exec 逃生路径保持原样,优先级:embed → exec → acp。 + +### 2026-09-23 WebSocket 事件流端点(GET /api/stream) + +17. **协议取舍**:(a) 恢复欠载以「最近 state.snapshot 为基线」近似严格基线(严格版需按需重建快照,留待共享快照构建器提取时精确化);(b) 馈送按连接引用计数订阅,最后连接断开即退订,无连接期事件不入环形缓冲(恢复时走快照回退);(c) 心跳/配额/环形容量经参数注入便于测试,生产默认 30 秒 / 稳态 20 帧每秒 + 突发 40 / 4096 条;(d) 普通 GET → 426,升级门链复用 origin / LAN / token 三关(原「与 /api/events 同款」—— superseded by decision 20:`/api/events` 已删除,门链与全部 `/api/*` 相同;原「默认 MCODE_WEBUI_TRANSPORT=sse 时直接拒绝升级」—— superseded by decision 20:开关删除,端点始终启用);(e) CLOSE_CODE 附加 1008 / 1013(RFC 6455 §7.4.1 保留值,附加常量不影响既有测试);(f) 解码器按 RFC 6455 §5.3 严格要求客户端帧掩码(未掩码帧以 1002 拒绝,实测确认),服务端帧不掩码;非浏览器客户端须自备掩码编码(RFC 义务,非本实现的宽容/严格选择)。 + +### 2026-09-23 WebSocket 端点集成测试(补充决策) + +19. **契约修正(推翻 9 号决策两处,守护测试优先)**:(a) 错误消息恢复 "mcode 0.1.5" 溯源(合并措辞:`mcode acp does not implement X (mcode 0.1.5 server returns "Method not found")`,同时满足 checks 的 /mcode 0\.1\.5/ 与能力测试的 /does not implement|Method not found/);(b) session/cancel 由「恒 supported + notify」改为**声明条件式**——能力注册表未声明支持时短路为 unsupported 且不触碰 client(守护 checks/lib-mcode-rpc.check.mjs 的 no mcode spawn;该派生曾引发种子级联:真实 client 启动后 initialize 重播种注册表,令 set_mode/activate 逃逸黑名单),仅当引擎 initialize 声明该方法才走 notification 语义。前端能力映射 cancel 位随之在旧引擎下为 false。 + +18. **集成测试的三个实证**:(a) 环形缓冲 `replay()` 返回 `{seq, item}` 包装条目,`frameForItem` 归一化兼容包装/裸条目两种形状;(b) hello 帧携带 `cid` 回显(协议补充,客户端确认身份 + 测试注入对齐);(c) 测试基建语义:升级套接字脱离 http 连接跟踪后 `server.close()` 回调在超时/异常路径个别不落定,测试清理以 500ms 竞速尽力而为(仅测试基建,不影响生产关闭语义——生产进程由 SIGINT/SIGTERM 钩子收尾)。入站配额按帧计数(TCP 粘包下按数据块计数无意义)。 + +### 2026-09-23 Downlink transport — remove SSE (decision 20) + +20. **Remove SSE; keep only the WebSocket event stream + REST** (user decision, 2026-09-23): (a) the `GET /api/events` endpoint and `sse-adapter.js` are deleted — real-time downstream delivery is `GET /api/stream` (always enabled) plus REST; (b) `GET /api/alerts` becomes a REST snapshot (`{"kind":"snapshot","alerts":[...]}`, ring buffer capped at 100 entries) and live alert traffic moves to `alerts.append` / `alerts.update` control frames on `/api/stream`, with clients de-duplicating by `alert.id`; (c) the `MCODE_WEBUI_TRANSPORT` switch is deleted — `/api/stream` has no sse/ws toggle anymore and its upgrade runs the same origin/LAN/token gate chain as every other `/api/*` route (`MCODE_ENGINE` is unaffected and stays `acp` by default); (d) the shipped SPA switches to consuming `/api/stream` (first-connect baseline via `GET /api/state`, alert snapshot via `GET /api/alerts`), so the frontend changes accordingly and the "前端页面不动 / zero frontend modification" hard constraint is lifted by explicit user confirmation for this change; (e) the statements in decisions 9 and 17 that retained an "SSE compatibility surface" / the sse fallback switch are superseded by this decision. + +## 附:文档集 + +见 [README.md](README.md) 索引。 \ No newline at end of file diff --git a/packages/webui/package.json b/packages/webui/package.json index 29583f2d..74f10f77 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -3,7 +3,7 @@ "version": "2.0.0", "private": true, "type": "module", - "description": "Browser frontend for MiniMax Code. Serves the web chat UI over HTTP/SSE and drives the mcode engine (ACP over stdio). Migrated from the mcode-webui community plugin (PR #55 of MiniMax-Code-Plugins) into the product as a first-class package.", + "description": "Browser frontend for MiniMax Code. Serves the web chat UI over HTTP/WebSocket and drives the mcode engine (ACP over stdio). Migrated from the mcode-webui community plugin (PR #55 of MiniMax-Code-Plugins) into the product as a first-class package.", "main": "server.js", "scripts": { "start": "node server.js", @@ -29,7 +29,7 @@ "capabilities": [ { "name": "chat-streaming", - "description": "Streams model output token-by-token from the mcode acp subprocess via Server-Sent Events (`GET /api/events`). Each delta event includes `text` and `isPartial` so the browser can render progressive assistant replies without buffering the full response." + "description": "Streams model output token-by-token from the mcode acp subprocess over the WebSocket event stream (`GET /api/stream`). Each delta event includes `text` and `isPartial` so the browser can render progressive assistant replies without buffering the full response." }, { "name": "tool-execution", @@ -82,7 +82,8 @@ ], "endpoints": { "health": "GET /api/health", - "events": "GET /api/events (SSE)", + "alerts": "GET /api/alerts (REST snapshot)", + "stream": "GET /api/stream (WebSocket event stream)", "state": "GET /api/state", "sessions": "GET|POST|DELETE /api/sessions[/:id|/switch]", "chat": "POST /api/send|stop|cmd", diff --git a/packages/webui/public/app/state.js b/packages/webui/public/app/state.js index 25e84d6e..34abc9ae 100644 --- a/packages/webui/public/app/state.js +++ b/packages/webui/public/app/state.js @@ -1,10 +1,10 @@ // webui/public/app/state.js — REFACTORING.md batch 4 step 2 // Owns: config consts (TOKEN/CID/API_SUFFIX/HEADERS), the mutable `state` // binding + its 3 rebinding sites (connect/refreshSessions/refreshUsage), -// SSE connection, panel flags (leftOpen/rightOpen/sidebarReady/ +// the /api/stream WebSocket connection, panel flags (leftOpen/rightOpen/sidebarReady/ // sessionSearchQuery) with setters, usage quota data + popover surface, // v2 per-request authorization queue (needs_authorization / -// authorization_decided SSE frames + /api/auth/decision POST). +// authorization_decided control frames + /api/auth/decision POST). // NOTE: cycles with render.js/events.js are intentional and safe — imported // bindings are only touched inside functions, never at module eval time. @@ -25,7 +25,7 @@ import { SLASH_COMMANDS, SLASH_SKILLS, attachEvents, attachModalEvents, attached // 4. 同步写到 localStorage,下次启动继续用 // // 注意: token 不能 log, 不能 echo back, 不能进 URL fragment, 不能进 -// 任何 SSE / API 的 log。SECURITY-NOTES.md §2 完整说明了 trade-off。 +// 任何 stream / API 的 log。SECURITY-NOTES.md §2 完整说明了 trade-off。 const WEBUI_TOKEN_LS_KEY = 'webui_token' function readToken() { @@ -44,7 +44,7 @@ function readToken() { } // URL strip — must run exactly once at module load, before any -// fetch / EventSource is created (so the address bar is clean and +// fetch / WebSocket connection is created (so the address bar is clean and // the browser never sends the token via Referer to same-origin assets). function stripTokenFromUrl() { if (!urlParams.has('token')) return @@ -58,7 +58,7 @@ function stripTokenFromUrl() { export const urlParams = new URLSearchParams(window.location.search) export let TOKEN = readToken() -stripTokenFromUrl() // must run after readToken(), before any fetch/SSE +stripTokenFromUrl() // must run after readToken(), before any fetch/WS connect export let TOKEN_QUERY = TOKEN ? `?token=${encodeURIComponent(TOKEN)}` : '' // Back-compat: events.js + render.js still import `tokenParam` from @@ -68,7 +68,7 @@ export let TOKEN_QUERY = TOKEN ? `?token=${encodeURIComponent(TOKEN)}` : '' export const tokenParam = TOKEN_QUERY // v0.5.ai: A2 per-client — 每个 webui tab 一个 client id (localStorage 持久化) -// 拼到所有 /api/xxx URL query string,server 端按 cid 路由 SSE + state +// 拼到所有 /api/xxx URL query string,server 端按 cid 路由 stream + state export const CID = (() => { let c = localStorage.getItem('webui_cid') if (!c) { @@ -82,14 +82,14 @@ export const CID_QUERY = `cid=${encodeURIComponent(CID)}` export let API_SUFFIX = TOKEN_QUERY ? `${TOKEN_QUERY}&${CID_QUERY}` : `?${CID_QUERY}` // v1.0.1: HEADERS is a live object — its properties are mutated in place -// when the token rotates (SSE auth.token_rotated event). All callers use +// when the token rotates (the auth.token_rotated control frame). All callers use // the object reference (not a snapshot) so they always read the current // Authorization header at fetch time. Tokens are NEVER logged (per // SECURITY-NOTES.md §2). export const HEADERS = {} if (TOKEN) HEADERS['Authorization'] = `Bearer ${TOKEN}` -// setToken — called by the SSE handler when server pushes a new token +// setToken — called by the stream handler when server pushes a new token // (auth.token_rotated). Updates module-level state + localStorage + // recomputes the URL query suffix. The next fetch() call automatically // picks up the new header (HEADERS is a live binding). @@ -129,10 +129,10 @@ export let sessionSearchQuery = '' // v0.5.x: 侧边栏会话搜索词 // server/lib/authorize.js gates destructive / privacy-sensitive actions // (session delete / export / cross-workspace search / cleanup-orphans / // /clear / /new / token reset / startup cleanup) behind a fail-closed -// 5-minute user confirmation. The server pushes `event: -// needs_authorization` frames — JSON {requestId, action, ctx, expiresAt} -// — over this same /api/events SSE stream, and broadcasts `event: -// authorization_decided` {requestId, approved, decidedBy} when the +// 5-minute user confirmation. The server pushes `needs_authorization` +// control frames — JSON {requestId, action, ctx, expiresAt} +// — over this same /api/stream WebSocket, and pushes +// `authorization_decided` {requestId, approved, decidedBy} when the // pending promise resolves (this tab's click, ANOTHER tab's click, or // the server-side timeout). // @@ -141,7 +141,7 @@ export let sessionSearchQuery = '' // v0.5.x: 侧边栏会话搜索词 // so every gated action hung silently for 5 minutes and then declined // ("clicking does nothing"). // -// Ownership split: the queue lives here (state.js owns the SSE +// Ownership split: the queue lives here (state.js owns the WebSocket // connection + HEADERS); the modal that displays it lives in render.js // (renderAuthModal, following the ask_user modal pattern); the // Approve/Deny button wiring lives in events.js (attachModalEvents). @@ -155,7 +155,7 @@ export function getPendingAuthRequests() { } // Drop one requestId from the queue. Idempotent on purpose — called -// from BOTH the authorization_decided SSE handler and +// from BOTH the authorization_decided control-frame handler and // submitAuthDecision's local removal, whichever lands first. export function _removePendingAuthRequest(requestId) { const i = PENDING_AUTH_REQUESTS.findIndex((r) => r.requestId === requestId) @@ -177,7 +177,7 @@ export async function submitAuthDecision(requestId, approve) { }) // 200 = resolved. 404 = already decided elsewhere (another tab or the // server timeout evicted it) — the request is finished either way, so - // drop it locally even if the authorization_decided SSE broadcast is + // drop it locally even if the authorization_decided control frame is // delayed or lost; removal is idempotent. Any other status / a thrown // network error is left to the caller (events.js shows the error and // re-enables the buttons so the user can retry). @@ -193,20 +193,23 @@ export async function submitAuthDecision(requestId, approve) { // ============================================================ // server/lib/alerts.js pushes system-level signals — mcode subprocess // crash (spawn ENOENT), sqlite failure, token expiry, protocol -// unsupported… — on the INDEPENDENT /api/alerts SSE channel (routes/ -// alerts.js) instead of polluting chat lines. Lease B02 §AP3: "the +// unsupported… — on the INDEPENDENT alerts channel: a REST ring +// snapshot from GET /api/alerts plus alerts.append / alerts.update +// control frames on /api/stream, instead of polluting chat lines. +// Lease B02 §AP3: "the // bell icon (frontend) shows the alert with the matching id" — but // public/ had ZERO wiring, so a failed chat send was invisible (the -// audit's P1). The store lives HERE (state.js owns SSE + module-level +// audit's P1). The store lives HERE (state.js owns the stream +// connection + module-level // state that survives the full-state re-renders — same rationale as // PENDING_AUTH_REQUESTS above); the DOM surface lives in render.js // (renderAlerts) and the click wiring in events.js (attachEvents). // -// Wire contract (routes/alerts.js frames, default `message` events): -// data: {"kind":"snapshot","alerts":[…]} — ring replay on connect -// data: {"kind":"append","alert":{…}} — new alert -// data: {"kind":"update","alert":{…}} — dedup merge (count++/ts) -// event: heartbeat — named event, not onmessage +// Wire contract (GET /api/alerts REST + /api/stream control frames): +// GET /api/alerts → {"kind":"snapshot","alerts":[…]} — ring replay, +// refetched on every stream hello (replay dedup via _seenAlertIds) +// control alerts.append, data={"kind":"append","alert":{…}} — new alert +// control alerts.update, data={"kind":"update","alert":{…}} — dedup merge // Alert shape (lib/alerts.js normalize): {id, ts, level, msg, src, // cid, sessionId, data, count} — all of it is UNTRUSTED wire data. const ALERTS_MAX = 100 // mirror the server ring size; local cap for the popover @@ -252,8 +255,8 @@ function _handleAlertFrame(frame) { if (frame.kind === 'snapshot' && Array.isArray(frame.alerts)) { // Connect / reconnect replay (server ring, oldest → newest). // Replace the list wholesale, but only ids never seen before may - // bump the unread count — an EventSource auto-reconnect replays - // the identical snapshot and must not re-mark everything unread. + // bump the unread count — a stream reconnect refetches GET /api/alerts + // and replays the identical snapshot; it must not re-mark everything unread. let fresh = 0 const next = [] for (const raw of frame.alerts) { @@ -319,146 +322,273 @@ export function clearAlerts() { } // v2 (2026-09-20 webui-manual-audit D1): the alerts SSE connection. -// Separate EventSource from the state stream on purpose (lease B02: -// anomaly channel is its own chokepoint with its own replay/dedup -// semantics). Created once — the browser's native EventSource -// auto-reconnect handles drops, and every (re)connect gets a fresh -// snapshot frame whose ids dedup against _seenAlertIds. -export let alertsEs = null +// (transport retired with SSE removal.) The anomaly channel stays its +// own chokepoint with its own replay/dedup semantics (lease B02), now +// carried by the /api/stream WebSocket: live frames arrive as +// alerts.append / alerts.update control frames, and the first snapshot +// is fetched from GET /api/alerts on every stream hello — its ids +// dedup against _seenAlertIds above. // ============================================================ -// SSE connection +// WebSocket connection (/api/stream) + REST baselines // ============================================================ +// Shared WS protocol (server: server/lib/ws-server.js). Every +// server→client text frame is JSON {v:1, type, …}: +// {v:1,type:"hello",payload:{resumeSupported,latestSeq,heartbeatMs, +// ringCapacity,cid}} — first frame after connect +// {v:1,seq,ts,type:"state.snapshot",payload:} +// {v:1,seq,ts,type:"control",payload:{name,data}} — data is a string +// {v:1,type:"error",payload:{code,message}} +// First connect (no wsLastSeq yet) takes its baseline from REST — +// GET /api/state (full snapshot) + GET /api/alerts (alert ring). A +// reconnect that kept its wsLastSeq sends +// {v:1,type:"resume",payload:{lastSeq}} instead and the server replays +// the buffered frames in seq order; on ring underrun the server either +// sends a state.snapshot baseline or error code "resume-underrun" +// (handled below). Heartbeat is a server-side WS protocol ping every +// 30s that the browser answers automatically — this client keeps NO +// heartbeat timer of its own. export let es = null export let autoRefreshTimer = null -export function connect() { - if (es) { try { es.close() } catch {} } - const url = '/api/events' + API_SUFFIX - es = new EventSource(url) - // v1.0.1: named event "auth.token_rotated" — server pushes this when - // an operator triggers a token rotation. The body is plain text - // (the new token) — we use it to update localStorage + live HEADERS. - es.addEventListener('auth.token_rotated', (ev) => { - try { - const newToken = (ev.data || '').trim() - if (!newToken) return - setToken(newToken) - console.log('[webui] token rotated (SSE); updated HEADERS + localStorage') - } catch (e) { console.error('[webui] token rotation handler failed', e) } - }) +// Module-level stream state — deliberately survives connect() calls: +// wsLastSeq is the resume baseline across reconnects (null until the +// first seq-bearing frame); gotSnapshotSinceOpen is per-connection +// (reset at the top of connect()) and guards a slow REST baseline from +// overwriting a fresher WS snapshot. +let wsLastSeq = null +let wsHelloLatest = null +let reconnectTimer = null +let gotSnapshotSinceOpen = false + +// applySnapshot — the full-state replacement, shared by BOTH baseline +// sources: a WS state.snapshot frame payload and the GET /api/state +// REST baseline (both carry the complete state object, so the field +// preservation below applies unchanged). Body moved verbatim out of the +// old stream handler; only the input changed from JSON.parse(ev.data) +// to an already-parsed object. +function applySnapshot(obj) { + try { + // v0.5.bx-8: 保留 askUserAnswers (webui-only, server 不存) — 整包快照会覆盖 + // v1.0: 同理保留 mcodeSessions — pushOnlineCount 等推送点若缺该字段, 整包替换后 + // mcodeSessions 变 undefined, 侧栏闪跌; 旧值好过没值 + // v2026-08-28 modacker: 同理保留 Token Plan (套餐用量) feature fields + // (quotaEnabled / hasTokenPlanKey / tokenPlanApiKeyMasked). + // The server now includes them in the snapshot (state-bus.js), + // but we still preserve them defensively: if any future + // snapshot builder forgets one of these, the user's + // "已开启套餐用量" toggle should not silently revert. State + // class is read directly in renderUsage() to decide btn-usage + // visibility, so losing quotaEnabled on a re-render hides the + // button right after the user opens it. + const preserved = state?.askUserAnswers + const preservedMcodeSessions = state?.mcodeSessions + const preservedQuotaEnabled = state?.quotaEnabled + const preservedHasTokenPlanKey = state?.hasTokenPlanKey + const preservedTokenPlanApiKeyMasked = state?.tokenPlanApiKeyMasked + // v2026-08-28 modacker (A+C): also preserve the source + // ("env" / "file" / "settings") + resolved file path so the + // modal can keep its "delete" button hidden if the operator + // reverts to a fresh snapshot. See state-bus.js. + const preservedTokenPlanApiKeySource = state?.tokenPlanApiKeySource + const preservedTokenPlanApiKeyFilePath = state?.tokenPlanApiKeyFilePath + state = obj + if (preserved) state.askUserAnswers = preserved + if (state.mcodeSessions === undefined && Array.isArray(preservedMcodeSessions)) { + state.mcodeSessions = preservedMcodeSessions + } + if (state.quotaEnabled === undefined && preservedQuotaEnabled !== undefined) { + state.quotaEnabled = preservedQuotaEnabled + } + if (state.hasTokenPlanKey === undefined && preservedHasTokenPlanKey !== undefined) { + state.hasTokenPlanKey = preservedHasTokenPlanKey + } + if (state.tokenPlanApiKeyMasked === undefined && preservedTokenPlanApiKeyMasked !== undefined) { + state.tokenPlanApiKeyMasked = preservedTokenPlanApiKeyMasked + } + if (state.tokenPlanApiKeySource === undefined && preservedTokenPlanApiKeySource !== undefined) { + state.tokenPlanApiKeySource = preservedTokenPlanApiKeySource + } + if (state.tokenPlanApiKeyFilePath === undefined && preservedTokenPlanApiKeyFilePath !== undefined) { + state.tokenPlanApiKeyFilePath = preservedTokenPlanApiKeyFilePath + } + // v0.5.bx-31 + v1.0: 收到权威 mcodeSessions 推送即 ready。 + // 旧门控要求 length>0 — 工作区会话被全删后列表合法为空, loading 永不消失; + // 现在用 server 的 mcodeSessionsPending 区分占位推送 (cache miss 空数组) 与权威推送 + if (!sidebarReady && Array.isArray(state.mcodeSessions) && !state.mcodeSessionsPending) { + console.log('[webui] sidebar ready: mcodeSessions.length=' + state.mcodeSessions.length) + sidebarReady = true + } + render() + } catch (e) { console.error('snapshot apply', e) } +} - // v2 (2026-09-20 webui-manual-audit): needs_authorization — the - // server's authorize() gate is asking this tab to confirm a gated - // action. Parse the frame, queue it, and let render.js's - // renderAuthModal() display it (one at a time, queue order). Frames - // can be replayed on SSE reconnect — dedup by requestId so a replay - // never double-queues or resets the displayed request. - es.addEventListener('needs_authorization', (ev) => { - try { - const req = JSON.parse(ev.data || '{}') - if (!req || typeof req.requestId !== 'string' || !req.requestId) return - if (PENDING_AUTH_REQUESTS.some((r) => r.requestId === req.requestId)) return - PENDING_AUTH_REQUESTS.push({ - requestId: req.requestId, - action: typeof req.action === 'string' ? req.action : '', - ctx: (req.ctx && typeof req.ctx === 'object') ? req.ctx : {}, - expiresAt: Number(req.expiresAt) || 0, - receivedAt: Date.now(), - }) - renderAuthModal() - } catch (e) { console.error('[webui] needs_authorization handler failed', e) } - }) +// _fetchStateBaseline — first-connect REST baseline (GET /api/state, +// the complete snapshot incl. settings/quota fields). gotSnapshotSinceOpen +// guards the in-flight race: if a WS state.snapshot lands while this +// REST call is travelling, drop the (older) REST result rather than +// overwriting newer state. +function _fetchStateBaseline() { + fetch('/api/state' + API_SUFFIX) + .then((r) => r.json()) + .then((obj) => { + if (gotSnapshotSinceOpen) return + applySnapshot(obj) + }) + .catch((e) => console.warn('[webui] /api/state baseline failed', e)) +} - // v2 (2026-09-20 webui-manual-audit): authorization_decided — a - // pending request resolved (this tab's POST, another tab's decision, - // or the server's fail-closed 5-min timeout; the server broadcasts - // this on EVERY resolution path). Drop it and re-render: the modal - // closes when the queue empties, or advances to the next queued - // request. - es.addEventListener('authorization_decided', (ev) => { - try { - const d = JSON.parse(ev.data || '{}') - if (!d || typeof d.requestId !== 'string' || !d.requestId) return - _removePendingAuthRequest(d.requestId) - renderAuthModal() - } catch (e) { console.error('[webui] authorization_decided handler failed', e) } - }) +// dispatchControl — routes one /api/stream control frame {name,data} +// (data is always a string). Bodies moved verbatim from the old +// state-stream named-event listeners; only the envelope changed (SSE +// event names → control frames). +function dispatchControl(name, data) { + switch (name) { + // v1.0.1: "auth.token_rotated" — server pushes this when an + // operator triggers a token rotation. data is plain text (the new + // token) — we use it to update localStorage + live HEADERS. + case 'auth.token_rotated': { + try { + const newToken = (data || '').trim() + if (!newToken) return + setToken(newToken) + console.log('[webui] token rotated (stream); updated HEADERS + localStorage') + } catch (e) { console.error('[webui] token rotation handler failed', e) } + return + } - es.onmessage = (ev) => { - try { - // v0.5.bx-8: 保留 askUserAnswers (webui-only, server 不存) — SSE 推送整 state 会覆盖 - // v1.0: 同理保留 mcodeSessions — pushOnlineCount 等推送点若缺该字段, 整包替换后 - // mcodeSessions 变 undefined, 侧栏闪跌; 旧值好过没值 - // v2026-08-28 modacker: 同理保留 Token Plan (套餐用量) feature fields - // (quotaEnabled / hasTokenPlanKey / tokenPlanApiKeyMasked). - // The server now includes them in the snapshot (state-bus.js), - // but we still preserve them defensively: if any future - // snapshot builder forgets one of these, the user's - // "已开启套餐用量" toggle should not silently revert. State - // class is read directly in renderUsage() to decide btn-usage - // visibility, so losing quotaEnabled on a re-render hides the - // button right after the user opens it. - const preserved = state?.askUserAnswers - const preservedMcodeSessions = state?.mcodeSessions - const preservedQuotaEnabled = state?.quotaEnabled - const preservedHasTokenPlanKey = state?.hasTokenPlanKey - const preservedTokenPlanApiKeyMasked = state?.tokenPlanApiKeyMasked - // v2026-08-28 modacker (A+C): also preserve the source - // ("env" / "file" / "settings") + resolved file path so the - // modal can keep its "delete" button hidden if the operator - // reverts to a fresh snapshot. See state-bus.js. - const preservedTokenPlanApiKeySource = state?.tokenPlanApiKeySource - const preservedTokenPlanApiKeyFilePath = state?.tokenPlanApiKeyFilePath - state = JSON.parse(ev.data) - if (preserved) state.askUserAnswers = preserved - if (state.mcodeSessions === undefined && Array.isArray(preservedMcodeSessions)) { - state.mcodeSessions = preservedMcodeSessions - } - if (state.quotaEnabled === undefined && preservedQuotaEnabled !== undefined) { - state.quotaEnabled = preservedQuotaEnabled - } - if (state.hasTokenPlanKey === undefined && preservedHasTokenPlanKey !== undefined) { - state.hasTokenPlanKey = preservedHasTokenPlanKey - } - if (state.tokenPlanApiKeyMasked === undefined && preservedTokenPlanApiKeyMasked !== undefined) { - state.tokenPlanApiKeyMasked = preservedTokenPlanApiKeyMasked - } - if (state.tokenPlanApiKeySource === undefined && preservedTokenPlanApiKeySource !== undefined) { - state.tokenPlanApiKeySource = preservedTokenPlanApiKeySource - } - if (state.tokenPlanApiKeyFilePath === undefined && preservedTokenPlanApiKeyFilePath !== undefined) { - state.tokenPlanApiKeyFilePath = preservedTokenPlanApiKeyFilePath + // v2 (2026-09-20 webui-manual-audit): needs_authorization — the + // server's authorize() gate is asking this tab to confirm a gated + // action. Parse the frame, queue it, and let render.js's + // renderAuthModal() display it (one at a time, queue order). Frames + // can be replayed on reconnect — dedup by requestId so a replay + // never double-queues or resets the displayed request. + case 'needs_authorization': { + try { + const req = JSON.parse(data || '{}') + if (!req || typeof req.requestId !== 'string' || !req.requestId) return + if (PENDING_AUTH_REQUESTS.some((r) => r.requestId === req.requestId)) return + PENDING_AUTH_REQUESTS.push({ + requestId: req.requestId, + action: typeof req.action === 'string' ? req.action : '', + ctx: (req.ctx && typeof req.ctx === 'object') ? req.ctx : {}, + expiresAt: Number(req.expiresAt) || 0, + receivedAt: Date.now(), + }) + renderAuthModal() + } catch (e) { console.error('[webui] needs_authorization handler failed', e) } + return + } + + // v2 (2026-09-20 webui-manual-audit): authorization_decided — a + // pending request resolved (this tab's POST, another tab's decision, + // or the server's fail-closed 5-min timeout; the server pushes this + // on EVERY resolution path). Drop it and re-render: the modal + // closes when the queue empties, or advances to the next queued + // request. + case 'authorization_decided': { + try { + const d = JSON.parse(data || '{}') + if (!d || typeof d.requestId !== 'string' || !d.requestId) return + _removePendingAuthRequest(d.requestId) + renderAuthModal() + } catch (e) { console.error('[webui] authorization_decided handler failed', e) } + return + } + + // Anomaly-channel live frames — same payload shape the old alerts + // stream carried; _handleAlertFrame owns normalization + dedup. + case 'alerts.append': + case 'alerts.update': { + try { _handleAlertFrame(JSON.parse(data)) } catch (e) { console.error('alerts parse', e) } + return + } + + case 'token.first_run': + default: + // token.first_run has never had a consumer in this SPA (unchanged + // from the SSE era); unknown control names are ignored on purpose. + return + } +} + +export function connect() { + // Close any previous socket + drop a pending reconnect timer first, + // so a deliberate connect() never races its own onclose retry. + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null } + if (es) { try { es.close() } catch {} } + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' + const url = proto + '//' + location.host + '/api/stream' + API_SUFFIX + gotSnapshotSinceOpen = false + const ws = new WebSocket(url) + es = ws + + // onopen deliberately does nothing — everything waits for the server's + // hello frame. No client heartbeat: the server pings every 30s and + // the browser answers protocol pongs automatically. + + ws.onmessage = (ev) => { + let frame + try { frame = JSON.parse(ev.data) } catch (e) { console.error('[webui] stream frame parse failed', e); return } + if (!frame || typeof frame !== 'object') return + const payload = (frame.payload && typeof frame.payload === 'object') ? frame.payload : {} + switch (frame.type) { + case 'hello': { + // Remember the server's latest seq, then establish the baseline: + // resume from wsLastSeq when we have one, otherwise pull the + // REST state baseline. Either way the alert ring snapshot is a + // REST fetch — control frames only carry deltas, never the + // ring replay. + wsHelloLatest = payload.latestSeq + if (wsLastSeq != null) { + ws.send(JSON.stringify({ v: 1, type: 'resume', payload: { lastSeq: wsLastSeq } })) + } else { + _fetchStateBaseline() + } + fetch('/api/alerts' + API_SUFFIX) + .then((r) => r.json()) + .then((f) => _handleAlertFrame(f)) + .catch(() => {}) + return } - // v0.5.bx-31 + v1.0: 收到权威 mcodeSessions 推送即 ready。 - // 旧门控要求 length>0 — 工作区会话被全删后列表合法为空, loading 永不消失; - // 现在用 server 的 mcodeSessionsPending 区分占位推送 (cache miss 空数组) 与权威推送 - if (!sidebarReady && Array.isArray(state.mcodeSessions) && !state.mcodeSessionsPending) { - console.log('[webui] sidebar ready: mcodeSessions.length=' + state.mcodeSessions.length) - sidebarReady = true + case 'state.snapshot': + wsLastSeq = frame.seq + gotSnapshotSinceOpen = true + applySnapshot(payload) + return + case 'control': + wsLastSeq = frame.seq + dispatchControl(payload.name, payload.data) + return + case 'error': { + if (payload.code === 'resume-underrun') { + // The ring buffer could not cover [wsLastSeq, latest]: drop the + // stale baseline (fall back to the hello's latestSeq) and take + // a fresh REST snapshot unless a WS snapshot already landed + // on this connection. + wsLastSeq = wsHelloLatest ?? null + if (!gotSnapshotSinceOpen) _fetchStateBaseline() + } else { + console.warn('[webui] stream error frame', payload.code, payload.message) + } + return } - render() - } catch (e) { console.error('sse parse', e) } - } - es.onerror = () => { setTimeout(connect, 3000) } - - // v2 (2026-09-20 webui-manual-audit D1): anomaly channel — a SECOND, - // independent EventSource (routes/alerts.js), so system-level error - // signals (failed chat send, subprocess crash…) reach the bell even - // though they never touch the state/chat streams. Frames are default - // `message` events; the server's 30s `heartbeat` is a NAMED event so - // onmessage never sees it. Created once per page: connect() re-runs - // on state-stream errors, and a duplicate alerts connection would - // double-count every frame. EventSource auto-reconnect + snapshot - // replay + _seenAlertIds dedup make that path safe. - if (!alertsEs) { - alertsEs = new EventSource('/api/alerts' + API_SUFFIX) - alertsEs.onmessage = (ev) => { - try { - _handleAlertFrame(JSON.parse(ev.data)) - } catch (e) { console.error('alerts parse', e) } + case 'pong': + return // no client-side heartbeat exists; ignore a stray pong anyway + default: + return // unknown frame types are ignored on purpose } } + + ws.onclose = () => { + // A close from a socket connect() already replaced is deliberate — + // only a live connection dropping (server shutdown / network loss) + // schedules a retry. Same 3s cadence as the old error retry; + // connect() clears any outstanding timer on entry. + if (es !== ws) return + reconnectTimer = setTimeout(connect, 3000) + } + // v0.5.ak: user footer 已改为静态 GitHub 链接,不需要 ticker // 自动 /api/refresh 触发:页面打开 2s + 每 60s 拉一次 @@ -700,14 +830,14 @@ export async function refreshUsage(opts = {}) { // 等真实数据到来(fetchedAt 必须 > postTime)才停转 + toast // v0.5.bb: deadline 18s > server 端 mmx quota 15s 超时(确保 mmx 跑完后 pushStateFor 的 fetchedAt > postTime) const deadline = Date.now() + 18_000 - let gotSseUpdate = false + let gotStreamUpdate = false while (Date.now() < deadline) { const fetchedAt = state?.usage?.fetchedAt - if (typeof fetchedAt === 'number' && fetchedAt > postTime) { gotSseUpdate = true; break } + if (typeof fetchedAt === 'number' && fetchedAt > postTime) { gotStreamUpdate = true; break } await new Promise((r) => setTimeout(r, 100)) } - if (!gotSseUpdate) { - // SSE 没收到(典型场景:init 时 EventSource 还在 CONNECTING) + if (!gotStreamUpdate) { + // 事件流还没收到(典型场景:init 时 WebSocket 还在 CONNECTING) // 兜底轮询 /api/state(每 1.5s 一次直到 deadline)— 避免 mmx 慢的情况下拿不到数据 const fallbackDeadline = Date.now() + 8_000 while (Date.now() < fallbackDeadline) { @@ -716,7 +846,7 @@ export async function refreshUsage(opts = {}) { if (r2.ok) { state = await r2.json() const fa = state?.usage?.fetchedAt - if (typeof fa === 'number' && fa > postTime) { gotSseUpdate = true; break } + if (typeof fa === 'number' && fa > postTime) { gotStreamUpdate = true; break } } } catch {} await new Promise((r) => setTimeout(r, 1500)) diff --git a/packages/webui/references/SECURITY-NOTES.md b/packages/webui/references/SECURITY-NOTES.md index 56e0bfe3..6821110a 100644 --- a/packages/webui/references/SECURITY-NOTES.md +++ b/packages/webui/references/SECURITY-NOTES.md @@ -77,7 +77,7 @@ settings API discloses it via `lanExposed` / `bindRestartPending` / **No upstream model API calls from the webui itself.** The webui is a front-end for `mcode acp` / `mcode exec`, which handles the model call. -The webui only forwards stdin / parses stdout / renders the SSE stream. +The webui only forwards stdin / parses stdout / renders the event stream (`GET /api/stream`). --- @@ -98,8 +98,9 @@ The webui only forwards stdin / parses stdout / renders the SSE stream. ### 2.3 Token in URL query string - Browser opens `http://:18090/?token=` and the webui - auto-injects the token into every `fetch` / `EventSource` call as - `?token=` AND as `Authorization: Bearer`. + auto-injects the token into every `fetch` call (and into the + `/api/stream` WebSocket handshake) as `?token=` AND as + `Authorization: Bearer`. - **Risk**: query string ends up in browser history, server access logs (if any proxy / dev-tools captures it), and `Referer` headers sent to any external resource (none, in our case, but the webui's static files @@ -118,7 +119,7 @@ The webui only forwards stdin / parses stdout / renders the SSE stream. `Authorization: Bearer` header instead of a token URL. ### 2.4 Error-message redaction -- Token is never included in JSON responses, error bodies, or SSE +- Token is never included in JSON responses, error bodies, or event-stream payloads. Error responses follow `{ok: false, error: ""}` — no request URL or headers are reflected. - See `test/lib-config.test.js` and `test/lib-lan.test.js` for coverage @@ -251,7 +252,8 @@ In order of operational cost: 5. **Rotate the token before any cross-origin exposure** — operators who open the server to the LAN should rotate the token (§9.3) so any previously-leaked value becomes inert. The new value is broadcast - over SSE to live clients and stored in their `localStorage`. + over the event stream (`/api/stream`) to live clients and stored in + their `localStorage`. ### Cross-references @@ -259,7 +261,7 @@ In order of operational cost: - §2.3 — Token in URL query string (related exfiltration vector) - §3 — Destructive endpoints (`DELETE /api/sessions/:id`, `POST /api/settings {resetToken: true}`, `/api/debug/*`) -- §9 — Token auth gate + `auth.token_rotated` SSE broadcast +- §9 — Token auth gate + `auth.token_rotated` broadcast over the event stream - `test/router-cors.test.js` — locks the trusted-origin policy (`test/lib-lan-origins.test.js` locks the trust-set builder) - `docs/HTTPS-REVERSE-PROXY.md` — adding a reverse proxy's external @@ -373,7 +375,7 @@ bind per §1 (env `HOST` > persisted `lanBind` > loopback `127.0.0.1`). Endpoints under `/api/debug/*` are gated by `DEBUG_INJECT=1`. The two currently implemented routes are `inject` (force a server-side event -into the SSE stream for testing) and `state` (return server-internal +into the event stream for testing) and `state` (return server-internal state for debugging). **Never set `DEBUG_INJECT=1` in production** — it bypasses the standard error handling. @@ -515,6 +517,14 @@ log + a disabled feature) — it does not crash. registry-installed or non-canonical layouts point at the right binary explicitly. Resolution priority: env override > `$MCODE_CMD` derived > dev layout fallback. +- Network-topology wave 2 (`docs/drafts/arch_net_solution_0922.md` §6/§7): + `MCODE_ENGINE` (`acp`, default — the per-turn subprocess transport; + `embed` — in-process engine hosted on a worker thread with automatic + fallback to `acp` on boot failure). The webui transport switch was + removed: `GET /api/stream` is always enabled, and its upgrade executes + the same origin/LAN/token gate chain as every other `/api/*` route — + see the [Origin / CSRF gate (Gate 1b)](#origin--csrf-gate-gate-1b) and + [§1 Network exposure](#1-network-exposure) above. - Cross-platform: there is **no CI matrix**. The only CI is the marketplace root gate (single ubuntu / Node 22 job: `npm ci` + `npm run check`, which recursively runs every file under `test/` @@ -533,7 +543,7 @@ panel. It centralizes the three most-relevant security / access controls: | **LAN access** (toggle) | On/off for the 403 gate on non-local requests (unchanged from v0.5.ap) | In-memory only; resets to `true` on restart (intentional — admins shouldn't get locked out) | | **Read-only mode** (toggle) | When on, non-local `POST` / `DELETE` to `/api/*` return 403 `{error: "read-only mode"}`. `GET`, `HEAD`, `OPTIONS` are exempt. Local requests are always exempt. `/api/settings` is exempt (escape hatch) | Persisted to `~/.mcode-webui/settings.json` | | **Token auth** (toggle) | When on, non-local requests must carry `?token=` or `Authorization: Bearer`. When off, the gate is bypassed even if a token is set (LAN-only deployment mode) | Persisted | -| **Token value + reset** | First-run: server generates a 32-hex-char token (`crypto.randomBytes(16).toString('hex')`) and writes it to `~/.mcode-webui/settings.json`. The token is **printed to stdout exactly once at first start** (not to `.server.log`). The settings card shows the token until the operator clicks "我已保存" (acknowledge). After acknowledgment, the server stops sending the token in `GET /api/settings` responses — only already-connected clients keep it. `Reset token` generates a new value, persists, broadcasts an `auth.token_rotated` SSE event so other connected clients update their `localStorage` + `Authorization` header live, and resets `tokenAcknowledged` to `false` (the new token is shown again). | Persisted to `~/.mcode-webui/settings.json` (mode 0600, atomic write via `.tmp` + rename) | +| **Token value + reset** | First-run: server generates a 32-hex-char token (`crypto.randomBytes(16).toString('hex')`) and writes it to `~/.mcode-webui/settings.json`. The token is **printed to stdout exactly once at first start** (not to `.server.log`). The settings card shows the token until the operator clicks "我已保存" (acknowledge). After acknowledgment, the server stops sending the token in `GET /api/settings` responses — only already-connected clients keep it. `Reset token` generates a new value, persists, broadcasts an `auth.token_rotated` control event over the event stream so other connected clients update their `localStorage` + `Authorization` header live, and resets `tokenAcknowledged` to `false` (the new token is shown again). | Persisted to `~/.mcode-webui/settings.json` (mode 0600, atomic write via `.tmp` + rename) | ### 9.1 Token resolution priority (per request) @@ -555,22 +565,24 @@ on first run, set `TOKEN=` in the environment. - Override path for tests / non-default installs: `MCODE_WEBUI_SETTINGS_PATH=/some/other/settings.json`. -### 9.3 Token rotation — SSE `auth.token_rotated` +### 9.3 Token rotation — `auth.token_rotated` control event When the operator hits "Reset token" in the UI: 1. `POST /api/settings {resetToken: true}` (must already be authenticated) 2. Server generates new 32-hex token, writes to disk -3. Server broadcasts `event: auth.token_rotated\ndata: \n\n` to - every connected SSE client (the connection is already authenticated, - so the token in cleartext over SSE is no worse than the periodic state - push that also includes `currentToken` for the same window). +3. Server broadcasts an `auth.token_rotated` control frame + (`type: "control"`, `name: "auth.token_rotated"`, `data: `) + to every client on the WebSocket event stream (the connection is + already authenticated, so the token in cleartext over the stream is no + worse than the periodic state snapshot that also includes + `currentToken` for the same window). 4. Server also broadcasts a regular state push (`currentToken` will be in the JSON body until the operator clicks "我已保存"). -5. Clients that receive the SSE event update their `localStorage` and the +5. Clients that receive the control event update their `localStorage` and the live `HEADERS.Authorization` object in place — subsequent `fetch` calls automatically use the new token. -6. Clients on the old token that didn't get the SSE event (offline, etc.) +6. Clients on the old token that didn't get the control event (offline, etc.) will see 401 on their next request and need to manually re-open with the new token URL. @@ -598,11 +610,11 @@ When the operator hits "Reset token" in the UI: LAN and token gates). Interface-allowlist gate was prototyped in v1.0.1 but removed before release per PR #16 reviewer scope. - `server/routes/settings.js` — accepts new fields, handles rotation. -- `server/lib/state-bus.js` — adds `broadcastTokenRotated`; SSE state - push now includes `readOnly`, `tokenEnabled`, `currentToken` (when +- `server/lib/state-bus.js` — adds `broadcastTokenRotated`; state + snapshots now include `readOnly`, `tokenEnabled`, `currentToken` (when not acknowledged), `tokenAcknowledged`, `tokenRotatedAt`. - `public/app/state.js` — `HEADERS` is now a live-mutable object; - new `setToken()` + SSE `auth.token_rotated` handler. + new `setToken()` + `auth.token_rotated` event-stream handler. - `public/app/render.js` — `renderLanCardContent(settings)` exported. - `public/app/events.js` — `#chip-lan` click toggles the sub-card (was: directly toggled `lanBroadcast`); new handlers for each control diff --git a/packages/webui/scripts/check-docs-alignment.mjs b/packages/webui/scripts/check-docs-alignment.mjs index 3f90581a..5e077ef6 100644 --- a/packages/webui/scripts/check-docs-alignment.mjs +++ b/packages/webui/scripts/check-docs-alignment.mjs @@ -279,6 +279,7 @@ const KNOWN_ENV_VARS = new Set([ "MAVIS_DATA_DIR", "SQLITE3_BIN", "DEBUG_INJECT", + "MCODE_ENGINE", ]); console.log(`${TAG.dim("[4/6]")} references/SECURITY-NOTES.md env vars → server/lib/config.js`); diff --git a/packages/webui/server.js b/packages/webui/server.js index c6f05c59..0eba9000 100644 --- a/packages/webui/server.js +++ b/packages/webui/server.js @@ -1,4 +1,4 @@ -// mcode-webui HTTP/SSE server — bootstrap. +// mcode-webui HTTP/WebSocket event-stream server — bootstrap. // // All actual logic lives in server/lib/* + server/routes/* + server/router.js. // This file only wires up: @@ -25,6 +25,7 @@ import { installGlobalErrorHandlers, MCODE_CMD, UPLOAD_DIR, WEBUI_DATA_DIR, PORT import { listenWithPortFallback, MAX_PORT_ATTEMPTS } from './server/lib/port.js' import { LAN_IP } from './server/lib/lan.js' import { handleRequest } from './server/router.js' +import { handleStreamUpgrade } from './server/lib/ws-server.js' import { runStartupCleanup } from './server/cleanup.js' import { shutdownMcodeAcpSingleton } from './server/lib/acp-client.js' import { init as initSettings, getPersistPath, getTokenEnabled } from './server/lib/settings.js' @@ -49,25 +50,27 @@ runStartupCleanup() // sync auth module). The printToken callback fires ONLY on first-ever // startup (when the token didn't exist on disk). After that, the token // value lives only in the settings file; if the operator rotates via -// the settings card, the new value is broadcast over SSE and shown in -// the settings card until acknowledged. +// the settings card, the new value is broadcast over the WebSocket +// event stream (/api/stream) and shown in the settings card until +// acknowledged. // v2 (Lease C08, ANTI-PATTERNS-FIX-PLAN §AP1): the raw token is NO LONGER -// echoed to stdout. Instead we push `token.first_run` over SSE so the -// web UI can show the onboarding modal. The raw token never leaves the -// controlled channel (SSE → already-authenticated local UI) and never -// touches shell history / Docker logs / systemd journal / screen shares. +// echoed to stdout. Instead we push `token.first_run` over /api/stream +// so the web UI can show the onboarding modal. The raw token never +// leaves the controlled channel (/api/stream → already-authenticated +// local UI) and never touches shell history / Docker logs / systemd +// journal / screen shares. // // TOKEN_STDOUT=1 keeps a single NEUTRAL line ("token persisted to: ") -// for docker / no-UI environments where no SSE client will connect to -// receive the modal. The token value itself is NEVER printed. +// for docker / no-UI environments where no event-stream client will +// connect to receive the modal. The token value itself is NEVER printed. let _printedFirstToken = false initSettings({ printToken: (token) => { if (_printedFirstToken) return _printedFirstToken = true const persistPath = getPersistPath() - // Push to any connected SSE client (the UI modal lives here). - // No-op if sseByCid is empty (e.g. server started headlessly). + // Push to any connected /api/stream client (the UI modal lives here). + // No-op until a client connects (e.g. server started headlessly). pushTokenFirstRun({ token, persistPath }) if (TOKEN_STDOUT) { // docker / no-UI fallback — single neutral line, NEVER raw token. @@ -80,6 +83,16 @@ setAuthTokenEnabled(getTokenEnabled()) const server = http.createServer(handleRequest) +// 波次 2b(docs/drafts/arch_net_solution_0922.md §7.2):WebSocket 事件流升级钩子。 +// 门链(origin/LAN/token)在 handleStreamUpgrade 内与原 /api/events 同款执行; +// 无 transport 开关,/api/stream 始终启用(发行版 SPA 消费本端点,决策 20)。 +// 其余升级路径一律拒绝(无升级监听时 Node 本就关套接字,行为等价)。 +server.on("upgrade", (req, socket, head) => { + const pathname = (req.url || "/").split("?")[0] + if (pathname === "/api/stream") handleStreamUpgrade(req, socket, head) + else socket.destroy() +}) + // 端口回退 (见 server/lib/port.js): 默认端口被占用时向后找空闲端口, 显式 PORT // 不回退。日志里的端口必须是实际绑定值 —— 启动器 (mcode-web / mcode webui) 正是 // 从 "listening on" 这一行取要打开的 URL, 而 origin 信任集 / share URL 按 diff --git a/packages/webui/server/cleanup.js b/packages/webui/server/cleanup.js index 4da43360..53b69528 100644 --- a/packages/webui/server/cleanup.js +++ b/packages/webui/server/cleanup.js @@ -13,7 +13,7 @@ // 1. dryRun: read SESSIONS_DB, compute how many orphans WOULD be // deleted, write one `kind:"cleanup.dry_run"` audit event with // the count + ids, and — if any orphans exist — fire a -// `startup.cleanup` authorize request via the SSE channel. +// `startup.cleanup` authorize request via the event-stream channel. // The function returns immediately (startup does not block). // 2. When the user confirms (via the UI listening on // `needs_authorization`), authorize() resolves with diff --git a/packages/webui/server/lib/acp-client.js b/packages/webui/server/lib/acp-client.js index 48aa9c45..f88879be 100644 --- a/packages/webui/server/lib/acp-client.js +++ b/packages/webui/server/lib/acp-client.js @@ -5,6 +5,7 @@ import { McodeAcpClient } from "../../acp.mjs"; import { DEFAULT_WORKSPACE, MCODE_RUNTIME_DB } from "./config.js"; +import { syncActiveCapabilities } from "./capability.js"; import { deleteMcodeSessionFromDb } from "./db.js"; // v0.5.bu: 拉 mcode 真实 session 列表(mcode acp session/list 协议) @@ -26,6 +27,8 @@ export async function getMcodeAcpClient() { try { await client.start(); _mcodeAcpSingleton = client; + // 播种运行时能力注册表(声明清单/惰性探测/旧引擎回退,见 capability.js) + syncActiveCapabilities(client.capabilities); console.log(`[acp] singleton client started pid=${client.pid || "?"}`); return client; } catch (e) { @@ -52,7 +55,13 @@ export async function listAllMcodeSessions() { return r && Array.isArray(r.sessions) ? r.sessions : []; } catch (e) { console.warn(`[acp] listAllMcodeSessions failed: ${e.message}`); - if (_mcodeAcpSingleton === client) _mcodeAcpSingleton = null; + // v2026-09-23: 失败时必须 stop 再弃引用 —— 只置 null 会泄漏 mcode acp 子进程, + // 其 stdio 管道撑住调用方事件循环(无登录态环境里 node --test 跑完不退出; + // 生产环境 auth 过期时每次失败都泄漏一个子进程)。 + if (_mcodeAcpSingleton === client) { + _mcodeAcpSingleton = null; + try { client.stop(); } catch {} + } return []; } } @@ -112,7 +121,11 @@ export async function getMcodeSessionTitle(mcodeSessionId) { return hit && hit.title ? hit.title : null; } catch (e) { console.warn(`[acp] getMcodeSessionTitle failed: ${e.message}`); - if (_mcodeAcpSingleton === client) _mcodeAcpSingleton = null; + // v2026-09-23: 同 listAllMcodeSessions —— stop 后再弃引用,避免泄漏子进程。 + if (_mcodeAcpSingleton === client) { + _mcodeAcpSingleton = null; + try { client.stop(); } catch {} + } return null; } } diff --git a/packages/webui/server/lib/alerts.js b/packages/webui/server/lib/alerts.js index 792b7d64..4812f961 100644 --- a/packages/webui/server/lib/alerts.js +++ b/packages/webui/server/lib/alerts.js @@ -1,11 +1,21 @@ // webui/server/lib/alerts.js -// Anomaly channel — independent SSE bus for system-level signals +// Anomaly channel — independent event stream for system-level signals // (mcode subprocess crash, token expired, sqlite failure, protocol // unsupported, etc.). // +// Transport (decision 20 — SSE removed, WebSocket + REST only): +// • REST snapshot: GET /api/alerts returns {"kind":"snapshot","alerts":[...]} +// (the ring buffer); the frontend pulls it once per connection and +// dedupes the live frames against it by alert.id. +// • Live frames: state-bus' alert bridge forwards every frame to the +// /api/stream subscribers as a named control frame — +// `alerts.append` / `alerts.update`, data = JSON.stringify(frame). +// • A sink therefore receives the frame object itself: +// {kind:"append"|"update", alert:{...}}. +// // Design (lease B02): // • Three levels: "info" / "warn" / "error" -// • Ring buffer (last 100 alerts) — SSE replay on connect +// • Ring buffer (last 100 alerts) — snapshot replay on connect // • Dedup window 60s — same {level, msg, src, cid} collapses to one // alert with `count` incremented (avoids spam) // • Optional event-stream emission (depends on B01 events.js — see @@ -27,7 +37,7 @@ const RING_SIZE = 100; const DEDUP_WINDOW_MS = 60_000; const _buffer = []; // newest at end -const _subscribers = new Set(); // SSE response objects +const _subscribers = new Set(); // sink callbacks (receive frame objects) // dedupKey → { alert, ts }. Storing the alert object (by reference) — not // its buffer index — means wrap-and-shift of the ring buffer does not // invalidate the dedup hit. (See Finding 1 fix; previously we stored idx @@ -133,7 +143,7 @@ function normalize(input) { } // pushAlert — add a system-level signal. Dedups, ring-buffers, -// broadcasts to SSE subscribers, and (best-effort) writes an audit +// broadcasts to subscribers, and (best-effort) writes an audit // event. Returns the alert object that was added (or the existing // dedup-matched alert with count incremented). export function pushAlert(input) { @@ -150,7 +160,7 @@ export function pushAlert(input) { if (target) { target.count = (target.count || 1) + 1; target.ts = now; - // SSE: tell subscribers the count changed + // update frame: tell subscribers the count changed broadcast({ kind: "update", alert: target }); } // Audit: one event per push attempt is too noisy; skip audit @@ -167,20 +177,20 @@ export function pushAlert(input) { return alert; } -// Broadcast a frame to every SSE subscriber. +// Broadcast a frame to every event-stream sink (the state-bus alert +// bridge turns it into alerts.* control frames on /api/stream). function broadcast(frame) { - const payload = `data: ${JSON.stringify(frame)}\n\n`; - for (const res of _subscribers) { + for (const sink of _subscribers) { try { - res.write(payload); + sink(frame); } catch { - // Subscriber write failed — drop on next subscribe cycle + // Subscriber threw — drop on next subscribe cycle } } } // getRecentAlerts — snapshot of the ring buffer (oldest → newest). -// Used for SSE replay on connect. +// Feeds the REST snapshot endpoint (GET /api/alerts). export function getRecentAlerts(limit) { if (typeof limit !== "number" || limit <= 0 || limit > RING_SIZE) { return _buffer.slice(); @@ -188,12 +198,12 @@ export function getRecentAlerts(limit) { return _buffer.slice(-limit); } -// subscribeAlerts — register an SSE response. Returns an `unsubscribe` -// thunk that the route must call on `req.on("close")`. -export function subscribeAlerts(res) { - _subscribers.add(res); +// subscribeAlerts — register a frame sink (callback). Returns an +// `unsubscribe` thunk (idempotent). +export function subscribeAlerts(sink) { + _subscribers.add(sink); return function unsubscribe() { - if (_subscribers.has(res)) _subscribers.delete(res); + if (_subscribers.has(sink)) _subscribers.delete(sink); }; } diff --git a/packages/webui/server/lib/auth.js b/packages/webui/server/lib/auth.js index 4af8b961..8210ddad 100644 --- a/packages/webui/server/lib/auth.js +++ b/packages/webui/server/lib/auth.js @@ -7,14 +7,14 @@ // - Local request (isLocalRequest === true) is always allowed (LAN card // switch + first page load without token). // - Non-local request requires a token when TOKEN auth is enabled: -// * `?token=` query string (for SSE EventSource — browsers -// can't set custom headers on EventSource). +// * `?token=` query string (for the /api/stream WebSocket — +// browsers can't set custom headers on a WebSocket handshake). // * `Authorization: Bearer ` header (for fetch / programmatic // callers; preferred to avoid URL-bar / referer / history leaks). // - Token auth can be turned off (tokenEnabled = false) at runtime via // the settings card; this is the "opt-in" escape hatch. // - Static files (HTML/CSS/JS/images) and OPTIONS preflight are always -// public so the SPA can bootstrap; only `/api/*` and SSE are gated. +// public so the SPA can bootstrap; only `/api/*` and the `/api/stream` upgrade are gated. // // v2 security note (PR #55 review point 1): the local bypass below is a // SOCKET-identity fact (the connection originated on this machine), not @@ -69,7 +69,7 @@ function getExpectedToken() { } // Pull a token candidate out of a request. Tries the header first -// (preferred), then the URL query string (for SSE / EventSource). +// (preferred), then the URL query string (for the /api/stream upgrade). // Returns "" if no token candidate is present. Caps length to defend // against unbounded `?token=...` allocations (e.g. 10 MB blob). const MAX_TOKEN_LEN = 256; @@ -81,7 +81,7 @@ function clip(s) { } export function extractToken(req) { - // EventSource / fetch with custom headers can use `Authorization: Bearer`. + // The /api/stream upgrade and fetch with custom headers use `Authorization: Bearer`. const auth = req.headers && req.headers.authorization; if (auth) { // v2 security fix (PR #55 / CodeQL): the old `^Bearer\s+(.+)$` paired @@ -92,7 +92,7 @@ export function extractToken(req) { const m = /^Bearer[ \t]+(\S.*)$/i.exec(String(auth)); if (m) return clip(m[1].trim()); } - // URL query fallback (also covers EventSource on browsers that strip + // URL query fallback (also covers the /api/stream upgrade and browsers that strip // custom headers). Safe-ish because we only use it for equality // comparison, never log it. try { @@ -135,7 +135,7 @@ export function isRequestAuthorized(req) { } // Reject with 401. Sends a small JSON body (or a plain string for -// EventSource which prefers text/event-stream). The response never +// clients that prefer text/event-stream). The response never // echoes the supplied token or the expected token. export function writeAuthRequired(res) { if (!res.headersSent) { @@ -172,13 +172,13 @@ export function isAuthEnforced() { // server.js used to print a 14-line ASCII box containing the raw // token to stdout on first-ever boot. That leaked into shell // history / Docker logs / systemd journal / screen shares. The fix -// pushes the token via SSE `token.first_run` so the UI can show it +// pushes the token via the `token.first_run` event-stream frame so the UI can show it // in a modal instead. Rotation uses `auth.token_rotated` (already in // state-bus.js since v1.0.1). // // Surface: // - isFirstRun() — true iff this process has NOT yet pushed a -// `token.first_run` SSE event in its lifetime. Used by +// `token.first_run` event-stream frame in its lifetime. Used by // state-bus.js#pushTokenFirstRun as a re-send guard. // - markFirstRunNotified(token) — flip the in-memory flag. Called // from the HTTP ack handler after the client closes the modal, @@ -188,7 +188,7 @@ export function isAuthEnforced() { // // Settings.js owns the persistent `tokenAcknowledged`; auth.js's // `_firstRunNotified` is the parallel in-memory mirror used to gate -// the SSE re-send. The two stay in sync via this helper. +// the event-stream re-send. The two stay in sync via this helper. // ============================================================ let _firstRunNotified = false; diff --git a/packages/webui/server/lib/authorize.js b/packages/webui/server/lib/authorize.js index 60a3463f..204fe386 100644 --- a/packages/webui/server/lib/authorize.js +++ b/packages/webui/server/lib/authorize.js @@ -3,7 +3,7 @@ // // Design (MATH-skeleton-webui-v2 §1.3 + BORROW-harness-v2 §3): // • `authorize(action, ctx, opts)` blocks on user confirmation; the -// UI pops a modal listening for the `needs_authorization` SSE event. +// UI pops a modal listening for the `needs_authorization` control frame. // The user accepts or declines; the server resolves the pending // promise via POST /api/auth/decision. // @@ -127,7 +127,7 @@ function _tryWriteEvent(evt) { // authorize(action, ctx, opts) → Promise<{approved, decidedBy, decidedAt}> // action: one of AUTHORIZE_ACTIONS (throws on invalid) // ctx: { cid: string, [any extra context] } — cid is optional; - // empty cid = broadcast to all SSE clients + // empty cid = broadcast to all event-stream clients // opts: { timeoutMs?: number, metadata?: object, bypass?: boolean } // bypass=true skips the user gate (only for trusted internal // callers — e.g. LAN token rotation triggered by C08 modal @@ -140,7 +140,7 @@ function _tryWriteEvent(evt) { // exercised the real decision path, and any future flag confusion // in the production flag vector would silently disable the gate. // Tests now drive the REAL path via test/_setup.js#withDecisions - // (in process) or SSE + POST /api/auth/decision (integration). + // (in process) or event-stream + POST /api/auth/decision (integration). // // Returns: // { approved: true, decidedBy: 'user', decidedAt: ms } @@ -193,7 +193,7 @@ export function authorize(action, ctx = {}, opts = {}) { metadata: opts.metadata || null, }, }); - // Mirror the resolution over SSE so other tabs close the modal + // Mirror the resolution over the event stream so other tabs close the modal try { pushAuthDecision({ requestId, approved: false, decidedBy: "timeout" }); } catch {} diff --git a/packages/webui/server/lib/capability.js b/packages/webui/server/lib/capability.js new file mode 100644 index 00000000..e58022e0 --- /dev/null +++ b/packages/webui/server/lib/capability.js @@ -0,0 +1,212 @@ +// webui/server/lib/capability.js +// ACP 方法能力协商(三层策略,替代旧 mcode-rpc.js 的静态 UNSUPPORTED 黑名单)。 +// +// 三层策略: +// 1. 声明清单 — initialize 响应的 agentCapabilities.sessionCapabilities +// 与 _meta["minimax-code/extensions"].methods 直接采信; +// 2. 惰性探测 — 未声明方法首次真实调用即探测:Method not found(-32601) +// 判为不支持并缓存;其余错误或成功判为支持; +// 3. 旧引擎回退 — initialize 无任何声明(declared=false)时沿用 +// LEGACY_UNSUPPORTED 静态表语义(0.1.5 实测行为)。 +// +// 设计约束:本模块零依赖、不 import 任何本地模块(避免 acp-client ↔ mcode-rpc +// 循环依赖)。契约修正(决策记录 19):session/cancel 不再恒视为支持—— +// 未声明时短路为 unsupported 且不派生 client(守护 no mcode spawn), +// 声明后由 mcode-rpc 走 notification 语义。 + +/** + * 旧引擎(无任何能力声明)不支持的方法集合(mcode 0.1.5 实测语义)。 + * 注意:session/cancel 不参与回退判定(notification 恒尝试,见 classifyMethod)。 + */ +export const LEGACY_UNSUPPORTED = new Set([ + "session/set_mode", + "session/set_config_option", + "session/cancel", + "session/activate", + "session/fork", + "session/resume", + "session/delete", +]); + +/** 核心方法:协议基础能力,任何引擎都视为支持。 */ +const CORE_ALWAYS_SUPPORTED = new Set([ + "initialize", + "session/new", + "session/load", + "session/prompt", + "session/list", + "session/close", +]); + +/** sessionCapabilities 键 → ACP 方法名映射。 */ +const SESSION_CAPABILITY_METHODS = { + list: "session/list", + fork: "session/fork", + resume: "session/resume", + close: "session/close", +}; + +/** UI 能力位键 → 方法名映射(GET /api/protocol/capabilities 响应形状,保持 12 键不变)。 */ +export const UI_METHOD_KEYS = { + set_mode: "session/set_mode", + set_config_option: "session/set_config_option", + cancel: "session/cancel", + activate: "session/activate", + fork: "session/fork", + resume: "session/resume", + delete: "session/delete", + load: "session/load", + close: "session/close", + list: "session/list", + new: "session/new", + prompt: "session/prompt", +}; + +/** + * 解析 initialize 响应的声明式能力。 + * + * @param {object} initializeResult initialize 的 JSON-RPC result + * @returns {{ declared: boolean, session: Record, extensionMethods: string[] }} + */ +export function resolveDeclaredCapabilities(initializeResult) { + const r = initializeResult && typeof initializeResult === "object" + ? initializeResult + : {}; + const sessionCaps = + r.agentCapabilities && typeof r.agentCapabilities === "object" + ? r.agentCapabilities.sessionCapabilities + : undefined; + const ext = r._meta && typeof r._meta === "object" + ? r._meta["minimax-code/extensions"] + : undefined; + const extensionMethods = Array.isArray(ext && ext.methods) + ? ext.methods.filter((m) => typeof m === "string") + : []; + const session = {}; + let anySessionDeclared = false; + for (const [key, method] of Object.entries(SESSION_CAPABILITY_METHODS)) { + const present = Boolean( + sessionCaps && typeof sessionCaps === "object" && key in sessionCaps, + ); + session[method] = present; + if (present) anySessionDeclared = true; + } + return { + declared: anySessionDeclared || extensionMethods.length > 0, + session, + extensionMethods, + }; +} + +/** + * 判定一个 JSON-RPC 错误是否为「方法不存在」(Method not found)。 + * 兼容数值码 -32601、字符串码与 message 文本匹配。 + * + * @param {unknown} error 被拒的错误对象(acp.mjs 附带 data: jsonrpc error) + */ +function isMethodNotFound(error) { + if (!error) return false; + const code = error && error.data ? error.data.code : undefined; + if (code === -32601 || code === "-32601") return true; + const msg = String( + (error && error.data && error.data.message) || (error && error.message) || "", + ); + return /method not found/i.test(msg); +} + +/** + * 创建能力注册表。 + * + * @param {object} [opts] + * @param {object} [opts.initializeResult] initialize 结果;缺省按旧引擎回退 + */ +export function createCapabilityRegistry({ initializeResult } = {}) { + const declared = resolveDeclaredCapabilities(initializeResult); + const supported = new Set(); + const unsupported = new Set(); + // 声明清单预置 + for (const [method, present] of Object.entries(declared.session)) { + (present ? supported : unsupported).add(method); + } + for (const m of declared.extensionMethods) supported.add(m); + for (const m of CORE_ALWAYS_SUPPORTED) supported.add(m); + // 旧引擎回退:无声明 → 黑名单语义(含 session/cancel——契约修正 19: + // 未声明时短路不得触碰 client;声明 extensionMethods 含之才视为支持) + if (!declared.declared) { + for (const m of LEGACY_UNSUPPORTED) unsupported.add(m); + } + + return { + /** @returns {"supported"|"unsupported"|"unknown"} */ + classify(method) { + if (supported.has(method)) return "supported"; + if (unsupported.has(method)) return "unsupported"; + return "unknown"; + }, + /** 真实调用结果即探测结果:Method not found → 不支持并缓存,其余 → 支持。 */ + recordProbeResult(method, error) { + unsupported.delete(method); + supported.delete(method); + (error && isMethodNotFound(error) ? unsupported : supported).add(method); + }, + markSupported(method) { + unsupported.delete(method); + supported.add(method); + }, + /** @returns {{ supported: string[], unsupported: string[], unknown: string[] }} */ + snapshot() { + const known = new Set([ + ...Object.values(UI_METHOD_KEYS), + ...declared.extensionMethods, + ...CORE_ALWAYS_SUPPORTED, + ]); + const out = { supported: [], unsupported: [], unknown: [] }; + for (const m of [...known].sort()) { + out[this.classify(m)].push(m); + } + return out; + }, + }; +} + +/** + * 显式惰性探测的无副作用参数(缺必填字段,服务端在副作用前即以 + * invalidParams / resourceNotFound 拒绝)。真实调用的错误同样可作探测 + * 结果(recordProbeResult),本函数供显式预探测路径使用。 + * + * @param {string} method ACP 方法名 + */ +export function probeParamsFor(method) { + if (method === "session/set_mode") return { sessionId: "" }; + if (method === "session/set_config_option") return { sessionId: "" }; + return {}; +} + +// ------------------------------------------------------------------ +// 进程级活动注册表(acp-client 播种 / mcode-rpc 消费,无循环依赖) +// ------------------------------------------------------------------ +let activeRegistry = createCapabilityRegistry({}); + +/** @returns {ReturnType} */ +export function getActiveRegistry() { + return activeRegistry; +} + +/** + * 用 initialize 结果播种活动注册表并刷新 UI 能力映射。 + * 由 acp-client 在 client 启动成功后调用。 + */ +export function syncActiveCapabilities(initializeResult) { + activeRegistry = createCapabilityRegistry({ initializeResult }); + refreshCapabilityUi(); +} + +/** UI 能力映射(可变对象,routes/protocol.js 每次请求读取最新值)。 */ +export const CAPABILITY_UI = {}; + +function refreshCapabilityUi() { + for (const [key, method] of Object.entries(UI_METHOD_KEYS)) { + CAPABILITY_UI[key] = activeRegistry.classify(method) !== "unsupported"; + } +} +refreshCapabilityUi(); \ No newline at end of file diff --git a/packages/webui/server/lib/config.js b/packages/webui/server/lib/config.js index 725aa06a..24764401 100644 --- a/packages/webui/server/lib/config.js +++ b/packages/webui/server/lib/config.js @@ -120,20 +120,24 @@ function readPersistedLanBind() { } } // v1.0.1: optional auth token for non-local requests. When set, all -// /api/* and SSE requests must carry either `?token=` or +// /api/* and /api/stream requests must carry either `?token=` or // `Authorization: Bearer `. Local requests always bypass. See // plugins/Wzdhehe/mcode-webui/references/SECURITY-NOTES.md §2. export const TOKEN = process.env.TOKEN || ""; // v2 (lease C08): TOKEN_STDOUT — escape hatch for docker / no-UI -// environments where the operator has no SSE client to receive the +// environments where the operator has no event-stream client to receive the // `token.first_run` modal. When "1", server.js prints a single // NEUTRAL line ("token persisted to: ") — the raw token is // NEVER echoed. Default off: production operators use the web UI -// modal that the SSE event drives. See ANTI-PATTERNS-FIX-PLAN §AP1. +// modal that the event-stream frame drives. See ANTI-PATTERNS-FIX-PLAN §AP1. export const TOKEN_STDOUT = process.env.MCODE_WEBUI_TOKEN_STDOUT === "1"; export const DEFAULT_MODEL = process.env.MCODE_MODEL || "minimax_api/MiniMax-M3"; export const DEFAULT_TIMEOUT = process.env.MCODE_TIMEOUT || "120s"; +// v2 波次 2(arch_net_solution_0922.md §6/§8):引擎传输开关。 +// 默认 "acp" = 旧行为不变(每回合 mcode acp 子进程);"embed" = 引擎宿主 +// Worker 线程(boot 失败自动回退 acp)。 +export const MCODE_ENGINE = process.env.MCODE_ENGINE || "acp"; export const DEFAULT_MAX_STEPS = Number(process.env.MCODE_MAX_STEPS) || 6; export const MAX_CONCURRENT = Number(process.env.MCODE_MAX_CONCURRENT) || 3; export const UPLOAD_DIR = diff --git a/packages/webui/server/lib/embed-consumer.js b/packages/webui/server/lib/embed-consumer.js new file mode 100644 index 00000000..942806b4 --- /dev/null +++ b/packages/webui/server/lib/embed-consumer.js @@ -0,0 +1,120 @@ +// webui/server/lib/embed-consumer.js +// mcode-embed 传输的聊天行归约器 —— 传输接缝的消费侧(技术方案 §5.1)。 +// +// 职责:消费 runMcodeEmbed 的 NormalizedEvent 流,把增量落成 cs.chat 行 +// (▲ 思考 / ● 正文 / → 工具标记),回合收尾时清理流式光标、记账用量、 +// 复位 running 并推送状态。与 streamAcpPrompt(mcode-acp.js)/ collectExecResult +// (mcode-exec.js)职责对位;生成器自身已累积并 finalize 结果 r,本模块只做 +// 呈现层归约,不重复计算 r。 +// +// 范围说明(决策记录 14):收尾记账镜像 streamAcpPrompt 的真实值/估算两条 +// 分支;mavis 真值覆盖层与工具输出全文渲染为后续共享归约器提取轮次。 + +import { streamUpdateLine } from "./sessions.js"; +import { pushStateFor } from "./state-bus.js"; + +/** + * 归约一轮 embed 事件流并返回生成器的结果 r。 + * + * @param {AsyncGenerator} gen runMcodeEmbed(...) 生成器 + * @param {object} opts + * @param {object} opts.cs per-cid 状态(写 chat/running/context/usage) + * @param {string} opts.cid 客户端标识(状态推送路由) + * @param {string} [opts.label] 回合标签(running.prompt) + * @returns {Promise} 与 runMcodeAcp 同形的收尾结果 r + */ +export async function collectEmbedResult(gen, { cs, cid, label = "prompt" }) { + const t0 = Date.now(); + cs.running = { + active: true, + prompt: label, + pid: null, + startedAt: t0, + model: (cs.model && cs.model.name) || null, + sessionId: cs.mcodeSessionId || null, + lastDeltaAt: t0, + tps: 0, + }; + cs.context.thinkingStatus = "Running"; + pushStateFor(cid); + + let thinking = ""; + let answer = ""; + // 手写迭代:for-await 会丢弃生成器 return 值,而 r 正是返回值 + const it = gen[Symbol.asyncIterator](); + for (;;) { + const step = await it.next(); + if (step.done) { + return finalize(cs, cid, step.value, { thinking, answer, t0 }); + } + const ev = step.value; + cs.running.lastDeltaAt = Date.now(); + if (ev && ev.kind === "thought" && typeof ev.text === "string") { + thinking += ev.text; + streamUpdateLine(cs.chat, "▲", thinking.replace(/\n+/g, " ").trim()); + } else if (ev && ev.kind === "message" && typeof ev.text === "string") { + answer += ev.text; + streamUpdateLine(cs.chat, "●", answer.replace(/\n+/g, " ").trim()); + } else if (ev && ev.kind === "tool_call") { + // 工具标记行(输出全文渲染属后续共享归约器范围) + const u = ev.update || {}; + cs.chat = [...cs.chat, `→ ${u.title || u.name || "tool"}`]; + } + } +} + +// 收尾:清理流式光标 + 用量记账 + 状态复位(镜像 streamAcpPrompt 要点) +function finalize(cs, cid, r, { thinking, answer, t0 }) { + if (Array.isArray(cs.chat)) { + cs.chat = cs.chat.map((line) => + typeof line === "string" && line.endsWith(" ▍") ? line.slice(0, -2) : line, + ); + } + const usage = r && r.usage; + if (usage) { + cs.context.tokens = (cs.context.tokens || 0) + (usage.totalTokens || 0); + cs.context.used = cs.context.tokens; + cs.context.percent = cs.context.limit + ? Math.round((cs.context.tokens / cs.context.limit) * 100) + : 0; + cs.context.lastUsageAt = Date.now(); + cs.usage.sessionInput = (cs.usage.sessionInput || 0) + (usage.inputTokens || 0); + cs.usage.sessionOutput = + (cs.usage.sessionOutput || 0) + (usage.outputTokens || 0); + cs.usage.sessionTotal = cs.usage.sessionInput + cs.usage.sessionOutput; + cs.context.estimated = false; + } else if (answer || thinking) { + // 无 usage 时按 text/3 粗估(streamAcpPrompt 同款估算规则) + const estOut = Math.ceil(((thinking || "") + (answer || "")).length / 3); + const lastUser = [...(cs.chat || [])] + .reverse() + .find((l) => typeof l === "string" && l.startsWith("› ")); + const estIn = Math.ceil((lastUser ? lastUser.length : 0) / 3); + const estTotal = estOut + estIn; + cs.context.tokens = (cs.context.tokens || 0) + estTotal; + cs.context.used = cs.context.tokens; + cs.context.estimated = true; + cs.context.percent = cs.context.limit + ? Math.round((cs.context.tokens / cs.context.limit) * 100) + : 0; + cs.context.lastUsageAt = Date.now(); + cs.usage.sessionInput = (cs.usage.sessionInput || 0) + estIn; + cs.usage.sessionOutput = (cs.usage.sessionOutput || 0) + estOut; + cs.usage.sessionTotal = cs.usage.sessionInput + cs.usage.sessionOutput; + } + if (r && r.sessionId) cs.mcodeSessionId = r.sessionId; + cs.running = { + active: false, + prompt: null, + pid: null, + startedAt: null, + model: null, + sessionId: null, + lastDeltaAt: null, + tps: 0, + }; + cs.context.thinkingStatus = "Idle"; + cs.context.tps = 0; + pushStateFor(cid); + return r; +} \ No newline at end of file diff --git a/packages/webui/server/lib/engine-host.worker.js b/packages/webui/server/lib/engine-host.worker.js new file mode 100644 index 00000000..a36b1491 --- /dev/null +++ b/packages/webui/server/lib/engine-host.worker.js @@ -0,0 +1,238 @@ +// webui/server/lib/engine-host.worker.js +// 引擎宿主 Worker 入口 —— MessagePort RPC v:1(主线程侧见 mcode-embed.js)。 +// +// 职责单一(一文件一职责): +// 1. RPC v:1 协议编解码(boot / prompt / steer / cancel / shutdown → +// booted / boot-failed / event / reply / fatal); +// 2. boot 时动态 import 引擎应用服务(解析策略见 loadEngineAppService), +// 失败发 'boot-failed' —— 主线程据此回退到既有传输(ACP / exec); +// 3. 未捕获异常 → 发 'fatal' 后退出,绝不让异常跨线程传播。 +// +// 引擎会话编排(组合 RuntimeApplications / CliService 的完整启动面)超出本 +// 「传输骨架」切片:prompt / steer / cancel 经「引擎适配器缝」调用 —— 引擎模块 +// 导出 createEngineAdapter() 时生效,否则 reply {ok:false, error:'engine-adapter-missing'} +// (明确错误,由调用方决定是否回退)。适配器契约: +// createEngineAdapter({ workspace }) → { +// prompt(payload, emit) → Promise<{sessionId?, answer?, thinking?, stopReason?, usage?}> +// steer(payload) → Promise +// cancel(payload) → Promise +// } +// emit(NormalizedEvent) 逐字上行(→ {type:'event', payload});prompt 返回值 +// 形状对齐 acp.mjs prompt() 的 result(thinking / answer / stopReason / usage)。 + +import { parentPort, workerData } from "node:worker_threads"; +import { readFileSync } from "node:fs"; + +// ── 引擎应用服务解析策略(按序尝试,各步均置于 try/catch)────────────────── +// 1. 仓库内相对路径 ../../../local-runtime-v2/dist/local/index.js +// (= packages/local-runtime-v2/dist/local/index.js,构建产物入口) +// 2. 包导出 '@mavis/local-runtime-v2/cli-service'(安装布局) +// 当前仓库未构建 dist 属预期 —— 两种解析都会失败,走 'boot-failed' 路径。 +const RELATIVE_ENTRY = new URL( + "../../../local-runtime-v2/dist/local/index.js", + import.meta.url, +); +const RELATIVE_PACKAGE_JSON = new URL( + "../../../local-runtime-v2/package.json", + import.meta.url, +); +const PACKAGE_SPEC = "@mavis/local-runtime-v2/cli-service"; + +// ── 模块状态 ──────────────────────────────────────────────────────────────── +let engineModule = null; // boot 成功后的引擎应用服务模块 +let adapter = null; // createEngineAdapter() 的产物(可缺省) +let workspace = ""; // boot 载荷里的工作区 + +/** + * 消毒错误文本(复用 mcode-rpc.js 的 sanitizeError 规则:换行/控制字符去除、 + * 截断 200 字符),供 'boot-failed' / 'fatal' / reply error 载荷使用。 + * @param {unknown} e + * @returns {string} + */ +function sanitizeError(e) { + let msg; + if (e && typeof e.message === "string") msg = e.message; + else if (typeof e === "string") msg = e; + else msg = String((e && e.message) || e); + msg = msg + .replace(/[\r\n]+/g, " ") + .replace(/[\x00-\x1f\x7f]/g, "") + .trim(); + if (msg.length > 200) msg = msg.slice(0, 200) + "…"; + return msg; +} + +/** 向主线程发一帧(端口已关闭时静默)。 */ +function post(msg) { + try { + parentPort.postMessage(msg); + } catch { + /* 端口关闭中 —— 无需上行 */ + } +} + +/** 回 RPC 应答(协议:{id, type:'reply', ok, payload | error})。 */ +function reply(id, ok, payload, error) { + if (ok) post({ id, type: "reply", ok: true, payload: payload ?? {} }); + else post({ id, type: "reply", ok: false, error: sanitizeError(error) }); +} + +/** 发一帧后退出线程(fatal 用;下一跳让端口把帧冲出去)。 */ +function postThenExit(msg, code) { + post(msg); + setImmediate(() => process.exit(code)); +} + +// 未捕获异常 → 'fatal' 后退出(协议规定:异常绝不跨线程传播) +process.on("uncaughtException", (e) => + postThenExit({ type: "fatal", payload: { error: sanitizeError(e) } }, 1), +); +process.on("unhandledRejection", (e) => + postThenExit({ type: "fatal", payload: { error: sanitizeError(e) } }, 1), +); + +/** + * 读取引擎版本:模块导出(engineVersion / version)优先,否则取相对布局下 + * local-runtime-v2/package.json 的 version,读不到返回 null。 + * @param {object} mod 引擎应用服务模块 + * @returns {string|null} + */ +function readEngineVersion(mod) { + if (mod && typeof mod.engineVersion === "string") return mod.engineVersion; + if (mod && typeof mod.version === "string") return mod.version; + try { + const pkg = JSON.parse(readFileSync(RELATIVE_PACKAGE_JSON, "utf8")); + if (pkg && typeof pkg.version === "string") return pkg.version; + } catch { + /* 非仓库布局 —— 无版本可报 */ + } + return null; +} + +/** + * 动态 import 引擎应用服务。两种解析策略依次尝试(均 try/catch),全部失败 + * 返回 {error} —— 调用方发 'boot-failed',绝不抛出。 + * @returns {Promise<{module:object, engineVersion:string|null}|{error:string}>} + */ +async function loadEngineAppService() { + const failures = []; + try { + const mod = await import(RELATIVE_ENTRY.href); + return { module: mod, engineVersion: readEngineVersion(mod) }; + } catch (e) { + failures.push(`relative(${RELATIVE_ENTRY.pathname}): ${sanitizeError(e)}`); + } + try { + const mod = await import(PACKAGE_SPEC); + return { module: mod, engineVersion: readEngineVersion(mod) }; + } catch (e) { + failures.push(`package(${PACKAGE_SPEC}): ${sanitizeError(e)}`); + } + return { error: failures.join(" | ") }; +} + +/** 取引擎适配器(引擎模块导出 createEngineAdapter 时建立,未接线返回 null)。 */ +function getAdapter() { + if (adapter) return adapter; + if (engineModule && typeof engineModule.createEngineAdapter === "function") { + adapter = engineModule.createEngineAdapter({ workspace }); + } + return adapter; +} + +/** prompt RPC:经适配器跑一轮,事件逐字上行,结果回 reply。 */ +async function handlePrompt(msg) { + const a = getAdapter(); + if (!a || typeof a.prompt !== "function") { + reply( + msg.id, + false, + null, + "engine-adapter-missing: engine module does not export createEngineAdapter() (engine wiring is a later slice)", + ); + return; + } + try { + const result = await a.prompt(msg.payload, (event) => + post({ type: "event", payload: event }), + ); + const r = result || {}; + reply(msg.id, true, { + ...(r.sessionId ? { sessionId: r.sessionId } : {}), + ...(r.answer ? { answer: r.answer } : {}), + ...(r.thinking ? { thinking: r.thinking } : {}), + // stopReason 缺省 'end_turn' —— 对齐 acp.mjs prompt() 的 result 归一化 + stopReason: r.stopReason || "end_turn", + ...(r.usage ? { usage: r.usage } : {}), + }); + } catch (e) { + reply(msg.id, false, null, e); + } +} + +/** steer / cancel RPC:经适配器转发,结果回 reply。 */ +async function handleAdapterCall(msg, method) { + const a = getAdapter(); + if (!a || typeof a[method] !== "function") { + reply( + msg.id, + false, + null, + `engine-adapter-missing: no ${method}() on engine adapter`, + ); + return; + } + try { + reply(msg.id, true, (await a[method](msg.payload)) || {}); + } catch (e) { + reply(msg.id, false, null, e); + } +} + +// ── RPC v:1 分发 ──────────────────────────────────────────────────────────── +parentPort.on("message", (msg) => { + if (!msg || typeof msg !== "object") return; + if (msg.v != null && msg.v !== 1) { + if (msg.id != null) { + reply(msg.id, false, null, `unsupported protocol version ${msg.v}`); + } + return; + } + switch (msg.type) { + case "boot": { + workspace = (msg.payload && msg.payload.workspace) || ""; + loadEngineAppService().then((r) => { + if (r.error) { + post({ type: "boot-failed", payload: { error: sanitizeError(r.error) } }); + return; + } + engineModule = r.module; + post({ + type: "booted", + payload: { engineVersion: r.engineVersion }, + }); + }); + return; + } + case "prompt": + void handlePrompt(msg); + return; + case "steer": + void handleAdapterCall(msg, "steer"); + return; + case "cancel": + void handleAdapterCall(msg, "cancel"); + return; + case "shutdown": { + reply(msg.id, true, { shutdown: true }); + // 关闭端口后线程自然退出 —— 无悬挂句柄;若引擎残留句柄, + // 主线程 stopEmbed() 的 terminate 兜底会强制收束。 + parentPort.close(); + return; + } + default: + if (msg.id != null) { + reply(msg.id, false, null, `unknown request type ${msg.type}`); + } + } +}); diff --git a/packages/webui/server/lib/event-bus.js b/packages/webui/server/lib/event-bus.js new file mode 100644 index 00000000..55739454 --- /dev/null +++ b/packages/webui/server/lib/event-bus.js @@ -0,0 +1,78 @@ +// webui/server/lib/event-bus.js +// cid 分区的有序事件总线(下行汇聚点)。 +// +// 职责:接收 state 快照与命名控制事件,按 cid 分配单调递增序列号后 +// 同步扇出给订阅者(总线是唯一下行通道:WebSocket /api/stream 适配器 +// 订阅本总线;SSE 已按决策 20 移除,见技术方案 §10 决策记录)。 +// +// 设计约束:零 npm 依赖;同步分发(订阅者回调在 emit 调用栈内执行, +// 与旧直写路径的时序语义一致);订阅者异常隔离(单个 sink 抛错不影响 +// 其他订阅者与调用方)。 + +/** cid -> 订阅者集合 */ +const _sinksByCid = new Map(); +/** cid -> 最新序列号 */ +const _seqByCid = new Map(); + +/** + * 发布一个事件(按 cid 有序)。 + * + * @param {string} cid 客户端标识 + * @param {{ type: "state.snapshot", snapshot: object } | { type: "control", name: string, data: string }} event + */ +export function emitEvent(cid, event) { + const seq = (_seqByCid.get(cid) || 0) + 1; + _seqByCid.set(cid, seq); + const item = { seq, ts: Date.now(), event }; + const sinks = _sinksByCid.get(cid); + if (!sinks || sinks.size === 0) return; + for (const sink of [...sinks]) { + try { + sink(item); + } catch (e) { + console.warn(`[event-bus] sink error: ${e.message}`); + } + } +} + +/** + * 订阅某 cid 的事件流。 + * + * @param {string} cid + * @param {(item: { seq: number, ts: number, event: object }) => void} sink + * @returns {() => void} 取消订阅(幂等) + */ +export function subscribeEvents(cid, sink) { + let sinks = _sinksByCid.get(cid); + if (!sinks) { + sinks = new Set(); + _sinksByCid.set(cid, sinks); + } + sinks.add(sink); + let active = true; + return () => { + if (!active) return; + active = false; + const current = _sinksByCid.get(cid); + if (!current) return; + current.delete(sink); + if (current.size === 0) _sinksByCid.delete(cid); + }; +} + +/** 读取某 cid 的最新序列号(无事件时为 null)。 */ +export function getLatestSeq(cid) { + const seq = _seqByCid.get(cid); + return seq === undefined ? null : seq; +} + +/** 返回当前存在订阅者的 cid 列表(即在线事件流客户端集合)。 */ +export function getSubscribedCids() { + return [..._sinksByCid.keys()]; +} + +/** 测试钩子:清空全部订阅与序列号。 */ +export function resetEventBusForTests() { + _sinksByCid.clear(); + _seqByCid.clear(); +} \ No newline at end of file diff --git a/packages/webui/server/lib/feedback/command-feedback.js b/packages/webui/server/lib/feedback/command-feedback.js index c06ff981..23c24c8b 100644 --- a/packages/webui/server/lib/feedback/command-feedback.js +++ b/packages/webui/server/lib/feedback/command-feedback.js @@ -3,7 +3,7 @@ // appended to chat after a command executes. Closes the command- // feedback seam from BORROW-dsh-deepseek-harness-2026-08-28 § 3. // -// All writes route through state-bus.pushStateFor (no direct SSE). +// All writes route through state-bus.pushStateFor (no direct stream writes). // Future Borrow 1 (transcript) + Borrow 3 (hook seam) call sites can // intercept emitCommandFeedback rather than touching every command // body in interaction/commands.js. diff --git a/packages/webui/server/lib/interaction/commands.js b/packages/webui/server/lib/interaction/commands.js index fbd68b70..6a0d34f8 100644 --- a/packages/webui/server/lib/interaction/commands.js +++ b/packages/webui/server/lib/interaction/commands.js @@ -3,7 +3,7 @@ // harness-2026-08-28 § 3 ("interaction/commands"). Owns the inline // handlers used by /api/send (handleLocalSlash) and /api/cmd // (handleCmdCommand); each handler mutates `cs` and routes through -// state-bus.pushStateFor (no direct SSE writes). +// state-bus.pushStateFor (no direct stream writes). // // Why a single file: the dispatcher stays small (one switch) and the // bodies are private functions — external callers only need the two diff --git a/packages/webui/server/lib/interaction/permission-presets.js b/packages/webui/server/lib/interaction/permission-presets.js index a5b322a3..4aed6341 100644 --- a/packages/webui/server/lib/interaction/permission-presets.js +++ b/packages/webui/server/lib/interaction/permission-presets.js @@ -8,7 +8,7 @@ // 1. PERMISSION_PRESETS — the canonical list of named presets. // 2. webuiModeToLabel — pure: mode string → display label. // 3. applyPermissionPreset — cs+cid mutator that routes through -// state-bus.pushStateFor (no direct SSE). +// state-bus.pushStateFor (no direct stream writes). import { pushStateFor } from "../state-bus.js"; diff --git a/packages/webui/server/lib/interaction/tool-ask-user.js b/packages/webui/server/lib/interaction/tool-ask-user.js index 02d480f1..77c9d2f1 100644 --- a/packages/webui/server/lib/interaction/tool-ask-user.js +++ b/packages/webui/server/lib/interaction/tool-ask-user.js @@ -7,8 +7,8 @@ // NOTE: the `cs.ask` state is currently mutated by mcode-acp.js // stream code paths; this module provides the helper API so future // Borrow 1 (transcript) and Borrow 3 (hook seam) call sites can -// route through one chokepoint. SSE writes always go via -// state-bus.pushStateFor — no direct SSE writes from here. +// route through one chokepoint. Stream writes always go via +// state-bus.pushStateFor — no direct stream writes from here. import { pushStateFor } from "../state-bus.js"; diff --git a/packages/webui/server/lib/interaction/user-questions.js b/packages/webui/server/lib/interaction/user-questions.js index a01f392b..697e0ab5 100644 --- a/packages/webui/server/lib/interaction/user-questions.js +++ b/packages/webui/server/lib/interaction/user-questions.js @@ -5,7 +5,7 @@ // display. Closes the user-questions seam from BORROW-dsh-deepseek- // harness-2026-08-28 § 3. // -// All exports are pure (no SSE, no state-bus writes) — this is the +// All exports are pure (no direct stream writes, no state-bus writes) — this is the // data layer that tool-ask-user.js and the UI both consume. const QUESTION_KINDS = new Set(["text", "choice", "confirm"]); diff --git a/packages/webui/server/lib/mcode-embed.js b/packages/webui/server/lib/mcode-embed.js new file mode 100644 index 00000000..868481c6 --- /dev/null +++ b/packages/webui/server/lib/mcode-embed.js @@ -0,0 +1,518 @@ +// webui/server/lib/mcode-embed.js +// 传输抽象层第三实现 —— 引擎宿主 Worker(arch_net_draft_0922.md §6 方案 C: +// worker_threads + MessagePort 结构化克隆)。与 mcode-acp.js(ACP 子进程)、 +// mcode-exec.js(headless 子进程)同属「runMcode → NormalizedEvent」传输契约, +// 三条泳道的角色分配见草案 §6.3(主 agent / side-chat / 子 agent)。 +// +// 本模块是纯新增内部模块:不接线 chat.js、不改前端,默认旧行为完全不变; +// 是否启用本传输的回退决策在调用方(阶段 1 的 MCODE_ENGINE=acp 逃生门)。 +// +// ── 导出面与传输契约对齐 mcode-acp.js / mcode-exec.js ───────────────────── +// runMcodeEmbed(content, opts) → AsyncGenerator +// - 事件与 mcode-acp.js 流回调消费的对象逐字段一致(见下方 NormalizedEvent), +// 逐字透传、不改写; +// - 生成器返回值 = streamAcpPrompt 的收尾结果 r(字段与 runMcodeAcp 返回值 +// 一致:answer / thinking / status / error / usage / sessionId / +// durationMs / stopReason / tps),finalize 语义逐点对齐:恰好一次、 +// durationMs 兜底、空闲看门狗停止、状态词汇 'succeeded' | 'failed' | +// 'timeout' | 'stopped'('stopped' 对应 mcode-exec.js 的 r._stopped 词汇)。 +// stopEmbed() / isEmbedRunning() +// - 对齐 ARCHITECTURE.md §3 传输契约的 stopExec() / isRunning() 配对语义。 +// steerEmbed(sessionId, text) / cancelEmbed(sessionId) +// - 协议 v:1 的 steer / cancel RPC 发送方(草案 §7.7 的 runtime.steer 与 +// 语义 cancel 通道);供后续接线使用,本切片以桩测试锁往返语义。 +// +// ── NormalizedEvent(tagged union:{kind, …})──────────────────────────── +// 与 acp.mjs prompt() 的 onChunk 载荷、即 mcode-acp.js streamAcpPrompt 流回调 +// `c` 的字段逐项一致(specs: "NormalizedEvent 逐字段对齐 mcode-acp.js"): +// {kind:'thought', text} 思考增量(agent_thought_chunk → r.thinking 累加) +// {kind:'message', text} 正文增量(agent_message_chunk → r.answer 累加) +// {kind:'tool_call', update} 工具调用开始(tool_call,update 原样透传) +// {kind:'tool_update', update} 工具完成(tool_call_update,update 原样透传) +// {kind:'usage', update} 上下文用量(usage_update,字段为累计值) +// {kind:'plan_update' | 'plan_removed' | 'mode_update' | 'goal_update' +// | 'config_option_update' | 'session_info_update' | 'other', update} +// {kind:'done', stopReason, usage} prompt 结束(acp.mjs 的 done 载荷) +// {kind:'error', text} 失败事件(mcode-acp.js 的 c.kind === 'error' 分支; +// r.error = {message: c.text || c.error}) +// 任何失败路径(未 boot / RPC 失败 / fatal / 宿主退出 / 空闲超时)都先产出一个 +// {kind:'error'} 事件再优雅结束 —— 回退决策在调用方,本模块不抛异常。 +// +// ── MessagePort RPC v:1(主线程 ↔ engine-host.worker.js)────────────────── +// 主→Worker: {v:1, id, type:'boot', payload:{workspace}} +// {id, type:'prompt', payload:{sessionId?, content, model, permission}} +// {id, type:'steer', payload:{sessionId, text}} +// {id, type:'cancel', payload:{sessionId}} +// {id, type:'shutdown'} +// Worker→主: {type:'booted', payload:{engineVersion}} +// {type:'boot-failed', payload:{error}} +// {type:'event', payload: NormalizedEvent} +// {id, type:'reply', ok, payload | error} +// {type:'fatal', payload:{error}} +// 注意:event / booted / boot-failed / fatal 帧不带 id(协议规定),因此同一宿主 +// 内只允许一个在途 prompt(单主会话语义,见草案 §6.3 可重入性边界);并发调用 +// 以失败事件快速返回,不排队。 + +import { Worker } from "node:worker_threads"; +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; +import { + DEFAULT_MODEL, + DEFAULT_WORKSPACE, + PROMPT_IDLE_TIMEOUT_MS, +} from "./config.js"; +import { createIdleWatchdog } from "./idle-watchdog.js"; + +// 默认 Worker 入口(与本模块同目录)。测试经 workerPath 注入桩 Worker。 +const DEFAULT_WORKER_URL = new URL("./engine-host.worker.js", import.meta.url); + +// ── 模块状态(宿主单例 + 单在途 prompt)──────────────────────────────────── +let host = null; // { worker, bootState, engineVersion, bootWaiter, exitWaiters } +let activeRun = null; // { id, queue, stopping } —— 在途 prompt +let requestSeq = 0; // RPC 请求 id(自增,协议要求关联 reply) +const pendingReplies = new Map(); // id → resolve({ok, payload, error}) + +/** + * 极简异步队列:把 Worker 消息帧转换成生成器可 await 的拉取序列。 + * 无界缓冲(事件量 = 单回合流式增量,内存可控),终态帧保证拉取方必然退出。 + */ +function createFrameQueue() { + const frames = []; + let waiter = null; + return { + push(frame) { + if (waiter) { + const w = waiter; + waiter = null; + w(frame); + } else { + frames.push(frame); + } + }, + next() { + if (frames.length > 0) return Promise.resolve(frames.shift()); + return new Promise((resolve) => { + waiter = resolve; + }); + }, + }; +} + +/** workerPath 归一化为 URL 对象(file: 串包成 URL;路径经 pathToFileURL,Windows 盘符安全)。 */ +function toWorkerUrl(workerPath) { + if (!workerPath) return DEFAULT_WORKER_URL; + const s = String(workerPath); + // new Worker() 只接受 URL 实例或路径,不接受 file: URL 字符串 + return s.startsWith("file:") ? new URL(s) : pathToFileURL(resolve(s)); +} + +/** 向 Worker 发一帧(宿主已退出时静默丢弃,调用方经 reply/exit 帧感知失败)。 */ +function post(msg) { + if (!host) return; + try { + host.worker.postMessage(msg); + } catch { + /* 宿主正在拆除 —— 由 exit 帧统一收尾 */ + } +} + +/** 发送带 reply 的 RPC(steer / cancel / shutdown)并等待应答。 */ +function sendRpc(type, payload) { + return new Promise((resolve) => { + if (!host) { + resolve({ ok: false, error: "mcode embed engine host is not running" }); + return; + } + const id = ++requestSeq; + pendingReplies.set(id, resolve); + post({ id, type, payload }); + }); +} + +/** 宿主拆除统一收尾:清空状态、唤醒所有在途等待者。 */ +function settleHostDown(reason) { + if (!host) return; + const h = host; + host = null; + const msg = reason || "mcode embed engine host exited"; + for (const resolve of pendingReplies.values()) { + resolve({ ok: false, error: msg }); + } + pendingReplies.clear(); + if (activeRun) { + activeRun.queue.push({ type: "down", reason: msg, stopped: !!activeRun.stopping }); + } + for (const resolve of h.exitWaiters) resolve(); + if (h.bootWaiter) { + h.bootWaiter({ ok: false, reason: msg }); + h.bootWaiter = null; + } +} + +/** 挂接宿主 Worker 的消息/生命周期路由(每次 boot 一个)。 */ +function attachRouter(worker) { + worker.on("message", (msg) => { + if (!msg || typeof msg !== "object") return; + switch (msg.type) { + case "booted": { + const h = host; + if (!h) return; + h.bootState = "booted"; + h.engineVersion = + msg.payload && typeof msg.payload.engineVersion === "string" + ? msg.payload.engineVersion + : null; + if (h.bootWaiter) { + h.bootWaiter({ ok: true, engineVersion: h.engineVersion }); + h.bootWaiter = null; + } + return; + } + case "boot-failed": { + const h = host; + if (!h) return; + h.bootState = "failed"; + const reason = + (msg.payload && msg.payload.error) || "engine boot-failed (no reason)"; + if (h.bootWaiter) { + const waiter = h.bootWaiter; + h.bootWaiter = null; + // 失败即拆除宿主(主线程据此回退到 ACP 等既有传输)后才落定结果。 + stopEmbed().then(() => waiter({ ok: false, reason })); + } + return; + } + case "event": { + // NormalizedEvent 逐字转发给在途 prompt 的事件队列 + if (activeRun) activeRun.queue.push({ type: "event", payload: msg.payload }); + return; + } + case "reply": { + const id = msg.id; + const resolver = pendingReplies.get(id); + if (resolver) { + pendingReplies.delete(id); + // 干净的二选一形状:{ok:true, payload} | {ok:false, error} + resolver( + msg.ok === true + ? { ok: true, payload: msg.payload } + : { ok: false, error: msg.error }, + ); + } else if (activeRun && activeRun.id === id) { + activeRun.queue.push({ + type: "reply", + ok: msg.ok === true, + payload: msg.payload, + error: msg.error, + }); + } + return; + } + case "fatal": { + // 未捕获异常:失败事件 + 优雅结束(worker 随后自行退出) + if (activeRun) { + activeRun.queue.push({ + type: "fatal", + error: (msg.payload && msg.payload.error) || "engine host fatal", + }); + } + return; + } + default: + return; + } + }); + worker.on("error", (e) => settleHostDown(`mcode embed engine host error: ${e.message}`)); + worker.on("exit", () => settleHostDown("mcode embed engine host exited")); +} + +/** + * 启动引擎宿主 Worker(boot RPC)。失败不抛 —— 一律以结果对象返回,回退决策 + * 在调用方。boot-failed 会顺带拆除宿主,isEmbedRunning() 随后为 false。 + * + * @param {object} [opts] + * @param {string} [opts.workerPath] Worker 入口(缺省 server/lib/engine-host.worker.js; + * 测试注入 test/fixtures/engine-host.stub.worker.js) + * @param {string} [opts.workspace] boot 载荷的 workspace(缺省 config.DEFAULT_WORKSPACE, + * 与 mcode-acp.js 的 workspace 取值规则一致) + * @param {object} [opts.workerData] 透传给 new Worker 的 workerData(桩脚本测试缝) + * @returns {Promise<{ok:true, engineVersion:string|null} | {ok:false, reason:string}>} + */ +export function bootEngineHost({ workerPath, workspace, workerData } = {}) { + return new Promise((resolve) => { + if (host) { + resolve({ ok: false, reason: "mcode embed engine host already running" }); + return; + } + let settled = false; + const settle = (result) => { + if (settled) return; + settled = true; + resolve(result); + }; + let worker; + try { + worker = new Worker(toWorkerUrl(workerPath), { + workerData: workerData === undefined ? null : workerData, + }); + } catch (e) { + settle({ ok: false, reason: `spawn engine host worker failed: ${e.message}` }); + return; + } + host = { + worker, + bootState: "booting", + engineVersion: null, + bootWaiter: settle, + exitWaiters: [], + }; + attachRouter(worker); + post({ v: 1, id: ++requestSeq, type: "boot", payload: { workspace: workspace || DEFAULT_WORKSPACE } }); + }); +} + +/** + * 查询引擎宿主是否可用(boot 成功且存活)—— 对齐传输契约的 isRunning()。 + * @returns {boolean} + */ +export function isEmbedRunning() { + return !!host && host.bootState === "booted"; +} + +/** + * 停止引擎宿主(shutdown RPC → 等 Worker 自然退出;超时兜底 terminate)。 + * 对齐传输契约的 stopExec();额外返回 Promise 以便确定性验证「无悬挂句柄」。 + * @returns {Promise} 宿主 Worker 退出后落定 + */ +export function stopEmbed() { + const h = host; + if (!h) return Promise.resolve(); + if (activeRun) activeRun.stopping = true; // 在途 prompt 将以 'stopped' 收尾 + const exited = new Promise((resolve) => h.exitWaiters.push(resolve)); + sendRpc("shutdown").catch(() => {}); + // 兜底:Worker 2s 内未自然退出则强制终止(草案 §7.7:worker.terminate() 是 + // 干净的强取消边界)。 + const fallback = setTimeout(() => { + try { + h.worker.terminate(); + } catch {} + }, 2000); + return exited.then(() => clearTimeout(fallback)); +} + +/** + * 中途转向(草案 §7.7 的 runtime.steer 通道)。 + * @param {string} sessionId 引擎会话 id + * @param {string} text 转向文本 + * @returns {Promise<{ok:true, payload:object} | {ok:false, error:string}>} + */ +export function steerEmbed(sessionId, text) { + return sendRpc("steer", { sessionId, text }); +} + +/** + * 语义取消在途回合(优先于 worker.terminate() 的温和取消)。 + * @param {string} sessionId 引擎会话 id + * @returns {Promise<{ok:true, payload:object} | {ok:false, error:string}>} + */ +export function cancelEmbed(sessionId) { + return sendRpc("cancel", { sessionId }); +} + +/** + * 构造 prompt RPC 载荷(协议 v:1:{sessionId?, content, model, permission})。 + * 字段取值与 mcode-acp.js / mcode-exec.js 对齐: + * model = opts.model || opts.cs.model.name || DEFAULT_MODEL + * permission = mcode-exec.js 的 webui→mcode 映射('Ask'→'ask' / 'Auto'→'auto' / + * 'Read'→'read' / 其余→'full'),输入取 opts.permission || opts.cs.permissions + * @param {string} content + * @param {object} opts 见 runMcodeEmbed + * @returns {{sessionId?:string, content:string, model:string, permission:string}} + */ +function buildPromptPayload(content, opts) { + const model = opts.model || (opts.cs && opts.cs.model && opts.cs.model.name) || DEFAULT_MODEL; + const webuiMode = + opts.permission || (opts.cs && opts.cs.permissions) || "Full access"; + const permission = + webuiMode === "Ask" + ? "ask" + : webuiMode === "Auto" + ? "auto" + : webuiMode === "Read" + ? "read" + : "full"; + const payload = { content: String(content ?? ""), model, permission }; + if (opts.sessionId) payload.sessionId = opts.sessionId; + return payload; +} + +/** + * 发起一轮 prompt,以 AsyncGenerator 产出 NormalizedEvent 流。 + * + * 生命周期与 streamAcpPrompt(mcode-acp.js)逐点对齐: + * - 逐事件累积 r.thinking / r.answer(thought / message 增量); + * - reply 应答按 `result.answer || r.answer` 语义收尾(`.then` 分支); + * - finalize 恰好一次:durationMs 兜底、空闲看门狗停止、状态词汇一致; + * - 生成器提前退出(消费方 break)时以语义 cancel 收束在途回合(对齐 + * runMcodeAcp 的 finally 清理)。 + * + * @param {string} content 用户输入正文 + * @param {object} [opts] + * @param {string} [opts.sessionId] 续接已有引擎会话 + * @param {string} [opts.model] 模型(缺省 opts.cs.model.name || DEFAULT_MODEL) + * @param {string} [opts.permission] webui 权限标签('Ask'|'Auto'|'Read'|'Full access', + * 或 opts.cs.permissions)→ mcode-exec.js 同款映射 + * @param {object} [opts.cs] per-cid 状态(只读 model.name / permissions,不改写) + * @param {number} [opts.idleTimeoutMs] 空闲看门狗窗口(缺省 PROMPT_IDLE_TIMEOUT_MS; + * 事件续命语义与 mcode-acp.js 相同) + * @returns {AsyncGenerator} 事件流;生成器返回值 = 收尾结果 r + */ +export async function* runMcodeEmbed(content, opts = {}) { + const t0 = Date.now(); + const r = { + answer: null, + thinking: null, + status: "unknown", + error: null, + usage: null, + sessionId: opts.sessionId || null, + durationMs: null, + stopReason: null, + tps: null, + }; + // finalize —— 与 streamAcpPrompt 的 finalize() 相同语义:恰好一次。 + let finalized = false; + let watchdog = null; + const finalize = () => { + if (finalized) return; + finalized = true; + if (watchdog) watchdog.stop(); + r.durationMs = r.durationMs || Date.now() - t0; + }; + + // ── 失败快路径:产出失败事件后优雅结束(回退决策在调用方)────────────── + const failFast = (message) => { + r.status = "failed"; + r.error = { message }; + finalize(); + return r; + }; + const h = host; + if (!h || h.bootState !== "booted") { + const message = + "mcode embed engine host is not booted (call bootEngineHost first; fallback is the caller's decision)"; + yield { kind: "error", text: message }; + return failFast(message); + } + if (activeRun) { + // event 帧不带 id(协议 v:1),单宿主只允许一个在途 prompt(见文件头注释)。 + const message = "another mcode embed prompt is already active"; + yield { kind: "error", text: message }; + return failFast(message); + } + + const id = ++requestSeq; + const queue = createFrameQueue(); + const run = (activeRun = { id, queue, stopping: false }); + + // 空闲看门狗 —— 事件续命,只掐「流静默」的回合(与 mcode-acp.js 的 + // createIdleWatchdog 用法一致)。 + let lastEventAt = t0; + const idleMs = + Number(opts.idleTimeoutMs) > 0 ? Number(opts.idleTimeoutMs) : PROMPT_IDLE_TIMEOUT_MS; + watchdog = createIdleWatchdog({ + idleMs, + activityAt: () => lastEventAt, + onTimeout: () => queue.push({ type: "timeout" }), + }); + + post({ id, type: "prompt", payload: buildPromptPayload(content, opts) }); + + let settled = false; + try { + for (;;) { + const frame = await queue.next(); + if (frame.type === "event") { + lastEventAt = Date.now(); + const ev = frame.payload; + // 累积规则与 streamAcpPrompt 的流回调逐行一致 + if (ev && ev.kind === "thought" && typeof ev.text === "string") { + r.thinking = (r.thinking || "") + ev.text; + } else if (ev && ev.kind === "message" && typeof ev.text === "string") { + r.answer = (r.answer || "") + ev.text; + } else if (ev && ev.kind === "done") { + if (ev.stopReason) r.stopReason = r.stopReason || ev.stopReason; + if (ev.usage) r.usage = r.usage || ev.usage; + } else if (ev && (ev.kind === "error" || ev.error)) { + r.error = { message: ev.text || ev.error || JSON.stringify(ev) }; + r.status = "failed"; + } + yield ev; // NormalizedEvent 逐字透传(与 mcode-acp.js 逐字段一致) + if (r.status === "failed") { + // mcode-acp.js 收到 error 事件即 finalize —— 此处同语义落定 + settled = true; + break; + } + continue; + } + if (frame.type === "reply") { + if (frame.ok) { + // streamAcpPrompt 的 .then 分支:result.answer || r.answer 语义 + const p = frame.payload || {}; + r.answer = p.answer || r.answer; + r.thinking = p.thinking || r.thinking; + r.stopReason = p.stopReason || r.stopReason || "end_turn"; + if (p.usage) r.usage = p.usage; + if (p.sessionId) r.sessionId = p.sessionId; + r.status = "succeeded"; + } else { + const message = frame.error || "mcode embed prompt failed"; + r.error = { message }; + r.status = "failed"; + yield { kind: "error", text: message }; + } + settled = true; + break; + } + if (frame.type === "fatal") { + // 未捕获异常:失败事件 + 优雅结束(不在本模块抛出) + const message = frame.error || "engine host fatal"; + r.error = { message }; + r.status = "failed"; + yield { kind: "error", text: message }; + settled = true; + break; + } + if (frame.type === "timeout") { + const seconds = Math.round(idleMs / 1000); + const message = `mcode embed prompt inactive for ${seconds}s (no stream events)`; + r.error = { message }; + r.status = "timeout"; + yield { kind: "error", text: message }; + // 语义 cancel 收束引擎侧回合(草案 §7.7:优先于 worker.terminate()) + cancelEmbed(r.sessionId).catch(() => {}); + settled = true; + break; + } + if (frame.type === "down") { + const message = frame.stopped + ? "mcode embed engine host stopped" + : `mcode embed engine host exited: ${frame.reason}`; + r.error = { message }; + r.status = frame.stopped ? "stopped" : "failed"; + yield { kind: "error", text: message }; + settled = true; + break; + } + } + } finally { + finalize(); + if (activeRun === run) activeRun = null; + if (!settled) { + // 消费方提前 break(gen.return()):以语义 cancel 收束在途回合, + // 对齐 runMcodeAcp 的 finally 清理。 + cancelEmbed(r.sessionId).catch(() => {}); + } + } + return r; +} diff --git a/packages/webui/server/lib/mcode-rpc.js b/packages/webui/server/lib/mcode-rpc.js index 4404e52a..75e2c0da 100644 --- a/packages/webui/server/lib/mcode-rpc.js +++ b/packages/webui/server/lib/mcode-rpc.js @@ -1,16 +1,18 @@ // webui/server/lib/mcode-rpc.js // v0.5.by: 干净的 mcode acp 协议 RPC wrapper // -// mcode acp 0.1.5 server 实际支持的方法 (从 probes/probe-mcode-rpc-v2.mjs 实测): -// ✅ initialize / session/list / session/new / session/load / session/close / session/prompt -// ❌ session/set_mode / set_config_option / cancel / activate / fork / resume / delete -// (Method not found — mcode 0.1.5 协议层根本没暴露) +// 能力协商 (server/lib/capability.js, 三层策略, 替代旧静态黑名单): +// 1. 声明清单 — initialize 响应的 agentCapabilities.sessionCapabilities +// 与 _meta["minimax-code/extensions"].methods 直接采信; +// 2. 惰性探测 — 未声明方法首次真实调用即探测: Method not found(-32601) +// 判为不支持并缓存, 其余错误/成功判为支持; +// 3. 旧引擎回退 — initialize 无任何声明时沿用 LEGACY_UNSUPPORTED 语义。 // -// 设计: 调不支持的方法不 throw,返 {ok:false, error, code:'unsupported'} -// 路由层能据此给前端 501 Not Implemented 错,而不是 500 Internal Server Error -// 前端可以降级处理 (比如用 slash command 代替 RPC,或提示用户升级 mcode) +// 设计: 调不支持的方法不 throw, 返 {ok:false, error, code:'unsupported'} +// 路由层据此给前端 501 Not Implemented (语义逐字节不变), 前端降级路径照旧。 import { getMcodeAcpClient, listAllMcodeSessions } from "./acp-client.js"; +import { getActiveRegistry, probeParamsFor, CAPABILITY_UI } from "./capability.js"; function ok(data) { return { ok: true, data }; @@ -35,31 +37,39 @@ function fail(error, code) { return { ok: false, error: sanitizeError(error), code: code || "rpc_error" }; } -// mcode 0.1.5 acp 不支持的方法 (实探测得, 2026-08-20) -const UNSUPPORTED = new Set([ - "session/set_mode", - "session/set_config_option", - "session/cancel", - "session/activate", - "session/fork", - "session/resume", - "session/delete", -]); +// 旧静态黑名单已迁至 capability.js 的 LEGACY_UNSUPPORTED;判定走 getActiveRegistry()。 async function callRpc(method, params) { - if (UNSUPPORTED.has(method)) { + const registry = getActiveRegistry(); + if (registry.classify(method) === "unsupported") { return fail( - `mcode 0.1.5 acp does not implement ${method} (server returns "Method not found")`, + `mcode acp does not implement ${method} (mcode 0.1.5 server returns "Method not found")`, "unsupported", ); } const client = await getMcodeAcpClient(); if (!client) return fail(new Error("mcode acp client unavailable"), "no_client"); + // 播种后二次判定: client 启动时 acp-client 已按 initialize 响应刷新注册表 + if (getActiveRegistry().classify(method) === "unsupported") { + return fail( + `mcode acp does not implement ${method} (mcode 0.1.5 server returns "Method not found")`, + "unsupported", + ); + } try { - const r = await client.request(method, params); + const r = await client.request(method, params ?? probeParamsFor(method)); + getActiveRegistry().markSupported(method); return ok(r); } catch (e) { + // 惰性探测: 真实调用的错误即探测结果 — Method not found 判不支持并缓存 + getActiveRegistry().recordProbeResult(method, e); + if (getActiveRegistry().classify(method) === "unsupported") { + return fail( + `mcode acp does not implement ${method} (mcode 0.1.5 server returns "Method not found")`, + "unsupported", + ); + } if (e && e.data && typeof e.data.code === "string") return fail(e, e.data.code); return fail(e); @@ -89,12 +99,28 @@ export async function setConfigOption(_sessionId, _key, _value) { } // ============================================================ -// session/cancel — ❌ mcode 0.1.5 不支持 -// webui 想取消正在跑 prompt 的唯一路径: 杀 mcode acp 子进程 (SIGKILL) -// 不温和但有效 +// session/cancel — 声明条件式 notification (契约修正 19: +// checks/lib-mcode-rpc.check.mjs「no mcode spawn」守护) +// 能力注册表未声明支持时短路为 unsupported 且不触碰 client; +// 仅当引擎 initialize 声明该方法才走 notify 语义。 +// /api/stop 的杀进程兜底仍在 chat.js (本模块不杀进程)。 // ============================================================ export async function cancelSession(_sessionId) { - return callRpc("session/cancel", { sessionId: _sessionId }); + if (getActiveRegistry().classify("session/cancel") === "unsupported") { + return fail( + `mcode acp does not implement session/cancel (mcode 0.1.5 server returns "Method not found")`, + "unsupported", + ); + } + const client = await getMcodeAcpClient(); + if (!client) + return fail(new Error("mcode acp client unavailable"), "no_client"); + try { + client.notify("session/cancel", { sessionId: _sessionId }); + return ok({ mode: "notify" }); + } catch (e) { + return fail(e); + } } // ============================================================ @@ -160,20 +186,9 @@ export async function listSessions() { // ============================================================ // mcode 0.1.5 acp 接受的能力清单 (供前端 capability detection) // ============================================================ -export const MCODE_ACP_CAPABILITIES = { - set_mode: false, - set_config_option: false, - cancel: false, - activate: false, - fork: false, - resume: false, - delete: false, - load: true, - close: true, - list: true, - new: true, - prompt: true, -}; +// UI 能力映射(12 键形状不变);值由 capability.js 的能力协商实时刷新。 +// cancel 恒 true: notification 尝试无害, 新实现不再依赖引擎支持。 +export const MCODE_ACP_CAPABILITIES = CAPABILITY_UI; // 权限 mode 合法值 — cli.js 0.1.5 配置 schema 里有这 6 个, 但 session/set_config_option // 调不通, 所以这些值暂时只能用 mcode 启动 --permission 标志传, 不能 mid-session 改 diff --git a/packages/webui/server/lib/ring-buffer.js b/packages/webui/server/lib/ring-buffer.js new file mode 100644 index 00000000..5d3dbbf3 --- /dev/null +++ b/packages/webui/server/lib/ring-buffer.js @@ -0,0 +1,102 @@ +// webui/server/lib/ring-buffer.js +// seq 索引重放窗口(环形缓冲区)。 +// +// 职责:为事件流下行帧的断线续传保留最近 capacity 条有序条目。 +// 写入方为传输层(push),读取方为 resume 路径(replay)。 +// +// 设计约束: +// - 纯内存、零 npm 依赖(packages/webui 保持零依赖包)。 +// - seq 由调用方分配并保证严格递增;本模块只做单调性校验, +// 不生成、不改写 seq。 +// - 容量满时覆盖最旧条目(环形语义),replay 对被覆盖区间返回 +// complete=false,提示调用方降级为全量快照。 + +/** + * 创建 seq 索引的环形重放缓冲区。 + * + * @param {number} capacity 保留条目数上限,必须为 >=1 的整数 + * @returns {{ + * push: (seq: number, item: unknown) => boolean, + * replay: (fromSeq: number) => { items: Array<{seq: number, item: unknown}>, complete: boolean }, + * latestSeq: () => number | null, + * size: () => number, + * capacity: number, + * }} + */ +export function createRingBuffer(capacity) { + if (!Number.isInteger(capacity) || capacity < 1) { + throw new RangeError( + `ring-buffer: capacity must be an integer >= 1, got ${capacity}`, + ); + } + /** @type {Array<{seq: number, item: unknown}>} 按 seq 升序保存的条目 */ + const entries = []; + + return { + /** + * 追加一个条目;seq 必须严格大于当前最新 seq,否则拒绝。 + * 容量满时覆盖最旧条目。 + * + * @param {number} seq 单调递增序列号 + * @param {unknown} item 被保留的条目(引用保存,不做拷贝) + * @returns {boolean} 是否接受 + */ + push(seq, item) { + // 防御性输入校验:非有限数(含 NaN)无法参与单调比较,直接拒绝 + if (!Number.isFinite(seq)) return false; + const latest = entries.length ? entries[entries.length - 1].seq : null; + if (latest !== null && seq <= latest) return false; + entries.push({ seq, item }); + if (entries.length > capacity) entries.shift(); // 覆盖最旧 + return true; + }, + + /** + * 重放 fromSeq 起(含)的全部保留条目,按 seq 升序返回。 + * + * complete=false 表示缓冲区无法证明 [fromSeq, latestSeq] 的连续性 + * (请求起点早于最旧保留 seq,或缓冲区从未写入且 fromSeq>1), + * 调用方应降级为全量快照。 + * + * @param {number} fromSeq 续传起点(含) + * @returns {{ items: Array<{seq: number, item: unknown}>, complete: boolean }} + */ + replay(fromSeq) { + const items = []; + for (const e of entries) { + if (e.seq >= fromSeq) items.push({ seq: e.seq, item: e.item }); + } + let complete; + if (entries.length === 0) { + // 空缓冲(从未写入,latestSeq()===null): + // fromSeq===1 无历史缺口,视为完整;否则无法证明连续性。 + complete = fromSeq === 1; + } else { + // 有保留条目:只有请求起点早于最旧保留 seq(已被覆盖)才不完整。 + // fromSeq===latestSeq()+1 是空重放的完整边界情形,自然涵盖。 + complete = fromSeq >= entries[0].seq; + } + return { items, complete }; + }, + + /** + * 最新(最大)已写入 seq;从未写入时为 null。 + * + * @returns {number | null} + */ + latestSeq() { + return entries.length ? entries[entries.length - 1].seq : null; + }, + + /** + * 当前保留条目数(0..capacity)。 + * + * @returns {number} + */ + size() { + return entries.length; + }, + + capacity, + }; +} diff --git a/packages/webui/server/lib/sessions.js b/packages/webui/server/lib/sessions.js index 7b62864e..5db4bb49 100644 --- a/packages/webui/server/lib/sessions.js +++ b/packages/webui/server/lib/sessions.js @@ -167,7 +167,7 @@ export function bindDraftToMcodeSid(cs, sid) { } // v2.3: memoize by (mtimeMs, size). pushStateFor calls loadSessions on EVERY -// snapshot (per SSE push, up to 60Hz), and switch/persist paths read too — +// snapshot (per event-stream push, up to 60Hz), and switch/persist paths read too — // re-reading + JSON.parsing a multi-MB store that often made long-turn // streaming and session switching visibly janky. Corrupt files are never // cached (each attempt re-parses so recovery is immediate), external diff --git a/packages/webui/server/lib/settings.js b/packages/webui/server/lib/settings.js index 2c07a928..1c4dac49 100644 --- a/packages/webui/server/lib/settings.js +++ b/packages/webui/server/lib/settings.js @@ -798,7 +798,7 @@ export function setQuotaEnabled(v) { } // rotateToken — generate a new token, persist, sync to auth module. -// Caller is responsible for broadcasting the new token via SSE. +// Caller is responsible for broadcasting the new token over the event stream. // Returns the new token string. // // v1.0.1: order of operations is critical for crash-safety. diff --git a/packages/webui/server/lib/state-bus.js b/packages/webui/server/lib/state-bus.js index b6ea9be3..bfce03de 100644 --- a/packages/webui/server/lib/state-bus.js +++ b/packages/webui/server/lib/state-bus.js @@ -1,5 +1,5 @@ // webui/server/lib/state-bus.js -// Per-cid state + SSE channel management. +// Per-cid state + event stream (WebSocket /api/stream) management. import { DEFAULT_WORKSPACE, DEFAULT_MODEL } from "./config.js"; import { isFirstRun } from "./auth.js"; @@ -23,10 +23,12 @@ import { getTokenRotatedAt, maskTokenPlanKey, } from "./settings.js"; +import { emitEvent, getSubscribedCids } from "./event-bus.js"; +import { pushAlert, subscribeAlerts } from "./alerts.js"; // v0.5.ai: A2 per-client 架构 // 每个 webui tab 一个 client (cid = localStorage webui_cid) -// 每个 client 独立:state (chat/mcodeSessionId/context/usage/running), activeChild, SSE connection +// 每个 client 独立:state (chat/mcodeSessionId/context/usage/running), activeChild, /api/stream connection // 缺 cid 的请求 fallback 到 'default' client (兼容老 client) // v2.0 (lease B02): pushAlert re-export — chokepoint-friendly alias. @@ -36,7 +38,23 @@ import { // state-bus touches per-cid state) extends naturally: only // state-bus touches the alert bus too. alerts.js remains the // pure module; state-bus is the wire. -export { pushAlert } from "./alerts.js"; +export { pushAlert }; + +// 告警桥接(决策 20 移除 SSE 后的下行出口):alerts.js 的每个 frame 在这里 +// 转成 /api/stream 上的命名控制帧(alerts.append / alerts.update),data 为 +// frame 的 JSON 字符串。幂等:重复调用先退订上一个订阅再接新的。 +let _alertBridgeUnsubscribe = null; +export function attachAlertBridge() { + if (typeof _alertBridgeUnsubscribe === "function") _alertBridgeUnsubscribe(); + _alertBridgeUnsubscribe = subscribeAlerts((frame) => { + const data = JSON.stringify(frame); + const name = frame.kind === "update" ? "alerts.update" : "alerts.append"; + for (const cid of getSubscribedCids()) { + emitEvent(cid, { type: "control", name, data }); + } + }); +} +attachAlertBridge(); // 模块加载即接线(每进程一次) // v0.5.ai: 每个 webui tab 一个独立 state。 export function makeClientState() { @@ -103,7 +121,6 @@ export function makeClientState() { } export const clients = new Map(); // cid -> clientState -export const sseByCid = new Map(); // cid -> SSE response export const activeChildByCid = new Map(); // cid -> child process // v2.3 (in-product): a fresh client (page reload, new tab) must resume the @@ -158,13 +175,6 @@ export function getCidFromReq(req) { } } -export const SSE_HEADERS = { - "Content-Type": "text/event-stream; charset=utf-8", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - "X-Accel-Buffering": "no", -}; - // v2.3: the sessions list in a snapshot is sidebar metadata only — the // frontend never reads session.chat from state.sessions (the chat area // hydrates from state.chat / the switch response). Shipping every @@ -187,7 +197,7 @@ function sessionsListForSnapshot() { // pushStateFor: 推 state 给指定 cid(或 '__broadcast__' 推给所有) // opts.lanBroadcast: 当前 LAN 广播状态(从 settings.js 注入) // opts.mcodeSessions: 已过滤的 mcode sessions 数组(从 acp-client.js 注入) -// v0.5.bx-31: cache miss 时 fire-and-forget 拉一次, 拉完自动 push 给所有 SSE 客户端 +// v0.5.bx-31: cache miss 时 fire-and-forget 拉一次, 拉完自动 push 给所有事件流客户端 // v1.0: 推送带 mcodeSessionsPending 标记 — 占位推送 (cache miss 空数组) 为 true, 权威推送为 false; // fetch 失败也要推终态 (否则 client 侧栏 ready 门控永远等不到权威值, loading 卡死) const _mcodeSessionsFetchPending = new Set(); // workspace keys currently being fetched @@ -195,7 +205,7 @@ function ensureMcodeSessionsFetchedAndPush(workspace) { if (_mcodeSessionsFetchPending.has(workspace)) return; _mcodeSessionsFetchPending.add(workspace); const pushAuthoritative = () => { - for (const [c, res] of sseByCid) { + for (const c of getSubscribedCids()) { const ccs = clients.get(c) || makeClientState(); const cws = (ccs.workspace && ccs.workspace.dir) || ""; // v1.0: 权威推送优先 fresh cache, 退而求其次 stale (同 ws 过期列表), 避免空列表闪跌 @@ -206,13 +216,13 @@ function ensureMcodeSessionsFetchedAndPush(workspace) { const snapshot = { ...ccs, // qa (session-workspace-crud): 复用瘦身投影 — 这条权威推送路径原来 - // 直接 loadSessions(),把每个 session 的完整 chat 数组推进 SSE, + // 直接 loadSessions(),把每个 session 的完整 chat 数组推进事件流, // 是 v2.3 修掉的主负载;两处(本处 + pushOnlineCount)漏改。 sessions: sessionsListForSnapshot(), mcodeSessions: cached, mcodeSessionsPending: false, availableCommands: getCachedMcodeCommands(), - onlineCount: sseByCid.size, + onlineCount: getSubscribedCids().length, lanBroadcast: getLanBroadcast(), readOnly: getReadOnly(), tokenEnabled: getTokenEnabled(), @@ -222,7 +232,7 @@ function ensureMcodeSessionsFetchedAndPush(workspace) { tokenRotatedAt: getTokenRotatedAt(), // v2026-08-28 modacker: Token Plan (套餐用量) feature fields. // Previously these were only synced via the one-shot - // /api/settings fetch in loadLanInfo(); the SSE replace-state + // /api/settings fetch in loadLanInfo(); the event-stream replace-state // pattern (state = JSON.parse(ev.data)) then clobbered them // on the next push, so toggling the switch appeared to do // nothing — the usage button stayed hidden. Including them @@ -239,9 +249,8 @@ function ensureMcodeSessionsFetchedAndPush(workspace) { tokenPlanApiKeySource: getTokenPlanApiKeySource(), tokenPlanApiKeyFilePath: getTokenPlanApiKeyFilePath(), }; - // v2 (Lease C04): route through 60Hz coalescer — multiple authoritative - // pushes within STATE_PUSH_THROTTLE_MS collapse to one write per cid. - _schedulePush(c, JSON.stringify(snapshot), res); + // 事件总线下行汇聚(/api/stream 订阅面) + emitEvent(c, { type: "state.snapshot", snapshot }); } }; getMcodeSessionsForWorkspace(workspace) @@ -264,7 +273,7 @@ export function pushStateFor(cid, opts = {}) { const cachedCmds = getCachedMcodeCommands(); if (cid === "__broadcast__") { - for (const [c, res] of sseByCid) { + for (const c of getSubscribedCids()) { const ccs = clients.get(c) || makeClientState(); const cws = (ccs.workspace && ccs.workspace.dir) || ""; const fields = @@ -276,7 +285,7 @@ export function pushStateFor(cid, opts = {}) { sessions: sessionsListForSnapshot(), ...fields, availableCommands: cachedCmds, - onlineCount: sseByCid.size, + onlineCount: getSubscribedCids().length, lanBroadcast, readOnly: getReadOnly(), tokenEnabled: getTokenEnabled(), @@ -299,9 +308,8 @@ export function pushStateFor(cid, opts = {}) { tokenPlanApiKeySource: getTokenPlanApiKeySource(), tokenPlanApiKeyFilePath: getTokenPlanApiKeyFilePath(), }; - // v2 (Lease C04): coalesced write — N broadcasts within the throttle - // window collapse to ONE write per cid (last call's snapshot wins). - _schedulePush(c, JSON.stringify(snapshot), res); + // 事件总线下行汇聚(/api/stream 订阅面) + emitEvent(c, { type: "state.snapshot", snapshot }); } return; } @@ -311,14 +319,14 @@ export function pushStateFor(cid, opts = {}) { opts.mcodeSessions !== undefined ? { mcodeSessions: opts.mcodeSessions, mcodeSessionsPending: false } : mcodeSessionsSnapshotFields((cs.workspace && cs.workspace.dir) || ""); - // 注入 sessions 列表(来自磁盘 db)— 让 webui 侧边栏 "最近会话" 不被 SSE 推送覆盖 + // 注入 sessions 列表(来自磁盘 db)— 让 webui 侧边栏 "最近会话" 不被事件流推送覆盖 // v0.5.bv: 同步带 mcodeSessions(cache 命中,0 cost;cache miss 才 await) const snapshot = { ...cs, sessions: sessionsListForSnapshot(), ...fields, availableCommands: cachedCmds, - onlineCount: sseByCid.size, + onlineCount: getSubscribedCids().length, lanBroadcast, readOnly: getReadOnly(), tokenEnabled: getTokenEnabled(), @@ -327,7 +335,7 @@ export function pushStateFor(cid, opts = {}) { tokenRotatedAt: getTokenRotatedAt(), // v2026-08-28 modacker: Token Plan (套餐用量) feature fields — // see note on the broadcast-branch snapshot above. Same fields, - // same rationale. Without these the per-cid SSE push also + // same rationale. Without these the per-cid event-stream push also // clobbers the local `state.quotaEnabled` and the usage button // hides itself right after the user toggles it on. quotaEnabled: getQuotaEnabled(), @@ -338,17 +346,12 @@ export function pushStateFor(cid, opts = {}) { tokenPlanApiKeySource: getTokenPlanApiKeySource(), tokenPlanApiKeyFilePath: getTokenPlanApiKeyFilePath(), }; - const payload = JSON.stringify(snapshot); - const res = sseByCid.get(cid); - // v2 (Lease C04): 60Hz coalescing — multiple pushStateFor() calls for - // the same cid within STATE_PUSH_THROTTLE_MS collapse to ONE SSE write. - // Diff mode: if the payload is byte-identical to the last write, the - // client receives nothing (no full-state replace, no DOM thrash). - _schedulePush(cid, payload, res); + // 事件总线下行汇聚(/api/stream 订阅面) + emitEvent(cid, { type: "state.snapshot", snapshot }); } -// v1.0: 统一的 mcodeSessions 快照字段构造 — 所有 SSE 推送点必须带这两个字段。 -// 之前 pushOnlineCount / SSE 首推不带, 客户端整包替换 state 后 mcodeSessions 变 undefined, +// v1.0: 统一的 mcodeSessions 快照字段构造 — 所有事件流推送点必须带这两个字段。 +// 之前 pushOnlineCount / 首推不带, 客户端整包替换 state 后 mcodeSessions 变 undefined, // 侧栏随机从 ~36 条闪跌到 ~16 条 (只剩 webui 本地条目), 下次完整推送又弹回。 // v1.0 (改): 缓存过期但同 workspace 时推过期列表 (pending=true), 不再推空占位 — // 过期值好过空值, 权威值到达前侧栏不闪跌 @@ -367,213 +370,30 @@ export function mcodeSessionsSnapshotFields(workspace) { } // ============================================================ -// v2 (Lease C04) — 60Hz SSE coalescing + diff mode -// -// What this adds: -// - _schedulePush(cid, payloadStr, res): routes an SSE write through -// a per-cid diff gate. The diff gate compares the incoming payload -// against the last written payload for this cid (byte-identical -// JSON). If identical, the write is suppressed — no full-state -// JSON goes out, the client doesn't render() against identical -// bytes, no DOM thrash. This is the "diff 模式 — 不复位整个 state" -// half of the lease spec. -// - 60Hz coalescing: when STATE_PUSH_THROTTLE_MS > 0, the diff gate -// is gated by a time window as well. Subsequent pushes within the -// window are stored as "pending" — when the window expires, the -// LAST pending payload is written (last-call-wins). The first push -// in any window writes synchronously (preserves the existing -// sync-write contract that callers like runUsageQuery rely on). -// The 16ms default targets 60Hz, matching common display refresh -// rates so the client render loop never starves. -// - STATE_PUSH_THROTTLE_MS env var: configurable throttle window. -// Default 16ms per lease spec. Set to 0 to disable the time-based -// throttle (every push writes synchronously — useful for tests -// that depend on the pre-coalescer contract, and for low-latency -// debugging). The diff gate is always active regardless. -// - resetCoalesceState() / flushPendingPushes() / peekLastPushed() / -// peekLastWriteTs(): test escape hatches. -// -// What this does NOT change: -// - Named SSE events (auth.token_rotated / token.first_run / -// needs_authorization / authorization_decided) keep their -// immediate-write path. They're low-frequency and benefit from -// minimum latency. Coalescing is only applied to the `state` -// stream (the full snapshot replacement path). -// - Wire format: client still receives full state, not diffs. The -// diff check is purely a "should I emit this byte?" decision; the -// payload structure is unchanged. This keeps state.js#connect() -// compatible without touching the client. +// 下行通道:事件总线(event-bus.js)是唯一下行通道 —— SSE 已按决策 20 +// 移除(sse-adapter.js 删除)。此处仅保留状态构造与事件发布。 // ============================================================ -export const STATE_PUSH_THROTTLE_MS = Math.max( - 0, - Number(process.env.STATE_PUSH_THROTTLE_MS) || 0, -); - -// cid -> { payloadStr, res, timer? } -// - payloadStr: pending JSON payload (last-call-wins within window) -// - res: SSE response object to write to -// - timer: setTimeout to flush pending when window expires (absent if -// the pending is being flushed right now) -const _pendingByCid = new Map(); -// cid -> setTimeout handle for the pending flush -const _flushTimers = new Map(); -// cid -> millisecond timestamp of the last successful write -const _lastWriteTsByCid = new Map(); -// cid -> JSON string of the last payload that was successfully written -const _lastPushedByCid = new Map(); -// cid -> res reference of the last successful write. Used to detect -// "this cid got a fresh SSE response (re-connect / test reset)" -// — when the res changes, we MUST write unconditionally regardless -// of throttle/diff state. Tests that do `sseByCid.set(cid, fakeSse())` -// directly create a new fakeSse each time, so this naturally resets. -const _lastPushedResByCid = new Map(); - -function _writeNow(cid, payloadStr, res) { - // qa (OOM hardening): 写入前做背压/死套接字判断。状态推送是全量快照, - // 被丢弃的一帧会被下一帧取代 —— 积压时丢弃是安全的;之前无背压 - // res.write 在慢客户端上让 Node socket 写缓冲无界增长(长任务的 - // 高频全量快照可堆到 GB 级)。注意:跳过时不写 diff 缓存,同一 - // payload 在套接字排空后重推仍会真正落线。 - if (!res || res.writableEnded || res.destroyed) return; - if (res.writableNeedDrain) return; - _lastWriteTsByCid.set(cid, Date.now()); - _lastPushedByCid.set(cid, payloadStr); - _lastPushedResByCid.set(cid, res); - try { - res.write(`data: ${payloadStr}\n\n`); - } catch {} -} -// Test-only: inspect the res reference retained for fresh-client -// detection. Used by checks/lib-state-bus.check.mjs to verify -// endSseClient drops dead response objects (OOM hardening). -export function peekLastPushedRes(cid) { - return _lastPushedResByCid.get(cid); -} -function _schedulePush(cid, payloadStr, res) { - if (!res) return; // no client to write to (cid without SSE) - - // Fresh-client detection: if the cid's stored res differs from - // the current res, treat as a brand-new SSE connection. The - // previous writes went to a different res (or no res at all if - // this is the first connection), so the diff cache must be - // discarded — otherwise the new client would silently miss its - // very first state. Tests that re-bind a cid's res between cases - // hit this branch automatically. - const cachedRes = _lastPushedResByCid.get(cid); - if (cachedRes !== res) { - // Drop any pending push + timer for this cid — they're stale - // (would go to the wrong res or never get scheduled right). - const oldTimer = _flushTimers.get(cid); - if (oldTimer) { - try { - clearTimeout(oldTimer); - } catch {} - _flushTimers.delete(cid); - } - _pendingByCid.delete(cid); - _lastWriteTsByCid.delete(cid); - _lastPushedByCid.delete(cid); - // Write immediately, unconditionally. This restores the - // pre-coalescer sync-write contract: after pushStateFor(cid) - // returns, the data is on the wire to the (new) client. - _writeNow(cid, payloadStr, res); - return; - } - // Diff: skip the write if the payload is byte-identical to the - // last successful write for this cid. This is the "不复位整个 state" - // half of the lease spec — the client doesn't receive a redundant - // full-state replace that would force a render() + DOM rebuild. - if (_lastPushedByCid.get(cid) === payloadStr) return; - // Throttle disabled (env = 0) — write synchronously every push. - if (STATE_PUSH_THROTTLE_MS <= 0) { - _writeNow(cid, payloadStr, res); - return; - } - const now = Date.now(); - const lastTs = _lastWriteTsByCid.get(cid) || 0; - const elapsed = now - lastTs; - if (elapsed >= STATE_PUSH_THROTTLE_MS) { - // Outside throttle window — write immediately (preserves - // the original sync-write contract for the first push in any - // new window). Calls like runUsageQuery depend on the write - // being observable to the client by the time the call returns. - _writeNow(cid, payloadStr, res); - return; - } - // Inside throttle window — store as pending. Last call within - // the window wins; the timer's flush emits the freshest payload. - _pendingByCid.set(cid, { payloadStr, res }); - if (!_flushTimers.has(cid)) { - const delay = STATE_PUSH_THROTTLE_MS - elapsed; - const timer = setTimeout(() => _flushPending(cid), delay); - if (typeof timer.unref === "function") timer.unref(); - _flushTimers.set(cid, timer); - } -} -function _flushPending(cid) { - _flushTimers.delete(cid); - const pending = _pendingByCid.get(cid); - if (!pending) return; - _pendingByCid.delete(cid); - // Re-check diff in case the timer fired late (another write - // happened in the meantime and already wrote this payload). - if (_lastPushedByCid.get(cid) === pending.payloadStr) return; - // Re-check res in case the client disconnected/reconnected. - if (_lastPushedResByCid.get(cid) !== pending.res) return; - _writeNow(cid, pending.payloadStr, pending.res); -} -// Test-only: clear pending timers + diff cache + last-write timestamps. -// Production code never calls this — production throttles stay "live" -// for the process lifetime. Exported so test/lib-state-bus.test.js can -// deterministically reset between cases. -export function resetCoalesceState() { - for (const [, timer] of _flushTimers) { - try { - clearTimeout(timer); - } catch {} - } - _flushTimers.clear(); - _pendingByCid.clear(); - _lastWriteTsByCid.clear(); - _lastPushedByCid.clear(); - _lastPushedResByCid.clear(); -} -// Test-only: force-flush all pending pushes immediately (without -// waiting for the throttle window to expire). Returns the number of -// cids flushed. Used in test/lib-state-bus.test.js to assert "within -// a coalesce window, exactly N writes went out" without dealing with -// real timer timing. -export function flushPendingPushes() { - const cids = Array.from(_pendingByCid.keys()); - for (const cid of cids) _flushPending(cid); - return cids.length; -} -// Test-only: peek at the last-written payload for cid. Used to assert -// "after coalescing, this cid's wire frame contains this data". -export function peekLastPushed(cid) { - return _lastPushedByCid.get(cid); -} -// Test-only: peek at the last-write timestamp for cid. Used to assert -// throttle-window arithmetic. -export function peekLastWriteTs(cid) { - return _lastWriteTsByCid.get(cid); -} -// v0.5.ak: SSE 客户端数变化时广播(让所有 tab 实时看到 onlineCount) + + + + +// v0.5.ak: 事件流客户端数变化时广播(让所有 tab 实时看到 onlineCount) export function pushOnlineCount(lanBroadcast) { const cachedCmds = getCachedMcodeCommands(); - for (const [c, res] of sseByCid) { + const subs = getSubscribedCids(); + for (const c of subs) { const cs = clients.get(c) || makeClientState(); const snapshot = { ...cs, @@ -582,7 +402,7 @@ export function pushOnlineCount(lanBroadcast) { sessions: sessionsListForSnapshot(), ...mcodeSessionsSnapshotFields((cs.workspace && cs.workspace.dir) || ""), availableCommands: cachedCmds, - onlineCount: sseByCid.size, + onlineCount: subs.length, lanBroadcast, readOnly: getReadOnly(), tokenEnabled: getTokenEnabled(), @@ -590,7 +410,7 @@ export function pushOnlineCount(lanBroadcast) { tokenAcknowledged: getTokenAcknowledged(), tokenRotatedAt: getTokenRotatedAt(), // v2026-08-28 modacker: Token Plan (套餐用量) feature fields — - // see pushStateFor above. pushOnlineCount fires on every SSE + // see pushStateFor above. pushOnlineCount fires on every stream // client connect/disconnect, so without these the next push // after a tab opens would also clobber quotaEnabled. quotaEnabled: getQuotaEnabled(), @@ -601,9 +421,8 @@ export function pushOnlineCount(lanBroadcast) { tokenPlanApiKeySource: getTokenPlanApiKeySource(), tokenPlanApiKeyFilePath: getTokenPlanApiKeyFilePath(), }; - // v2 (Lease C04): coalesced write — multiple pushOnlineCount() calls - // within the throttle window collapse to ONE write per cid. - _schedulePush(c, JSON.stringify(snapshot), res); + // 事件总线下行汇聚(/api/stream 订阅面) + emitEvent(c, { type: "state.snapshot", snapshot }); } } @@ -634,83 +453,29 @@ export function getCidsByMcodeSession(mvsSessionId) { return out; } -// SSE channel helpers — only state-bus.js should touch sseByCid directly. -export function getSseClient(cid) { - return sseByCid.get(cid) || null; -} -export function setSseClient(cid, res) { - sseByCid.set(cid, res); - // v2 (Lease C04): when an SSE client (re)connects, the previous - // diff cache + throttle timestamps are stale — the new client - // hasn't seen the prior writes, so "diff against last push" is - // wrong (would skip the very first push this client should receive). - // Reset coalesce state for this cid so the next pushStateFor emits - // the full snapshot unconditionally. - const timer = _flushTimers.get(cid); - if (timer) { - try { - clearTimeout(timer); - } catch {} - _flushTimers.delete(cid); - } - _pendingByCid.delete(cid); - _lastWriteTsByCid.delete(cid); - _lastPushedByCid.delete(cid); -} - -export function endSseClient(cid, res) { - // Only clear the map entry if it still points at the same res (avoid races) - if (sseByCid.get(cid) === res) sseByCid.delete(cid); - // v2 (Lease C04): drop the coalesce state for this cid too — the - // client disconnected, no point in keeping pending pushes around - // (they'd flush to a dead res anyway and the `try/catch` would - // silently swallow it). Cleanup keeps the map bounded for long-lived - // processes that see many transient clients. - const timer = _flushTimers.get(cid); - if (timer) { - try { - clearTimeout(timer); - } catch {} - _flushTimers.delete(cid); - } - _pendingByCid.delete(cid); - _lastWriteTsByCid.delete(cid); - _lastPushedByCid.delete(cid); - // qa (OOM hardening): 释放死 res 引用 —— 之前 _lastPushedResByCid 永不 - // 清理,每个断开的 SSE 响应(连同其 socket 写缓冲)被进程终身持有。 - _lastPushedResByCid.delete(cid); -} - -// v1.0.1: broadcastTokenRotated — push a named SSE event so all +// v1.0.1: broadcastTokenRotated — push a named control frame so all // already-authenticated clients can update their HEADERS + localStorage // without waiting for the periodic state push. Body is the new token // (raw string, not JSON, to make it obvious in logs / devtools that // this is sensitive — never log it). // -// IMPORTANT: the token is sent in cleartext over the SSE channel. The +// IMPORTANT: the token is sent in cleartext over the event stream. The // connection is already authenticated (caller must have presented a -// valid token to reach the rotation handler), and SSE is in-band -// with the existing /api/events stream which the client already -// authorized. So this is no worse than the periodic state push that -// also includes currentToken in the same channel. +// valid token to reach the rotation handler), and /api/stream is +// in-band with the state channel the client already authorized. So this +// is no worse than the periodic state push that also includes +// currentToken in the same channel. export function broadcastTokenRotated(token) { if (!token) return; - // SSE custom event format: - // event: \n - // data: \n - // \n - const frame = `event: auth.token_rotated\ndata: ${token}\n\n`; - for (const [, res] of sseByCid) { - try { - res.write(frame); - } catch {} + for (const c of getSubscribedCids()) { + emitEvent(c, { type: "control", name: "auth.token_rotated", data: token }); } } // v2 (Lease C08) — pushTokenFirstRun // -// Fires the `token.first_run` SSE event exactly once per process +// Fires the `token.first_run` control frame exactly once per process // lifetime. server.js calls this from inside `initSettings({printToken})` // when settings.js has just generated a fresh token (no settings.json // on disk + no TOKEN env). The UI listens for this event and pops the @@ -734,24 +499,21 @@ export function pushTokenFirstRun({ token, persistPath }) { persistPath: typeof persistPath === "string" ? persistPath : "", ts: Date.now(), }); - const frame = `event: token.first_run\ndata: ${payload}\n\n`; - for (const [, res] of sseByCid) { - try { - res.write(frame); - } catch {} + for (const c of getSubscribedCids()) { + emitEvent(c, { type: "control", name: "token.first_run", data: payload }); } } // ============================================================ -// v2 (Lease B03) — Per-request authorization SSE channel +// v2 (Lease B03) — Per-request authorization channel // // authorize.js (server/lib/authorize.js) gates destructive actions // behind a user-confirmation modal. The frontend listens for -// `needs_authorization` events on its /api/events stream and pops a -// confirmation; the user accepts or declines and the server resolves -// the pending request via POST /api/auth/decision. +// `needs_authorization` control frames on its /api/stream connection +// and pops a confirmation; the user accepts or declines and the server +// resolves the pending request via POST /api/auth/decision. // -// pushAuthRequest — fire a `needs_authorization` SSE frame to the +// pushAuthRequest — fire a `needs_authorization` control frame to the // target cid (or every connected client if cid is empty). Body is the // pending request payload {requestId, action, ctx, expiresAt}. // @@ -759,27 +521,18 @@ export function pushTokenFirstRun({ token, persistPath }) { // listeners (e.g. devtools, audit dashboards) can mirror the modal // state. Body is {requestId, approved, decidedBy}. // -// The SSE channel is the SAME /api/events stream the client already -// opened — no new connection needed. The frame is a named SSE event -// so it won't be confused with `state`/`chat`/`delta` payloads. +// The frame travels on the SAME /api/stream connection the client +// already opened — no new connection needed. It is a named control +// frame so it won't be confused with state.snapshot payloads. // ============================================================ -function _writeAuthFrame(targetCid, frame) { +function _writeAuthFrame(targetCid, event) { if (targetCid) { - const res = sseByCid.get(targetCid); - if (res) { - try { - res.write(frame); - } catch {} - } + emitEvent(targetCid, event); return; } // broadcast (empty / undefined targetCid) - for (const [, res] of sseByCid) { - try { - res.write(frame); - } catch {} - } + for (const c of getSubscribedCids()) emitEvent(c, event); } export function pushAuthRequest({ requestId, action, ctx, expiresAt }) { @@ -790,9 +543,12 @@ export function pushAuthRequest({ requestId, action, ctx, expiresAt }) { ctx: ctx && typeof ctx === "object" ? ctx : {}, expiresAt: Number(expiresAt) || 0, }); - const frame = `event: needs_authorization\ndata: ${payload}\n\n`; const targetCid = ctx && typeof ctx.cid === "string" ? ctx.cid : ""; - _writeAuthFrame(targetCid, frame); + _writeAuthFrame(targetCid, { + type: "control", + name: "needs_authorization", + data: payload, + }); } export function pushAuthDecision({ requestId, approved, decidedBy }) { @@ -802,7 +558,10 @@ export function pushAuthDecision({ requestId, approved, decidedBy }) { approved: !!approved, decidedBy: decidedBy ? String(decidedBy).slice(0, 32) : "user", }); - const frame = `event: authorization_decided\ndata: ${payload}\n\n`; // broadcast — every connected tab should mirror modal close - _writeAuthFrame("", frame); + _writeAuthFrame("", { + type: "control", + name: "authorization_decided", + data: payload, + }); } diff --git a/packages/webui/server/lib/transcript.js b/packages/webui/server/lib/transcript.js index eea220e7..e19fc09b 100644 --- a/packages/webui/server/lib/transcript.js +++ b/packages/webui/server/lib/transcript.js @@ -254,7 +254,7 @@ export function readMcodeTranscript(mcodeSid, opts = {}) { // re-encoded — conservative per the audit // fix contract: never invent a shape. // -// Caps (switch hot path — the SSE state push carries cs.chat wholesale): +// Caps (switch hot path — the event-stream state push carries cs.chat wholesale): // keep the LAST maxLines lines, then drop from the front while the total // UTF-8 byte size exceeds maxBytes. A single line that alone exceeds // maxBytes is byte-truncated with an explicit " …[truncated]" marker — diff --git a/packages/webui/server/lib/usage.js b/packages/webui/server/lib/usage.js index 99f7f34a..ba46b8ce 100644 --- a/packages/webui/server/lib/usage.js +++ b/packages/webui/server/lib/usage.js @@ -110,7 +110,7 @@ export function parseTokenPlanResponse(data, cs) { cs.usage.plan = data?.plan ?? null; cs.usage.expires = data?.expires ?? null; cs.usage.credits = data?.credits ?? null; - // Stash the raw response for debugging — the SSE push of cs + // Stash the raw response for debugging — the event-stream push of cs // exposes `usage.raw` to the client, and a "查看 raw 响应" // affordance in the popover would surface this when the // numbers look wrong (e.g., API shape drift). We cap it at diff --git a/packages/webui/server/lib/ws-frame.js b/packages/webui/server/lib/ws-frame.js new file mode 100644 index 00000000..7a4df540 --- /dev/null +++ b/packages/webui/server/lib/ws-frame.js @@ -0,0 +1,399 @@ +// webui/server/lib/ws-frame.js +// RFC 6455 WebSocket 帧层(服务端子集):握手密钥 + 帧编码 + 增量帧解码。 +// +// 职责:为 WebSocket 数据面(/api/stream)提供帧级原语,不含握手 HTTP +// 处理、心跳调度与消息路由——那些属于上层连接管理。 +// +// 范围(RFC 6455 服务端子集,按需最小实现): +// - computeAcceptKey:握手 Sec-WebSocket-Accept 计算(§1.3 标例)。 +// - encodeFrame:服务端 → 客户端帧编码,FIN=1、不掩码(§5.2)。 +// - createFrameDecoder:客户端 → 服务端帧流增量解码,要求掩码(§5.1), +// 支持分片重组、控制帧穿插、跨分片 UTF-8 校验、close 解析。 +// - 不实现压缩扩展:RSV 非 0 一律按协议错误(1002)拒绝。 +// +// 设计约束: +// - 零 npm 依赖(packages/webui 保持零依赖包):仅 node:crypto 与全局 TextDecoder。 +// - 帧原语为纯函数、解码器为显式状态机,便于 RFC 6455 一致性单测逐条覆盖。 + +import { createHash } from "node:crypto"; + +/** RFC 6455 §1.3 握手 GUID,与客户端 Sec-WebSocket-Key 拼接后做 SHA-1。 */ +export const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +/** RFC 6455 §5.6 操作码。 */ +export const OPCODE = { + CONT: 0x0, + TEXT: 0x1, + BINARY: 0x2, + CLOSE: 0x8, + PING: 0x9, + PONG: 0xa, +}; + +/** RFC 6455 §7.4.1 关闭状态码(本项目用到的子集)。 */ +export const CLOSE_CODE = { + NORMAL: 1000, + GOING_AWAY: 1001, + PROTOCOL_ERROR: 1002, + UNSUPPORTED_DATA: 1003, + NO_STATUS: 1005, + INVALID_UTF8: 1007, + TOO_LARGE: 1009, + POLICY_VIOLATION: 1008, + TRY_AGAIN_LATER: 1013, +}; + +/** 默认单帧负载上限:1 MiB(与网络层草案 §7.2 帧约束一致)。 */ +const DEFAULT_MAX_FRAME_BYTES = 1024 * 1024; + +/** + * 解码器产出的事件联合类型: + * {kind:'text', text} 文本消息(分片已重组、UTF-8 已校验) + * {kind:'binary', data} 二进制消息(分片已重组) + * {kind:'ping'|'pong', payload} 控制帧,穿插在分片间即时产出 + * {kind:'close', code, reason} 关闭帧(空负载按 1005 上报) + * {kind:'protocol-error', code, reason} 协议违规,此后解码器进入终态 + * + * @typedef {{kind: 'text', text: string} + * | {kind: 'binary', data: Buffer} + * | {kind: 'ping', payload: Buffer} + * | {kind: 'pong', payload: Buffer} + * | {kind: 'close', code: number, reason: string} + * | {kind: 'protocol-error', code: number, reason: string}} WsFrameEvent + */ + +/** 协议违规:内部信号,携带应答给对端的关闭码,由 push() 转成 protocol-error 事件。 */ +class ProtocolViolation extends Error { + /** + * @param {number} code 关闭码(1002/1007/1009) + * @param {string} reason 人类可读原因(会出现在 protocol-error 事件里) + */ + constructor(code, reason) { + super(reason); + this.name = "ProtocolViolation"; + this.code = code; + this.reason = reason; + } +} + +/** + * 计算握手应答 Sec-WebSocket-Accept(RFC 6455 §1.3)。 + * 公式:base64(sha1(clientKey + WS_GUID))。 + * + * @param {string} clientKey 客户端 Sec-WebSocket-Key 头原值 + * @returns {string} Sec-WebSocket-Accept 值 + */ +export function computeAcceptKey(clientKey) { + if (typeof clientKey !== "string") { + throw new TypeError("ws-frame: clientKey must be a string"); + } + return createHash("sha1").update(clientKey + WS_GUID, "utf8").digest("base64"); +} + +/** + * 编码一个服务端 → 客户端帧(RFC 6455 §5.2:FIN=1、RSV=0、不掩码)。 + * + * 长度编码:≤125 走 7 bit;≤65535 走 126 + 2 字节大端;更大走 127 + 8 字节 + * 大端(最高位恒为 0)。本子集不支持分片发送,每条消息一个帧。 + * + * @param {{opcode: number, payload?: string | Buffer | Uint8Array}} frame + * @returns {Buffer} 完整帧字节 + */ +export function encodeFrame({ opcode, payload } = {}) { + if (!Object.values(OPCODE).includes(opcode)) { + throw new TypeError(`ws-frame: unknown opcode 0x${Number(opcode).toString(16)}`); + } + let body; + if (payload === undefined || payload === null) { + body = Buffer.alloc(0); + } else if (typeof payload === "string") { + body = Buffer.from(payload, "utf8"); + } else if (payload instanceof Uint8Array) { + body = Buffer.from(payload); + } else { + throw new TypeError("ws-frame: payload must be a string, Buffer or Uint8Array"); + } + + const len = body.length; + let header; + if (len < 126) { + // 7 bit 长度直接落在第二个头字节 + header = Buffer.from([0x80 | opcode, len]); + } else if (len <= 0xffff) { + header = Buffer.allocUnsafe(4); + header[0] = 0x80 | opcode; + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.allocUnsafe(10); + header[0] = 0x80 | opcode; + header[1] = 127; + // 8 字节大端;Buffer 长度 < 2^31,最高位恒为 0(§5.2 要求) + header.writeBigUInt64BE(BigInt(len), 2); + } + return Buffer.concat([header, body]); +} + +/** + * 创建客户端 → 服务端帧流的增量解码器。 + * + * 用法:把 socket 收到的原始字节按序 push() 进来,每次拿回该批次产出的事件 + * 数组。字节可以任意切块(TCP 不保帧边界),解码器内部缓存半帧。 + * + * 违规映射(事件 code 即应答给对端的关闭码): + * RSV 非 0 / 未掩码 / FIN=0 控制帧 / 控制帧 >125B / 结构类违规 → 1002 + * 文本消息非法 UTF-8(含截断多字节序列) → 1007 + * 单帧或重组消息超过 maxFrameBytes → 1009 + * 违规后解码器进入终态:errored 置 true,后续 push() 只返回空数组。 + * + * @param {{maxFrameBytes?: number}} [options] + * @param {number} [options.maxFrameBytes] 单帧负载上限,默认 1 MiB;分片重组后 + * 的消息总量同样受此上限约束(防分片放大占内存) + * @returns {{ push: (chunk: Buffer | Uint8Array | string) => WsFrameEvent[], errored: boolean }} + */ +export function createFrameDecoder({ maxFrameBytes = DEFAULT_MAX_FRAME_BYTES } = {}) { + if (!Number.isInteger(maxFrameBytes) || maxFrameBytes < 1) { + throw new RangeError( + `ws-frame: maxFrameBytes must be an integer >= 1, got ${maxFrameBytes}`, + ); + } + + /** @type {Buffer} 已收到但尚未构成完整帧的字节 */ + let pending = Buffer.alloc(0); + /** @type {null | {opcode: number, parts: Buffer[], bytes: number, textDecoder: TextDecoder | null}} 进行中的分片消息 */ + let fragmented = null; + let erroredFlag = false; + + /** 抛出协议违规,统一在 push() 里转成 protocol-error 事件。 */ + function fail(code, reason) { + throw new ProtocolViolation(code, reason); + } + + /** + * 把消息的最后一个分片组装成完整消息事件(UTF-8 在此冲刷校验)。 + * @param {WsFrameEvent[]} events + */ + function emitMessage(events) { + if (fragmented.opcode === OPCODE.TEXT) { + let text = ""; + try { + // stream:true 让多字节字符可以横跨分片;末尾 decode() 冲刷残留, + // 截断的多字节序列会在这里以 fatal 模式抛出。 + for (const part of fragmented.parts) { + text += fragmented.textDecoder.decode(part, { stream: true }); + } + text += fragmented.textDecoder.decode(); + } catch { + fragmented = null; + fail(CLOSE_CODE.INVALID_UTF8, "text message is not valid UTF-8"); + } + events.push({ kind: "text", text }); + } else { + events.push({ kind: "binary", data: Buffer.concat(fragmented.parts) }); + } + fragmented = null; + } + + /** + * 追加一个数据帧分片(单帧消息 = 只有一个分片且立即封口)。 + * @param {Buffer} payload + * @param {boolean} fin + * @param {WsFrameEvent[]} events + */ + function appendFragment(payload, fin, events) { + fragmented.parts.push(payload); + fragmented.bytes += payload.length; + if (fragmented.bytes > maxFrameBytes) { + fragmented = null; + fail(CLOSE_CODE.TOO_LARGE, "reassembled message exceeds maxFrameBytes"); + } + if (fin) emitMessage(events); + } + + /** + * 解析 close 帧负载(§5.5):空负载 → 1005;否则 2 字节大端 code + UTF-8 reason。 + * @param {Buffer} payload + * @returns {WsFrameEvent} + */ + function parseClose(payload) { + if (payload.length === 1) { + // 负载非空时前两字节必须构成 code,1 字节是结构违规 + fail(CLOSE_CODE.PROTOCOL_ERROR, "close frame body must be empty or at least 2 bytes"); + } + if (payload.length === 0) { + return { kind: "close", code: CLOSE_CODE.NO_STATUS, reason: "" }; + } + const code = payload.readUInt16BE(0); + if (!isValidCloseCode(code)) { + fail(CLOSE_CODE.PROTOCOL_ERROR, `invalid close code ${code}`); + } + let reason; + try { + reason = new TextDecoder("utf-8", { fatal: true }).decode(payload.subarray(2)); + } catch { + fail(CLOSE_CODE.INVALID_UTF8, "close reason is not valid UTF-8"); + } + return { kind: "close", code, reason }; + } + + /** 按操作码分发一个已解掩码的完整帧。 */ + function dispatchFrame(opcode, fin, payload, events) { + switch (opcode) { + case OPCODE.PING: + events.push({ kind: "ping", payload }); + return; + case OPCODE.PONG: + events.push({ kind: "pong", payload }); + return; + case OPCODE.CLOSE: + events.push(parseClose(payload)); + return; + case OPCODE.CONT: { + if (fragmented === null) { + fail(CLOSE_CODE.PROTOCOL_ERROR, "continuation frame without an initial data frame"); + } + appendFragment(payload, fin, events); + return; + } + case OPCODE.TEXT: + case OPCODE.BINARY: { + if (fragmented !== null) { + fail(CLOSE_CODE.PROTOCOL_ERROR, "new data frame while a fragmented message is in progress"); + } + fragmented = { + opcode, + parts: [], + bytes: 0, + textDecoder: opcode === OPCODE.TEXT ? new TextDecoder("utf-8", { fatal: true }) : null, + }; + appendFragment(payload, fin, events); + return; + } + } + } + + /** 反复解析 pending 中的完整帧,直到字节不足。 */ + function parseAvailable(events) { + for (;;) { + if (pending.length < 2) return; + const b0 = pending[0]; + const b1 = pending[1]; + const fin = (b0 & 0x80) !== 0; + const rsv = b0 & 0x70; + const opcode = b0 & 0x0f; + const masked = (b1 & 0x80) !== 0; + const len7 = b1 & 0x7f; + + // —— 头部合法性:仅凭前两字节即可判定的违规立刻失败 —— + if (rsv !== 0) { + // 本子集不实现压缩等扩展,RSV 必须全 0(§5.2) + fail(CLOSE_CODE.PROTOCOL_ERROR, "RSV bits must be 0 (no extensions negotiated)"); + } + if (!Object.values(OPCODE).includes(opcode)) { + fail(CLOSE_CODE.PROTOCOL_ERROR, `unknown opcode 0x${opcode.toString(16)}`); + } + const isControl = (opcode & 0x08) !== 0; + if (isControl) { + if (!fin) fail(CLOSE_CODE.PROTOCOL_ERROR, "control frames must not be fragmented"); + // len7 为 126/127 时声明长度必然 >125,同属超长控制帧 + if (len7 > 125) fail(CLOSE_CODE.PROTOCOL_ERROR, "control frame payload must be <= 125 bytes"); + } + if (!masked) { + // 客户端 → 服务端帧必须掩码(§5.1) + fail(CLOSE_CODE.PROTOCOL_ERROR, "client frames must be masked"); + } + + // —— 扩展长度:必须用最小字节数编码(§5.2)—— + let headerLen = 2; + let payloadLen = len7; + if (len7 === 126) { + if (pending.length < 4) return; // 等待更多字节 + payloadLen = pending.readUInt16BE(2); + if (payloadLen < 126) { + fail(CLOSE_CODE.PROTOCOL_ERROR, "payload length must use the minimal encoding"); + } + headerLen = 4; + } else if (len7 === 127) { + if (pending.length < 10) return; + const big = pending.readBigUInt64BE(2); + if (big > 0x7fffffffffffffffn) { + fail(CLOSE_CODE.PROTOCOL_ERROR, "payload length high bit must be 0"); + } + if (big <= 0xffffn) { + fail(CLOSE_CODE.PROTOCOL_ERROR, "payload length must use the minimal encoding"); + } + payloadLen = Number(big); + headerLen = 10; + } + + if (payloadLen > maxFrameBytes) { + fail(CLOSE_CODE.TOO_LARGE, "frame payload exceeds maxFrameBytes"); + } + + // —— 等待掩码键 + 负载到齐后解掩码分发 —— + const frameLen = headerLen + 4 + payloadLen; + if (pending.length < frameLen) return; + const key = pending.subarray(headerLen, headerLen + 4); + const maskedPayload = pending.subarray(headerLen + 4, frameLen); + const payload = Buffer.allocUnsafe(payloadLen); + // 掩码 = 4 字节键按位循环异或(§5.3) + for (let i = 0; i < payloadLen; i++) { + payload[i] = maskedPayload[i] ^ key[i & 3]; + } + pending = pending.subarray(frameLen); + + dispatchFrame(opcode, fin, payload, events); + } + } + + return { + /** + * 喂入一段原始字节,返回本批次产出的事件(可能为空,可能含多帧)。 + * + * @param {Buffer | Uint8Array | string} chunk + * @returns {WsFrameEvent[]} + */ + push(chunk) { + /** @type {WsFrameEvent[]} */ + const events = []; + if (erroredFlag) return events; // 终态:不再解析 + if (!(chunk instanceof Uint8Array) && typeof chunk !== "string") { + throw new TypeError("ws-frame: chunk must be a Buffer/Uint8Array or string"); + } + const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk; + pending = Buffer.concat([pending, bytes]); + try { + parseAvailable(events); + } catch (err) { + if (err instanceof ProtocolViolation) { + erroredFlag = true; + fragmented = null; + events.push({ kind: "protocol-error", code: err.code, reason: err.reason }); + } else { + throw err; + } + } + return events; + }, + + /** 是否已进入终态(发生过协议违规)。终态不可恢复,需上层关闭连接。 */ + get errored() { + return erroredFlag; + }, + }; +} + +/** + * 关闭码是否允许出现在线上 close 帧里(§7.4.1): + * 1000–1014(1004/1005/1006 除外)与 3000–4999 合法;1005/1006/1015 仅作 + * 内部语义不得上线,其余为保留段。 + * + * @param {number} code + * @returns {boolean} + */ +function isValidCloseCode(code) { + return ( + (code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) || + (code >= 3000 && code <= 4999) + ); +} diff --git a/packages/webui/server/lib/ws-server.js b/packages/webui/server/lib/ws-server.js new file mode 100644 index 00000000..e4ef2664 --- /dev/null +++ b/packages/webui/server/lib/ws-server.js @@ -0,0 +1,288 @@ +// webui/server/lib/ws-server.js +// GET /api/stream 的 WebSocket 事件流端点(技术方案 §7.2:事件流 + REST)。 +// +// 职责:HTTP 升级握手 → 事件总线订阅 → 帧序列推送(seq 连续)+ 断线恢复 +// (环形缓冲重放 / 快照基线回退)+ 心跳 + 入站配额。帧编解码复用 ws-frame.js +// (RFC 6455 服务端子集),事件源复用 event-bus.js —— SSE 已按决策 20 移除, +// 事件总线(event-bus.js)是唯一下行通道,本端点是它唯一的线上出口。 +// +// 无 transport 开关:本端点始终启用(MCODE_WEBUI_TRANSPORT 已删除); +// 发行版 SPA 直接消费本端点(+ REST 快照 GET /api/state、GET /api/alerts)。 +// +// 协议帧(服务端 → 客户端,WS text,JSON): +// {v:1, type:"hello", payload:{resumeSupported, latestSeq, heartbeatMs, ringCapacity, cid}} +// {v:1, seq, ts, type:"state.snapshot", payload:<快照>} // 状态快照 +// {v:1, seq, ts, type:"control", payload:{name, data}} // 命名控制事件 +// {v:1, type:"error", payload:{code, message}} +// 客户端 → 服务端(WS text,JSON;二进制帧一律 1002 拒绝): +// {v:1, type:"resume", payload:{lastSeq}} // 断线恢复:重放 seq > lastSeq +// {v:1, type:"ping"} / {v:1, type:"pong"} / {v:1, type:"close"} +// +// 升级门链(决策记录 17):与原 /api/events 同款(origin / LAN / token)。 + +import { + computeAcceptKey, + encodeFrame, + createFrameDecoder, + OPCODE, + CLOSE_CODE, +} from "./ws-frame.js"; +import { createRingBuffer } from "./ring-buffer.js"; +import { subscribeEvents, getLatestSeq } from "./event-bus.js"; +import { getCidFromReq, getClient, pushOnlineCount, pushStateFor } from "./state-bus.js"; +import { + isLocalRequest, + buildTrustedOrigins, + normalizeOriginHeader, +} from "./lan.js"; +import { getServingPort } from "./config.js"; +import { getLanBroadcast, getTrustedOrigins } from "./settings.js"; +import { isRequestAuthorized } from "./auth.js"; +import { applyMavisUsageToCs } from "./mavis-usage.js"; +import { getMcodeModelLimit } from "./models.js"; + +const DEFAULT_HEARTBEAT_MS = 30_000; +const MAX_MISSED_PONGS = 2; +const INBOUND_RATE_PER_SEC = 20; +const INBOUND_BURST = 40; +const RING_CAPACITY = 4096; + +// cid → 馈送(重放缓冲 + 最近快照 + 连接集合 + 订阅句柄)。 +// 生命周期按连接引用计数:最后一个连接断开即退订并丢弃,避免僵尸订阅。 +const feeds = new Map(); + +/** 关闭帧体:2 字节大端 code + UTF-8 reason(RFC 6455 §5.5)。 */ +function closeBody(code, reason) { + const reasonBuf = Buffer.from(String(reason || ""), "utf8"); + const body = Buffer.alloc(2 + reasonBuf.length); + body.writeUInt16BE(code, 0); + reasonBuf.copy(body, 2); + return body; +} + +/** 总线条目 → 线上帧对象(兼容环形缓冲 replay 的 {seq, item} 包装形状)。 */ +function frameForItem(raw) { + const item = raw && raw.item ? raw.item : raw; + const ev = item.event || {}; + const payload = + ev.type === "control" + ? { name: ev.name, data: ev.data } + : ev.snapshot; + return { v: 1, seq: item.seq, ts: item.ts, type: ev.type, payload }; +} + +function acquireFeed(cid, ringCapacity) { + let feed = feeds.get(cid); + if (feed) return feed; + feed = { + ring: createRingBuffer(ringCapacity || RING_CAPACITY), + lastSnapshot: null, + conns: new Set(), + unsubscribe: null, + }; + feed.unsubscribe = subscribeEvents(cid, (item) => { + feed.ring.push(item.seq, item); + if (item.event && item.event.type === "state.snapshot") { + feed.lastSnapshot = item; + } + const frame = frameForItem(item); + for (const conn of feed.conns) conn.send(frame); + }); + feeds.set(cid, feed); + return feed; +} + +function releaseFeed(cid, feed) { + if (feed.conns.size > 0) return; + if (typeof feed.unsubscribe === "function") feed.unsubscribe(); + feeds.delete(cid); +} + +/** + * 处理 GET /api/stream 的 WebSocket 升级。 + * + * @param {import("node:http").IncomingMessage} req + * @param {import("node:net").Socket} socket + * @param {Buffer} head + * @param {object} [opts] 测试缝:heartbeatMs / ringCapacity / + * inboundPerSec / inboundBurst / maxFrameBytes + */ +export function handleStreamUpgrade(req, socket, head, opts = {}) { + const pathname = (req.url || "/").split("?")[0]; + const reject = (status, message) => { + try { + socket.write( + "HTTP/1.1 " + status + " X\r\nContent-Type: application/json; charset=utf-8\r\nConnection: close\r\n\r\n" + + JSON.stringify({ ok: false, error: message }), + ); + } catch {} + try { socket.destroy(); } catch {} + }; + + if (pathname !== "/api/stream") return reject(404, "not found"); + + // 门链(与原 /api/events 同款):origin / LAN / token。 + // 升级等同于带副作用的请求:有 Origin 必须在信任集内(CSRF 边界)。 + const originHeader = normalizeOriginHeader(req.headers.origin); + const trustedOrigins = buildTrustedOrigins({ + port: getServingPort(), + lanBroadcast: getLanBroadcast(), + extra: getTrustedOrigins(), + }); + if (originHeader !== "" && !trustedOrigins.has(originHeader)) { + return reject(403, "cross-origin request rejected"); + } + const local = isLocalRequest(req); + if (!local && !getLanBroadcast()) return reject(403, "LAN access disabled"); + if (!local && !isRequestAuthorized(req)) return reject(401, "token required"); + + const key = req.headers["sec-websocket-key"]; + if (typeof key !== "string" || key.length === 0) { + return reject(400, "missing Sec-WebSocket-Key"); + } + socket.write( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + + computeAcceptKey(key) + "\r\n\r\n", + ); + socket.setNoDelay(true); + + const cid = getCidFromReq(req); + const heartbeatMs = + Number(opts.heartbeatMs) > 0 ? Number(opts.heartbeatMs) : DEFAULT_HEARTBEAT_MS; + const maxFrameBytes = + Number(opts.maxFrameBytes) > 0 ? Number(opts.maxFrameBytes) : 1024 * 1024; + const burst = Number(opts.inboundBurst) > 0 ? Number(opts.inboundBurst) : INBOUND_BURST; + const refillPerMs = + (Number(opts.inboundPerSec) > 0 ? Number(opts.inboundPerSec) : INBOUND_RATE_PER_SEC) / 1000; + + const writeFrame = (opcode, payload) => { + try { socket.write(encodeFrame({ opcode, payload })); } catch {} + }; + const writeJson = (obj) => writeFrame(OPCODE.TEXT, JSON.stringify(obj)); + let closed = false; + const closeWith = (code, reason) => { + if (closed) return; + closed = true; + writeFrame(OPCODE.CLOSE, closeBody(code, reason)); + try { socket.end(); } catch {} + }; + + const decoder = createFrameDecoder({ maxFrameBytes }); + let missedPongs = 0; + // 入站配额:令牌桶(突发 burst,稳态 inboundPerSec 帧/秒,决策记录 17) + let tokens = burst; + let lastRefill = Date.now(); + + const feed = acquireFeed(cid, opts.ringCapacity); + const conn = { send: (frame) => writeJson(frame) }; + feed.conns.add(conn); + // 清理只解订阅,不主动 destroy —— destroy 会冲掉未刷写的收尾帧 + // (error / close),让 socket.end() 自然收尾、close 事件触发清理。 + const release = () => { + if (feed.conns.delete(conn)) { + releaseFeed(cid, feed); + // 只在最后一个连接真正离开时广播在线数(close + error 双触发只算一次) + pushOnlineCount(getLanBroadcast()); + } + clearInterval(heartbeat); + }; + socket.on("close", release); + socket.on("error", release); + + writeJson({ + v: 1, + type: "hello", + payload: { + resumeSupported: true, + latestSeq: getLatestSeq(cid), + heartbeatMs, + ringCapacity: feed.ring.capacity, + cid, + }, + }); + + // 承接原 handleEvents(/api/events)的两个连接副作用: + // a) 在线数广播 —— 所有已连接客户端同步 onlineCount。 + pushOnlineCount(getLanBroadcast()); + // b) mavis 真值 hydrate(fire-and-forget,与原实现同款):该 cid 已绑定 + // mcodeSessionId 时立刻查 mavis db,有真值就补推一帧快照,没有就保留估算。 + const cs = getClient(cid); + if (cs && cs.mcodeSessionId) { + const sid = cs.mcodeSessionId; + Promise.resolve().then(() => { + applyMavisUsageToCs(cs, sid, { getMcodeModelLimit }) + .then((applied) => { + if (applied) pushStateFor(cid); + }) + .catch(() => { + /* swallow — keep estimate */ + }); + }); + } + + const heartbeat = setInterval(() => { + if (missedPongs >= MAX_MISSED_PONGS) { + closeWith(CLOSE_CODE.GOING_AWAY, "heartbeat timeout"); + return; + } + missedPongs += 1; + writeFrame(OPCODE.PING); + }, heartbeatMs); + if (typeof heartbeat.unref === "function") heartbeat.unref(); + socket.on("close", () => clearInterval(heartbeat)); + + function handleClientJson(text) { + let msg; + try { msg = JSON.parse(text); } catch { + writeJson({ v: 1, type: "error", payload: { code: "bad-frame", message: "client text frames must be JSON" } }); + return; + } + const t = msg && msg.type; + if (t === "resume") { + const lastSeq = Number(msg.payload && msg.payload.lastSeq) || 0; + const replay = feed.ring.replay(lastSeq + 1); + if (replay.complete) { + for (const it of replay.items) writeJson(frameForItem(it)); + } else if (feed.lastSnapshot) { + // 欠载(环形缓冲已覆盖不到):以最近快照为基线(决策记录 17 近似语义) + writeJson(frameForItem(feed.lastSnapshot)); + } else { + writeJson({ v: 1, type: "error", payload: { code: "resume-underrun", message: "no buffered events and no snapshot baseline yet" } }); + } + return; + } + if (t === "ping") { writeJson({ v: 1, type: "pong" }); return; } + if (t === "pong") { missedPongs = 0; return; } + if (t === "close") { closeWith(CLOSE_CODE.NORMAL); release(); return; } + writeJson({ v: 1, type: "error", payload: { code: "unknown-type", message: "unsupported client frame type: " + String(t) } }); + } + + socket.on("data", (chunk) => { + if (closed) return; + const events = decoder.push(chunk) || []; + for (const ev of events) { + // 入站配额按帧计数(TCP 粘包下块计数无意义),令牌桶:突发 burst、稳态 refillPerMs + const now = Date.now(); + tokens = Math.min(burst, tokens + (now - lastRefill) * refillPerMs); + lastRefill = now; + tokens -= 1; + if (tokens < 0) { + writeJson({ v: 1, type: "error", payload: { code: "quota", message: "inbound frame quota exceeded" } }); + closeWith(CLOSE_CODE.TRY_AGAIN_LATER, "inbound frame quota exceeded"); + return; + } + if (ev.kind === "protocol-error") { + closeWith(ev.code || CLOSE_CODE.PROTOCOL_ERROR, ev.message || "protocol error"); + return; + } + if (ev.kind === "binary") { + closeWith(CLOSE_CODE.PROTOCOL_ERROR, "binary frames not accepted"); + return; + } + if (ev.kind === "ping") { missedPongs = 0; writeFrame(OPCODE.PONG, ev.payload); continue; } + if (ev.kind === "pong") { missedPongs = 0; continue; } + if (ev.kind === "close") { closeWith(CLOSE_CODE.NORMAL); return; } + if (ev.kind === "text") handleClientJson(ev.text); + } + }); +} diff --git a/packages/webui/server/router.js b/packages/webui/server/router.js index 65db542f..dca4b712 100644 --- a/packages/webui/server/router.js +++ b/packages/webui/server/router.js @@ -122,19 +122,14 @@ const ROUTES = [ handler: healthRoute.handleHealth, }, - // State + SSE - { - method: "GET", - match: (p) => p === "/api/events", - handler: stateRoute.handleEvents, - }, + // State { method: "GET", match: (p) => p === "/api/state", handler: stateRoute.handleState, }, - // v2.0 (lease B02): anomaly / system-signal SSE channel + // v2.0 (lease B02): anomaly / system-signal REST snapshot (live frames via /api/stream) { method: "GET", match: (p) => p === "/api/alerts", @@ -314,7 +309,7 @@ const ROUTES = [ match: (p) => p === "/api/settings", handler: settingsRoute.handlePostSettings, }, - // v2.0 (reconcile §6.1): POST /api/auth/decision — the SSE-driven + // v2.0 (reconcile §6.1): POST /api/auth/decision — the stream-driven // authorize gate close path. Was exported by server/lib/authorize.js // (B03) but never bound to a route. Client posts {requestId, approve} // to resolve the per-request authorize() Promise. Method-gated POST, @@ -402,6 +397,17 @@ const ROUTES = [ match: (p) => p === "/api/protocol/list-sessions", handler: protocolRoute.handleListSessions, }, + // 波次 2b(docs/drafts/arch_net_solution_0922.md §7.2):WebSocket 事件流端点。 + // 升级请求由 server.js 的 upgrade 钩子转 handleStreamUpgrade(门链同款); + // 普通 GET 到这里仅作 426 应答(保持路由表与文档端点清单逐字对齐)。 + { + method: "GET", + match: (p) => p === "/api/stream", + handler: (req, res) => { + res.writeHead(426, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ ok: false, error: "upgrade required (WebSocket)" })); + }, + }, { method: "GET", match: (p) => p === "/api/protocol/capabilities", @@ -481,7 +487,7 @@ export async function handleRequest(req, res) { // Gate 3: token auth (v1.0.1). // - Local request: always allowed. - // - /api/* routes (incl. SSE /api/events): gated when TOKEN auth enabled. + // - /api/* routes: gated when TOKEN auth enabled. // - OPTIONS preflight: always allowed (browsers cannot attach // Authorization to a preflight; CORS spec says server must respond // to OPTIONS with the negotiated CORS headers, not 401). @@ -490,7 +496,7 @@ export async function handleRequest(req, res) { // - Static files (HTML/CSS/JS/images): always public so the SPA can // bootstrap (load index.html, fetch app/main.js). // - The SPA reads ?token= from the URL (browser) and stores it in - // localStorage; subsequent fetch + EventSource attach it as + // localStorage; subsequent fetch + the /api/stream WebSocket attach it as // Authorization: Bearer / ?token=. if ( pathname.startsWith("/api/") && diff --git a/packages/webui/server/routes/alerts.js b/packages/webui/server/routes/alerts.js index 6d74fc1e..4273f401 100644 --- a/packages/webui/server/routes/alerts.js +++ b/packages/webui/server/routes/alerts.js @@ -1,71 +1,16 @@ // webui/server/routes/alerts.js -// GET /api/alerts (SSE) — independent anomaly / system-signal channel. +// GET /api/alerts — REST snapshot of the alert ring buffer. // -// Protocol (mirrors /api/events but simpler): -// 1. Server writes SSE headers + a snapshot of the ring buffer. -// 2. On every pushAlert(), an `append` or `update` frame is sent. -// 3. Heartbeat: every 30s a `event: heartbeat` frame. -// 4. On req "close", unsubscribe + clear heartbeat interval. -// -// Frame shapes: -// data: {"kind":"snapshot","alerts":[...]} -// data: {"kind":"append","alert":{...}} -// data: {"kind":"update","alert":{...}} -// event: heartbeat -// data: {"ts":} -// -// Frontend (bell icon — owned by C batch, not this lease) is expected -// to subscribe via `new EventSource("/api/alerts")` and render the -// ring buffer + increment an unread counter on each `append`. - -import { - getRecentAlerts, - subscribeAlerts, -} from "../lib/alerts.js"; +// Real-time append/update traffic is delivered over the WebSocket event +// stream (/api/stream) as the `alerts.append` / `alerts.update` control +// frames (see state-bus.js#attachAlertBridge). The frontend fetches this +// snapshot once after each stream connection is established and dedupes +// the live frames against it by alert.id. -const SSE_HEADERS = { - "Content-Type": "text/event-stream; charset=utf-8", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - "X-Accel-Buffering": "no", -}; - -const HEARTBEAT_MS = 30_000; +import { getRecentAlerts } from "../lib/alerts.js"; export async function handleAlerts(req, res, _ctx) { - res.writeHead(200, SSE_HEADERS); - - // 1. Replay ring buffer as a single snapshot frame. - const snapshot = { - kind: "snapshot", - alerts: getRecentAlerts(), - }; - try { - res.write(`data: ${JSON.stringify(snapshot)}\n\n`); - } catch { - // Client already gone — nothing to do. - return true; - } - - // 2. Subscribe to live updates. - const unsubscribe = subscribeAlerts(res); - - // 3. Heartbeat — keeps proxies from idling the SSE channel out. - const heartbeat = setInterval(() => { - try { - res.write(`event: heartbeat\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`); - } catch { - // Will be cleaned up on close. - } - }, HEARTBEAT_MS); - // Don't keep the event loop alive just for heartbeats. - if (typeof heartbeat.unref === "function") heartbeat.unref(); - - // 4. Cleanup on disconnect. - req.on("close", () => { - clearInterval(heartbeat); - unsubscribe(); - }); - + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ kind: "snapshot", alerts: getRecentAlerts() })); return true; -} \ No newline at end of file +} diff --git a/packages/webui/server/routes/chat.js b/packages/webui/server/routes/chat.js index f4bd86ef..ca727ac5 100644 --- a/packages/webui/server/routes/chat.js +++ b/packages/webui/server/routes/chat.js @@ -19,7 +19,13 @@ import { pushStateFor, pushAlert, getActiveChild } from "../lib/state-bus.js"; import { handleLocalSlash, handleCmdCommand } from "../lib/slash.js"; import { runMcodeAcp } from "../lib/mcode-acp.js"; import { collectExecResult, runMcodeExec } from "../lib/mcode-exec.js"; -import { DEFAULT_MODEL } from "../lib/config.js"; +import { + bootEngineHost, + isEmbedRunning, + runMcodeEmbed, +} from "../lib/mcode-embed.js"; +import { collectEmbedResult } from "../lib/embed-consumer.js"; +import { DEFAULT_MODEL, MCODE_ENGINE } from "../lib/config.js"; async function readJson(req) { let body = ""; @@ -75,7 +81,7 @@ function resetThinkingClaim(cs) { } } -// POST /api/send — main chat entry, fire-and-forget (response = ack; output via /api/events SSE) +// POST /api/send — main chat entry, fire-and-forget (response = ack; output via /api/stream) export async function handleSend(req, res, ctx) { const cs = ctx.cs; const cid = ctx.cid; @@ -138,24 +144,41 @@ export async function handleSend(req, res, ctx) { `[send] cid=${cid} content=${JSON.stringify(content.slice(0, 80))} model=${modelToUse} sessionId=${cs.mcodeSessionId} workspace=${(cs && cs.workspace && cs.workspace.dir) || "null"}`, ); const t0 = Date.now(); - const r = - process.env.MCODE_USE_ACP === "0" - ? await collectExecResult( - runMcodeExec(content, { - label: "prompt", - sessionId: cs.mcodeSessionId, - model: modelToUse, - cs, - cid, - }), - ) - : await runMcodeAcp(content, { - label: "prompt", - sessionId: cs.mcodeSessionId, - model: modelToUse, - cs, - cid, - }); + const engineOpts = { + label: "prompt", + sessionId: cs.mcodeSessionId, + model: modelToUse, + cs, + cid, + }; + // 波次 2(arch_net_solution_0922.md §6.3):MCODE_ENGINE=embed 走引擎宿主 + // Worker(boot 失败自动回退旧路径);默认 "acp" 完全走下方旧路径,行为不变。 + let r = null; + if (MCODE_ENGINE === "embed") { + if (!isEmbedRunning()) { + const boot = await bootEngineHost({ + workspace: (cs && cs.workspace && cs.workspace.dir) || undefined, + }); + if (!boot.ok) { + console.warn( + `[send] mcode embed boot failed, falling back to legacy transport: ${boot.reason}`, + ); + } + } + if (isEmbedRunning()) { + r = await collectEmbedResult(runMcodeEmbed(content, engineOpts), { + cs, + cid, + label: engineOpts.label, + }); + } + } + if (!r) { + r = + process.env.MCODE_USE_ACP === "0" + ? await collectExecResult(runMcodeExec(content, engineOpts)) + : await runMcodeAcp(content, engineOpts); + } console.log( `[send] result ${Date.now() - t0}ms:`, JSON.stringify({ diff --git a/packages/webui/server/routes/export.js b/packages/webui/server/routes/export.js index fe02794a..badbf9dd 100644 --- a/packages/webui/server/routes/export.js +++ b/packages/webui/server/routes/export.js @@ -15,7 +15,7 @@ // - format validation → 400 { error: "unsupported format" } // - session lookup → 404 { error: "session not found" } // - B03 authorize gate: `session.export` action. UI gets a -// `needs_authorization` SSE modal; decline → 403. Tests drive the +// `needs_authorization` event-stream modal; decline → 403. Tests drive the // decision via test/_setup.js#withDecisions (no auto-approve — // removed in the 2026-09-20 rigor fix). // diff --git a/packages/webui/server/routes/sessions.js b/packages/webui/server/routes/sessions.js index 4cdcf7bf..fc975cb7 100644 --- a/packages/webui/server/routes/sessions.js +++ b/packages/webui/server/routes/sessions.js @@ -310,7 +310,7 @@ export async function handleSwitchSession(req, res, ctx) { // the mcode transcript from the runtime DB (read-only) and map it into // the webui chat-line grammar BEFORE responding, so response session.chat // and cs.chat carry history. Caps inside (last 400 lines / 200KB) keep - // the SSE state push bounded; a 1000+-message session must not balloon + // the event-stream state push bounded; a 1000+-message session must not balloon // it. FAILURE MUST NOT BREAK SWITCHING: any error logs and continues // with chat: [] — the switch itself always succeeds. if ( @@ -853,7 +853,7 @@ export async function handleAcpSessionTitle(req, res, _ctx) { // gate with authorize("session.search", ctx). The new action name // is appended to AUTHORIZE_ACTIONS in server/lib/authorize.js so // the whitelist check accepts it. In production this pops the same -// needs_authorization SSE modal as session.delete / session.export; +// needs_authorization modal (event-stream control frame) as session.delete / session.export; // tests drive the decision via test/_setup.js#withDecisions (the // execArgv auto-approve was removed in the 2026-09-20 rigor fix). // diff --git a/packages/webui/server/routes/settings.js b/packages/webui/server/routes/settings.js index 23ae73ac..4ad67fde 100644 --- a/packages/webui/server/routes/settings.js +++ b/packages/webui/server/routes/settings.js @@ -3,7 +3,7 @@ // // v0.5.ap: lanBroadcast toggle // v1.0.1: readOnly / tokenEnabled / resetToken / acknowledgeToken. -// Rotation broadcasts an SSE event so other clients can update their +// Rotation broadcasts an event-stream frame so other clients can update their // localStorage. import { @@ -166,7 +166,7 @@ export async function handlePostSettings(req, res, ctx) { changed = true; } - // resetToken — generate a new token, broadcast SSE, return the new value + // resetToken — generate a new token, broadcast over the event stream, return the new value if (payload.resetToken === true) { // B03: token rotation is destructive — every remote client loses // its HEADERS / localStorage credential and must re-handshake. @@ -212,7 +212,7 @@ export async function handlePostSettings(req, res, ctx) { res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" }); return res.end(JSON.stringify({ ok: false, error: e.message })); } - // Broadcast the new token to all currently-connected SSE clients. + // Broadcast the new token to all currently-connected event-stream clients. // We push BOTH the dedicated auth.token_rotated event (so clients // can update their HEADERS + localStorage immediately, before the // state push arrives) AND the full state push (which includes diff --git a/packages/webui/server/routes/state.js b/packages/webui/server/routes/state.js index 4343cd62..c3827c4a 100644 --- a/packages/webui/server/routes/state.js +++ b/packages/webui/server/routes/state.js @@ -1,16 +1,8 @@ // webui/server/routes/state.js -// GET /api/events (SSE) + GET /api/state +// GET /api/state — full state snapshot (first-connect baseline for the +// /api/stream client; live frames then arrive over the stream). -import { - getClient, - getCidFromReq, - pushStateFor, - pushOnlineCount, - mcodeSessionsSnapshotFields, - getSseClient, - setSseClient, - endSseClient, -} from "../lib/state-bus.js"; +import { getClient } from "../lib/state-bus.js"; import { loadSessions } from "../lib/sessions.js"; import { getMcodeSessionsForWorkspace, @@ -32,83 +24,6 @@ import { maskTokenPlanKey, } from "../lib/settings.js"; -export async function handleEvents(req, res, ctx) { - const cid = getCidFromReq(req); - const cs = getClient(cid); - // 关掉旧 SSE(避免同一个 cid 有多个挂起连接) - const old = getSseClient(cid); - if (old) { - try { - old.end(); - } catch {} - } - res.writeHead(200, SSE_HEADERS); - // v1.0: 首推也必须带 mcodeSessions 字段 (之前缺, 侧栏先渲染 webui 本地条目再闪回全量) - // v1.0.1: 首推也必须带 settings fields (readOnly / tokenEnabled / currentToken - // conditional on acknowledged, etc) — 否则 sub-card 第一次 render 时是空的 - const snapshot = { - ...cs, - sessions: loadSessions(), - ...mcodeSessionsSnapshotFields((cs.workspace && cs.workspace.dir) || ""), - lanBroadcast: getLanBroadcast(), - readOnly: getReadOnly(), - tokenEnabled: getTokenEnabled(), - currentToken: getTokenAcknowledged() ? "" : getCurrentToken(), - tokenAcknowledged: getTokenAcknowledged(), - tokenRotatedAt: getTokenRotatedAt(), - // v2026-08-28 modacker: Token Plan (套餐用量) feature fields. - // See state-bus.js for the rationale. The first SSE push on - // connection must include them too, otherwise the appearance - // card's quota toggle would initialize in the wrong state. - quotaEnabled: getQuotaEnabled(), - hasTokenPlanKey: getTokenPlanApiKey().length > 0, - tokenPlanApiKeyMasked: maskTokenPlanKey(), - // v2026-08-28 modacker (A+C): external key source surface. - tokenPlanApiKeySource: getTokenPlanApiKeySource(), - tokenPlanApiKeyFilePath: getTokenPlanApiKeyFilePath(), - }; - res.write(`data: ${JSON.stringify(snapshot)}\n\n`); - setSseClient(cid, res); - const ping = setInterval(() => { - try { - res.write(": ping\n\n"); - } catch {} - }, 20000); - req.on("close", () => { - clearInterval(ping); - endSseClient(cid, res); - pushOnlineCount(getLanBroadcast()); // v0.5.ak: 客户端断开时广播在线数 - }); - pushOnlineCount(getLanBroadcast()); // v0.5.ak: 客户端新连接时广播在线数 - - // v0.5.bx-29: SSE 连接时主动 hydrate mavis db 真值 - // 修: 之前只在 finalize() 里查 mavis db, 只更新发起 prompt 的那个 cid - // 其它 CID (比如手机开了同一 session 但没发消息) 永远只看估算 - // 现在: 新 SSE 连接建立时, 如果该 cid 已经绑定了 mcodeSessionId, 立刻查 mavis db - // 有真值就 pushStateFor 让该 cid 看到真值, 没有就保留估算 - // fire-and-forget, 不阻塞 SSE 响应 - if (cs.mcodeSessionId) { - const sid = cs.mcodeSessionId; - Promise.resolve().then(() => { - applyMavisUsageToCs(cs, sid, { getMcodeModelLimit }) - .then((applied) => { - if (applied) pushStateFor(cid); - }) - .catch(() => { - /* swallow — keep estimate */ - }); - }); - } - return true; -} - -const SSE_HEADERS = { - "Content-Type": "text/event-stream; charset=utf-8", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - "X-Accel-Buffering": "no", -}; - export async function handleState(req, res, ctx) { const cs = getClient(ctx.cid); const mcodeSessions = await getMcodeSessionsForWorkspace( @@ -116,7 +31,7 @@ export async function handleState(req, res, ctx) { ); res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); // v0.5.bx-29: /api/state 也尝试 hydrate mavis db 真值 (best-effort) - // SSE 客户端 (EventSource) 也会调这个端点, 所以 hydrate 也能发生在 reconnect 时 + // 事件流客户端 (WebSocket /api/stream) 也会调这个端点, 所以 hydrate 也能发生在重连时 if (cs.mcodeSessionId) { try { await applyMavisUsageToCs(cs, cs.mcodeSessionId, { getMcodeModelLimit }); @@ -132,20 +47,20 @@ export async function handleState(req, res, ctx) { availableCommands: getCachedMcodeCommands(), lanBroadcast: getLanBroadcast(), // v1.0.1: include the full settings surface so the sub-card - // renders correctly on first /api/state fetch (before the SSE - // connection delivers its first state push). + // renders correctly on first /api/state fetch (before the event + // stream delivers its first state push). readOnly: getReadOnly(), tokenEnabled: getTokenEnabled(), // Only send currentToken when not acknowledged — same policy as - // the SSE push (see state-bus.js). + // the event-stream push (see state-bus.js). currentToken: getTokenAcknowledged() ? "" : getCurrentToken(), tokenAcknowledged: getTokenAcknowledged(), tokenRotatedAt: getTokenRotatedAt(), // v2026-08-28 modacker: Token Plan (套餐用量) feature fields — // see state-bus.js for the rationale. /api/state is the path - // the client uses as a fallback when SSE isn't connected yet - // (e.g., before attachEvents runs); it must carry the same - // fields as the SSE snapshot. + // the client uses as a fallback when the event stream isn't + // connected yet (e.g., before the WebSocket attaches); it must + // carry the same fields as the event-stream snapshot. quotaEnabled: getQuotaEnabled(), hasTokenPlanKey: getTokenPlanApiKey().length > 0, tokenPlanApiKeyMasked: maskTokenPlanKey(), diff --git a/packages/webui/test/_setup.js b/packages/webui/test/_setup.js index ffa3b884..7eee2f27 100644 --- a/packages/webui/test/_setup.js +++ b/packages/webui/test/_setup.js @@ -611,125 +611,275 @@ export async function withDecisions(fn, { approve = true } = {}) { } } -// ----------------------------------------------------------------------- -// decideNextAuthorization — HTTP-level decision driver for integration -// tests that spawn the REAL server.js in a child process. Subscribes -// to the SSE stream, waits for the next `needs_authorization` frame, -// extracts the requestId, and POSTs /api/auth/decision — the exact -// wire path the production modal uses. +// --------------------------------------------------------------------------- +// openEventStream — minimal WebSocket client for GET /api/stream, shared +// by decideNextAuthorization() and the integration tests (ws-channel, +// event-chain). The SSE channel is gone; the live surface is now +// "WebSocket event stream + REST". +// +// Performs the RFC 6455 §1.3 upgrade BY HAND: GET /api/stream?cid=... +// with Connection: Upgrade / Upgrade: websocket headers and a random +// 16-byte Sec-WebSocket-Key, then waits for the "HTTP/1.1 101" response +// (verifying Sec-WebSocket-Accept via the RFC GUID SHA-1 formula). +// +// Resolves { frames, send(text), close(), timedOut? }: +// - frames: every server TEXT frame accumulated so far, JSON-parsed +// ({ raw } for non-JSON text). Server → client frames are NOT masked +// (RFC 6455 §5.1), while lib/ws-frame.js#createFrameDecoder is the +// client → server direction decoder and rejects unmasked frames with +// 1002 (verified) — hence the local unmasked header parser below, +// shaped like test/lib-ws-server.test.js#parseServerFrames. +// - send(text): client → server TEXT frame, masked per RFC 6455 §5.3 +// (FIN|0x81, mask bit, 4-byte random key, XOR; payload < 126 bytes). +// - close(): destroys the socket (idempotent). +// - 超时守卫: resolves { timedOut: true } after 2500ms instead of +// hanging the suite (timeout-resolve pattern from the old +// sse-channel.test.js / test/lib-ws-server.test.js connect()). +// --------------------------------------------------------------------------- +export function openEventStream(port, cid) { + return new Promise((resolve, reject) => { + Promise.all([import("node:net"), import("node:crypto")]) + .then(([{ default: net }, { randomBytes, createHash }]) => { + const frames = []; + const st = { header: "", buf: Buffer.alloc(0), handshook: false, socket: null }; + let settled = false; + const key = randomBytes(16).toString("base64"); + const api = { + frames, + send(text) { + const body = Buffer.from(String(text), "utf8"); + if (body.length >= 126) throw new Error("openEventStream: frame must stay under 126 bytes"); + const maskKey = randomBytes(4); + const head = Buffer.alloc(6); + head[0] = 0x81; // FIN | TEXT + head[1] = 0x80 | body.length; // mask bit + 7-bit length + maskKey.copy(head, 2); + const masked = Buffer.from(body); + for (let i = 0; i < masked.length; i++) masked[i] ^= maskKey[i & 3]; + st.socket.write(Buffer.concat([head, masked])); + }, + close() { + try { st.socket.destroy(); } catch {} + }, + }; + const ingest = (chunk) => { + st.buf = Buffer.concat([st.buf, chunk]); + parseServerTextFrames(st, frames); + }; + const guard = setTimeout(() => { + if (settled) return; + settled = true; + try { st.socket.destroy(); } catch {} + resolve({ ...api, timedOut: true }); + }, 2500); + const done = (err) => { + if (settled) return; + settled = true; + clearTimeout(guard); + if (err) { + try { st.socket.destroy(); } catch {} + reject(err); + } else { + resolve(api); + } + }; + st.socket = net.connect(port, "127.0.0.1"); + st.socket.on("error", (e) => { + if (!st.handshook) done(e); // post-handshake teardown noise is ours + }); + st.socket.on("data", (chunk) => { + if (!st.handshook) { + st.header += chunk.toString("latin1"); + const idx = st.header.indexOf("\r\n\r\n"); + if (idx === -1) return; + const head = st.header.slice(0, idx); + const rest = Buffer.from(st.header.slice(idx + 4), "latin1"); + if (!/^HTTP\/1\.1 101\b/.test(head)) { + done(new Error(`openEventStream: expected 101 upgrade, got: ${head.split("\r\n")[0] || "(empty)"}`)); + return; + } + const accept = head.match(/^sec-websocket-accept:\s*(.+)$/im); + const expected = createHash("sha1") + .update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", "utf8") + .digest("base64"); + if (accept && accept[1].trim() !== expected) { + done(new Error("openEventStream: bad Sec-WebSocket-Accept")); + return; + } + st.handshook = true; + if (rest.length) ingest(rest); + done(); + return; + } + ingest(chunk); + }); + st.socket.on("connect", () => { + st.socket.write( + `GET /api/stream${cid ? `?cid=${encodeURIComponent(cid)}` : ""} HTTP/1.1\r\n` + + `Host: 127.0.0.1:${port}\r\n` + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + `Sec-WebSocket-Key: ${key}\r\n` + + "Sec-WebSocket-Version: 13\r\n" + + "\r\n", + ); + }); + }) + .catch(reject); + }); +} + +/** + * Incrementally parse server → client frames (unmasked per RFC 6455 §5.1) + * and push every TEXT frame's JSON payload onto `out`. Control frames + * (ping/pong/close) and binary frames are consumed but not surfaced — + * the helpers only care about protocol text frames. + */ +function parseServerTextFrames(st, out) { + while (st.buf.length >= 2) { + const opcode = st.buf[0] & 0x0f; + let len = st.buf[1] & 0x7f; + let offset = 2; + if (len === 126) { + if (st.buf.length < 4) return; + len = st.buf.readUInt16BE(2); + offset = 4; + } else if (len === 127) { + if (st.buf.length < 10) return; + len = Number(st.buf.readBigUInt64BE(2)); + offset = 10; + } + const masked = (st.buf[1] & 0x80) !== 0; + const maskLen = masked ? 4 : 0; + if (st.buf.length < offset + maskLen + len) return; + let payload = st.buf.subarray(offset + maskLen, offset + maskLen + len); + if (masked) { + const mk = st.buf.subarray(offset, offset + 4); + const un = Buffer.allocUnsafe(len); + for (let i = 0; i < len; i++) un[i] = payload[i] ^ mk[i & 3]; + payload = un; + } + st.buf = st.buf.subarray(offset + maskLen + len); + if (opcode === 0x1) { + const text = payload.toString("utf8"); + try { out.push(JSON.parse(text)); } catch { out.push({ raw: text }); } + } + // 0x8 close / 0x9 ping / 0xa pong / 0x2 binary: consumed, not surfaced. + } +} + +// --------------------------------------------------------------------------- +// decideNextAuthorization — decision driver for integration tests that +// spawn the REAL server.js in a child process. Opens the /api/stream +// WebSocket (openEventStream above), waits for the control frame +// `needs_authorization`, extracts the requestId from its JSON-string +// `data`, and POSTs /api/auth/decision — the exact wire path the +// production modal uses. // // Resolves { requestId, decision } where decision is the parsed // /api/auth/decision response. Rejects if no auth request arrives // within `timeoutMs` (default 3s). // // IMPORTANT: start this helper BEFORE firing the gated HTTP request -// and allow a short delay for the SSE subscription to register — +// and allow a short delay for the WebSocket handshake to register — // needs_authorization broadcasts are NOT replayed to late subscribers -// (the pending-request SSE frame is fire-once). -// ----------------------------------------------------------------------- +// (the pending-request control frame is fire-once). +// --------------------------------------------------------------------------- export function decideNextAuthorization({ port, approve = true, cid, timeoutMs = 3000 }) { return new Promise((resolve, reject) => { - import("node:http").then((http) => { - // settled: outer promise has resolved/rejected. - // deciding: a needs_authorization frame was seen and the decision - // POST is in flight — from that point, teardown noise from our own - // SSE destroy() must NOT reject the outer promise; the POST's - // outcome is the answer. - let settled = false; - let deciding = false; - let sseReq = null; - let postReq = null; - // U4 (2026-09-20): settlement guarantee — the bail-out timer now covers - // BOTH phases (waiting for the SSE frame AND the decision POST). The - // old code cleared it as soon as the frame arrived, so a server that - // accepted the POST but never responded left this promise pending - // forever with no timeout. On fire, everything is destroyed and the - // promise rejects — waiting sides must never depend on the peer (or - // incidental event-loop handles) for liveness. - const timer = setTimeout(() => { - if (settled) return; - settled = true; - try { if (sseReq) sseReq.destroy(); } catch {} - try { if (postReq) postReq.destroy(); } catch {} - reject(new Error( - `decideNextAuthorization: no completed needs_authorization decision within ${timeoutMs}ms`, - )); - }, timeoutMs); - const settle = (fn, value) => { - if (settled) return; - settled = true; - clearTimeout(timer); - fn(value); - }; - sseReq = http.request( - { - method: "GET", - host: "127.0.0.1", - port, - path: "/api/events" + (cid ? `?cid=${encodeURIComponent(cid)}` : ""), - }, - (res) => { - let body = ""; - res.setEncoding("utf8"); - res.on("data", (chunk) => { - if (deciding) return; // POST already dispatched; ignore trailing data - body += chunk; - // Frames arrive as `event: needs_authorization\ndata: {...}\n\n`. - // Scan the accumulated body each chunk — cheap at test scale. - const frames = body.split("\n\n"); - for (const frame of frames) { - const evMatch = frame.match(/^event: needs_authorization$/m); - if (!evMatch) continue; - const dataLine = frame.split("\n").find((l) => l.startsWith("data: ")); - if (!dataLine) continue; - let payload; - try { payload = JSON.parse(dataLine.slice("data: ".length)); } catch { continue; } - const requestId = payload && payload.requestId; - if (!requestId) continue; - deciding = true; // decision POST in flight — ignore SSE teardown noise - try { sseReq.destroy(); } catch {} - const data = JSON.stringify({ requestId, approve }); - postReq = http.request( - { - method: "POST", - host: "127.0.0.1", - port, - path: "/api/auth/decision", - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(data), + // settled: outer promise has resolved/rejected. + // deciding: a needs_authorization frame was seen and the decision + // POST is in flight — from that point, teardown noise from our own + // stream close() must NOT reject the outer promise; the POST's + // outcome is the answer. + let settled = false; + let deciding = false; + let stream = null; + let poll = null; + let postReq = null; + // U4 (2026-09-20): settlement guarantee — the bail-out timer covers + // BOTH phases (waiting for the control frame AND the decision POST). + // The old code cleared it as soon as the frame arrived, so a server + // that accepted the POST but never responded left this promise + // pending forever with no timeout. On fire, everything is destroyed + // and the promise rejects — waiting sides must never depend on the + // peer (or incidental event-loop handles) for liveness. + const teardown = () => { + if (poll) clearInterval(poll); + poll = null; + try { if (stream) stream.close(); } catch {} + try { if (postReq) postReq.destroy(); } catch {} + }; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + teardown(); + reject(new Error( + `decideNextAuthorization: no completed needs_authorization decision within ${timeoutMs}ms`, + )); + }, timeoutMs); + const settle = (fn, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (poll) clearInterval(poll); + poll = null; + fn(value); + }; + openEventStream(port, cid || `decider-${Date.now().toString(36)}`) + .then((s) => { + stream = s; + if (settled) { try { s.close(); } catch {} return; } + // Frames accumulate in s.frames; scan cheaply at test scale. + poll = setInterval(() => { + if (deciding || settled) return; // POST in flight / already done + for (const f of s.frames) { + if (!f || f.type !== "control" || !f.payload) continue; + if (f.payload.name !== "needs_authorization") continue; + let payload; + try { payload = JSON.parse(f.payload.data); } catch { continue; } + const requestId = payload && payload.requestId; + if (!requestId) continue; + deciding = true; // decision POST in flight — ignore stream teardown noise + if (poll) clearInterval(poll); + poll = null; + try { s.close(); } catch {} + import("node:http") + .then((http) => { + const data = JSON.stringify({ requestId, approve }); + postReq = http.request( + { + method: "POST", + host: "127.0.0.1", + port, + path: "/api/auth/decision", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(data), + }, }, - }, - (postRes) => { - const chunks = []; - postRes.on("data", (c) => chunks.push(c)); - postRes.on("end", () => { - let decision; - try { decision = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch {} - settle(resolve, { requestId, decision, status: postRes.statusCode }); - }); - postRes.on("error", (e) => { - settle(resolve, { requestId, decision: null, status: postRes.statusCode, error: e.message }); - }); - }, - ); - postReq.on("error", (e) => settle(reject, e)); - postReq.write(data); - postReq.end(); - return; - } - }); - res.on("error", (e) => { - if (settled || deciding) return; // our own destroy() - settle(reject, e); - }); - }, - ); - sseReq.on("error", (e) => { - if (settled || deciding) return; // our own destroy() - settle(reject, e); - }); - sseReq.end(); - }).catch(reject); + (postRes) => { + const chunks = []; + postRes.on("data", (c) => chunks.push(c)); + postRes.on("end", () => { + let decision; + try { decision = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch {} + settle(resolve, { requestId, decision, status: postRes.statusCode }); + }); + postRes.on("error", (e) => { + settle(resolve, { requestId, decision: null, status: postRes.statusCode, error: e.message }); + }); + }, + ); + postReq.on("error", (e) => settle(reject, e)); + postReq.write(data); + postReq.end(); + }) + .catch((e) => settle(reject, e)); + return; + } + }, 10); + }) + .catch((e) => settle(reject, e)); }); } diff --git a/packages/webui/test/fixtures/engine-host.stub.worker.js b/packages/webui/test/fixtures/engine-host.stub.worker.js new file mode 100644 index 00000000..d22bb133 --- /dev/null +++ b/packages/webui/test/fixtures/engine-host.stub.worker.js @@ -0,0 +1,133 @@ +// webui/test/fixtures/engine-host.stub.worker.js +// engine-host.worker.js 的协议桩:不加载引擎,完整实现 MessagePort RPC v:1 +// (boot / prompt / steer / cancel / shutdown → booted / boot-failed / event / +// reply / fatal)。事件序列 / boot-failed / fatal 均可经 workerData.script 脚本化。 +// +// script = { +// boot: 'ok' | 'fail', // 缺省 'ok' +// engineVersion: string, // boot:'ok' → {type:'booted', payload:{engineVersion}} +// bootError: string, // boot:'fail' → {type:'boot-failed', payload:{error}} +// prompts: [{ // 按 prompt 到达顺序消费;最后一项复用于后续 prompt +// events: [ NormalizedEvent | {delay: ms} | {fatal: {error}} ], +// reply: {ok: true, payload: {...}} | {ok: false, error: '...'}, +// }], +// steer: {ok: true, payload: {...}} | {ok: false, error: '...'}, +// cancel: {ok: true, payload: {...}} | {ok: false, error: '...'}, +// } +// events 里的 NormalizedEvent 原样作为 {type:'event', payload} 上行(逐字透传, +// 与生产 Worker 同语义);{delay} 是脚本排程用的停顿;{fatal} 发 'fatal' 帧后 +// 退出线程且不回 reply。 + +import { parentPort, workerData } from "node:worker_threads"; + +const script = (workerData && workerData.script) || {}; +let promptCount = 0; + +/** 向主线程发一帧(端口已关闭时静默 —— shutdown 后脚本可能仍有余波)。 */ +function post(msg) { + try { + parentPort.postMessage(msg); + } catch { + /* 端口关闭中 */ + } +} + +/** 回 RPC 应答(协议:{id, type:'reply', ok, payload | error})。 */ +function reply(id, ok, payload, error) { + if (ok) post({ id, type: "reply", ok: true, payload: payload ?? {} }); + else post({ id, type: "reply", ok: false, error: String(error || "stub error") }); +} + +/** 发 fatal 帧后退出线程(协议:未捕获异常不跨线程传播)。 */ +function postThenExit(msg, code) { + post(msg); + setImmediate(() => process.exit(code)); +} + +// 桩也完整实现 fatal 语义:真正的未捕获异常同样发 'fatal' 后退出。 +process.on("uncaughtException", (e) => + postThenExit({ type: "fatal", payload: { error: String((e && e.message) || e) } }, 1), +); +process.on("unhandledRejection", (e) => + postThenExit({ type: "fatal", payload: { error: String((e && e.message) || e) } }, 1), +); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** 消费一段 prompt 脚本:事件逐帧上行后回 reply(fatal 指令除外)。 */ +async function runPromptScript(msg) { + const list = + Array.isArray(script.prompts) && script.prompts.length + ? script.prompts + : [{}]; + const s = list[Math.min(promptCount++, list.length - 1)]; + for (const item of Array.isArray(s.events) ? s.events : []) { + if (item && typeof item.delay === "number") { + await sleep(item.delay); + continue; + } + if (item && item.fatal) { + postThenExit( + { type: "fatal", payload: { error: String(item.fatal.error || "scripted fatal") } }, + 1, + ); + return; // fatal 后不回 reply + } + post({ type: "event", payload: item }); + } + const r = s.reply || { ok: true, payload: {} }; + if (r.ok === false) reply(msg.id, false, null, r.error || "scripted failure"); + else reply(msg.id, true, r.payload || {}); +} + +/** steer / cancel 的脚本化应答。 */ +function runSimpleScript(msg, key, fallback) { + const s = script[key] || fallback; + if (s.ok === false) reply(msg.id, false, null, s.error || "scripted failure"); + else reply(msg.id, true, s.payload || {}); +} + +parentPort.on("message", (msg) => { + if (!msg || typeof msg !== "object") return; + if (msg.v != null && msg.v !== 1) { + if (msg.id != null) { + reply(msg.id, false, null, `unsupported protocol version ${msg.v}`); + } + return; + } + switch (msg.type) { + case "boot": { + if (script.boot === "fail") { + post({ + type: "boot-failed", + payload: { error: String(script.bootError || "stub boot-failed") }, + }); + } else { + post({ + type: "booted", + payload: { engineVersion: String(script.engineVersion || "stub-0.0.0") }, + }); + } + return; + } + case "prompt": + void runPromptScript(msg); + return; + case "steer": + runSimpleScript(msg, "steer", { ok: true, payload: { steered: true } }); + return; + case "cancel": + runSimpleScript(msg, "cancel", { ok: true, payload: { canceled: true } }); + return; + case "shutdown": { + reply(msg.id, true, { shutdown: true }); + // 关闭端口后线程自然退出 —— 「shutdown 无悬挂句柄」的被测语义 + parentPort.close(); + return; + } + default: + if (msg.id != null) { + reply(msg.id, false, null, `unknown request type ${msg.type}`); + } + } +}); diff --git a/packages/webui/test/integration/chat-wiring.test.js b/packages/webui/test/integration/chat-wiring.test.js index 52a3abd9..cbce9122 100644 --- a/packages/webui/test/integration/chat-wiring.test.js +++ b/packages/webui/test/integration/chat-wiring.test.js @@ -28,7 +28,7 @@ import { absPath, decideNextAuthorization } from "../_setup.js"; // child process (no module mocks — works identically with and without // --experimental-test-module-mocks) and drive /clear through the // production HTTP wire path, deciding the authorize() gate exactly -// like the browser modal does (SSE needs_authorization frame + POST +// like the browser modal does (WS needs_authorization control frame + POST // /api/auth/decision via the _setup.js decideNextAuthorization // helper). Same pattern as test/integration/event-chain.test.js. // ============================================================ @@ -229,7 +229,7 @@ describe("chat route production wiring — /clear must pass the slash.js gate", // needs_authorization frame is pushed to the sender's cid only). async function clearWithDecision(port, approve, { viaCmd = false } = {}) { const decider = decideNextAuthorization({ port, approve, cid: GATE_CID }); - // Let the decider's SSE subscription register before the gate + // Let the decider's WebSocket handshake register before the gate // broadcast fires (frames are not replayed to late subscribers). await new Promise((r) => setTimeout(r, 150)); const post = viaCmd diff --git a/packages/webui/test/integration/event-chain.test.js b/packages/webui/test/integration/event-chain.test.js index e864ae4c..7a09b4e1 100644 --- a/packages/webui/test/integration/event-chain.test.js +++ b/packages/webui/test/integration/event-chain.test.js @@ -9,7 +9,7 @@ // 3. Tamper detection: corrupt line N → verify() returns // { ok:false, error:"hash_mismatch", line:N } // 4. B01 + B02 + B03 三方整合: authorize() gated token.reset driven -// through the REAL wire path (SSE needs_authorization frame + +// through the REAL wire path (WS needs_authorization control + // POST /api/auth/decision) → settings mutation writes // events.ndjson + no alert fires for benign writes. // 5. Gate-blocking (2026-09-20 rigor fix): user decline → 403 + @@ -29,7 +29,7 @@ import { join, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import http from "node:http"; import { createHash } from "node:crypto"; -import { decideNextAuthorization } from "../_setup.js"; +import { decideNextAuthorization, openEventStream } from "../_setup.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const serverJsPath = join(__dirname, "..", "..", "server.js"); @@ -62,7 +62,7 @@ async function spawnServer(opts = {}) { }; // Plain node: no mock flag. The authorize() gate is fully live in // the child; gated requests are decided through the production - // wire path (SSE + POST /api/auth/decision). + // wire path (WS /api/stream + POST /api/auth/decision). const proc = spawn("node", [serverJsPath], { stdio: ["ignore", "pipe", "pipe"], cwd: join(__dirname, "..", ".."), @@ -408,17 +408,17 @@ describe("event-chain: tamper detection via verify()", () => { // // The child server runs the REAL gate (no auto-approve). A POST // /api/settings resetToken=true: -// - authorize() pends and pushes needs_authorization over SSE +// - authorize() pends and pushes needs_authorization over the WS event stream // - the test captures the frame, POSTs /api/auth/decision (approve) // - the gate resolves approved → settings.js#rotateToken() runs // - events.js#append() writes auth.* + token.reset.* + settings.* lines // - state-bus.js#broadcastTokenRotated() pushes auth.token_rotated -// to all SSE clients +// to all /api/stream clients // - NO alert fires (benign state change — alerts are for system // signals only) // -// We assert that the chain gains settings.* events AND the SSE -// channel receives auth.token_rotated. +// We assert that the chain gains settings.* events AND the event +// stream receives auth.token_rotated. // ----------------------------------------------------------------------- describe("event-chain: B01 + B02 + B03 integration via token reset", () => { let server; @@ -430,40 +430,13 @@ describe("event-chain: B01 + B02 + B03 integration via token reset", () => { server = null; }); - test("token.reset writes settings.write event + emits auth.token_rotated SSE", async () => { - // Subscribe to /api/events first so we see the broadcast. - const ssePromise = new Promise((resolve, reject) => { - const req = http.request( - { - method: "GET", - host: "127.0.0.1", - port: server.port, - path: "/api/events?cid=cid-b03-integrate", - }, - (res) => { - let body = ""; - res.setEncoding("utf8"); - res.on("data", (c) => (body += c)); - const timer = setTimeout(() => { - try { req.destroy(); } catch {} - resolve({ status: res.statusCode, body }); - }, 2500); - res.on("end", () => { - clearTimeout(timer); - resolve({ status: res.statusCode, body }); - }); - res.on("error", (e) => { - clearTimeout(timer); - reject(e); - }); - }, - ); - req.on("error", reject); - req.end(); - }); - // Let the SSE connect. + test("token.reset writes settings.write event + emits auth.token_rotated control frame", async () => { + // Open the /api/stream WebSocket first so we see the broadcast. + const stream = await openEventStream(server.port, "cid-b03-integrate"); + assert.notEqual(stream.timedOut, true, "WS upgrade should finish before the 2500ms guard"); + // Let the subscription settle server-side. await new Promise((r) => setTimeout(r, 200)); - // Start the decider FIRST and let its SSE subscription register + // Start the decider FIRST and let its WebSocket handshake register // (broadcasts are not replayed to late subscribers), THEN fire // the gated POST, then drive the decision through the // production wire path. @@ -493,12 +466,19 @@ describe("event-chain: B01 + B02 + B03 integration via token reset", () => { const tokenResetEvents = events.filter((e) => /^token\.reset\./.test(e.kind || "")); assert.ok(tokenResetEvents.length >= 2, "token.reset.intent + token.reset.done both recorded"); - // The auth.token_rotated SSE frame must be on the wire. - const res = await ssePromise; - assert.match( - res.body, - /event: auth\.token_rotated/, - `SSE body should contain auth.token_rotated frame. body: ${res.body.slice(0, 500)}`, + // The auth.token_rotated control frame must be on the stream. + const waitStart = Date.now(); + let rotated = null; + while (!rotated && Date.now() - waitStart < 2500) { + rotated = stream.frames.find( + (f) => f && f.type === "control" && f.payload && f.payload.name === "auth.token_rotated", + ); + if (!rotated) await new Promise((r) => setTimeout(r, 25)); + } + stream.close(); + assert.ok( + rotated, + `expected auth.token_rotated control frame. frames: ${JSON.stringify(stream.frames.slice(0, 20))}`, ); }); }); @@ -570,10 +550,10 @@ describe("event-chain: gate-blocking (decline / timeout / approve)", () => { } // Fire a gated DELETE and drive the decision through the real wire - // path. Subscribes the decider SSE first, then fires the request. + // path. Opens the decider's WebSocket first, then fires the request. async function deleteWithDecision(port, id, approve) { const decisionPromise = decideNextAuthorization({ port, approve, cid: "cid-decider" }); - // Give the decider's SSE connection a moment to register before + // Give the decider's WebSocket a moment to register before // the gate broadcast fires (broadcasts are not replayed). await new Promise((r) => setTimeout(r, 150)); const reqPromise = requestJson({ method: "DELETE", port, path: `/api/sessions/${id}` }); diff --git a/packages/webui/test/integration/router-boot.test.js b/packages/webui/test/integration/router-boot.test.js index 65383e38..ec731197 100644 --- a/packages/webui/test/integration/router-boot.test.js +++ b/packages/webui/test/integration/router-boot.test.js @@ -17,7 +17,7 @@ // Scope (D02 brief): // GET /api/health (happy + 404 path) // GET /api/state (happy + wrong method) -// GET /api/alerts (SSE happy; bad path = wrong method) +// GET /api/alerts (REST snapshot happy; wrong method) // GET /api/usage/forecast (happy + zero-history reason) // GET /api/sessions//export (happy md + error 404) // GET / (happy static index) @@ -216,8 +216,8 @@ test("router-boot: GET /api/state returns client state snapshot", async () => { assert.equal(res.status, 200, `expected 200, got ${res.status}. body: ${res.body}`); assert.ok(res.json, "response must be JSON"); // Snapshot fields confirmed in server/routes/state.js#handleState - // (lines 117-156). Note: `onlineCount` is only on the SSE push - // (state-bus.js line 156), not the /api/state JSON response. + // (lines 117-156). Note: `onlineCount` is only on the event-stream + // push (state-bus.js), not the /api/state JSON response. assert.equal(typeof res.json.version, "string", "version field present"); assert.ok(res.json.workspace, "workspace field present"); assert.ok(res.json.model, "model field present"); @@ -237,54 +237,25 @@ test("router-boot: POST /api/state returns 404 (route is GET-only)", async () => }); // ----------------------------------------------------------------------- -// /api/alerts — SSE. We just hit it once and read the first frame -// (snapshot). For full streaming tests, see sse-channel.test.js. The -// "error path" here is the wrong-method attempt. +// /api/alerts — REST snapshot (SSE removed by decision 20; the live +// alerts.append/alerts.update traffic rides /api/stream — see +// ws-channel.test.js). The "error path" here is the wrong-method +// attempt. // ----------------------------------------------------------------------- -test("router-boot: GET /api/alerts opens SSE + emits snapshot frame", async () => { - const res = await new Promise((resolve, reject) => { - const req = http.request( - { method: "GET", host: "127.0.0.1", port: server.port, path: "/api/alerts" }, - (r) => { - const chunks = []; - r.on("data", (c) => chunks.push(c)); - const timer = setTimeout(() => { - req.destroy(); - resolve({ - status: r.statusCode, - headers: r.headers, - body: Buffer.concat(chunks).toString("utf8"), - }); - }, 200); - r.on("end", () => { - clearTimeout(timer); - resolve({ - status: r.statusCode, - headers: r.headers, - body: Buffer.concat(chunks).toString("utf8"), - }); - }); - r.on("error", (e) => { - clearTimeout(timer); - reject(e); - }); - }, - ); - req.on("error", reject); - req.end(); - }); - assert.equal(res.status, 200, `expected 200 SSE, got ${res.status}`); - assert.equal( - String(res.headers["content-type"] || "").startsWith("text/event-stream"), - true, - "Content-Type must be text/event-stream", +test("router-boot: GET /api/alerts returns REST snapshot (application/json)", async () => { + const res = await httpRequest({ port: server.port, path: "/api/alerts" }); + assert.equal(res.status, 200, `expected 200, got ${res.status}. body: ${res.body}`); + assert.ok( + String(res.headers["content-type"] || "").includes("application/json"), + `Content-Type must be application/json, got ${res.headers["content-type"]}`, ); - // Snapshot frame: data: {"kind":"snapshot","alerts":[]} - assert.match(res.body, /data: \{[^]*"kind":\s*"snapshot"/); - assert.match(res.body, /"alerts":\s*\[\]/); + assert.ok(res.json, "response must parse as JSON"); + assert.equal(res.json.kind, "snapshot"); + assert.ok(Array.isArray(res.json.alerts), "body.alerts must be an array"); + assert.equal(res.json.alerts.length, 0, "fresh server has no alerts"); }); -test("router-boot: POST /api/alerts returns 404 (SSE route is GET-only)", async () => { +test("router-boot: POST /api/alerts returns 404 (REST route is GET-only)", async () => { const res = await httpRequest({ method: "POST", port: server.port, diff --git a/packages/webui/test/integration/sse-channel.test.js b/packages/webui/test/integration/sse-channel.test.js deleted file mode 100644 index 4fbd7123..00000000 --- a/packages/webui/test/integration/sse-channel.test.js +++ /dev/null @@ -1,435 +0,0 @@ -// webui/test/integration/sse-channel.test.js -// D02 lease: end-to-end SSE channel test. -// -// Boots the real server.js (with isolated settings/events paths so -// tests don't bleed state) and exercises the three live SSE surfaces: -// 1. /api/events — per-cid state + named events (auth.token_rotated) -// 2. /api/alerts — independent anomaly channel (B02) -// 3. /api/state — one-shot JSON snapshot (not streaming, but covered -// for parity with router-boot.test.js and to anchor -// the test that pushes arrive in the right order) -// -// Sub-tests: -// - Subscribe order + initial frame shape for /api/events and /api/alerts -// - auth.token_rotated event fires on /api/events when settings resets token -// - alerts push after pushAlert() (simulated via settings.write path — -// settings.js writes events.ndjson which triggers alerts.js to fire -// the audit event) -// - Dedup: two identical alerts within 60s collapse (alerts.js) -// - B04 60Hz coalescing: STATE_PUSH_THROTTLE_MS=16 — multiple rapid -// state pushes coalesce to fewer wire frames - -import { test, describe } from "node:test"; -import { strict as assert } from "node:assert"; -import { spawn } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import http from "node:http"; -import { decideNextAuthorization } from "../_setup.js"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const serverJsPath = join(__dirname, "..", "..", "server.js"); - -function pickPort() { - return 19600 + Math.floor(Math.random() * 80); -} - -// spawnServer returns { proc, port, tmpDir, settingsPath, eventsPath, -// stderr }. Pass opts.throttleMs to set STATE_PUSH_THROTTLE_MS for -// 60Hz coalescing tests. -async function spawnServer(opts = {}) { - const tmpDir = mkdtempSync(join(tmpdir(), "mcode-webui-d02-sse-")); - const settingsPath = join(tmpDir, "settings.json"); - const eventsPath = join(tmpDir, "events.ndjson"); - const port = opts.port || pickPort(); - const env = { - ...process.env, - PORT: String(port), - HOST: "127.0.0.1", - MCODE_WEBUI_SETTINGS_PATH: settingsPath, - MCODE_WEBUI_EVENTS_PATH: eventsPath, - // U1 (2026-09-20 rigor fix): redirect upload dir + sessions db - // away from MCODE_ROOT — see router-boot.test.js (stray - // .webui-uploads/ breaks marketplace validate.mjs). tmpDir is - // per-test mkdtemp'd and rmSync'd in stopServer below. - MCODE_WEBUI_UPLOAD_DIR: join(tmpDir, "uploads"), - MCODE_WEBUI_SESSIONS_DB: join(tmpDir, "sessions.json"), - TOKEN: "", - MCODE_WEBUI_TOKEN_STDOUT: "0", - }; - if (opts.throttleMs !== undefined) { - env.STATE_PUSH_THROTTLE_MS = String(opts.throttleMs); - } - // Plain node (no mock flag): the authorize() test-mode auto-approve - // was removed in the 2026-09-20 rigor fix. The token-reset test - // below drives the gate through the production wire path (SSE - // needs_authorization + POST /api/auth/decision). - const proc = spawn("node", [serverJsPath], { - stdio: ["ignore", "pipe", "pipe"], - cwd: join(__dirname, "..", ".."), - env, - }); - let stderr = ""; - let stdout = ""; - proc.stdout.on("data", (d) => (stdout += d.toString())); - proc.stderr.on("data", (d) => (stderr += d.toString())); - const ready = new Promise((resolve, reject) => { - const onChunk = () => { - if (/listening on/.test(stdout)) { - proc.stdout.off("data", onChunk); - resolve(); - } - }; - proc.stdout.on("data", onChunk); - setTimeout(() => { - reject( - new Error( - `server.js did not start within 3s on port ${port}\n` + - `stdout: ${stdout}\nstderr: ${stderr}`, - ), - ); - }, 3000); - }); - await ready; - return { proc, port, tmpDir, settingsPath, eventsPath, stderr }; -} - -async function stopServer(proc, tmpDir) { - if (proc && proc.exitCode === null) { - try { proc.kill("SIGTERM"); } catch {} - await Promise.race([ - new Promise((r) => proc.on("exit", r)), - new Promise((r) => setTimeout(r, 1500)), - ]); - if (proc.exitCode === null) { - try { proc.kill("SIGKILL"); } catch {} - } - } - try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} -} - -// Open an SSE stream against the server, accumulate frames for `ms` -// milliseconds, then resolve with the joined body. Caller parses out -// event: / data: lines itself. -function openSse({ port, path, ms = 800, headers = {} }) { - return new Promise((resolve, reject) => { - const req = http.request( - { method: "GET", host: "127.0.0.1", port, path, headers }, - (res) => { - let body = ""; - res.setEncoding("utf8"); - res.on("data", (c) => (body += c)); - const timer = setTimeout(() => { - try { req.destroy(); } catch {} - resolve({ status: res.statusCode, headers: res.headers, body }); - }, ms); - res.on("end", () => { - clearTimeout(timer); - resolve({ status: res.statusCode, headers: res.headers, body }); - }); - res.on("error", (e) => { - clearTimeout(timer); - reject(e); - }); - }, - ); - req.on("error", reject); - req.end(); - }); -} - -// POST helper — returns parsed JSON or raw body. -function postJson({ port, path, body, headers = {} }) { - return new Promise((resolve, reject) => { - const data = JSON.stringify(body || {}); - const req = http.request( - { - method: "POST", - host: "127.0.0.1", - port, - path, - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(data), - ...headers, - }, - }, - (res) => { - const chunks = []; - res.on("data", (c) => chunks.push(c)); - res.on("end", () => { - const raw = Buffer.concat(chunks).toString("utf8"); - let json; - try { json = JSON.parse(raw); } catch {} - resolve({ status: res.statusCode, headers: res.headers, body: raw, json }); - }); - res.on("error", reject); - }, - ); - req.on("error", reject); - req.write(data); - req.end(); - }); -} - -// Parse a raw SSE body into an array of { event, data } frames. -// data is JSON-parsed when possible (else kept as raw string). -function parseSse(body) { - const frames = []; - let event = "message"; // SSE default event name - let dataBuf = ""; - let lineNo = 0; - for (const raw of body.split("\n")) { - const line = raw.replace(/\r$/, ""); - lineNo++; - if (!line) { - if (dataBuf) { - let data = dataBuf; - try { data = JSON.parse(dataBuf); } catch {} - frames.push({ event, data }); - event = "message"; - dataBuf = ""; - } - continue; - } - if (line.startsWith(":")) continue; // comment / heartbeat - if (line.startsWith("event: ")) { - event = line.slice("event: ".length); - } else if (line.startsWith("data: ")) { - dataBuf = dataBuf ? dataBuf + "\n" + line.slice("data: ".length) : line.slice("data: ".length); - } - } - return frames; -} - -// ----------------------------------------------------------------------- -// Test 1: /api/events opens with Content-Type text/event-stream and the -// first frame is a state snapshot (matching state.js#handleEvents -// line 70). -// ----------------------------------------------------------------------- -describe("sse-channel: /api/events", () => { - let server; - test.beforeEach(async () => { - server = await spawnServer(); - }); - test.afterEach(async () => { - if (server) await stopServer(server.proc, server.tmpDir); - server = null; - }); - - test("opens text/event-stream + emits state snapshot as first frame", async () => { - const res = await openSse({ - port: server.port, - path: "/api/events?cid=test-cid-1", - ms: 500, - }); - assert.equal(res.status, 200, `expected 200, got ${res.status}`); - assert.equal( - String(res.headers["content-type"] || "").startsWith("text/event-stream"), - true, - "Content-Type must be text/event-stream", - ); - // The first data frame is the snapshot. Parse it and check the - // shape — state.js#handleEvents line 70 writes - // `data: ${JSON.stringify(snapshot)}`. Note: the first SSE - // snapshot does NOT include `onlineCount` (that's only added - // by pushStateFor() in state-bus.js, line 156); the fields - // we assert here ARE present in the initial snapshot. - const frames = parseSse(res.body); - assert.ok(frames.length >= 1, "at least one SSE frame received"); - const snap = frames[0].data; - assert.ok(snap && typeof snap === "object", "first frame is object"); - assert.equal(typeof snap.version, "string"); - assert.ok(snap.workspace, "workspace present in snapshot"); - assert.ok(snap.model, "model present in snapshot"); - assert.equal(typeof snap.tokenEnabled, "boolean", "tokenEnabled flag present"); - assert.equal(typeof snap.quotaEnabled, "boolean", "quotaEnabled flag present"); - }); - - test("auth.token_rotated SSE event fires on token reset", async () => { - // Open the SSE stream FIRST, then trigger the reset. The server - // will push the named event auth.token_rotated to all connected - // cids. We wait ~2500ms so the rotation broadcast reaches us. - const ssePromise = openSse({ - port: server.port, - path: "/api/events?cid=test-cid-rot", - ms: 2500, - }); - // Give the SSE a moment to connect before POSTing the reset, - // so the server's sseByCid.set(cid, res) has run. - await new Promise((r) => setTimeout(r, 200)); - // The reset is authorize()-gated (no auto-approve since the - // 2026-09-20 rigor fix). Subscribe the decider BEFORE firing - // the POST (needs_authorization broadcasts are fire-once), - // then drive the real wire path. - const decisionPromise = decideNextAuthorization({ - port: server.port, - approve: true, - cid: "test-cid-decider", - }); - await new Promise((r) => setTimeout(r, 150)); - const postPromise = postJson({ - port: server.port, - path: "/api/settings", - body: { resetToken: true }, - headers: { "x-test-cid": "test-cid-rot" }, - }); - const { decision } = await decisionPromise; - assert.ok(decision, "auth decision must have been posted"); - const post = await postPromise; - assert.equal(post.status, 200, `POST /api/settings resetToken returned ${post.status}`); - assert.equal(post.json && post.json.ok, true); - assert.equal(post.json && post.json.tokenRotated, true); - const res = await ssePromise; - const frames = parseSse(res.body); - // Find the auth.token_rotated frame. - const rotated = frames.find((f) => f.event === "auth.token_rotated"); - assert.ok( - rotated, - `expected auth.token_rotated event. body: ${res.body.slice(0, 500)}`, - ); - // The data is the raw new token (state-bus.js#broadcastTokenRotated - // line 625: data: ${token}). It's a 32-hex string. - assert.match(String(rotated.data), /^[a-f0-9]{16,}$/); - }); - - test("state snapshot includes Token Plan fields (qotaEnabled / hasTokenPlanKey)", async () => { - // Confirms the v2026-08-28 modacker fields flow through the SSE - // channel. Without these the webui's popover would have no data. - const res = await openSse({ - port: server.port, - path: "/api/events?cid=test-cid-plan", - ms: 400, - }); - const frames = parseSse(res.body); - const snap = frames[0].data; - assert.ok(snap, "snapshot received"); - assert.equal(typeof snap.quotaEnabled, "boolean"); - assert.equal(typeof snap.hasTokenPlanKey, "boolean"); - assert.equal(typeof snap.tokenPlanApiKeyMasked, "string"); - assert.equal(typeof snap.tokenPlanApiKeySource, "string"); - }); -}); - -// ----------------------------------------------------------------------- -// Test 2: /api/alerts — independent anomaly channel (B02). -// - Snapshot frame on connect -// - SSE heartbeat is scheduled (HEARTBEAT_MS = 30_000 in alerts.js) -// — we don't wait 30s but we assert the channel stays open + the -// Content-Type is correct. -// - Dedup: we trigger two identical alerts and verify the count -// bumps in the second frame. -// ----------------------------------------------------------------------- -describe("sse-channel: /api/alerts", () => { - let server; - test.beforeEach(async () => { - server = await spawnServer(); - }); - test.afterEach(async () => { - if (server) await stopServer(server.proc, server.tmpDir); - server = null; - }); - - test("opens text/event-stream + emits snapshot frame with alerts array", async () => { - const res = await openSse({ - port: server.port, - path: "/api/alerts", - ms: 400, - }); - assert.equal(res.status, 200, `expected 200, got ${res.status}`); - assert.equal( - String(res.headers["content-type"] || "").startsWith("text/event-stream"), - true, - ); - const frames = parseSse(res.body); - assert.ok(frames.length >= 1, "at least one frame"); - const snap = frames[0].data; - assert.ok(snap && snap.kind === "snapshot", "first frame is snapshot"); - assert.ok(Array.isArray(snap.alerts), "snapshot.alerts is array"); - // Fresh server → ring buffer empty. - assert.equal(snap.alerts.length, 0); - }); - - test("ring buffer survives SSE reconnect (snapshot replays recent)", async () => { - // First connect + close, second connect should still see the - // empty buffer (fresh server, no pushAlert calls). - const r1 = await openSse({ port: server.port, path: "/api/alerts", ms: 200 }); - const r2 = await openSse({ port: server.port, path: "/api/alerts", ms: 200 }); - const frames2 = parseSse(r2.body); - const snap = frames2[0].data; - assert.equal(snap.alerts.length, 0, "fresh server has no alerts"); - // Both connections opened 200 OK — assert r1 also opened cleanly. - assert.equal(r1.status, 200); - }); -}); - -// ----------------------------------------------------------------------- -// Test 3: B04 60Hz coalescing. -// -// Set STATE_PUSH_THROTTLE_MS=16 (≈60Hz) for the server process. POST -// /api/settings three rapid changes (lanBroadcast → readOnly → tokenEnabled), -// then drain the SSE stream and count `state` frames. With coalescing, -// N settings changes produce AT MOST ~1 wire frame per throttle window -// for the same cid. The exact count depends on flush timing, so we -// use an upper bound (≤ 3 wire frames for 3 changes that should be -// compressed to 1 or 2 depending on flush ordering) — what we really -// assert is that the per-cid wire rate is BELOW the call rate. -// ----------------------------------------------------------------------- -describe("sse-channel: 60Hz coalescing (STATE_PUSH_THROTTLE_MS=16)", () => { - let server; - test.beforeEach(async () => { - server = await spawnServer({ throttleMs: 16 }); - }); - test.afterEach(async () => { - if (server) await stopServer(server.proc, server.tmpDir); - server = null; - }); - - test("rapid settings updates collapse to fewer wire frames than calls", async () => { - const ssePromise = openSse({ - port: server.port, - path: "/api/events?cid=cid-coalesce", - ms: 1200, - }); - // Let the SSE connect first. - await new Promise((r) => setTimeout(r, 200)); - // 5 rapid changes inside ~50ms — each toggles a setting and - // calls pushStateFor("__broadcast__"). With a 16ms throttle, - // these should coalesce to ≤ 3 wire frames. - for (let i = 0; i < 5; i++) { - const v = i % 2 === 0; - await postJson({ - port: server.port, - path: "/api/settings", - body: { readOnly: v }, - }); - } - const res = await ssePromise; - // Count state frames (data-only frames, event=message, with - // a JSON object that has `onlineCount`). - const frames = parseSse(res.body); - let stateFrames = 0; - for (const f of frames) { - if (f.event === "message" && f.data && typeof f.data === "object" - && typeof f.data.onlineCount === "number") { - stateFrames++; - } - } - // Subtract the initial snapshot from the count — coalescing - // assertion is about subsequent pushes only. - const subsequent = Math.max(0, stateFrames - 1); - assert.ok( - subsequent <= 4, - `5 rapid changes should coalesce to ≤ 4 subsequent frames, ` + - `got ${subsequent}. body: ${res.body.slice(0, 600)}`, - ); - // And we got AT LEAST one subsequent frame (otherwise coalescing - // would have eaten everything — that's also a bug). - assert.ok( - subsequent >= 1, - `5 rapid changes should produce ≥ 1 subsequent frame, got ${subsequent}`, - ); - }); -}); \ No newline at end of file diff --git a/packages/webui/test/integration/ws-channel.test.js b/packages/webui/test/integration/ws-channel.test.js new file mode 100644 index 00000000..37327e3e --- /dev/null +++ b/packages/webui/test/integration/ws-channel.test.js @@ -0,0 +1,431 @@ +// webui/test/integration/ws-channel.test.js +// D02 lease successor of sse-channel.test.js — end-to-end WebSocket event +// channel test after the SSE removal (live surface = WS event stream + +// REST; the old stream endpoint and its SSE adapter are gone). +// +// Boots the real server.js (isolated settings/events paths so tests don't +// bleed state) and exercises the live surfaces: +// 1. /api/stream — WS handshake → hello frame shape +// 2. auth.token_rotated control frame fires when settings resets the +// token (driven through the real authorize gate wire path) +// 3. GET /api/alerts — REST snapshot (200 application/json) +// 4. resume smoke — {type:"resume"} replays state.snapshot/control +// frames or answers error "resume-underrun"; an empty stream +// (hello.latestSeq === null) may legitimately answer nothing. +// +// Startup/timeout/teardown pattern inherited from the old +// sse-channel.test.js: spawn a child server.js, 3s boot guard, per-test +// beforeEach/afterEach, SIGTERM → exit-wait(1500) → SIGKILL, rm tmpdir. +// +// WS client is hand-rolled in this file (RFC 6455): +// - net upgrade: Connection: Upgrade / Upgrade: websocket headers + a +// random 16-byte Sec-WebSocket-Key (base64); read the response and +// require "HTTP/1.1 101" (Sec-WebSocket-Accept verified via the RFC +// GUID SHA-1 formula). +// - Server → client frames are NOT masked (RFC 6455 §5.1), so decoding +// uses a local unmasked frame parser (same shape as +// test/lib-ws-server.test.js#parseServerFrames — +// lib/ws-frame.js#createFrameDecoder is the client→server direction +// decoder and rejects unmasked frames with 1002, verified). +// - Client → server frames are masked locally: FIN|0x81, mask bit set, +// 4-byte random key, payload XOR; text length < 126 (7-bit length). +// - 2500ms timeout-resolve guard on connect: a hung handshake resolves +// with timedOut:true instead of hanging the suite. + +import { test, describe } from "node:test"; +import { strict as assert } from "node:assert"; +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createHash, randomBytes } from "node:crypto"; +import net from "node:net"; +import http from "node:http"; +import { decideNextAuthorization } from "../_setup.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const serverJsPath = join(__dirname, "..", "..", "server.js"); +const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +function pickPort() { + return 19600 + Math.floor(Math.random() * 80); +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function waitUntil(pred, ms = 2500) { + const t0 = Date.now(); + while (Date.now() - t0 < ms) { + if (pred()) return; + await sleep(15); + } + assert.fail("waitUntil: condition not met within " + ms + "ms"); +} + +// spawnServer returns { proc, port, tmpDir, settingsPath, eventsPath, +// stderr }. Same boot contract as the old sse-channel.test.js (minus the +// STATE_PUSH_THROTTLE_MS knob — the throttle/coalesce adapter is gone). +async function spawnServer() { + const tmpDir = mkdtempSync(join(tmpdir(), "mcode-webui-d02-ws-")); + const settingsPath = join(tmpDir, "settings.json"); + const eventsPath = join(tmpDir, "events.ndjson"); + const port = pickPort(); + const env = { + ...process.env, + PORT: String(port), + HOST: "127.0.0.1", + MCODE_WEBUI_SETTINGS_PATH: settingsPath, + MCODE_WEBUI_EVENTS_PATH: eventsPath, + // U1 (2026-09-20 rigor fix): redirect upload dir + sessions db + // away from MCODE_ROOT — see router-boot.test.js (stray + // .webui-uploads/ breaks marketplace validate.mjs). tmpDir is + // per-test mkdtemp'd and rmSync'd in stopServer below. + MCODE_WEBUI_UPLOAD_DIR: join(tmpDir, "uploads"), + MCODE_WEBUI_SESSIONS_DB: join(tmpDir, "sessions.json"), + TOKEN: "", + MCODE_WEBUI_TOKEN_STDOUT: "0", + }; + // Plain node (no mock flag): the authorize() test-mode auto-approve + // was removed in the 2026-09-20 rigor fix. The token-reset test + // below drives the gate through the production wire path (WS + // needs_authorization control frame + POST /api/auth/decision). + const proc = spawn("node", [serverJsPath], { + stdio: ["ignore", "pipe", "pipe"], + cwd: join(__dirname, "..", ".."), + env, + }); + let stderr = ""; + let stdout = ""; + proc.stdout.on("data", (d) => (stdout += d.toString())); + proc.stderr.on("data", (d) => (stderr += d.toString())); + const ready = new Promise((resolve, reject) => { + const onChunk = () => { + if (/listening on/.test(stdout)) { + proc.stdout.off("data", onChunk); + resolve(); + } + }; + proc.stdout.on("data", onChunk); + setTimeout(() => { + reject( + new Error( + `server.js did not start within 3s on port ${port}\n` + + `stdout: ${stdout}\nstderr: ${stderr}`, + ), + ); + }, 3000); + }); + await ready; + return { proc, port, tmpDir, settingsPath, eventsPath, stderr }; +} + +async function stopServer(proc, tmpDir) { + if (proc && proc.exitCode === null) { + try { proc.kill("SIGTERM"); } catch {} + await Promise.race([ + new Promise((r) => proc.on("exit", r)), + new Promise((r) => setTimeout(r, 1500)), + ]); + if (proc.exitCode === null) { + try { proc.kill("SIGKILL"); } catch {} + } + } + try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} +} + +// --------------------------------------------------------------------------- +// Hand-rolled WS client. Resolves { header, frames, send(text), close(), +// timedOut } — frames accumulate every server TEXT frame as parsed JSON. +// --------------------------------------------------------------------------- +function parseServerTextFrames(st, frames) { + while (st.buf.length >= 2) { + const opcode = st.buf[0] & 0x0f; + let len = st.buf[1] & 0x7f; + let offset = 2; + if (len === 126) { + if (st.buf.length < 4) return; + len = st.buf.readUInt16BE(2); + offset = 4; + } else if (len === 127) { + if (st.buf.length < 10) return; + len = Number(st.buf.readBigUInt64BE(2)); + offset = 10; + } + const masked = (st.buf[1] & 0x80) !== 0; + const maskLen = masked ? 4 : 0; + if (st.buf.length < offset + maskLen + len) return; + let payload = st.buf.subarray(offset + maskLen, offset + maskLen + len); + if (masked) { + const mk = st.buf.subarray(offset, offset + 4); + const un = Buffer.allocUnsafe(len); + for (let i = 0; i < len; i++) un[i] = payload[i] ^ mk[i & 3]; + payload = un; + } + st.buf = st.buf.subarray(offset + maskLen + len); + if (opcode === 0x1) { + const text = payload.toString("utf8"); + try { frames.push(JSON.parse(text)); } catch { frames.push({ raw: text }); } + } + // ping/pong/close/binary: consumed, not surfaced. + } +} + +function encodeMaskedTextFrame(text) { + const body = Buffer.from(String(text), "utf8"); + assert.ok(body.length < 126, "test frames must fit the 7-bit length field"); + const key = randomBytes(4); + const head = Buffer.alloc(6); + head[0] = 0x81; // FIN | TEXT + head[1] = 0x80 | body.length; // mask bit + 7-bit length + key.copy(head, 2); + const masked = Buffer.from(body); + for (let i = 0; i < masked.length; i++) masked[i] ^= key[i & 3]; + return Buffer.concat([head, masked]); +} + +function openWs({ port, path }) { + return new Promise((resolve) => { + const st = { header: "", buf: Buffer.alloc(0), handshook: false }; + const frames = []; + let settled = false; + const key = randomBytes(16).toString("base64"); + const api = { + frames, + send(text) { st.socket.write(encodeMaskedTextFrame(text)); }, + close() { try { st.socket.destroy(); } catch {} }, + }; + // 2500ms timeout-resolve guard: a hung handshake settles as + // timedOut:true so the case fails loudly instead of hanging. + const guard = setTimeout(() => { + if (settled) return; + settled = true; + try { st.socket.destroy(); } catch {} + resolve({ ...api, header: st.header, timedOut: true }); + }, 2500); + const done = (err) => { + if (settled) return; + settled = true; + clearTimeout(guard); + if (err) { + try { st.socket.destroy(); } catch {} + resolve({ ...api, header: st.header, error: err, timedOut: false }); + } else { + resolve({ ...api, header: st.header, timedOut: false }); + } + }; + st.socket = net.connect(port, "127.0.0.1"); + st.socket.on("error", (e) => { if (!st.handshook) done(e); }); + st.socket.on("data", (chunk) => { + if (!st.handshook) { + st.header += chunk.toString("latin1"); + const idx = st.header.indexOf("\r\n\r\n"); + if (idx === -1) return; + const head = st.header.slice(0, idx); + const rest = Buffer.from(st.header.slice(idx + 4), "latin1"); + if (!/^HTTP\/1\.1 101\b/.test(head)) { + done(new Error("expected 101 upgrade, got: " + (head.split("\r\n")[0] || "(empty)"))); + return; + } + const accept = head.match(/^sec-websocket-accept:\s*(.+)$/im); + const expected = createHash("sha1").update(key + WS_GUID, "utf8").digest("base64"); + if (accept && accept[1].trim() !== expected) { + done(new Error("bad Sec-WebSocket-Accept")); + return; + } + st.handshook = true; + if (rest.length) { + st.buf = Buffer.concat([st.buf, rest]); + parseServerTextFrames(st, frames); + } + done(); + return; + } + st.buf = Buffer.concat([st.buf, chunk]); + parseServerTextFrames(st, frames); + }); + st.socket.on("connect", () => { + st.socket.write( + "GET " + path + " HTTP/1.1\r\n" + + "Host: 127.0.0.1:" + port + "\r\n" + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Key: " + key + "\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "\r\n", + ); + }); + }); +} + +// Minimal JSON request helper (POST body / GET) — mirrors the old +// sse-channel.test.js postJson, extended with headers-only GETs. +function requestJson({ method = "GET", port, path, body, headers = {} }) { + return new Promise((resolve, reject) => { + const data = body === undefined ? "" : JSON.stringify(body); + const req = http.request( + { + method, + host: "127.0.0.1", + port, + path, + headers: { + ...(body === undefined ? {} : { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(data), + }), + ...headers, + }, + }, + (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + let json; + try { json = JSON.parse(raw); } catch {} + resolve({ status: res.statusCode, headers: res.headers, body: raw, json }); + }); + res.on("error", reject); + }, + ); + req.on("error", reject); + if (body !== undefined) req.write(data); + req.end(); + }); +} + +function helloOf(ws) { + return ws.frames.find((f) => f && f.type === "hello"); +} + +function controlNamed(ws, name) { + return ws.frames.find((f) => f && f.type === "control" && f.payload && f.payload.name === name); +} + +// --------------------------------------------------------------------------- +describe("ws-channel: GET /api/stream", () => { + let server; + test.beforeEach(async () => { + server = await spawnServer(); + }); + test.afterEach(async () => { + if (server) await stopServer(server.proc, server.tmpDir); + server = null; + }); + + test("handshake returns 101 and the first frame is a well-formed hello", async () => { + const ws = await openWs({ port: server.port, path: "/api/stream?cid=test-cid-1" }); + assert.equal(ws.timedOut, false, "connect timeout: header=" + ws.header); + assert.equal(ws.error, undefined, "upgrade failed: " + (ws.error && ws.error.message)); + assert.ok(ws.header.startsWith("HTTP/1.1 101"), "header=" + ws.header); + await waitUntil(() => helloOf(ws)); + const hello = helloOf(ws); + assert.equal(hello.v, 1); + assert.equal(hello.type, "hello"); + assert.equal(hello.payload.resumeSupported, true); + assert.ok( + hello.payload.latestSeq === null || typeof hello.payload.latestSeq === "number", + `latestSeq must be number|null, got ${JSON.stringify(hello.payload.latestSeq)}`, + ); + assert.equal(typeof hello.payload.heartbeatMs, "number"); + ws.close(); + }); + + test("auth.token_rotated control frame fires on token reset", async () => { + // Open the stream FIRST, then trigger the reset. The server pushes + // the named control event auth.token_rotated to all connected cids. + const ws = await openWs({ port: server.port, path: "/api/stream?cid=test-cid-rot" }); + assert.equal(ws.timedOut, false, "connect timeout: header=" + ws.header); + await waitUntil(() => helloOf(ws)); + // The reset is authorize()-gated (no auto-approve since the + // 2026-09-20 rigor fix). Subscribe the decider BEFORE firing the + // POST (needs_authorization broadcasts are fire-once), then drive + // the real wire path. + const decisionPromise = decideNextAuthorization({ + port: server.port, + approve: true, + cid: "test-cid-decider", + }); + await sleep(150); + const postPromise = requestJson({ + method: "POST", + port: server.port, + path: "/api/settings", + body: { resetToken: true }, + headers: { "x-test-cid": "test-cid-rot" }, + }); + const { decision } = await decisionPromise; + assert.ok(decision, "auth decision must have been posted"); + const post = await postPromise; + assert.equal(post.status, 200, `POST /api/settings resetToken returned ${post.status}`); + assert.equal(post.json && post.json.ok, true); + assert.equal(post.json && post.json.tokenRotated, true); + // The control frame must land on our stream. + await waitUntil(() => controlNamed(ws, "auth.token_rotated"), 2500); + const rotated = controlNamed(ws, "auth.token_rotated"); + assert.ok(rotated, "expected auth.token_rotated control frame"); + // data is the raw new token (a 32-hex-ish string) — must arrive as + // a NON-EMPTY string per the WS control-frame contract. + assert.equal(typeof rotated.payload.data, "string"); + assert.ok(rotated.payload.data.length > 0, "auth.token_rotated data must be non-empty"); + ws.close(); + }); + + test("resume smoke: any post-resume frame is replay or resume-underrun", async () => { + const ws = await openWs({ port: server.port, path: "/api/stream?cid=test-cid-resume" }); + assert.equal(ws.timedOut, false, "connect timeout: header=" + ws.header); + await waitUntil(() => helloOf(ws), 2500); + const hello = helloOf(ws); + assert.equal(hello.v, 1); + // Settle: let connect-side pushes (pushOnlineCount / mavis + // hydrate) land, then mark the boundary BEFORE sending resume. + await sleep(300); + const mark = ws.frames.length; + ws.send(JSON.stringify({ v: 1, type: "resume", payload: { lastSeq: 0 } })); + await sleep(600); + const after = ws.frames.slice(mark); + for (const f of after) { + const ok = + f.type === "state.snapshot" || + f.type === "control" || + (f.type === "error" && f.payload && f.payload.code === "resume-underrun"); + assert.ok(ok, `unexpected frame after resume: ${JSON.stringify(f)}`); + } + // For an empty stream (latestSeq===null) the server may replay + // nothing at all — the per-frame assertion above is vacuous then, + // which is exactly the contract for this smoke test. + assert.ok( + hello.payload.latestSeq === null || typeof hello.payload.latestSeq === "number", + ); + ws.close(); + }); +}); + +// --------------------------------------------------------------------------- +// GET /api/alerts — REST snapshot (was SSE; now plain JSON). +// --------------------------------------------------------------------------- +describe("ws-channel: GET /api/alerts (REST)", () => { + let server; + test.beforeEach(async () => { + server = await spawnServer(); + }); + test.afterEach(async () => { + if (server) await stopServer(server.proc, server.tmpDir); + server = null; + }); + + test("returns 200 application/json with kind=snapshot + alerts array", async () => { + const res = await requestJson({ port: server.port, path: "/api/alerts" }); + assert.equal(res.status, 200, `expected 200, got ${res.status}`); + assert.ok( + String(res.headers["content-type"] || "").includes("application/json"), + `Content-Type must be application/json, got ${res.headers["content-type"]}`, + ); + assert.ok(res.json, "body must parse as JSON: " + res.body.slice(0, 200)); + assert.equal(res.json.kind, "snapshot"); + assert.ok(Array.isArray(res.json.alerts), "body.alerts must be an array"); + }); +}); diff --git a/packages/webui/test/lib-auth.test.js b/packages/webui/test/lib-auth.test.js index 58f05a32..0e60c6ec 100644 --- a/packages/webui/test/lib-auth.test.js +++ b/packages/webui/test/lib-auth.test.js @@ -245,12 +245,12 @@ test("isRequestAuthorized: header wrong token => false", () => { } }); -test("isRequestAuthorized: query matches TOKEN (SSE path)", () => { +test("isRequestAuthorized: query matches TOKEN (/api/stream query path)", () => { process.env.TOKEN = "expected-token"; try { assert.equal( isRequestAuthorized( - makeReq({}, "/api/events?token=expected-token&cid=x"), + makeReq({}, "/api/stream?token=expected-token&cid=x"), ), true, ); @@ -263,7 +263,7 @@ test("isRequestAuthorized: query wrong token => false", () => { process.env.TOKEN = "expected-token"; try { assert.equal( - isRequestAuthorized(makeReq({}, "/api/events?token=wrong")), + isRequestAuthorized(makeReq({}, "/api/stream?token=wrong")), false, ); } finally { diff --git a/packages/webui/test/lib-capability.test.js b/packages/webui/test/lib-capability.test.js new file mode 100644 index 00000000..62ed2768 --- /dev/null +++ b/packages/webui/test/lib-capability.test.js @@ -0,0 +1,178 @@ +// webui/test/lib-capability.test.js +// ACP 能力协商(server/lib/capability.js)三层策略单测 + mcode-rpc 接线冒烟。 +// 注意:mcode-rpc 相关断言只走「不触发 client spawn」的路径(旧回退早退)。 + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + LEGACY_UNSUPPORTED, + createCapabilityRegistry, + resolveDeclaredCapabilities, + probeParamsFor, + syncActiveCapabilities, + getActiveRegistry, + UI_METHOD_KEYS, +} from "../server/lib/capability.js"; + +const MODERN_INITIALIZE = { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + sessionCapabilities: { list: {}, fork: {}, resume: {}, close: {} }, + }, + _meta: { + "minimax-code/extensions": { + version: 1, + methods: ["session/activate", "mcode/session/steer"], + notifications: ["mcode/session/queue_update"], + }, + }, +}; +const LEGACY_INITIALIZE = { protocolVersion: 1, agentCapabilities: {} }; + +describe("resolveDeclaredCapabilities", () => { + it("现代引擎样本:声明清单解析(session 四键 + 扩展方法)", () => { + const d = resolveDeclaredCapabilities(MODERN_INITIALIZE); + assert.equal(d.declared, true); + assert.equal(d.session["session/list"], true); + assert.equal(d.session["session/fork"], true); + assert.equal(d.session["session/resume"], true); + assert.equal(d.session["session/close"], true); + assert.deepEqual(d.extensionMethods, ["session/activate", "mcode/session/steer"]); + }); + + it("0.1.5 样本:两者皆缺 → declared=false", () => { + const d = resolveDeclaredCapabilities(LEGACY_INITIALIZE); + assert.equal(d.declared, false); + assert.equal(d.session["session/fork"], false); + assert.deepEqual(d.extensionMethods, []); + }); + + it("空 / 非对象输入安全降级", () => { + assert.equal(resolveDeclaredCapabilities(undefined).declared, false); + assert.equal(resolveDeclaredCapabilities(null).declared, false); + }); +}); + +describe("createCapabilityRegistry — 三层策略", () => { + it("声明清单:session 能力与扩展方法直接采信", () => { + const r = createCapabilityRegistry({ initializeResult: MODERN_INITIALIZE }); + assert.equal(r.classify("session/list"), "supported"); + assert.equal(r.classify("session/fork"), "supported"); + assert.equal(r.classify("session/activate"), "supported"); + assert.equal(r.classify("mcode/session/steer"), "supported"); + }); + + it("声明模式下未声明方法 → unknown(惰性探测候选)", () => { + const r = createCapabilityRegistry({ initializeResult: MODERN_INITIALIZE }); + assert.equal(r.classify("session/set_mode"), "unknown"); + assert.equal(r.classify("session/delete"), "unknown"); + }); + + it("旧引擎回退:LEGACY_UNSUPPORTED 预置(cancel 除外),核心方法支持", () => { + const r = createCapabilityRegistry({ initializeResult: LEGACY_INITIALIZE }); + for (const m of LEGACY_UNSUPPORTED) { + if (m === "session/cancel") continue; + assert.equal(r.classify(m), "unsupported", m); + } + assert.equal(r.classify("session/load"), "supported"); + assert.equal(r.classify("session/close"), "supported"); + assert.equal(r.classify("session/list"), "supported"); + assert.equal(r.classify("session/new"), "supported"); + assert.equal(r.classify("session/prompt"), "supported"); + }); + + it("session/cancel:声明条件式(契约修正 19)", () => { + const legacy = createCapabilityRegistry({ initializeResult: LEGACY_INITIALIZE }); + const modern = createCapabilityRegistry({ initializeResult: MODERN_INITIALIZE }); + // 未声明 → unsupported 短路(守护 no mcode spawn) + assert.equal(legacy.classify("session/cancel"), "unsupported"); + // 引擎 initialize 声明该方法 → supported(notify 语义) + const declared = createCapabilityRegistry({ + initializeResult: { + _meta: { "minimax-code/extensions": { methods: ["session/cancel"] } }, + }, + }); + assert.equal(declared.classify("session/cancel"), "supported"); + }); +}); + +describe("惰性探测分类(recordProbeResult)", () => { + const cases = [ + { name: "数值 -32601 → unsupported 且缓存", err: { data: { code: -32601, message: "x" } }, want: "unsupported" }, + { name: "字符串 -32601 → unsupported", err: { data: { code: "-32601" } }, want: "unsupported" }, + { name: "message 含 Method not found → unsupported", err: { message: "Method not found: session/set_mode" }, want: "unsupported" }, + { name: "invalidParams(-32602) → supported(方法存在)", err: { data: { code: -32602 } }, want: "supported" }, + { name: "resourceNotFound(-32000) → supported", err: { data: { code: -32000 } }, want: "supported" }, + { name: "成功(null)→ supported", err: null, want: "supported" }, + ]; + for (const c of cases) { + it(c.name, () => { + const r = createCapabilityRegistry({ initializeResult: MODERN_INITIALIZE }); + assert.equal(r.classify("session/set_mode"), "unknown"); + r.recordProbeResult("session/set_mode", c.err); + assert.equal(r.classify("session/set_mode"), c.want); + }); + } + + it("unknown → 探测 → 缓存路径(snapshot 反映)", () => { + const r = createCapabilityRegistry({ initializeResult: MODERN_INITIALIZE }); + r.recordProbeResult("session/delete", { data: { code: -32601 } }); + assert.equal(r.classify("session/delete"), "unsupported"); + assert.ok(r.snapshot().unsupported.includes("session/delete")); + // 已缓存:再次 classify 不再是 unknown + assert.notEqual(r.classify("session/delete"), "unknown"); + }); +}); + +describe("probeParamsFor", () => { + it("缺必填字段的无副作用探测参数", () => { + assert.deepEqual(probeParamsFor("session/set_mode"), { sessionId: "" }); + assert.deepEqual(probeParamsFor("session/set_config_option"), { sessionId: "" }); + assert.deepEqual(probeParamsFor("other/method"), {}); + }); +}); + +describe("snapshot 形状", () => { + it("三键数组且方法归类互斥", () => { + const r = createCapabilityRegistry({ initializeResult: MODERN_INITIALIZE }); + const s = r.snapshot(); + for (const key of ["supported", "unsupported", "unknown"]) { + assert.ok(Array.isArray(s[key]), `${key} 应为数组`); + } + const all = [...s.supported, ...s.unsupported, ...s.unknown]; + assert.equal(new Set(all).size, all.length, "无重复"); + }); +}); + +describe("mcode-rpc 接线冒烟(不触发 client spawn)", () => { + it("UI 映射 12 键形状稳定", () => { + assert.equal(Object.keys(UI_METHOD_KEYS).length, 12); + }); + + it("旧引擎注册表下 setMode 早退 unsupported(前端 501 语义)", async () => { + syncActiveCapabilities(LEGACY_INITIALIZE); + const { setMode, MCODE_ACP_CAPABILITIES } = await import("../server/lib/mcode-rpc.js"); + const r = await setMode("mvs_test", "plan_mode"); + assert.equal(r.ok, false); + assert.equal(r.code, "unsupported"); + // 旧引擎 UI 映射值(形状不变;cancel 修正为 false——契约修正 19: + // 未声明即 unsupported 短路,守护 no mcode spawn) + assert.equal(MCODE_ACP_CAPABILITIES.set_mode, false); + assert.equal(MCODE_ACP_CAPABILITIES.set_config_option, false); + assert.equal(MCODE_ACP_CAPABILITIES.fork, false); + assert.equal(MCODE_ACP_CAPABILITIES.cancel, false); + assert.equal(MCODE_ACP_CAPABILITIES.load, true); + assert.equal(MCODE_ACP_CAPABILITIES.prompt, true); + }); + + it("播种现代引擎声明后 UI 映射实时刷新", async () => { + syncActiveCapabilities(MODERN_INITIALIZE); + const { MCODE_ACP_CAPABILITIES } = await import("../server/lib/mcode-rpc.js"); + assert.equal(MCODE_ACP_CAPABILITIES.fork, true); + assert.equal(MCODE_ACP_CAPABILITIES.resume, true); + assert.equal(MCODE_ACP_CAPABILITIES.activate, true); + assert.equal(getActiveRegistry().classify("session/activate"), "supported"); + }); +}); \ No newline at end of file diff --git a/packages/webui/test/lib-embed-consumer.test.js b/packages/webui/test/lib-embed-consumer.test.js new file mode 100644 index 00000000..362e5aa9 --- /dev/null +++ b/packages/webui/test/lib-embed-consumer.test.js @@ -0,0 +1,115 @@ +// webui/test/lib-embed-consumer.test.js +// embed 聊天行归约器(server/lib/embed-consumer.js):事件→聊天行、收尾记账、 +// 生成器返回值捕获。状态推送经 setupMocks 隔离真实目录。 + +import { describe, it, before, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +import { setupMocks, registerAcpMock, registerSessionsStore } from "./_setup.js"; + +// 延迟导入(checks 文件的已证实模式):mock.module 必须先注册, +// 否则被测模块在加载期绑定真实 acp-client,pushStateFor 缓存未命中时 +// 会 spawn 真实引擎子进程导致挂死。 +let collectEmbedResult; + +function fakeCs() { + return { + chat: ["› 测试问题"], + mcodeSessionId: "mvs_x", + model: { name: "minimax_api/MiniMax-M3" }, + context: { tokens: 0, used: 0, percent: 0, limit: 512000, tps: 0, thinkingStatus: "Idle", thinkingDuration: null, lastUsageAt: null }, + usage: { sessionInput: 0, sessionOutput: 0, sessionTotal: 0 }, + running: {}, + }; +} + +function fakeGen(events, result) { + return { + [Symbol.asyncIterator]() { + let i = 0; + return { + next() { + if (i < events.length) return Promise.resolve({ value: events[i++], done: false }); + return Promise.resolve({ value: result, done: true }); + }, + }; + }, + }; +} + +// 装配置于模块顶层(checks/lib-state-bus.check.mjs 已证实的模式: +// describe 内的 before 钩子上下文不带 .mock,顶层 before 带)。 +before(async (t) => { + await setupMocks(t); + ({ collectEmbedResult } = await import("../server/lib/embed-consumer.js")); +}); +beforeEach(() => { + registerAcpMock({ + getMcodeSessionsForWorkspace: async () => [], + getMcodeSessionsCacheSync: () => null, + getMcodeSessionsStaleSync: () => null, + }); + registerSessionsStore({ initial: [] }); +}); + +describe("collectEmbedResult", () => { + + it("事件→聊天行:▲/● 增量 + 工具标记行", async () => { + const cs = fakeCs(); + const r = await collectEmbedResult( + fakeGen([ + { kind: "thought", text: "思考" }, + { kind: "message", text: "正文" }, + { kind: "tool_call", update: { title: "Bash" } }, + ], { status: "succeeded", answer: "正文", thinking: "思考", sessionId: "mvs_x", usage: null }), + { cs, cid: "t1", label: "prompt" }, + ); + // 断言语义(▲/● 与文本存在),不耦合 sessions.js mock 桩的行格式 + assert.ok(cs.chat.some((l) => typeof l === "string" && l.includes("▲") && l.includes("思考"))); + assert.ok(cs.chat.some((l) => typeof l === "string" && l.includes("●") && l.includes("正文"))); + assert.ok(cs.chat.includes("→ Bash")); + assert.equal(r.status, "succeeded"); + assert.equal(r.answer, "正文"); + }); + + it("收尾:running 复位、状态 Idle、流式光标清除、sessionId 回写", async () => { + const cs = fakeCs(); + const r = await collectEmbedResult( + fakeGen([{ kind: "message", text: "流式" }], { status: "succeeded", answer: "流式", sessionId: "mvs_new" }), + { cs, cid: "t2" }, + ); + assert.equal(cs.running.active, false); + assert.equal(cs.context.thinkingStatus, "Idle"); + assert.equal(cs.mcodeSessionId, "mvs_new"); + assert.ok(cs.chat.every((l) => typeof l !== "string" || !l.endsWith(" ▍"))); + assert.equal(r.sessionId, "mvs_new"); + }); + + it("用量记账:真实 usage 直记,无 usage 走估算(除 3 规则)", async () => { + const csA = fakeCs(); + await collectEmbedResult( + fakeGen([{ kind: "message", text: "abc" }], { status: "succeeded", answer: "abc", usage: { totalTokens: 10, inputTokens: 4, outputTokens: 6 } }), + { cs: csA, cid: "t3a" }, + ); + assert.equal(csA.context.tokens, 10); + assert.equal(csA.context.estimated, false); + assert.equal(csA.usage.sessionTotal, 10); + + const csB = fakeCs(); + await collectEmbedResult( + fakeGen([{ kind: "message", text: "abcdefghi" }], { status: "failed", error: { message: "x" } }), + { cs: csB, cid: "t3b" }, + ); + assert.equal(csB.context.estimated, true); + // estOut = ceil(9/3) = 3;estIn = ceil(len("› 测试问题")/3) + assert.equal(csB.usage.sessionOutput, 3); + assert.ok(csB.usage.sessionInput > 0); + }); + + it("生成器返回值即结果 r(手写迭代不丢返回值)", async () => { + const cs = fakeCs(); + const marker = { status: "succeeded", answer: "z", marker: true }; + const r = await collectEmbedResult(fakeGen([], marker), { cs, cid: "t4" }); + assert.equal(r.marker, true); + }); +}); \ No newline at end of file diff --git a/packages/webui/test/lib-engine-mode.test.js b/packages/webui/test/lib-engine-mode.test.js new file mode 100644 index 00000000..66ead80a --- /dev/null +++ b/packages/webui/test/lib-engine-mode.test.js @@ -0,0 +1,25 @@ +// webui/test/lib-engine-mode.test.js +// 引擎传输开关(config.js):默认旧行为、env 生效。 +// (浏览器下行通道开关 MCODE_WEBUI_TRANSPORT 已随决策 20 移除 —— SSE 删除后 +// /api/stream 始终启用,无开关可测。) + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { MCODE_ENGINE } from "../server/lib/config.js"; + +describe("引擎开关", () => { + it("默认值即旧行为:acp", () => { + assert.equal(MCODE_ENGINE, "acp"); + }); + + it("env=embed 生效(cache-bust 模块实例)", async () => { + process.env.MCODE_ENGINE = "embed"; + try { + const m = await import("../server/lib/config.js?bust=switches"); + assert.equal(m.MCODE_ENGINE, "embed"); + } finally { + delete process.env.MCODE_ENGINE; + } + }); +}); diff --git a/packages/webui/test/lib-event-bus.test.js b/packages/webui/test/lib-event-bus.test.js new file mode 100644 index 00000000..320a3a0b --- /dev/null +++ b/packages/webui/test/lib-event-bus.test.js @@ -0,0 +1,74 @@ +// webui/test/lib-event-bus.test.js +// 事件总线(server/lib/event-bus.js):seq 语义、cid 隔离、订阅扇出。 + +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +import { + emitEvent, + subscribeEvents, + getLatestSeq, + resetEventBusForTests, +} from "../server/lib/event-bus.js"; + +describe("event-bus", () => { + beforeEach(() => resetEventBusForTests()); + + it("seq 按 cid 从 1 起单调递增、连续", () => { + const got = []; + subscribeEvents("a", (item) => got.push(item.seq)); + emitEvent("a", { type: "control", name: "x", data: "1" }); + emitEvent("a", { type: "control", name: "x", data: "2" }); + emitEvent("a", { type: "control", name: "x", data: "3" }); + assert.deepEqual(got, [1, 2, 3]); + assert.equal(getLatestSeq("a"), 3); + }); + + it("cid 隔离:互不影响序列空间", () => { + emitEvent("a", { type: "control", name: "x", data: "" }); + emitEvent("b", { type: "control", name: "x", data: "" }); + emitEvent("b", { type: "control", name: "x", data: "" }); + assert.equal(getLatestSeq("a"), 1); + assert.equal(getLatestSeq("b"), 2); + }); + + it("fan-out:多订阅者同序收到;退订后停止", () => { + const a = []; + const b = []; + const unsubA = subscribeEvents("c", (i) => a.push(i.seq)); + subscribeEvents("c", (i) => b.push(i.seq)); + emitEvent("c", { type: "control", name: "x", data: "" }); + unsubA(); + emitEvent("c", { type: "control", name: "x", data: "" }); + assert.deepEqual(a, [1]); + assert.deepEqual(b, [1, 2]); + }); + + it("unsubscribe 幂等", () => { + const unsub = subscribeEvents("d", () => {}); + unsub(); + unsub(); + emitEvent("d", { type: "control", name: "x", data: "" }); + assert.equal(getLatestSeq("d"), 1); + }); + + it("订阅者异常隔离:抛错不影响其他订阅者", () => { + const ok = []; + subscribeEvents("e", () => { throw new Error("boom"); }); + subscribeEvents("e", (i) => ok.push(i.seq)); + emitEvent("e", { type: "control", name: "x", data: "" }); + assert.deepEqual(ok, [1]); + }); + + it("事件体原样透传(快照与控制两类)", () => { + const got = []; + subscribeEvents("f", (i) => got.push(i.event)); + const snap = { version: "1.0" }; + emitEvent("f", { type: "state.snapshot", snapshot: snap }); + emitEvent("f", { type: "control", name: "auth.token_rotated", data: "tok" }); + assert.equal(got[0].type, "state.snapshot"); + assert.equal(got[0].snapshot, snap); + assert.equal(got[1].name, "auth.token_rotated"); + assert.equal(got[1].data, "tok"); + }); +}); \ No newline at end of file diff --git a/packages/webui/test/lib-mcode-embed.test.js b/packages/webui/test/lib-mcode-embed.test.js new file mode 100644 index 00000000..a39a8f34 --- /dev/null +++ b/packages/webui/test/lib-mcode-embed.test.js @@ -0,0 +1,317 @@ +// webui/test/lib-mcode-embed.test.js +// mcode-embed.js(引擎宿主 Worker 传输骨架)单元测试 —— 桩 Worker 驱动 RPC v:1 +// 协议全路径。验收清单: +// 1. boot 成功 / 失败(含真实 engine-host.worker.js 的 boot-failed 路径); +// 2. prompt 事件流的 NormalizedEvent 与 mcode-acp.js 逐字段一致; +// 3. prompt-done 落定(finalize 语义:收尾结果与 runMcodeAcp 返回值同形); +// 4. steer / cancel RPC 往返; +// 5. fatal → generator 优雅结束(失败事件 + 不抛); +// 6. shutdown 无悬挂句柄(自然退出,不走 terminate 兜底)。 +// +// NormalizedEvent 的期望对象逐字段取自 acp.mjs prompt() 的 onChunk 载荷构造 +// (agent_thought_chunk → {kind:'thought', text};tool_call → {kind:'tool_call', +// update};tool_call_update → {kind:'tool_update', update};usage_update → +// {kind:'usage', update};done → {kind:'done', stopReason, usage}),也就是 +// mcode-acp.js streamAcpPrompt 流回调消费的同一对象 —— 生成器必须逐字透传。 +// 桩不加载引擎(test/fixtures/engine-host.stub.worker.js),事件序列经 +// workerData.script 脚本化。 + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const TEST_DIR = dirname(fileURLToPath(import.meta.url)); +const SERVER_DIR = join(TEST_DIR, "..", "server"); +const STUB_WORKER = pathToFileURL( + join(TEST_DIR, "fixtures", "engine-host.stub.worker.js"), +).href; +const ENGINE_ENTRY = join( + TEST_DIR, + "..", + "..", + "local-runtime-v2", + "dist", + "local", + "index.js", +); + +const { + bootEngineHost, + runMcodeEmbed, + steerEmbed, + cancelEmbed, + stopEmbed, + isEmbedRunning, +} = await import( + pathToFileURL(join(SERVER_DIR, "lib", "mcode-embed.js")).href +); + +/** + * 驱动生成器到终点:收集全部 NormalizedEvent 与收尾结果(生成器返回值 = + * streamAcpPrompt 的 r)。 + */ +async function collect(gen) { + const events = []; + for (;;) { + const { value, done } = await gen.next(); + if (done) return { events, result: value }; + events.push(value); + } +} + +/** 以脚本化桩 Worker 启动宿主;测试结束自动拆除(无跨用例状态残留)。 */ +async function bootStub(t, script) { + const r = await bootEngineHost({ + workerPath: STUB_WORKER, + workerData: { script }, + }); + t.after(() => stopEmbed()); + return r; +} + +// ── 参考数据(字段与 acp.mjs / mcode-acp.js 的载荷构造一致)───────────────── +const USAGE = { + totalTokens: 1300, + inputTokens: 800, + outputTokens: 500, + thoughtTokens: 120, +}; +const TOOL_CALL_UPDATE = { + sessionUpdate: "tool_call", + toolCallId: "call-1", + title: "Bash", + name: "Bash", + status: "running", + rawInput: { command: "ls" }, +}; +const TOOL_DONE_UPDATE = { + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + status: "completed", + rawOutput: { content: [{ type: "text", text: "file.txt" }] }, + locations: [{ path: "/tmp/file.txt" }], +}; +const USAGE_UPDATE = { used: 1200, size: 512000, cost: 0.01 }; +const PLAN_UPDATE = { + sessionUpdate: "plan_update", + planId: "p1", + title: "方案", + summary: "摘要", + options: [{ label: "a", description: "A" }], +}; + +describe("bootEngineHost — 桩 Worker", () => { + test("boot 成功 → {ok:true, engineVersion},isEmbedRunning() 为真", async (t) => { + const r = await bootStub(t, { boot: "ok", engineVersion: "stub-1.0.0" }); + assert.deepStrictEqual(r, { ok: true, engineVersion: "stub-1.0.0" }); + assert.equal(isEmbedRunning(), true); + }); + + test("boot-failed → {ok:false, reason},失败不抛且宿主自动拆除", async (t) => { + const r = await bootStub(t, { + boot: "fail", + bootError: "engine dist not built", + }); + assert.equal(r.ok, false); + assert.match(r.reason, /engine dist not built/); + // 失败即回退语义:宿主拆除后 isEmbedRunning() 为假 + assert.equal(isEmbedRunning(), false); + }); + + test("真实 engine-host.worker.js 的 boot-failed 路径(dist 未构建属预期)", async (t) => { + // 引擎 dist 未构建时必须落定 {ok:false}(可测的 boot-failed 路径); + // 若未来仓库构建了 dist,则只校验结果形状(避免测试绑定构建状态)。 + const engineBuilt = existsSync(ENGINE_ENTRY); + const r = await bootEngineHost(); + t.after(() => stopEmbed()); + assert.equal(typeof r.ok, "boolean"); + if (!engineBuilt) { + assert.equal(r.ok, false); + assert.equal(typeof r.reason, "string"); + assert.ok(r.reason.length > 0, "boot-failed 必须给出 reason"); + } else if (r.ok) { + assert.ok("engineVersion" in r); + } + }); +}); + +describe("runMcodeEmbed — prompt 事件流与收尾", () => { + test("NormalizedEvent 与 mcode-acp.js 逐字段一致(逐字透传)", async (t) => { + const expectedEvents = [ + { kind: "thought", text: "让我想想" }, + {kind: "message", text: "你好" }, + { kind: "tool_call", update: TOOL_CALL_UPDATE }, + { kind: "tool_update", update: TOOL_DONE_UPDATE }, + { kind: "usage", update: USAGE_UPDATE }, + { kind: "plan_update", update: PLAN_UPDATE }, + { kind: "done", stopReason: "end_turn", usage: USAGE }, + ]; + await bootStub(t, { + prompts: [ + { + events: expectedEvents, + reply: { + ok: true, + payload: { sessionId: "mvs_stub_1", stopReason: "end_turn", usage: USAGE }, + }, + }, + ], + }); + const { events } = await collect(runMcodeEmbed("你好", {})); + assert.deepStrictEqual(events, expectedEvents); + }); + + test("prompt-done 落定:收尾结果与 runMcodeAcp 的 r 同形同义", async (t) => { + await bootStub(t, { + prompts: [ + { + events: [ + { kind: "thought", text: "思考" }, + { kind: "message", text: "正文" }, + { kind: "done", stopReason: "end_turn", usage: USAGE }, + ], + reply: { + ok: true, + payload: { sessionId: "mvs_stub_2", stopReason: "end_turn", usage: USAGE }, + }, + }, + ], + }); + const { events, result: r } = await collect(runMcodeEmbed("hi", {})); + // done 事件是流的最后一帧(prompt-done) + assert.deepStrictEqual(events[events.length - 1], { + kind: "done", + stopReason: "end_turn", + usage: USAGE, + }); + // 返回值与 streamAcpPrompt 的 r 字段逐项一致 + assert.deepStrictEqual( + Object.keys(r).sort(), + ["answer", "thinking", "status", "error", "usage", "sessionId", "durationMs", "stopReason", "tps"].sort(), + ); + assert.equal(r.status, "succeeded"); + assert.equal(r.answer, "正文"); + assert.equal(r.thinking, "思考"); + assert.equal(r.stopReason, "end_turn"); + assert.deepStrictEqual(r.usage, USAGE); + assert.equal(r.sessionId, "mvs_stub_2"); + assert.equal(r.error, null); + assert.equal(typeof r.durationMs, "number"); + // mcode-acp.js 的 r.tps 保持 null(tps 只写 cs.running) + assert.equal(r.tps, null); + }); + + test("reply 覆盖累积值(streamAcpPrompt 的 result.answer || r.answer 语义)", async (t) => { + await bootStub(t, { + prompts: [ + { + events: [{ kind: "message", text: "Hello" }], + reply: { + ok: true, + payload: { answer: "Hello world", stopReason: "max_tokens" }, + }, + }, + ], + }); + const { result: r } = await collect(runMcodeEmbed("hi", {})); + assert.equal(r.answer, "Hello world"); + assert.equal(r.thinking, null); // 无 thought 增量且 reply 未给 —— 保持 null + assert.equal(r.stopReason, "max_tokens"); + }); + + test("reply 失败 → {kind:'error'} 事件 + status:'failed' 优雅结束", async (t) => { + await bootStub(t, { + prompts: [{ events: [], reply: { ok: false, error: "engine exploded" } }], + }); + const { events, result: r } = await collect(runMcodeEmbed("hi", {})); + assert.deepStrictEqual(events, [{ kind: "error", text: "engine exploded" }]); + assert.equal(r.status, "failed"); + assert.deepStrictEqual(r.error, { message: "engine exploded" }); + }); + + test("未 boot 调用 → 失败事件 + status:'failed'(不抛,回退决策在调用方)", async () => { + assert.equal(isEmbedRunning(), false); + const { events, result: r } = await collect(runMcodeEmbed("hi", {})); + assert.equal(events.length, 1); + assert.equal(events[0].kind, "error"); + assert.equal(r.status, "failed"); + assert.match(r.error.message, /not booted/); + }); + + test("空闲看门狗:流静默超时 → status:'timeout'(事件续命语义同 mcode-acp.js)", async (t) => { + await bootStub(t, { + prompts: [ + { + events: [{ delay: 600 }, { kind: "message", text: "late" }], + reply: { ok: true, payload: { answer: "late" } }, + }, + ], + }); + const { events, result: r } = await collect( + runMcodeEmbed("hi", { idleTimeoutMs: 60 }), + ); + assert.equal(r.status, "timeout"); + assert.match(r.error.message, /inactive for/); + assert.equal(events[events.length - 1].kind, "error"); + }); +}); + +describe("steerEmbed / cancelEmbed — RPC 往返", () => { + test("steer / cancel 往返:ok 载荷与 error 应答", async (t) => { + await bootStub(t, { + steer: { ok: true, payload: { steered: true } }, + cancel: { ok: false, error: "no-active-prompt" }, + }); + const steer = await steerEmbed("mvs_stub_3", "继续"); + assert.deepStrictEqual(steer, { ok: true, payload: { steered: true } }); + const cancel = await cancelEmbed("mvs_stub_3"); + assert.deepStrictEqual(cancel, { ok: false, error: "no-active-prompt" }); + }); + + test("宿主未运行时 RPC 返回 {ok:false}(不抛)", async () => { + assert.equal(isEmbedRunning(), false); + const r = await cancelEmbed("any"); + assert.equal(r.ok, false); + assert.match(r.error, /not running/); + }); +}); + +describe("fatal — 未捕获异常", () => { + test("fatal → generator 产出失败事件并优雅结束(不抛)", async (t) => { + await bootStub(t, { + prompts: [ + { + events: [{ kind: "thought", text: "a" }, { fatal: { error: "boom" } }], + reply: { ok: true, payload: {} }, // fatal 后不会到达 + }, + ], + }); + const { events, result: r } = await collect(runMcodeEmbed("hi", {})); + assert.deepStrictEqual(events, [ + { kind: "thought", text: "a" }, + { kind: "error", text: "boom" }, + ]); + assert.equal(r.status, "failed"); + assert.deepStrictEqual(r.error, { message: "boom" }); + }); +}); + +describe("stopEmbed — shutdown 无悬挂句柄", () => { + test("shutdown 后 Worker 自然退出(不走 2s terminate 兜底)且可再次 boot", async (t) => { + const booted = await bootStub(t, { boot: "ok", engineVersion: "stub-1.0.0" }); + assert.equal(booted.ok, true); + assert.equal(isEmbedRunning(), true); + const t0 = Date.now(); + await stopEmbed(); + const elapsed = Date.now() - t0; + // 自然退出远快于 terminate 兜底(2000ms)—— 若有悬挂句柄会拖到兜底才落定 + assert.ok(elapsed < 1500, `stopEmbed 耗时 ${elapsed}ms,疑似有悬挂句柄`); + assert.equal(isEmbedRunning(), false); + // 状态已清空 —— 可立即再次 boot(无跨用例残留) + const again = await bootStub(t, { boot: "ok", engineVersion: "stub-1.0.0" }); + assert.deepStrictEqual(again, { ok: true, engineVersion: "stub-1.0.0" }); + assert.equal(isEmbedRunning(), true); + }); +}); diff --git a/packages/webui/test/lib-ring-buffer.test.js b/packages/webui/test/lib-ring-buffer.test.js new file mode 100644 index 00000000..72069e09 --- /dev/null +++ b/packages/webui/test/lib-ring-buffer.test.js @@ -0,0 +1,104 @@ +// webui/test/lib-ring-buffer.test.js +// 环形缓冲(seq 索引重放窗口)单元测试。 +// 覆盖规格:push/replay 基本行为、覆盖最旧、欠载 complete=false、 +// fromSeq===latestSeq()+1 边界、空缓冲两例,以及实现的防御性行为。 + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { pathToFileURL } from "node:url"; +import { join } from "node:path"; + +const absPath = (rel) => + pathToFileURL(join(import.meta.dirname, "..", "server", rel)).href; +const { createRingBuffer } = await import(absPath("lib/ring-buffer.js")); + +describe("ring-buffer", () => { + test("push / replay 基本行为:按 seq 过滤并升序返回", () => { + const buf = createRingBuffer(10); + assert.equal(buf.push(1, "a"), true); + assert.equal(buf.push(2, "b"), true); + assert.equal(buf.push(3, "c"), true); + const r = buf.replay(2); + assert.deepEqual(r.items, [ + { seq: 2, item: "b" }, + { seq: 3, item: "c" }, + ]); + assert.equal(r.complete, true); + }); + + test("push 拒绝非递增 seq(重复与回退均返回 false)", () => { + const buf = createRingBuffer(10); + assert.equal(buf.push(5, "a"), true); + assert.equal(buf.push(5, "dup"), false); + assert.equal(buf.push(4, "back"), false); + assert.equal(buf.push(6, "ok"), true); + assert.equal(buf.size(), 2); // 被拒绝的写入不占位 + }); + + test("容量满时覆盖最旧条目", () => { + const buf = createRingBuffer(3); + for (const n of [1, 2, 3, 4]) buf.push(n, `item-${n}`); + assert.equal(buf.size(), 3); + const r = buf.replay(1); + // 最旧的 seq=1 已被覆盖,保留 2..4 + assert.deepEqual(r.items.map((e) => e.seq), [2, 3, 4]); + }); + + test("欠载:fromSeq 早于最旧保留 seq → complete=false", () => { + const buf = createRingBuffer(3); + for (const n of [5, 6, 7, 8]) buf.push(n, n); // 覆盖掉 5,保留 6..8 + const r = buf.replay(1); + assert.equal(r.complete, false); + assert.deepEqual(r.items.map((e) => e.seq), [6, 7, 8]); + }); + + test("边界:fromSeq === latestSeq()+1 → 空列表且 complete=true", () => { + const buf = createRingBuffer(5); + for (const n of [1, 2, 3]) buf.push(n, n); + assert.equal(buf.latestSeq(), 3); + const r = buf.replay(4); + assert.deepEqual(r.items, []); + assert.equal(r.complete, true); + }); + + test("空缓冲:fromSeq===1 → 空列表且 complete=true", () => { + const buf = createRingBuffer(5); + assert.equal(buf.latestSeq(), null); + const r = buf.replay(1); + assert.deepEqual(r.items, []); + assert.equal(r.complete, true); + }); + + test("空缓冲:fromSeq>1 且 latestSeq()===null → complete=false", () => { + const buf = createRingBuffer(5); + const r = buf.replay(2); + assert.deepEqual(r.items, []); + assert.equal(r.complete, false); + }); + + test("capacity 非法值抛 RangeError", () => { + assert.throws(() => createRingBuffer(0), RangeError); + assert.throws(() => createRingBuffer(-1), RangeError); + assert.throws(() => createRingBuffer(1.5), RangeError); + }); + + test("push 拒绝非有限 seq(NaN / Infinity)", () => { + const buf = createRingBuffer(5); + assert.equal(buf.push(NaN, "x"), false); + assert.equal(buf.push(Infinity, "x"), false); + assert.equal(buf.size(), 0); + assert.equal(buf.latestSeq(), null); + }); + + test("latestSeq / size / capacity 访问器随写入演进", () => { + const buf = createRingBuffer(2); + assert.equal(buf.capacity, 2); + buf.push(1, "a"); + assert.equal(buf.latestSeq(), 1); + assert.equal(buf.size(), 1); + buf.push(2, "b"); + buf.push(3, "c"); // 覆盖最旧 + assert.equal(buf.latestSeq(), 3); + assert.equal(buf.size(), 2); + }); +}); diff --git a/packages/webui/test/lib-ws-frame.test.js b/packages/webui/test/lib-ws-frame.test.js new file mode 100644 index 00000000..1f5dab98 --- /dev/null +++ b/packages/webui/test/lib-ws-frame.test.js @@ -0,0 +1,477 @@ +// webui/test/lib-ws-frame.test.js +// RFC 6455 WebSocket 帧库一致性单元测试。 +// 覆盖规格:§1.3 握手标例、编解码往返、125/126/127 长度边界、4 字节键循环 +// 异或掩码解码、未掩码 / 超长控制帧 / RSV / 分片结构类违规、TEXT+CONT 重组、 +// 分片间控制帧穿插、close 解析、UTF-8 跨分片校验、超限 1009、终态语义。 + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { pathToFileURL } from "node:url"; +import { join } from "node:path"; + +const absPath = (rel) => + pathToFileURL(join(import.meta.dirname, "..", "server", rel)).href; +const { + WS_GUID, + OPCODE, + CLOSE_CODE, + computeAcceptKey, + encodeFrame, + createFrameDecoder, +} = await import(absPath("lib/ws-frame.js")); + +// ----------------------------------------------------------------------- +// 测试辅助:构造客户端 → 服务端帧(掩码)。 +// 服务端解码器只接受掩码帧,违规用例需要手工拼头字节。 +// ----------------------------------------------------------------------- + +// RFC 6455 §5.7 标例用的掩码键 +const KEY = Buffer.from([0x37, 0xfa, 0x21, 0x3d]); + +function maskPayload(payload, key) { + const out = Buffer.allocUnsafe(payload.length); + for (let i = 0; i < payload.length; i++) out[i] = payload[i] ^ key[i & 3]; + return out; +} + +/** + * 构造一个客户端帧。 + * + * @param {object} opts + * @param {number} opts.opcode 操作码 + * @param {string | Buffer} [opts.payload] 负载 + * @param {boolean} [opts.fin] FIN 位,默认 true + * @param {number} [opts.rsv] RSV 位(0x10/0x20/0x40),默认 0 + * @param {boolean} [opts.masked] 是否掩码,默认 true + * @param {Buffer} [opts.key] 4 字节掩码键 + * @param {number | null} [opts.len7Override] 强制第二个头字节的低 7 位(构造非最小编码) + * @param {Buffer} [opts.extLenBytes] 强制扩展长度字段的原始字节 + */ +function clientFrame({ + opcode, + payload = Buffer.alloc(0), + fin = true, + rsv = 0, + masked = true, + key = KEY, + len7Override = null, + extLenBytes = null, +} = {}) { + const body = typeof payload === "string" ? Buffer.from(payload, "utf8") : payload; + const len = body.length; + const b0 = (fin ? 0x80 : 0) | (rsv & 0x70) | (opcode & 0x0f); + + let len7; + let ext = Buffer.alloc(0); + if (len7Override !== null) { + len7 = len7Override; + if (len7 === 126) { + ext = extLenBytes ?? (() => { const b = Buffer.allocUnsafe(2); b.writeUInt16BE(len); return b; })(); + } else if (len7 === 127) { + ext = extLenBytes ?? (() => { const b = Buffer.allocUnsafe(8); b.writeBigUInt64BE(BigInt(len)); return b; })(); + } + } else if (len < 126) { + len7 = len; + } else if (len <= 0xffff) { + len7 = 126; + ext = Buffer.allocUnsafe(2); + ext.writeUInt16BE(len); + } else { + len7 = 127; + ext = Buffer.allocUnsafe(8); + ext.writeBigUInt64BE(BigInt(len)); + } + + const b1 = (masked ? 0x80 : 0) | len7; + const parts = [Buffer.from([b0, b1]), ext]; + if (masked) { + parts.push(key, maskPayload(body, key)); + } else { + parts.push(body); + } + return Buffer.concat(parts); +} + +/** + * 把服务端帧(不掩码)转换成客户端帧(掩码), + * 以便对 encodeFrame → createFrameDecoder 做编解码往返。 + */ +function maskServerFrame(frame, key = KEY) { + const len7 = frame[1] & 0x7f; + let ext = 0; + let len = 0; + if (len7 < 126) { + len = len7; + } else if (len7 === 126) { + ext = 2; + len = frame.readUInt16BE(2); + } else { + ext = 8; + len = Number(frame.readBigUInt64BE(2)); + } + const headerLen = 2 + ext; + const body = frame.subarray(headerLen); + const out = Buffer.alloc(headerLen + 4 + len); + frame.copy(out, 0, 0, headerLen); + out[1] = 0x80 | len7; + key.copy(out, headerLen); + for (let i = 0; i < len; i++) out[headerLen + 4 + i] = body[i] ^ key[i & 3]; + return out; +} + +/** 构造 close 帧负载:2 字节大端 code + UTF-8 reason。 */ +function closeBody(code, reason = "") { + const head = Buffer.allocUnsafe(2); + head.writeUInt16BE(code); + return Buffer.concat([head, Buffer.from(reason, "utf8")]); +} + +// ----------------------------------------------------------------------- + +describe("ws-frame / computeAcceptKey", () => { + test("RFC 6455 §1.3 握手标例", () => { + // 规范原文:client key 'dGhlIHNhbXBsZSBub25jZQ==' 必须得到 + // 's3pPLMBiTxaQ9kYGzzhZRbK+xOo=' + assert.equal( + computeAcceptKey("dGhlIHNhbXBsZSBub25jZQ=="), + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", + ); + }); + + test("WS_GUID 为规范定值", () => { + assert.equal(WS_GUID, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + }); + + test("非字符串入参抛 TypeError", () => { + assert.throws(() => computeAcceptKey(123), TypeError); + assert.throws(() => computeAcceptKey(undefined), TypeError); + }); +}); + +describe("ws-frame / encodeFrame", () => { + test("服务端帧恒为 FIN=1、不掩码(RFC §5.7 单帧标例)", () => { + const frame = encodeFrame({ opcode: OPCODE.TEXT, payload: "Hello" }); + assert.deepEqual([...frame], [0x81, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f]); + }); + + test("编解码往返:encodeFrame → 转客户端帧 → 解码还原", () => { + const decoder = createFrameDecoder(); + const text = "你好, WebSocket ✓"; + const events = decoder.push( + maskServerFrame(encodeFrame({ opcode: OPCODE.TEXT, payload: text })), + ); + assert.deepEqual(events, [{ kind: "text", text }]); + + const bin = Buffer.from([0, 1, 2, 250, 255]); + const events2 = decoder.push( + maskServerFrame(encodeFrame({ opcode: OPCODE.BINARY, payload: bin })), + ); + assert.equal(events2.length, 1); + assert.equal(events2[0].kind, "binary"); + assert.deepEqual(events2[0].data, bin); + }); + + test("长度边界 125 / 126:7 bit 与 126+2B 的切换点", () => { + const f125 = encodeFrame({ opcode: OPCODE.BINARY, payload: Buffer.alloc(125) }); + assert.equal(f125[1], 125); + assert.equal(f125.length, 2 + 125); + + const f126 = encodeFrame({ opcode: OPCODE.BINARY, payload: Buffer.alloc(126) }); + assert.equal(f126[1], 126); + assert.equal(f126.readUInt16BE(2), 126); + assert.equal(f126.length, 4 + 126); + }); + + test("长度边界 65535 / 65536:126+2B 与 127+8B 的切换点", () => { + const f65535 = encodeFrame({ opcode: OPCODE.BINARY, payload: Buffer.alloc(65535) }); + assert.equal(f65535[1], 126); + assert.equal(f65535.readUInt16BE(2), 65535); + assert.equal(f65535.length, 4 + 65535); + + const f65536 = encodeFrame({ opcode: OPCODE.BINARY, payload: Buffer.alloc(65536) }); + assert.equal(f65536[1], 127); + assert.equal(f65536.readBigUInt64BE(2), 65536n); + // 8 字节大端最高位必须为 0(§5.2) + assert.equal(f65536[2] & 0x80, 0); + assert.equal(f65536.length, 10 + 65536); + + // 三档边界帧都能被解码器还原 + const decoder = createFrameDecoder({ maxFrameBytes: 70000 }); + for (const n of [125, 126, 65535, 65536]) { + const events = decoder.push( + maskServerFrame(encodeFrame({ opcode: OPCODE.BINARY, payload: Buffer.alloc(n, 7) })), + ); + assert.equal(events[0].kind, "binary"); + assert.equal(events[0].data.length, n); + } + }); + + test("非法 opcode 或负载类型抛 TypeError", () => { + assert.throws(() => encodeFrame({ opcode: 0x3, payload: "" }), TypeError); + assert.throws(() => encodeFrame({ opcode: OPCODE.TEXT, payload: 42 }), TypeError); + assert.throws(() => encodeFrame({}), TypeError); + }); +}); + +describe("ws-frame / createFrameDecoder 解码", () => { + test("掩码解码:RFC §5.7 掩码单帧标例(4 字节键循环异或)", () => { + // 81 85 37 fa 21 3d 7f 9f 4d 51 58 = 掩码后的 TEXT \"Hello\"(键 37 fa 21 3d) + const frame = Buffer.from([ + 0x81, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58, + ]); + const decoder = createFrameDecoder(); + assert.deepEqual(decoder.push(frame), [{ kind: "text", text: "Hello" }]); + }); + + test("掩码解码:负载超过 4 字节时掩码键按 i&3 循环", () => { + // 自校验:把已知明文按 4 字节键循环异或打码后喂给解码器 + const plain = Buffer.from("0123456789abcdef", "utf8"); + const decoder = createFrameDecoder(); + const events = decoder.push( + clientFrame({ opcode: OPCODE.BINARY, payload: plain, key: Buffer.from([1, 2, 3, 4]) }), + ); + assert.deepEqual(events[0].data, plain); + }); + + test("未掩码帧 → protocol-error 1002", () => { + const decoder = createFrameDecoder(); + const events = decoder.push( + clientFrame({ opcode: OPCODE.TEXT, payload: "hi", masked: false }), + ); + assert.equal(events.length, 1); + assert.equal(events[0].kind, "protocol-error"); + assert.equal(events[0].code, CLOSE_CODE.PROTOCOL_ERROR); + assert.equal(decoder.errored, true); + }); + + test("RSV 非 0 → protocol-error 1002(未协商扩展)", () => { + const decoder = createFrameDecoder(); + const events = decoder.push(clientFrame({ opcode: OPCODE.TEXT, payload: "x", rsv: 0x10 })); + assert.equal(events[0].kind, "protocol-error"); + assert.equal(events[0].code, CLOSE_CODE.PROTOCOL_ERROR); + }); + + test("FIN=0 控制帧 → protocol-error 1002", () => { + const decoder = createFrameDecoder(); + const events = decoder.push( + clientFrame({ opcode: OPCODE.PING, payload: "x", fin: false }), + ); + assert.equal(events[0].kind, "protocol-error"); + assert.equal(events[0].code, CLOSE_CODE.PROTOCOL_ERROR); + }); + + test("控制帧 >125 字节 → protocol-error 1002", () => { + // 声明 126 字节负载的 PING:扩展长度即超长 + const decoder = createFrameDecoder(); + const events = decoder.push( + clientFrame({ opcode: OPCODE.PING, payload: Buffer.alloc(126, 9) }), + ); + assert.equal(events[0].kind, "protocol-error"); + assert.equal(events[0].code, CLOSE_CODE.PROTOCOL_ERROR); + }); + + test("TEXT + CONT 重组为单条消息", () => { + const decoder = createFrameDecoder(); + const events = decoder.push( + Buffer.concat([ + clientFrame({ opcode: OPCODE.TEXT, payload: "Hel", fin: false }), + clientFrame({ opcode: OPCODE.CONT, payload: "lo ", fin: false }), + clientFrame({ opcode: OPCODE.CONT, payload: "world" }), + ]), + ); + assert.deepEqual(events, [{ kind: "text", text: "Hello world" }]); + }); + + test("分片间穿插 PING:控制帧即时产出,消息在其后封口", () => { + const decoder = createFrameDecoder(); + const events = decoder.push( + Buffer.concat([ + clientFrame({ opcode: OPCODE.TEXT, payload: "Hel", fin: false }), + clientFrame({ opcode: OPCODE.PING, payload: "beat" }), + clientFrame({ opcode: OPCODE.CONT, payload: "lo" }), + ]), + ); + assert.equal(events.length, 2); + // 顺序:PING 先(解析到即产出),TEXT 在最后一个分片到达时封口 + assert.equal(events[0].kind, "ping"); + assert.deepEqual(events[0].payload, Buffer.from("beat", "utf8")); + assert.deepEqual(events[1], { kind: "text", text: "Hello" }); + }); + + test("非法分片结构:CONT 起手 / 分片中插入新数据帧 → 1002", () => { + const d1 = createFrameDecoder(); + const e1 = d1.push(clientFrame({ opcode: OPCODE.CONT, payload: "x" })); + assert.equal(e1[0].kind, "protocol-error"); + assert.equal(e1[0].code, CLOSE_CODE.PROTOCOL_ERROR); + + const d2 = createFrameDecoder(); + const e2 = d2.push( + Buffer.concat([ + clientFrame({ opcode: OPCODE.TEXT, payload: "a", fin: false }), + clientFrame({ opcode: OPCODE.TEXT, payload: "b" }), + ]), + ); + assert.equal(e2[e2.length - 1].kind, "protocol-error"); + assert.equal(e2[e2.length - 1].code, CLOSE_CODE.PROTOCOL_ERROR); + }); + + test("close 解析:空负载 → 1005", () => { + const decoder = createFrameDecoder(); + const events = decoder.push(clientFrame({ opcode: OPCODE.CLOSE })); + assert.deepEqual(events, [ + { kind: "close", code: CLOSE_CODE.NO_STATUS, reason: "" }, + ]); + }); + + test("close 解析:大端 code + UTF-8 reason", () => { + const decoder = createFrameDecoder(); + const events = decoder.push( + clientFrame({ opcode: OPCODE.CLOSE, payload: closeBody(1000, "bye") }), + ); + assert.deepEqual(events, [{ kind: "close", code: 1000, reason: "bye" }]); + }); + + test("close 解析:1 字节负载 / 非法 code → 1002", () => { + const d1 = createFrameDecoder(); + const e1 = d1.push(clientFrame({ opcode: OPCODE.CLOSE, payload: Buffer.from([0x03]) })); + assert.equal(e1[0].kind, "protocol-error"); + assert.equal(e1[0].code, CLOSE_CODE.PROTOCOL_ERROR); + + // 1005/1006 只作内部语义,出现在线上即违规 + const d2 = createFrameDecoder(); + const e2 = d2.push( + clientFrame({ opcode: OPCODE.CLOSE, payload: closeBody(1005) }), + ); + assert.equal(e2[0].kind, "protocol-error"); + assert.equal(e2[0].code, CLOSE_CODE.PROTOCOL_ERROR); + + const d3 = createFrameDecoder(); + const e3 = d3.push(clientFrame({ opcode: OPCODE.CLOSE, payload: closeBody(999) })); + assert.equal(e3[0].kind, "protocol-error"); + }); + + test("close 解析:reason 非法 UTF-8 → 1007", () => { + const decoder = createFrameDecoder(); + const body = Buffer.concat([closeBody(1000), Buffer.from([0xff, 0xfe])]); + const events = decoder.push(clientFrame({ opcode: OPCODE.CLOSE, payload: body })); + assert.equal(events[0].kind, "protocol-error"); + assert.equal(events[0].code, CLOSE_CODE.INVALID_UTF8); + }); + + test("UTF-8 跨分片:多字节字符被拆进两个 CONT 分片仍正确重组", () => { + const decoder = createFrameDecoder(); + const mid = Buffer.from("中", "utf8"); // E4 B8 AD + assert.equal(mid.length, 3); + const events = decoder.push( + Buffer.concat([ + clientFrame({ opcode: OPCODE.TEXT, payload: mid.subarray(0, 2), fin: false }), + clientFrame({ opcode: OPCODE.CONT, payload: Buffer.concat([mid.subarray(2), Buffer.from("文", "utf8")]) }), + ]), + ); + assert.deepEqual(events, [{ kind: "text", text: "中文" }]); + }); + + test("UTF-8 跨 push 边界:同一帧的字节被任意切块仍正确解码", () => { + const decoder = createFrameDecoder(); + const frame = clientFrame({ opcode: OPCODE.TEXT, payload: "中文abc😀" }); + const events = []; + for (let i = 0; i < frame.length; i += 3) { + events.push(...decoder.push(frame.subarray(i, i + 3))); + } + assert.deepEqual(events, [{ kind: "text", text: "中文abc😀" }]); + }); + + test("UTF-8 截断 / 非法序列 → protocol-error 1007", () => { + const mid = Buffer.from("中", "utf8"); + const d1 = createFrameDecoder(); + // 消息在多字节序列中间结束 + const e1 = d1.push(clientFrame({ opcode: OPCODE.TEXT, payload: mid.subarray(0, 2) })); + assert.equal(e1[0].kind, "protocol-error"); + assert.equal(e1[0].code, CLOSE_CODE.INVALID_UTF8); + + const d2 = createFrameDecoder(); + const e2 = d2.push(clientFrame({ opcode: OPCODE.TEXT, payload: Buffer.from([0xff]) })); + assert.equal(e2[0].kind, "protocol-error"); + assert.equal(e2[0].code, CLOSE_CODE.INVALID_UTF8); + }); + + test("超限 → protocol-error 1009(单帧)", () => { + const decoder = createFrameDecoder({ maxFrameBytes: 16 }); + const events = decoder.push( + clientFrame({ opcode: OPCODE.BINARY, payload: Buffer.alloc(17, 1) }), + ); + assert.equal(events[0].kind, "protocol-error"); + assert.equal(events[0].code, CLOSE_CODE.TOO_LARGE); + assert.equal(decoder.errored, true); + }); + + test("超限 → protocol-error 1009(分片累计超出上限)", () => { + const decoder = createFrameDecoder({ maxFrameBytes: 16 }); + decoder.push(clientFrame({ opcode: OPCODE.BINARY, payload: Buffer.alloc(10), fin: false })); + const events = decoder.push(clientFrame({ opcode: OPCODE.CONT, payload: Buffer.alloc(10) })); + assert.equal(events[0].kind, "protocol-error"); + assert.equal(events[0].code, CLOSE_CODE.TOO_LARGE); + }); + + test("默认上限 1 MiB:恰在上限内放行,超一字节即 1009", () => { + const ok = createFrameDecoder(); + const events = ok.push( + clientFrame({ opcode: OPCODE.BINARY, payload: Buffer.alloc(1024 * 1024) }), + ); + assert.equal(events[0].kind, "binary"); + assert.equal(events[0].data.length, 1024 * 1024); + + const tooBig = createFrameDecoder(); + const events2 = tooBig.push( + clientFrame({ opcode: OPCODE.BINARY, payload: Buffer.alloc(1024 * 1024 + 1) }), + ); + assert.equal(events2[0].kind, "protocol-error"); + assert.equal(events2[0].code, CLOSE_CODE.TOO_LARGE); + }); + + test("一次 push 多帧按序产出,控制帧穿插在消息之间", () => { + const decoder = createFrameDecoder(); + const events = decoder.push( + Buffer.concat([ + clientFrame({ opcode: OPCODE.TEXT, payload: "one" }), + clientFrame({ opcode: OPCODE.PING }), + clientFrame({ opcode: OPCODE.BINARY, payload: Buffer.from([1, 2]) }), + clientFrame({ opcode: OPCODE.PONG, payload: "p" }), + ]), + ); + assert.deepEqual( + events.map((e) => e.kind), + ["text", "ping", "binary", "pong"], + ); + }); + + test("非最小编码的长度字段 → protocol-error 1002", () => { + // 5 字节负载却用 126+2B 编码长度:违反 §5.2 最小编码要求 + const decoder = createFrameDecoder(); + const events = decoder.push( + clientFrame({ + opcode: OPCODE.BINARY, + payload: Buffer.alloc(5), + len7Override: 126, + extLenBytes: Buffer.from([0x00, 0x05]), + }), + ); + assert.equal(events[0].kind, "protocol-error"); + assert.equal(events[0].code, CLOSE_CODE.PROTOCOL_ERROR); + }); + + test("protocol-error 是终态:后续 push 返回空数组", () => { + const decoder = createFrameDecoder(); + decoder.push(clientFrame({ opcode: OPCODE.TEXT, payload: "x", masked: false })); + assert.equal(decoder.errored, true); + const events = decoder.push(clientFrame({ opcode: OPCODE.TEXT, payload: "ok" })); + assert.deepEqual(events, []); + assert.equal(decoder.errored, true); + }); + + test("构造参数非法抛 RangeError", () => { + assert.throws(() => createFrameDecoder({ maxFrameBytes: 0 }), RangeError); + assert.throws(() => createFrameDecoder({ maxFrameBytes: -1 }), RangeError); + assert.throws(() => createFrameDecoder({ maxFrameBytes: 1.5 }), RangeError); + }); +}); diff --git a/packages/webui/test/lib-ws-server.test.js b/packages/webui/test/lib-ws-server.test.js new file mode 100644 index 00000000..1c0997fb --- /dev/null +++ b/packages/webui/test/lib-ws-server.test.js @@ -0,0 +1,232 @@ +// webui/test/lib-ws-server.test.js +// GET /api/stream WebSocket 事件流端点:握手 / 事件推送 / 恢复重放 / 快照回退 / +// 心跳 / 入站配额 / 二进制拒绝。真实回环 TCP + 自研帧解析。 +// 每用例独立 server + 套接字(withStream),杜绝共享状态;connect 超时也 +// 落定(把挂死变成可断言的诊断现场)。 + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import net from "node:net"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// 连接副作用(pushOnlineCount / mavis hydrate)经 state-bus 读会话库;config.js +// 在 import 期读 MCODE_WEBUI_SESSIONS_DB —— 先隔离到临时目录:测试不碰真实 +// ~/.mcode-webui/sessions.json,且 getClient 恢复为空(无 mavis hydrate 帧)。 +process.env.MCODE_WEBUI_SESSIONS_DB = join( + mkdtempSync(join(tmpdir(), "ws-server-test-")), + "sessions.json", +); + +let handleStreamUpgrade, emitEvent, resetEventBusForTests, encodeFrame; + +const CLIENT_KEY = "dGhlIHNhbXBsZSBub25jZQ=="; +const CLIENT_ACCEPT = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; + +before(async () => { + ({ handleStreamUpgrade } = await import("../server/lib/ws-server.js")); + ({ emitEvent, resetEventBusForTests } = await import("../server/lib/event-bus.js")); + ({ encodeFrame } = await import("../server/lib/ws-frame.js")); + // 预热 mcode sessions 缓存:连接时的 pushOnlineCount → mcodeSessionsSnapshotFields + // 在 cache miss 时会 fire-and-forget 拉取,完成时补推一帧权威快照 —— 那一帧 + // 会在用例中途随机落进某条连接,破坏 seq 计数断言。先 await 完成一次, + // 之后所有用例命中缓存,帧序列只剩连接时的 onlineCount 快照(确定性 seq 1)。 + const [{ getMcodeSessionsForWorkspace }, { DEFAULT_WORKSPACE }] = await Promise.all([ + import("../server/lib/acp-client.js"), + import("../server/lib/config.js"), + ]); + await getMcodeSessionsForWorkspace(DEFAULT_WORKSPACE); +}); + +after(async () => { + // 预热(以及连接时 cache miss 的补拉)会启动 mcode acp 单例子进程;它常驻会 + // 挂住测试进程的事件循环,node --test 跑完不退出 —— 收尾关掉。 + const { shutdownMcodeAcpSingleton } = await import("../server/lib/acp-client.js"); + shutdownMcodeAcpSingleton(); +}); + +function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } + +async function waitUntil(pred, ms = 2000) { + const t0 = Date.now(); + while (Date.now() - t0 < ms) { + if (pred()) return; + await sleep(10); + } + assert.fail("等待条件超时"); +} + +function parseServerFrames(st) { + while (st.buf.length >= 2) { + const opcode = st.buf[0] & 0x0f; + let len = st.buf[1] & 0x7f; + let offset = 2; + if (len === 126) { + if (st.buf.length < 4) return; + len = st.buf.readUInt16BE(2); + offset = 4; + } else if (len === 127) { + if (st.buf.length < 10) return; + len = Number(st.buf.readBigUInt64BE(2)); + offset = 10; + } + if (st.buf.length < offset + len) return; + st.frames.push({ opcode, payload: Buffer.from(st.buf.subarray(offset, offset + len)) }); + st.buf = st.buf.subarray(offset + len); + } +} + +function connect(port, path) { + return new Promise((resolve) => { + const socket = net.connect(port, "127.0.0.1"); + const st = { buf: Buffer.alloc(0), frames: [], header: "", handshook: false, socket, timedOut: false }; + // 超时也落定:挂死转为可断言的诊断现场 + const guard = setTimeout(() => { st.timedOut = true; resolve(st); }, 2500); + socket.on("data", (chunk) => { + if (!st.handshook) { + st.header += chunk.toString("latin1"); + const idx = st.header.indexOf("\r\n\r\n"); + if (idx === -1) return; + st.rawBody = st.header.slice(idx + 4); + st.buf = Buffer.from(st.rawBody, "latin1"); + st.header = st.header.slice(0, idx); + st.handshook = true; + parseServerFrames(st); + clearTimeout(guard); + resolve(st); + return; + } + st.buf = Buffer.concat([st.buf, chunk]); + parseServerFrames(st); + }); + socket.on("connect", () => { + socket.write( + "GET " + path + " HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: " + + CLIENT_KEY + "\r\nSec-WebSocket-Version: 13\r\nx-cid: " + path.split("cid=")[1] + "\r\n\r\n", + ); + }); + socket.on("error", () => { clearTimeout(guard); resolve(st); }); + }); +} + +// RFC 6455 §5.3:客户端帧必须掩码(解码器对未掩码帧以 1002 拒绝,服务端行为正确) +function encodeClientFrame(opcode, payload) { + const body = Buffer.from(payload || "", "utf8"); + const key = Buffer.from([1, 2, 3, 4]); + const head = Buffer.alloc(2 + 4); + head[0] = 0x80 | opcode; + head[1] = 0x80 | body.length; + key.copy(head, 2); + const masked = Buffer.from(body); + for (let i = 0; i < masked.length; i++) masked[i] ^= key[i % 4]; + return Buffer.concat([head, masked]); +} + +function jsonFrames(st) { + const out = []; + for (const f of st.frames) { + if (f.opcode !== 0x1) continue; + try { out.push(JSON.parse(f.payload.toString("utf8"))); } catch {} + } + return out; +} +function closeCode(st) { + const f = st.frames.find((f) => f.opcode === 0x8); + return f && f.payload.length >= 2 ? f.payload.readUInt16BE(0) : f ? 0 : null; +} + +let seq = 0; +const freshCid = () => "ws-t" + (++seq); + +async function withStream(opts, fn) { + resetEventBusForTests(); + const server = http.createServer((req, res) => { res.writeHead(404); res.end(); }); + server.on("upgrade", (req, socket, head) => handleStreamUpgrade(req, socket, head, opts)); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const st = await connect(server.address().port, opts.path || ("api/stream?cid=".replace("api", "/api") + (opts.cid || freshCid()))); + try { + await fn(st); + } finally { + // 清理限时(500ms 竞速):升级套接字脱离 http 连接跟踪后 close 回调 + // 在个别路径不落定——测试基建采用尽力而为语义,杜绝清理挂死。 + st.socket.destroy(); + if (typeof server.closeAllConnections === "function") server.closeAllConnections(); + await Promise.race([new Promise((r) => server.close(r)), sleep(500)]); + } +} + +describe("GET /api/stream WebSocket 事件流", { concurrency: 1 }, () => { + it("握手返回 101 + 正确 Accept,随后收到 hello 帧", async () => { + await withStream({ heartbeatMs: 60000 }, async (st) => { + assert.equal(st.timedOut, false, "连接超时: header=" + st.header); + assert.ok(st.header.startsWith("HTTP/1.1 101"), "header=" + st.header); + assert.ok(st.header.includes("Sec-WebSocket-Accept: " + CLIENT_ACCEPT)); + await waitUntil(() => jsonFrames(st).length >= 1); + const hello = jsonFrames(st)[0]; + assert.equal(hello.type, "hello"); + assert.equal(hello.payload.resumeSupported, true); + }); + }); + + it("事件推送 seq 连续;resume 按序重放未收到的部分", async () => { + await withStream({ heartbeatMs: 60000 }, async (st) => { + await waitUntil(() => jsonFrames(st).some((f) => f.type === "hello"), 3000); + const feedCid = jsonFrames(st).find((f) => f.type === "hello").payload.cid; + // 连接副作用:hello 之后服务端广播 onlineCount,先落一帧基线快照(seq 1)。 + emitEvent(feedCid, { type: "control", name: "a" }); + emitEvent(feedCid, { type: "control", name: "b" }); + emitEvent(feedCid, { type: "control", name: "c" }); + await waitUntil(() => jsonFrames(st).filter((f) => f.seq).length === 4, 3000); + assert.deepEqual(jsonFrames(st).filter((f) => f.seq).map((f) => f.seq), [1, 2, 3, 4]); + st.frames.length = 0; + st.socket.write(encodeClientFrame(0x1, JSON.stringify({ v: 1, type: "resume", payload: { lastSeq: 2 } }))); + await waitUntil(() => jsonFrames(st).filter((f) => f.seq).length === 2, 3000); + const replay = jsonFrames(st).filter((f) => f.seq).map((f) => ({ seq: f.seq, name: f.payload.name })); + assert.deepEqual(replay, [{ seq: 3, name: "b" }, { seq: 4, name: "c" }]); + }); + }); + + it("环形缓冲欠载 → 以最近快照为基线回退", async () => { + await withStream({ heartbeatMs: 60000, ringCapacity: 2 }, async (st) => { + await waitUntil(() => jsonFrames(st).some((f) => f.type === "hello"), 3000); + const feedCid = jsonFrames(st).find((f) => f.type === "hello").payload.cid; + // 连接基线快照(seq 1)+ 这里的快照(seq 2)+ 4 个 control(seq 3..6) + emitEvent(feedCid, { type: "state.snapshot", snapshot: { a: 1 } }); + for (let i = 0; i < 4; i++) emitEvent(feedCid, { type: "control", name: "n" + i }); + await waitUntil(() => jsonFrames(st).filter((f) => f.seq).length === 6, 3000); + st.frames.length = 0; + st.socket.write(encodeClientFrame(0x1, JSON.stringify({ v: 1, type: "resume", payload: { lastSeq: 0 } }))); + await waitUntil(() => jsonFrames(st).some((f) => f.type === "state.snapshot"), 3000); + const snap = jsonFrames(st).find((f) => f.type === "state.snapshot"); + assert.equal(snap.payload.a, 1); + }); + }); + + it("心跳发送 WS ping 控制帧", async () => { + await withStream({ heartbeatMs: 30 }, async (st) => { + await waitUntil(() => st.frames.some((f) => f.opcode === 0x9), 1500); + }); + }); + + it("入站配额超限 → error 帧 + 1013 关闭", async () => { + await withStream({ heartbeatMs: 60000, inboundBurst: 3, inboundPerSec: 1 }, async (st) => { + await waitUntil(() => jsonFrames(st).some((f) => f.type === "hello"), 3000); + for (let i = 0; i < 6; i++) { + st.socket.write(encodeClientFrame(0x1, JSON.stringify({ v: 1, type: "ping" }))); + } + await waitUntil(() => closeCode(st) !== null, 3000); + assert.ok(jsonFrames(st).some((f) => f.type === "error" && f.payload.code === "quota"), "frames=" + JSON.stringify(jsonFrames(st))); + assert.equal(closeCode(st), 1013); + }); + }); + + it("二进制帧 → 1002 关闭", async () => { + await withStream({ heartbeatMs: 60000 }, async (st) => { + st.socket.write(encodeClientFrame(0x2, "\u0001\u0002\u0003")); + await waitUntil(() => closeCode(st) !== null, 3000); + assert.equal(closeCode(st), 1002); + }); + }); +}); \ No newline at end of file diff --git a/release/public-source.json b/release/public-source.json index 7ab6677c..135d37d0 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3334,7 +3334,6 @@ "packages/webui/checks/lib-mcode-rpc.check.mjs", "packages/webui/checks/lib-sessions-reset-context.check.mjs", "packages/webui/checks/lib-slash.check.mjs", - "packages/webui/checks/lib-state-bus.check.mjs", "packages/webui/checks/lib-workspace-containment.check.mjs", "packages/webui/checks/lib-workspace.check.mjs", "packages/webui/checks/mavis-usage.check.mjs", @@ -3369,6 +3368,9 @@ "packages/webui/docs/HTTPS-REVERSE-PROXY.zh-CN.md", "packages/webui/docs/TROUBLESHOOTING.md", "packages/webui/docs/TROUBLESHOOTING.zh-CN.md", + "packages/webui/docs/drafts/README.md", + "packages/webui/docs/drafts/arch_net_draft_0922.md", + "packages/webui/docs/drafts/arch_net_solution_0922.md", "packages/webui/docs/screenshots/01-startup.png", "packages/webui/docs/screenshots/02-chat-session.png", "packages/webui/docs/screenshots/03-settings-panel.png", @@ -3423,8 +3425,12 @@ "packages/webui/server/lib/alerts.js", "packages/webui/server/lib/auth.js", "packages/webui/server/lib/authorize.js", + "packages/webui/server/lib/capability.js", "packages/webui/server/lib/config.js", "packages/webui/server/lib/db.js", + "packages/webui/server/lib/embed-consumer.js", + "packages/webui/server/lib/engine-host.worker.js", + "packages/webui/server/lib/event-bus.js", "packages/webui/server/lib/events.js", "packages/webui/server/lib/feedback/command-feedback.js", "packages/webui/server/lib/feedback/message-feedback.js", @@ -3438,12 +3444,14 @@ "packages/webui/server/lib/markdown.js", "packages/webui/server/lib/mavis-usage.js", "packages/webui/server/lib/mcode-acp.js", + "packages/webui/server/lib/mcode-embed.js", "packages/webui/server/lib/mcode-exec.js", "packages/webui/server/lib/mcode-rpc.js", "packages/webui/server/lib/models.js", "packages/webui/server/lib/port.js", "packages/webui/server/lib/quota-forecast.js", "packages/webui/server/lib/rate-limit.js", + "packages/webui/server/lib/ring-buffer.js", "packages/webui/server/lib/sessions.js", "packages/webui/server/lib/settings.js", "packages/webui/server/lib/slash.js", @@ -3454,6 +3462,8 @@ "packages/webui/server/lib/upload.js", "packages/webui/server/lib/usage.js", "packages/webui/server/lib/workspace.js", + "packages/webui/server/lib/ws-frame.js", + "packages/webui/server/lib/ws-server.js", "packages/webui/server/router.js", "packages/webui/server/routes/alerts.js", "packages/webui/server/routes/chat.js", @@ -3492,6 +3502,7 @@ "packages/webui/test/_setup.js", "packages/webui/test/chat-virtual-list.test.js", "packages/webui/test/check-docs-alignment.test.js", + "packages/webui/test/fixtures/engine-host.stub.worker.js", "packages/webui/test/fixtures/v2/sqlite/runtime-state.sqlite", "packages/webui/test/idle-watchdog.test.js", "packages/webui/test/integration/chat-wiring.test.js", @@ -3500,15 +3511,19 @@ "packages/webui/test/integration/event-chain.test.js", "packages/webui/test/integration/port-fallback.test.js", "packages/webui/test/integration/router-boot.test.js", - "packages/webui/test/integration/sse-channel.test.js", "packages/webui/test/integration/upload-limits.test.js", + "packages/webui/test/integration/ws-channel.test.js", "packages/webui/test/lib-auth.test.js", + "packages/webui/test/lib-capability.test.js", "packages/webui/test/lib-config-bindhost.test.js", "packages/webui/test/lib-config.test.js", "packages/webui/test/lib-db-outcomes.test.js", "packages/webui/test/lib-db-resolver-c01.test.js", "packages/webui/test/lib-db-resolver.test.js", "packages/webui/test/lib-db.test.js", + "packages/webui/test/lib-embed-consumer.test.js", + "packages/webui/test/lib-engine-mode.test.js", + "packages/webui/test/lib-event-bus.test.js", "packages/webui/test/lib-events-concurrency.test.js", "packages/webui/test/lib-events-hash.test.js", "packages/webui/test/lib-events.test.js", @@ -3516,12 +3531,14 @@ "packages/webui/test/lib-lan.test.js", "packages/webui/test/lib-markdown-edge.test.js", "packages/webui/test/lib-mcode-acp-note.test.js", + "packages/webui/test/lib-mcode-embed.test.js", "packages/webui/test/lib-mcode-exec.test.js", "packages/webui/test/lib-models.test.js", "packages/webui/test/lib-quota-forecast-edge.test.js", "packages/webui/test/lib-quota-forecast.test.js", "packages/webui/test/lib-rate-limit-edge.test.js", "packages/webui/test/lib-rate-limit.test.js", + "packages/webui/test/lib-ring-buffer.test.js", "packages/webui/test/lib-sessions-persist.test.js", "packages/webui/test/lib-sessions-single-identity.test.js", "packages/webui/test/lib-settings.test.js", @@ -3529,6 +3546,8 @@ "packages/webui/test/lib-static.test.js", "packages/webui/test/lib-transcript.test.js", "packages/webui/test/lib-upload.test.js", + "packages/webui/test/lib-ws-frame.test.js", + "packages/webui/test/lib-ws-server.test.js", "packages/webui/test/main-static.test.js", "packages/webui/test/matrix/transports.test.js", "packages/webui/test/port.test.js",