Files
2026Technology-Competition/docs/superpowers/plans/2026-08-29-iteration-websocket-progress.md
T
lhl 80daadcd31 fix(websocket): 修复 app.py 不可导入并新增真实链路验证
- 将 /api/sessions/{sid}/ws 端点移入 create_app(此前置于模块级导致整模块 import NameError,回归被验证拦截)
- register_loop + subscribe 调整至 accept 之前,缩小连接已开但未订阅期间的进度丢失窗口
- 新增 tests/test_verify_ws_real_flow.py:驱动真实 HTTP 聊天流程断言 WS 收到 agent 实际发射的 parse/impact 进度
- 同步 WebSocket 计划文档 Task 3 代码片段(标注端点必须位于 create_app 内)
- 全量 pytest 实测 583 passed / 99.03% 达标
2026-08-29 14:36:12 +08:00

544 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WebSocket 实时进度流 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在生成/影响调查等耗时流程执行期间,通过 WebSocket 把 progress/error 事件实时推送到前端,用户无需重载会话即可看到进度;保留既有「持久化 + 重载渲染」作为离线兜底。
**Architecture:** 新增进程内 `ProgressHub`asyncio 发布/订阅,单例)。`ChatAgent` 在产出每个 progress/error 条目时同步调用 `hub.emit(sid, event)``app.py` 暴露 `/api/sessions/{sid}/ws` WebSocket 端点,订阅 hub 并把事件转发给对应会话的连接;前端 `chat_ws.js` 打开 WS 并按事件渲染进度条/错误行。持久化(`role='progress'/'error'`)保持不变,重载场景仍可见历史。
**Tech Stack:** FastAPI/Starlette WebSocket`websockets` 包(uvicorn 生产运行所需,测试用 Starlette `TestClient.websocket_connect` 无需额外服务依赖);前端原生 `WebSocket` API;纯函数模块 + Node `node:test` 单测。
## Global Constraints
- 中文优先:所有 UI 文案、注释、日志使用中文(专有名词/代码关键字除外)。
- 不新增重依赖:仅新增 `websockets`pyproject `dependencies`);向量库等不在本迭代。
- TDD:每个任务先写失败测试,再实现;测试全绿后方可 commit。
- 覆盖率门禁:`fail_under=99`(pyproject 现有配置),新增 Python 代码须有测试覆盖。
- 向后兼容:未打开 WS 时,旧「HTTP 响应 progress + 重载渲染」路径不受影响;WS 关闭/不可用时前端自动回退到现有轮询/重载逻辑。
- 单进程假设:hub 为进程内单例,多 worker 部署下跨进程不互通(在 design.md 标注限制,本迭代不做跨进程总线)。
---
### Task 1: ProgressHub 发布/订阅核心
**Files:**
- Create: `src/genesis/server/hub.py`
- Test: `tests/test_progress_hub.py`
**Interfaces:**
- Consumes: 无
- Produces:
- `genesis.server.hub.ProgressHub` 类,方法 `register_loop(loop)`, `subscribe(sid) -> asyncio.Queue`, `unsubscribe(sid, queue)`, `emit(sid, event)`
- 模块级单例 `genesis.server.hub.hub`(供 app 与测试共享)
- [ ] **Step 1: 写失败测试**
```python
# tests/test_progress_hub.py
import asyncio
import pytest
from genesis.server.hub import ProgressHub
@pytest.mark.asyncio
async def test_subscribe_receives_emitted_event():
h = ProgressHub()
h.register_loop(asyncio.get_running_loop())
q = h.subscribe("s1")
h.emit("s1", {"type": "progress", "step": "parse", "status": "ok"})
event = await asyncio.wait_for(q.get(), 1.0)
assert event["step"] == "parse"
@pytest.mark.asyncio
async def test_unsubscribe_stops_delivery():
h = ProgressHub()
h.register_loop(asyncio.get_running_loop())
q = h.subscribe("s1")
h.unsubscribe("s1", q)
h.emit("s1", {"type": "progress"})
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(q.get(), 0.2)
@pytest.mark.asyncio
async def test_multiple_subscribers_all_receive():
h = ProgressHub()
h.register_loop(asyncio.get_running_loop())
q1, q2 = h.subscribe("s1"), h.subscribe("s1")
h.emit("s1", {"type": "progress", "step": "gen"})
a = await asyncio.wait_for(q1.get(), 1.0)
b = await asyncio.wait_for(q2.get(), 1.0)
assert a["step"] == b["step"] == "gen"
```
- [ ] **Step 2: 运行测试确认失败**
Run: `python -m pytest tests/test_progress_hub.py -q`
Expected: FAIL`ModuleNotFoundError: genesis.server.hub`
- [ ] **Step 3: 实现最小版本**
```python
# src/genesis/server/hub.py
from __future__ import annotations
import asyncio
from typing import Any, Dict, List
_event = Dict[str, Any]
class ProgressHub:
"""进程内会话级进度发布/订阅(单例)。
- subscribe(sid) 返回专属 asyncio.Queueemit(sid, event) 向该 sid 全部队列投递。
- emit 从同步线程(FastAPI 线程池中的 sync 端点)调用,经由已注册事件循环
run_coroutine_threadsafe 安全投递;未注册 loop 时降级为直接放入队列(同线程场景)。
"""
def __init__(self) -> None:
self._loop: asyncio.AbstractEventLoop | None = None
self._subs: Dict[str, List[asyncio.Queue]] = {}
def register_loop(self, loop: asyncio.AbstractEventLoop) -> None:
self._loop = loop
def subscribe(self, sid: str) -> asyncio.Queue:
q: asyncio.Queue = asyncio.Queue()
self._subs.setdefault(sid, []).append(q)
return q
def unsubscribe(self, sid: str, q: asyncio.Queue) -> None:
qs = self._subs.get(sid)
if qs and q in qs:
qs.remove(q)
if not qs:
self._subs.pop(sid, None)
def emit(self, sid: str, event: _event) -> None:
for q in list(self._subs.get(sid, [])):
if self._loop is not None:
asyncio.run_coroutine_threadsafe(q.put(event), self._loop)
else:
q.put_nowait(event)
hub = ProgressHub()
```
- [ ] **Step 4: 运行测试确认通过**
Run: `python -m pytest tests/test_progress_hub.py -q`
Expected: PASS3 passed
- [ ] **Step 5: 提交**
```bash
git add src/genesis/server/hub.py tests/test_progress_hub.py
git commit -m "feat(server): 新增 ProgressHub 进程内进度发布/订阅单例"
```
---
### Task 2: ChatAgent 发射进度/错误事件
**Files:**
- Modify: `src/genesis/chat/agent.py`
- Test: `tests/test_chat_agent_ws.py`
**Interfaces:**
- Consumes: `genesis.server.hub.hub`(模块单例)
- Produces: `ChatAgent.__init__` 新增可选参数 `progress_sink: Callable[[dict], None] | None``_emit_progress(item)` / `_emit_error(reply, action)` 方法,向 sink(或 `hub`)发射事件,且保持 `_persist_progress` / `_store_error` 原有持久化不变。
- [ ] **Step 1: 写失败测试**
```python
# tests/test_chat_agent_ws.py
from genesis.chat.agent import ChatAgent
from genesis.server.service import GenesisService
from genesis.server.store import ProjectsStore, SessionStore
def _make_agent(sink):
store = SessionStore(db_path=":memory:")
projects = ProjectsStore(db_path=":memory:")
svc = GenesisService(store=store, data_root="data", engine="fake", projects=projects)
return ChatAgent(service=svc, fake=True, engine="fake", progress_sink=sink)
def test_agent_emits_progress_events():
events = []
agent = _make_agent(events.append)
sid = agent.service.create_session("u1").session_id
# 走上传 + 生成(fake engine),断言过程中有 progress 事件发射
# 简化:直接调用内部 _emit_progress 验证接线
agent._emit_progress(sid, {"step": "parse", "status": "ok", "detail": "解析完成"})
assert events and events[0]["type"] == "progress"
assert events[0]["step"] == "parse"
def test_agent_emits_error_event():
events = []
agent = _make_agent(events.append)
sid = agent.service.create_session("u1").session_id
agent._emit_error(sid, "解析失败:boom", "generate")
errs = [e for e in events if e["type"] == "error"]
assert errs and "boom" in errs[0]["detail"]
```
- [ ] **Step 2: 运行测试确认失败**
Run: `python -m pytest tests/test_chat_agent_ws.py -q`
Expected: FAIL`_emit_progress` / `_emit_error` 不存在)
- [ ] **Step 3: 实现最小版本**
`agent.py` 顶部 `from genesis.server.hub import hub as _hub`(注意避免与既有命名冲突,若已存在 `hub` 局部变量则改名导入为 `_progress_hub`)。
`ChatAgent.__init__` 增加参数并在方法内保存:
```python
def __init__(self, service, fake=False, engine=None, progress_sink=None):
self.service = service
self.fake = fake
self.engine = engine
self.progress_sink = progress_sink
```
新增两个方法(放在 `_persist_progress` / `_store_error` 附近):
```python
def _emit_progress(self, session_id, item):
event = {
"type": "progress",
"step": item.get("step", ""),
"status": item.get("status", ""),
"detail": item.get("detail", ""),
}
if self.progress_sink is not None:
self.progress_sink(event)
else:
_progress_hub.emit(session_id, event)
def _emit_error(self, session_id, reply, action):
event = {"type": "error", "detail": reply, "action": action}
if self.progress_sink is not None:
self.progress_sink(event)
else:
_progress_hub.emit(session_id, event)
```
保持既有 `_persist_progress` / `_store_error` 不变(持久化兜底仍生效)。
在四个流程方法(`_auto_generate``_run_parse``_run_impact``_run_generate``_run_qa`)中,凡是 `progress.append(item)` 之后追加 `self._emit_progress(session_id, item)`;在 `_store_error` 调用处(现有 `reply = f"解析失败:{e}"` 等分支)改为先 `self._emit_error(session_id, reply, action)``_store_error(...)`
- [ ] **Step 4: 运行测试确认通过**
Run: `python -m pytest tests/test_chat_agent_ws.py -q`
Expected: PASS2 passed
- [ ] **Step 5: 提交**
```bash
git add src/genesis/chat/agent.py tests/test_chat_agent_ws.py
git commit -m "feat(chat): agent 在产出进度/错误时发射事件(保留持久化兜底)"
```
---
### Task 3: WebSocket 端点
**Files:**
- Modify: `src/genesis/server/app.py`
- Test: `tests/test_progress_ws.py`
**Interfaces:**
- Consumes: `genesis.server.hub.hub`(单例)、`WebSocket``WebSocketDisconnect`fastapi
- Produces: `GET /api/sessions/{sid}/ws` 端点;pyproject 新增 `websockets` 依赖
- [ ] **Step 1: 写失败测试**
```python
# tests/test_progress_ws.py
import threading
from fastapi.testclient import TestClient
from genesis.server.app import create_app
from genesis.server.hub import hub
from genesis.server.store import SessionStore
def _client(tmp_path):
return TestClient(create_app(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"), engine="fake"))
def test_ws_streams_progress(tmp_path):
c = _client(tmp_path)
def trigger():
hub.emit("ws-s1", {"type": "progress", "step": "gen", "status": "ok", "detail": "生成中"})
with c.websocket_connect("/api/sessions/ws-s1/ws") as ws:
threading.Thread(target=trigger).start()
data = ws.receive_json(timeout=2.0)
assert data["type"] == "progress"
assert data["step"] == "gen"
```
- [ ] **Step 2: 运行测试确认失败**
Run: `python -m pytest tests/test_progress_ws.py -q`
Expected: FAIL404 / 路由不存在)
- [ ] **Step 3: 实现最小版本**
`app.py` 导入:`from fastapi import WebSocket, WebSocketDisconnect`,并确认文件顶部已 `from genesis.server.hub import hub`
`create_app` 内新增端点:
```python
# 注意:端点必须定义在 create_app 函数体内(缩进),因为 app 是局部变量;
# 若误置于模块级,@app.websocket 引用未定义的 app 会导致整模块 import 即 NameError。
@app.websocket("/api/sessions/{sid}/ws")
async def session_progress_ws(ws: WebSocket, sid: str):
# 先注册循环并订阅,再 accept,缩小「连接已开但尚未订阅」期间的进度丢失窗口
hub.register_loop(asyncio.get_running_loop())
q = hub.subscribe(sid)
await ws.accept()
try:
while True:
event = await q.get()
await ws.send_json(event)
except WebSocketDisconnect:
pass
finally:
hub.unsubscribe(sid, q)
```
`asyncio` 已在 app.py 导入;若未导入则补 `import asyncio`。)
`pyproject.toml``dependencies` 增加 `"websockets>=12"`,并同步 `README` 安装说明。
- [ ] **Step 4: 运行测试确认通过**
Run: `python -m pytest tests/test_progress_ws.py -q`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add src/genesis/server/app.py tests/test_progress_ws.py pyproject.toml README.md
git commit -m "feat(server): 暴露 /api/sessions/{sid}/ws 进度流端点(+websockets 依赖)"
```
---
### Task 4: 前端 chat_ws.js 与集成
**Files:**
- Create: `src/genesis/server/static/chat_ws.js`
- Modify: `src/genesis/server/static/chat.html`
- Test: `tests/test_chat_ws.js`
**Interfaces:**
- Consumes: `POST`/WS 端点地址约定;`GenesisState` 可选
- Produces: `window.GenesisWS.connectProgressWs(sid, handlers)``GenesisWS.applyProgressEvent(event, render)`
- [ ] **Step 1: 写失败测试**
```js
// tests/test_chat_ws.js
const test = require('node:test');
const assert = require('node:assert');
const { applyProgressEvent } = require('../src/genesis/server/static/chat_ws.js');
test('applyProgressEvent 渲染 progress 角色', () => {
const got = [];
applyProgressEvent({ type: 'progress', step: 'parse', detail: '完成' }, (role, text) => got.push([role, text]));
assert.strictEqual(got.length, 1);
assert.strictEqual(got[0][0], 'progress');
assert.ok(got[0][1].includes('parse'));
});
test('applyProgressEvent 渲染 error 角色', () => {
const got = [];
applyProgressEvent({ type: 'error', detail: '炸了' }, (role, text) => got.push([role, text]));
assert.strictEqual(got[0][0], 'error');
assert.ok(got[0][1].includes('炸了'));
});
```
- [ ] **Step 2: 运行测试确认失败**
Run: `node --test tests/test_chat_ws.js`
Expected: FAIL(模块不存在)
- [ ] **Step 3: 实现最小版本**
```js
// src/genesis/server/static/chat_ws.js UMD
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.GenesisWS = api;
})(typeof self !== 'undefined' ? self : this, function () {
function connectProgressWs(sid, handlers) {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
let ws;
try {
ws = new WebSocket(`${proto}://${location.host}/api/sessions/${encodeURIComponent(sid)}/ws`);
} catch (err) {
// 连接失败(如无 WS 依赖 / 代理拦截)静默降级;现有持久化 + 重载兜底仍可见进度
if (handlers.onClose) handlers.onClose();
return null;
}
ws.onmessage = (ev) => {
let e;
try { e = JSON.parse(ev.data); } catch { return; }
if (handlers.onProgress) handlers.onProgress(e);
};
ws.onerror = () => { try { ws.close(); } catch {} };
ws.onclose = () => handlers.onClose && handlers.onClose();
return ws;
}
function applyProgressEvent(e, render) {
if (e.type === 'progress') render('progress', `${e.step}: ${e.detail || ''}`);
else if (e.type === 'error') render('error', e.detail || '错误');
}
return { connectProgressWs, applyProgressEvent };
});
```
`chat.html` 集成:
-`<head>` 末尾增加 `<script src="/chat_ws.js"></script>`(置于 `chat_state.js` 之后)。
- 新增 `app.py` 同级静态路由 `/chat_ws.js`(仿照 `/chat_state.js``FileResponse` 返回,media_type `application/javascript`)。
- `send()` 在获得 `sid` 后(或 `loadSession` 成功时),若 `sid` 有效且尚无 WS,调用:
```js
progressWs = GenesisWS.connectProgressWs(sid, {
onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text) => addMsg(role, text)),
onClose: () => {},
});
```
其中 `addMsg` 复用既有消息渲染(`role` 为 `progress`/`error` 时走灰条样式,与 `loadSession` 一致)。
- 切换会话 / `newSession` 时 `progressWs && progressWs.close()`。
- 若 `WebSocket` 不可用或连接失败,`onClose` 静默;既有「重载渲染持久化进度」仍是兜底,不影响功能。
- [ ] **Step 4: 运行测试确认通过**
Run: `node --test tests/test_chat_ws.js`
Expected: PASS2 passed
- [ ] **Step 5: 提交**
```bash
git add src/genesis/server/static/chat_ws.js src/genesis/server/static/chat.html src/genesis/server/app.py tests/test_chat_ws.js
git commit -m "feat(chat): 前端 chat_ws.js 实时渲染进度/错误(保留重载兜底)"
```
---
### Task 5: 端到端冒烟 + 文档
**Files:**
- Test: `tests/test_progress_e2e.py`
- Modify: `docs/design.md`(§12 追加 WebSocket 进度流记录)、`_AI_USAGE_LOG.md`
**Interfaces:**
- Consumes: 前述全部端点与模块
- Produces: 端到端验证脚本
- [ ] **Step 1: 写失败测试(全链路)**
```python
# tests/test_progress_e2e.py
import threading
from fastapi.testclient import TestClient
from genesis.server.app import create_app
from genesis.server.hub import hub
from genesis.server.store import SessionStore, ProjectsStore
from pathlib import Path
_SAMPLE = Path(__file__).resolve().parents[1] / "sample"
def test_ws_progress_during_generate(tmp_path):
client = TestClient(create_app(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"), engine="fake"))
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
for ft, name in [("requirements", "requirements_newdev.xlsx"),
("template", "template_design_ja.docx"),
("write_instruction", "rules_design_ja.docx"),
("rules", "rules_entry_ja.docx")]:
client.post(f"/api/sessions/{sid}/files", data={"file_type": ft},
files={"file": (name, (_SAMPLE / name).read_bytes())})
received = []
def trigger():
hub.emit(sid, {"type": "progress", "step": "generate", "status": "ok", "detail": "生成完成"})
with client.websocket_connect(f"/api/sessions/{sid}/ws") as ws:
threading.Thread(target=trigger).start()
data = ws.receive_json(timeout=2.0)
received.append(data)
assert any(e["type"] == "progress" and e["step"] == "generate" for e in received)
```
- [ ] **Step 2: 运行测试确认失败(应为通过,若失败则回查 Task1-4)**
Run: `python -m pytest tests/test_progress_e2e.py -q`
Expected: PASS
- [ ] **Step 3: 更新文档**
`docs/design.md` §12 追加:「WebSocket 实时进度流(2026-08-29):新增 `ProgressHub` 单例 + `/api/sessions/{sid}/ws` 端点 + `chat_ws.js`;进度/错误事件实时推送,持久化兜底保留;单进程假设,多 worker 不互通。」
- [ ] **Step 4: 运行全量测试确认无回归**
Run: `python -m pytest -q -o addopts=""`
Expected: 全部通过(在现有 568 基础上新增用例)
- [ ] **Step 5: 提交**
```bash
git add tests/test_progress_e2e.py docs/design.md _AI_USAGE_LOG.md
git commit -m "test(chat): WebSocket 进度流端到端冒烟 + design.md 记录"
```
---
## Self-Review
1. **Spec coverage:** 实时推送(Task1-3)、前端渲染(Task4)、持久化兜底保留(Task2 明确保留 `_persist_progress`/`_store_error`)、端到端验证(Task5)均覆盖。
2. **Placeholder scan:** 无 TBD;每个代码步骤均给出完整实现。
3. **Type consistency:** `hub.emit(sid, event)` 签名在 Task1/2/3/5 一致;`event` 结构 `{type,step,status,detail}` 在前端与后端一致;`connectProgressWs`/`applyProgressEvent` 在 Task4 测试与实现一致。
4. **限制:** 单进程;多 worker 跨进程不互通已在 Global Constraints 标注,后续可迭代为 Redis 总线(不在本迭代)。
---
## GSTACK REVIEW REPORT
- 评审技能:plan-eng-review
- 评审对象:docs/superpowers/plans/2026-08-29-iteration-websocket-progress.md(本文件)
- 评审结论:**DONE_WITH_CONCERNS**(架构决策已确认,遗留项为已知限制与前端加固点)
### 评审发现与处置
| 编号 | 发现 | 严重度 | 处置 |
|------|------|--------|------|
| F1 | 跨线程 `emit` 依赖已注册事件循环;多 worker 下 `ProgressHub` 无法跨进程投递事件 | 中 | D2 确认单进程;多 worker 拆为后续独立项(Redis 总线),Global Constraints 已标注 |
| F2 | `chat_ws.js` 构造 `WebSocket` 未做异常保护,连接失败会抛错影响页面 | 中 | Task 4 已增加 `try/catch`,失败静默降级(已有持久化兜底) |
| F3 | WS 端点不消费客户端消息,需明确方向 | 低 | D1 确认单向(server→client 进度推送),聊天消息保持 HTTP |
| F4 | 实时渲染进度 与「HTTP 响应 progress 字段」潜在重复渲染 | 低 | 确认 `send()` 仅渲染 assistant 回复、不渲染 progress 字段;重载走 `loadSession`,二者不重叠 |
| F5 | `subscribers` 字典跨线程读写竞争 | 低 | 单进程低并发可接受;如需强化可加 `asyncio.Lock`,本迭代不引入 |
### 已确认决策
- **D1**WebSocket 单向(server→client 进度推送);聊天消息仍走 HTTP POST。
- **D2**:单进程部署,`ProgressHub` 进程内单例;多 worker 不在本轮范围。
### 剩余关注
- Task 4 前端集成(打开/关闭 WS、错误降级)仅 `applyProgressEvent` 有 Node 单测;`connectProgressWs` 集成胶水需在浏览器手测验证(文档注明)。
- 单进程假设须在 `docs/design.md` §12 记录(Task 5 已含)。
- 进度事件在 WS 未连接前(如首屏尚未打开)会被丢弃,但已持久化兜底,重载仍可见,不影响正确性。