Commit 82b088118693d288e839e9dd127670af159cb89f
1 parent
cacd03bf
U-024: c_ws_subscribe + execution_event 实时推送(最小连通版)
Python stdlib 没有 WebSocket 支持,src/ws_subscribe.py 手写 RFC 6455 握手(hashlib+base64 算 Sec-WebSocket-Accept)+ 帧编解码(服务端帧不 加掩码,客户端帧强制解掩码),不引入 websockets 之类第三方库 (05 §5.7)。 推送用轮询而非写路径 hook:execution_event 的写入分散在十几个调用点 (org_node_trigger/work_graph_execute/work_node_run/task_create/ bug_record/...),没有单一写入路径可以插桩,backend_server.py 的 _handle_ws_subscribe 劫持 ThreadingHTTPServer 连接后每 0.5 秒按 rowid 增量查一次 execution_event(project_id/可选 task_id 过滤), 轻松满足 REQ-12 的 ≤2 秒目标。断线重连按 03-C §3.14 走 REST 快照,不 在 WS 层做消息重放,新连接只看连接那一刻起的新事件。 daemon_threads = True 避免长连接 WS 线程阻塞进程退出。 验证:test_backend_server.py 用原始 socket 走真实握手+推送延迟断言; 另外用 Node 原生 WebSocket 客户端做跨实现互操作性验证,连真实运行的 服务器,从建连到收到 c_task_create 触发的真实推送全程走通。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
6 changed files
with
508 additions
and
7 deletions
docs/development-plan.md
| @@ -231,6 +231,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | @@ -231,6 +231,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | ||
| 231 | - **U-024 [Value Path]** `c_ws_subscribe` + `execution_event` 实时推送(最小连通版) | 231 | - **U-024 [Value Path]** `c_ws_subscribe` + `execution_event` 实时推送(最小连通版) |
| 232 | - 依赖:U-012, U-013(work graph 执行、work node run 是最早、最稳定产出 `execution_event` 的两个来源,足够验证推送链路本身是否通) | 232 | - 依赖:U-012, U-013(work graph 执行、work node run 是最早、最稳定产出 `execution_event` 的两个来源,足够验证推送链路本身是否通) |
| 233 | - Done:`c_ws_subscribe` 对真实 `execution_event` 做实时推送,≤2 秒延迟达成;REQ-12 happy 用例通过(boundary/failure——尤其是断线重连拉快照在多样事件场景下的表现——留给 U-024.5 补齐完整覆盖) | 233 | - Done:`c_ws_subscribe` 对真实 `execution_event` 做实时推送,≤2 秒延迟达成;REQ-12 happy 用例通过(boundary/failure——尤其是断线重连拉快照在多样事件场景下的表现——留给 U-024.5 补齐完整覆盖) |
| 234 | + - **实现补充(2026-07-31)**:Python stdlib 没有 WebSocket 支持,`src/ws_subscribe.py` 手写了 RFC 6455 握手(`Sec-WebSocket-Accept` 用 hashlib+base64 算)+ 帧编解码(`socket` 原始读写,服务端帧不加掩码,客户端帧强制解掩码),没有引入 `websockets` 之类的第三方库(05 §5.7)。推送机制是**轮询**,不是写路径 hook——`execution_event` 的写入分散在 `org_node_trigger`/`work_graph_execute`/`work_node_run`/`task_create`/`bug_record` 等十几个调用点,没有单一写入路径可以插桩,Unit 名字里的"最小连通版"本身也在暗示这个方向;`_handle_ws_subscribe` 用 `ThreadingHTTPServer` 劫持连接后每 0.5 秒查一次 `execution_event`(按 `rowid` 增量,`project_id`/可选 `task_id` 过滤),轻松满足 REQ-12 的 ≤2 秒目标(该数字本身写的是"建议"而非硬 SLA)。03-C §3.14 自己写明断线重连走 REST 快照补齐、不在 WebSocket 层做消息重放缓冲,所以新连接只看"连接那一刻起"的新事件,不做历史重放。给 `ThreadingHTTPServer` 加了 `daemon_threads = True`,避免长连接的 WS 线程阻塞进程退出。端到端验证:`src/test_backend_server.py` 用原始 socket 走真实握手+推送延迟断言;另外用 Node 原生 `WebSocket` 客户端(跨实现的真实互操作性验证,不是同一套代码自己测自己)连真实运行的服务器,从建连到收到 `c_task_create` 触发的真实 `task_status_changed` 推送全程走通。 | ||
| 234 | - 覆盖:01-M `execution_event`;03-C `c_ws_subscribe`;REQ-12(部分) | 235 | - 覆盖:01-M `execution_event`;03-C `c_ws_subscribe`;REQ-12(部分) |
| 235 | 236 | ||
| 236 | - **U-024.5(2026-07-30 新增)[Value Path]** REQ-12 完整验收(事件多样性联调) | 237 | - **U-024.5(2026-07-30 新增)[Value Path]** REQ-12 完整验收(事件多样性联调) |
docs/sdd/unit_matrix.json
| @@ -38,7 +38,7 @@ | @@ -38,7 +38,7 @@ | ||
| 38 | 38 | ||
| 39 | {"id": "U-022", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_project_setup + c_pick_folder 全平台(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-007", "U-008"], "coverage": {"model": [], "view": ["v_project_setup"], "controller": ["c_pick_folder", "c_project_create"], "process": [], "requirement": ["REQ-11"]}, "done": "REQ-11 里 v_project_setup/c_pick_folder/c_project_create 部分通过;c_pick_folder 补齐 Windows/Linux;c_project_create 首次接上 HTTP 路由;REQ-11 完整 happy(含建 task)随 U-023 累计满足"}, | 39 | {"id": "U-022", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_project_setup + c_pick_folder 全平台(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-007", "U-008"], "coverage": {"model": [], "view": ["v_project_setup"], "controller": ["c_pick_folder", "c_project_create"], "process": [], "requirement": ["REQ-11"]}, "done": "REQ-11 里 v_project_setup/c_pick_folder/c_project_create 部分通过;c_pick_folder 补齐 Windows/Linux;c_project_create 首次接上 HTTP 路由;REQ-11 完整 happy(含建 task)随 U-023 累计满足"}, |
| 40 | {"id": "U-023", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_goal_composer + v_bug_feedback(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-022"], "coverage": {"model": [], "view": ["v_goal_composer", "v_bug_feedback"], "controller": ["c_task_create", "c_bug_report_create"], "process": [], "requirement": []}, "done": "能通过 UI 创建 task 和人工 bug_record;补上 c_bug_report_create 的 project_id 必填缺口(03-C input_contract 遗漏);related_task_id 简化为文本输入,等 U-025 的 c_task_list 落地后换真下拉"}, | 40 | {"id": "U-023", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_goal_composer + v_bug_feedback(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-022"], "coverage": {"model": [], "view": ["v_goal_composer", "v_bug_feedback"], "controller": ["c_task_create", "c_bug_report_create"], "process": [], "requirement": []}, "done": "能通过 UI 创建 task 和人工 bug_record;补上 c_bug_report_create 的 project_id 必填缺口(03-C input_contract 遗漏);related_task_id 简化为文本输入,等 U-025 的 c_task_list 落地后换真下拉"}, |
| 41 | - {"id": "U-024", "phase": "PH3", "sub_phase": "PH3-S2", "name": "c_ws_subscribe + execution_event 实时推送(最小连通版)", "classification": "Value Path", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["execution_event"], "view": [], "controller": ["c_ws_subscribe"], "process": [], "requirement": ["REQ-12"]}, "done": "推送链路本身连通,≤2 秒延迟达成;REQ-12 happy 用例通过(boundary/failure 留给 U-024.5)"}, | 41 | + {"id": "U-024", "phase": "PH3", "sub_phase": "PH3-S2", "name": "c_ws_subscribe + execution_event 实时推送(最小连通版,见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["execution_event"], "view": [], "controller": ["c_ws_subscribe"], "process": [], "requirement": ["REQ-12"]}, "done": "推送链路本身连通,≤2 秒延迟达成;REQ-12 happy 用例通过(boundary/failure 留给 U-024.5);手写 RFC 6455 握手+帧编解码(无第三方 websockets 依赖),轮询式推送(无单一写路径可插桩),Node 原生 WebSocket 客户端验证过真实互操作性"}, |
| 42 | {"id": "U-024.5", "phase": "PH3", "sub_phase": "PH3-S2", "name": "REQ-12 完整验收:事件多样性联调(2026-07-30 新增)", "classification": "Value Path", "depends_on": ["U-024", "U-015", "U-016", "U-018", "U-019", "U-020", "U-021"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-12"]}, "done": "REQ-12 全部 happy/boundary/failure 用例通过;PH3 Phase 验收里的 REQ-12 以本 Unit 为准"}, | 42 | {"id": "U-024.5", "phase": "PH3", "sub_phase": "PH3-S2", "name": "REQ-12 完整验收:事件多样性联调(2026-07-30 新增)", "classification": "Value Path", "depends_on": ["U-024", "U-015", "U-016", "U-018", "U-019", "U-020", "U-021"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-12"]}, "done": "REQ-12 全部 happy/boundary/failure 用例通过;PH3 Phase 验收里的 REQ-12 以本 Unit 为准"}, |
| 43 | {"id": "U-025", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_dashboard + v_task_board + v_task_detail", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_dashboard", "v_task_board", "v_task_detail"], "controller": [], "process": [], "requirement": []}, "done": "三个页面可正常展示实时数据"}, | 43 | {"id": "U-025", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_dashboard + v_task_board + v_task_detail", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_dashboard", "v_task_board", "v_task_detail"], "controller": [], "process": [], "requirement": []}, "done": "三个页面可正常展示实时数据"}, |
| 44 | {"id": "U-026", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_graph_visualizer", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_graph_visualizer"], "controller": [], "process": [], "requirement": []}, "done": "Org/Work Graph 双层可视化含 waiting_for_quota 信息态"}, | 44 | {"id": "U-026", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_graph_visualizer", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_graph_visualizer"], "controller": [], "process": [], "requirement": []}, "done": "Org/Work Graph 双层可视化含 waiting_for_quota 信息态"}, |
src/backend_server.py
| @@ -10,20 +10,24 @@ via a `powershell` FolderBrowserDialog script and Linux via `zenity`/ | @@ -10,20 +10,24 @@ via a `powershell` FolderBrowserDialog script and Linux via `zenity`/ | ||
| 10 | the U-002 `project_scaffold.create_project()` function that had no HTTP | 10 | the U-002 `project_scaffold.create_project()` function that had no HTTP |
| 11 | route until now) for `v_project_setup`'s submit flow, `c_task_create`/ | 11 | route until now) for `v_project_setup`'s submit flow, `c_task_create`/ |
| 12 | `c_bug_report_create` (03-C §3.1/§3.1.1, U-023) for `v_goal_composer`/ | 12 | `c_bug_report_create` (03-C §3.1/§3.1.1, U-023) for `v_goal_composer`/ |
| 13 | -`v_bug_feedback`'s submit flows, and serving the U-008 frontend shell as | ||
| 14 | -static files (no build step, 05 §5.7: whitelisted by exact path, not a | ||
| 15 | -generic directory walker, so there's no path-traversal surface to reason | ||
| 16 | -about). | 13 | +`v_bug_feedback`'s submit flows, `c_ws_subscribe` (03-C §3.14, U-024, |
| 14 | +hand-rolled RFC 6455 WebSocket handshake + framing in src/ws_subscribe.py -- | ||
| 15 | +no `websockets` dependency) for real-time `execution_event` push, and | ||
| 16 | +serving the U-008 frontend shell as static files (no build step, 05 §5.7: | ||
| 17 | +whitelisted by exact path, not a generic directory walker, so there's no | ||
| 18 | +path-traversal surface to reason about). | ||
| 17 | """ | 19 | """ |
| 18 | 20 | ||
| 19 | import json | 21 | import json |
| 20 | import os | 22 | import os |
| 21 | import platform | 23 | import platform |
| 24 | +import socket | ||
| 22 | import subprocess | 25 | import subprocess |
| 23 | import threading | 26 | import threading |
| 27 | +import urllib.parse | ||
| 24 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | 28 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| 25 | 29 | ||
| 26 | -from src import bug_record, paths, project_scaffold, schema, task_create | 30 | +from src import bug_record, paths, project_scaffold, schema, task_create, ws_subscribe |
| 27 | 31 | ||
| 28 | _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") | 32 | _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") |
| 29 | 33 | ||
| @@ -103,8 +107,16 @@ def _pick_folder_linux(): | @@ -103,8 +107,16 @@ def _pick_folder_linux(): | ||
| 103 | raise DialogUnavailableError("neither zenity nor kdialog is installed") | 107 | raise DialogUnavailableError("neither zenity nor kdialog is installed") |
| 104 | 108 | ||
| 105 | 109 | ||
| 110 | +_WS_PROJECTS_PREFIX = "/api/ws/projects/" | ||
| 111 | + | ||
| 112 | + | ||
| 106 | class _Handler(BaseHTTPRequestHandler): | 113 | class _Handler(BaseHTTPRequestHandler): |
| 107 | def do_GET(self): | 114 | def do_GET(self): |
| 115 | + parsed = urllib.parse.urlsplit(self.path) | ||
| 116 | + if parsed.path.startswith(_WS_PROJECTS_PREFIX): | ||
| 117 | + self._handle_ws_subscribe(parsed.path, parsed.query) | ||
| 118 | + return | ||
| 119 | + | ||
| 108 | entry = _STATIC_FILES.get(self.path) | 120 | entry = _STATIC_FILES.get(self.path) |
| 109 | if entry is None: | 121 | if entry is None: |
| 110 | self._send_json(404, {"error": "NOT_FOUND"}) | 122 | self._send_json(404, {"error": "NOT_FOUND"}) |
| @@ -209,6 +221,65 @@ class _Handler(BaseHTTPRequestHandler): | @@ -209,6 +221,65 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 209 | else: | 221 | else: |
| 210 | self._send_json(200, result) | 222 | self._send_json(200, result) |
| 211 | 223 | ||
| 224 | + def _handle_ws_subscribe(self, path, query_string): | ||
| 225 | + """c_ws_subscribe (03-C §3.14): WS /api/ws/projects/:project_id. | ||
| 226 | + Rejects with a normal HTTP response (no 101) before ever touching | ||
| 227 | + the socket-hijack path if the request isn't a real WS upgrade or | ||
| 228 | + project_id doesn't exist -- 03-C: "直接拒绝连接", not a connection | ||
| 229 | + that opens and then immediately closes.""" | ||
| 230 | + project_id = path[len(_WS_PROJECTS_PREFIX):] | ||
| 231 | + if not project_id or "/" in project_id: | ||
| 232 | + self._send_json(404, {"error": "NOT_FOUND"}) | ||
| 233 | + return | ||
| 234 | + task_id = urllib.parse.parse_qs(query_string).get("task_id", [None])[0] | ||
| 235 | + | ||
| 236 | + ws_key = self.headers.get("Sec-WebSocket-Key") | ||
| 237 | + if self.headers.get("Upgrade", "").lower() != "websocket" or not ws_key: | ||
| 238 | + self._send_json(400, {"error": "INVALID_INPUT", "detail": "expected a WebSocket upgrade request"}) | ||
| 239 | + return | ||
| 240 | + | ||
| 241 | + with self.server.conn_lock: | ||
| 242 | + project_exists = self.server.conn.execute( | ||
| 243 | + "SELECT 1 FROM project WHERE project_id = ?", (project_id,) | ||
| 244 | + ).fetchone() is not None | ||
| 245 | + if not project_exists: | ||
| 246 | + self._send_json(404, {"error": "PROJECT_NOT_FOUND"}) | ||
| 247 | + return | ||
| 248 | + | ||
| 249 | + accept_key = ws_subscribe.compute_accept_key(ws_key) | ||
| 250 | + self.connection.sendall( | ||
| 251 | + ( | ||
| 252 | + "HTTP/1.1 101 Switching Protocols\r\n" | ||
| 253 | + "Upgrade: websocket\r\n" | ||
| 254 | + "Connection: Upgrade\r\n" | ||
| 255 | + "Sec-WebSocket-Accept: " + accept_key + "\r\n\r\n" | ||
| 256 | + ).encode("ascii") | ||
| 257 | + ) | ||
| 258 | + self.close_connection = True | ||
| 259 | + | ||
| 260 | + with self.server.conn_lock: | ||
| 261 | + after_rowid = ws_subscribe.latest_rowid(self.server.conn, project_id) | ||
| 262 | + self.connection.settimeout(ws_subscribe.POLL_INTERVAL_SECONDS) | ||
| 263 | + try: | ||
| 264 | + while True: | ||
| 265 | + try: | ||
| 266 | + opcode, _payload = ws_subscribe.decode_client_frame(self.connection) | ||
| 267 | + if opcode == ws_subscribe.OPCODE_CLOSE: | ||
| 268 | + break | ||
| 269 | + if opcode == ws_subscribe.OPCODE_PING: | ||
| 270 | + self.connection.sendall(ws_subscribe.encode_frame(b"", opcode=ws_subscribe.OPCODE_PONG)) | ||
| 271 | + except socket.timeout: | ||
| 272 | + pass # normal: nothing from the client this poll tick | ||
| 273 | + | ||
| 274 | + with self.server.conn_lock: | ||
| 275 | + events, after_rowid = ws_subscribe.poll_new_events( | ||
| 276 | + self.server.conn, project_id, after_rowid, task_id=task_id | ||
| 277 | + ) | ||
| 278 | + for event in events: | ||
| 279 | + self.connection.sendall(ws_subscribe.encode_text_frame(json.dumps(event))) | ||
| 280 | + except (ConnectionError, OSError): | ||
| 281 | + pass # client disconnected -- nothing more to send | ||
| 282 | + | ||
| 212 | def _send_json(self, status, obj): | 283 | def _send_json(self, status, obj): |
| 213 | body = json.dumps(obj).encode("utf-8") | 284 | body = json.dumps(obj).encode("utf-8") |
| 214 | self.send_response(status) | 285 | self.send_response(status) |
| @@ -230,6 +301,10 @@ def _ensure_schema(conn): | @@ -230,6 +301,10 @@ def _ensure_schema(conn): | ||
| 230 | 301 | ||
| 231 | 302 | ||
| 232 | class _Server(ThreadingHTTPServer): | 303 | class _Server(ThreadingHTTPServer): |
| 304 | + # long-lived WS connections (c_ws_subscribe) shouldn't be able to block | ||
| 305 | + # process shutdown -- ThreadingMixIn.daemon_threads defaults to False. | ||
| 306 | + daemon_threads = True | ||
| 307 | + | ||
| 233 | def server_close(self): | 308 | def server_close(self): |
| 234 | super().server_close() | 309 | super().server_close() |
| 235 | self.conn.close() | 310 | self.conn.close() |
src/test_backend_server.py
| @@ -6,18 +6,21 @@ Run: python3 -m unittest src.test_backend_server -v | @@ -6,18 +6,21 @@ Run: python3 -m unittest src.test_backend_server -v | ||
| 6 | """ | 6 | """ |
| 7 | 7 | ||
| 8 | import ast | 8 | import ast |
| 9 | +import base64 | ||
| 9 | import http.client | 10 | import http.client |
| 10 | import json | 11 | import json |
| 11 | import os | 12 | import os |
| 12 | import shutil | 13 | import shutil |
| 14 | +import socket | ||
| 13 | import subprocess | 15 | import subprocess |
| 14 | import sys | 16 | import sys |
| 15 | import tempfile | 17 | import tempfile |
| 16 | import threading | 18 | import threading |
| 19 | +import time | ||
| 17 | import unittest | 20 | import unittest |
| 18 | from unittest.mock import patch | 21 | from unittest.mock import patch |
| 19 | 22 | ||
| 20 | -from src import backend_server | 23 | +from src import backend_server, ws_subscribe |
| 21 | 24 | ||
| 22 | 25 | ||
| 23 | class ServerStartStopTest(unittest.TestCase): | 26 | class ServerStartStopTest(unittest.TestCase): |
| @@ -389,6 +392,160 @@ class TaskAndBugReportRouteTest(unittest.TestCase): | @@ -389,6 +392,160 @@ class TaskAndBugReportRouteTest(unittest.TestCase): | ||
| 389 | self.assertEqual(body["error"], "INVALID_PARAM") | 392 | self.assertEqual(body["error"], "INVALID_PARAM") |
| 390 | 393 | ||
| 391 | 394 | ||
| 395 | +def _ws_handshake_request(path, key): | ||
| 396 | + return ( | ||
| 397 | + "GET {} HTTP/1.1\r\n" | ||
| 398 | + "Host: 127.0.0.1\r\n" | ||
| 399 | + "Upgrade: websocket\r\n" | ||
| 400 | + "Connection: Upgrade\r\n" | ||
| 401 | + "Sec-WebSocket-Key: {}\r\n" | ||
| 402 | + "Sec-WebSocket-Version: 13\r\n" | ||
| 403 | + "\r\n" | ||
| 404 | + ).format(path, key).encode("ascii") | ||
| 405 | + | ||
| 406 | + | ||
| 407 | +def _read_http_response_head(sock): | ||
| 408 | + """Reads until the blank line terminating an HTTP response's headers -- | ||
| 409 | + good enough for both a 101 upgrade and a plain error response, since | ||
| 410 | + neither carries a body in this handler.""" | ||
| 411 | + buf = b"" | ||
| 412 | + while b"\r\n\r\n" not in buf: | ||
| 413 | + chunk = sock.recv(4096) | ||
| 414 | + if not chunk: | ||
| 415 | + break | ||
| 416 | + buf += chunk | ||
| 417 | + return buf.decode("ascii", errors="replace") | ||
| 418 | + | ||
| 419 | + | ||
| 420 | +def _read_server_frame(sock): | ||
| 421 | + """Server->client frames are never masked (RFC 6455 §5.1) -- simpler | ||
| 422 | + decode than ws_subscribe.decode_client_frame, which expects masking.""" | ||
| 423 | + first_two = _recv_exact(sock, 2) | ||
| 424 | + length = first_two[1] & 0x7F | ||
| 425 | + if length == 126: | ||
| 426 | + length = int.from_bytes(_recv_exact(sock, 2), "big") | ||
| 427 | + elif length == 127: | ||
| 428 | + length = int.from_bytes(_recv_exact(sock, 8), "big") | ||
| 429 | + payload = _recv_exact(sock, length) | ||
| 430 | + return json.loads(payload.decode("utf-8")) | ||
| 431 | + | ||
| 432 | + | ||
| 433 | +def _recv_exact(sock, n): | ||
| 434 | + chunks = [] | ||
| 435 | + remaining = n | ||
| 436 | + while remaining > 0: | ||
| 437 | + chunk = sock.recv(remaining) | ||
| 438 | + if not chunk: | ||
| 439 | + raise ConnectionError("socket closed") | ||
| 440 | + chunks.append(chunk) | ||
| 441 | + remaining -= len(chunk) | ||
| 442 | + return b"".join(chunks) | ||
| 443 | + | ||
| 444 | + | ||
| 445 | +class WsSubscribeRouteTest(unittest.TestCase): | ||
| 446 | + """U-024: c_ws_subscribe end-to-end over a real raw socket -- the actual | ||
| 447 | + RFC 6455 handshake + framing, not mocked, since that's exactly the | ||
| 448 | + hand-rolled logic (no `websockets` dependency, 05 §5.7) most likely to | ||
| 449 | + have a real bug.""" | ||
| 450 | + | ||
| 451 | + def setUp(self): | ||
| 452 | + self.server = backend_server.create_server(db_path=":memory:") | ||
| 453 | + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) | ||
| 454 | + self.thread.start() | ||
| 455 | + self.server.conn.execute( | ||
| 456 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | ||
| 457 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | ||
| 458 | + ) | ||
| 459 | + self.server.conn.commit() | ||
| 460 | + | ||
| 461 | + def tearDown(self): | ||
| 462 | + self.server.shutdown() | ||
| 463 | + self.server.server_close() | ||
| 464 | + self.thread.join(timeout=5) | ||
| 465 | + | ||
| 466 | + def _connect(self): | ||
| 467 | + host, port = self.server.server_address | ||
| 468 | + return socket.create_connection((host, port), timeout=5) | ||
| 469 | + | ||
| 470 | + def test_handshake_succeeds_with_correct_accept_key(self): | ||
| 471 | + sock = self._connect() | ||
| 472 | + self.addCleanup(sock.close) | ||
| 473 | + key = base64.b64encode(b"0123456789012345").decode("ascii") | ||
| 474 | + sock.sendall(_ws_handshake_request("/api/ws/projects/proj-1", key)) | ||
| 475 | + head = _read_http_response_head(sock) | ||
| 476 | + self.assertIn("101 Switching Protocols", head.splitlines()[0]) | ||
| 477 | + expected_accept = ws_subscribe.compute_accept_key(key) | ||
| 478 | + self.assertIn("Sec-WebSocket-Accept: " + expected_accept, head) | ||
| 479 | + | ||
| 480 | + def test_nonexistent_project_rejected_before_upgrade(self): | ||
| 481 | + sock = self._connect() | ||
| 482 | + self.addCleanup(sock.close) | ||
| 483 | + key = base64.b64encode(b"0123456789012345").decode("ascii") | ||
| 484 | + sock.sendall(_ws_handshake_request("/api/ws/projects/no-such-project", key)) | ||
| 485 | + head = _read_http_response_head(sock) | ||
| 486 | + self.assertIn("404", head.splitlines()[0]) | ||
| 487 | + self.assertNotIn("101", head.splitlines()[0]) | ||
| 488 | + | ||
| 489 | + def test_non_upgrade_request_rejected(self): | ||
| 490 | + sock = self._connect() | ||
| 491 | + self.addCleanup(sock.close) | ||
| 492 | + sock.sendall(b"GET /api/ws/projects/proj-1 HTTP/1.1\r\nHost: x\r\n\r\n") | ||
| 493 | + head = _read_http_response_head(sock) | ||
| 494 | + self.assertIn("400", head.splitlines()[0]) | ||
| 495 | + | ||
| 496 | + def test_new_execution_event_pushed_within_two_seconds(self): | ||
| 497 | + sock = self._connect() | ||
| 498 | + self.addCleanup(sock.close) | ||
| 499 | + key = base64.b64encode(b"0123456789012345").decode("ascii") | ||
| 500 | + sock.sendall(_ws_handshake_request("/api/ws/projects/proj-1", key)) | ||
| 501 | + _read_http_response_head(sock) | ||
| 502 | + | ||
| 503 | + start = time.monotonic() | ||
| 504 | + with self.server.conn_lock: | ||
| 505 | + self.server.conn.execute( | ||
| 506 | + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " | ||
| 507 | + "VALUES ('evt-1', 'proj-1', NULL, 'budget_updated', '{\"x\": 1}')" | ||
| 508 | + ) | ||
| 509 | + self.server.conn.commit() | ||
| 510 | + | ||
| 511 | + sock.settimeout(5) | ||
| 512 | + event = _read_server_frame(sock) | ||
| 513 | + elapsed = time.monotonic() - start | ||
| 514 | + self.assertLess(elapsed, 2.0) | ||
| 515 | + self.assertEqual(event["event_id"], "evt-1") | ||
| 516 | + self.assertEqual(event["event_type"], "budget_updated") | ||
| 517 | + self.assertEqual(event["payload"], {"x": 1}) | ||
| 518 | + | ||
| 519 | + def test_task_id_filter_excludes_unrelated_events(self): | ||
| 520 | + with self.server.conn_lock: | ||
| 521 | + self.server.conn.execute( | ||
| 522 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | ||
| 523 | + "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'planner')" | ||
| 524 | + ) | ||
| 525 | + self.server.conn.commit() | ||
| 526 | + | ||
| 527 | + sock = self._connect() | ||
| 528 | + self.addCleanup(sock.close) | ||
| 529 | + key = base64.b64encode(b"0123456789012345").decode("ascii") | ||
| 530 | + sock.sendall(_ws_handshake_request("/api/ws/projects/proj-1?task_id=task-1", key)) | ||
| 531 | + _read_http_response_head(sock) | ||
| 532 | + | ||
| 533 | + with self.server.conn_lock: | ||
| 534 | + self.server.conn.execute( | ||
| 535 | + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " | ||
| 536 | + "VALUES ('evt-unrelated', 'proj-1', NULL, 'budget_updated', '{}')" | ||
| 537 | + ) | ||
| 538 | + self.server.conn.execute( | ||
| 539 | + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " | ||
| 540 | + "VALUES ('evt-related', 'proj-1', 'task-1', 'task_status_changed', '{}')" | ||
| 541 | + ) | ||
| 542 | + self.server.conn.commit() | ||
| 543 | + | ||
| 544 | + sock.settimeout(5) | ||
| 545 | + event = _read_server_frame(sock) | ||
| 546 | + self.assertEqual(event["event_id"], "evt-related") | ||
| 547 | + | ||
| 548 | + | ||
| 392 | class ZeroThirdPartyDependencyTest(unittest.TestCase): | 549 | class ZeroThirdPartyDependencyTest(unittest.TestCase): |
| 393 | """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this | 550 | """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this |
| 394 | doesn't silently rot if someone adds an import later.""" | 551 | doesn't silently rot if someone adds an import later.""" |
src/test_ws_subscribe.py
0 → 100644
| 1 | +"""U-024 tests: c_ws_subscribe's pure pieces (handshake key, frame | ||
| 2 | +encode/decode, event polling) tested directly, no live socket needed for | ||
| 3 | +these -- the live-socket end-to-end path is covered in | ||
| 4 | +src/test_backend_server.py's WsSubscribeRouteTest. | ||
| 5 | + | ||
| 6 | +Run: python3 -m unittest src.test_ws_subscribe -v | ||
| 7 | +""" | ||
| 8 | + | ||
| 9 | +import io | ||
| 10 | +import json | ||
| 11 | +import unittest | ||
| 12 | +import uuid | ||
| 13 | + | ||
| 14 | +from src import schema, ws_subscribe | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +class ComputeAcceptKeyTest(unittest.TestCase): | ||
| 18 | + def test_matches_rfc6455_example(self): | ||
| 19 | + # RFC 6455 §1.3's own worked example | ||
| 20 | + self.assertEqual( | ||
| 21 | + ws_subscribe.compute_accept_key("dGhlIHNhbXBsZSBub25jZQ=="), | ||
| 22 | + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", | ||
| 23 | + ) | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +class _FakeSocket: | ||
| 27 | + """Minimal recv()-only stand-in so decode_client_frame can be tested | ||
| 28 | + without a real socket -- feeds back a bytes buffer in arbitrary chunks.""" | ||
| 29 | + | ||
| 30 | + def __init__(self, data): | ||
| 31 | + self._buf = io.BytesIO(data) | ||
| 32 | + | ||
| 33 | + def recv(self, n): | ||
| 34 | + return self._buf.read(n) | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +class FrameEncodeDecodeTest(unittest.TestCase): | ||
| 38 | + def test_text_frame_roundtrip_short_payload(self): | ||
| 39 | + frame = ws_subscribe.encode_text_frame("hello") | ||
| 40 | + # server frames aren't masked -- decode via a masked wrapper to | ||
| 41 | + # reuse decode_client_frame's parsing logic for the length/opcode | ||
| 42 | + # bits (masking bit is separately exercised in the masked test). | ||
| 43 | + self.assertEqual(frame[0], 0x81) # FIN=1, opcode=text | ||
| 44 | + self.assertEqual(frame[1], 5) # unmasked, length=5 | ||
| 45 | + self.assertEqual(frame[2:], b"hello") | ||
| 46 | + | ||
| 47 | + def test_text_frame_medium_payload_uses_16bit_length(self): | ||
| 48 | + text = "x" * 200 | ||
| 49 | + frame = ws_subscribe.encode_text_frame(text) | ||
| 50 | + self.assertEqual(frame[1], 126) | ||
| 51 | + self.assertEqual(int.from_bytes(frame[2:4], "big"), 200) | ||
| 52 | + self.assertEqual(frame[4:].decode(), text) | ||
| 53 | + | ||
| 54 | + def test_decode_masked_client_frame(self): | ||
| 55 | + payload = b"ping-ish" | ||
| 56 | + mask_key = bytes([0x12, 0x34, 0x56, 0x78]) | ||
| 57 | + masked_payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) | ||
| 58 | + raw = bytes([0x81, 0x80 | len(payload)]) + mask_key + masked_payload | ||
| 59 | + opcode, decoded = ws_subscribe.decode_client_frame(_FakeSocket(raw)) | ||
| 60 | + self.assertEqual(opcode, ws_subscribe.OPCODE_TEXT) | ||
| 61 | + self.assertEqual(decoded, payload) | ||
| 62 | + | ||
| 63 | + def test_decode_close_frame_empty_payload(self): | ||
| 64 | + raw = bytes([0x88, 0x80]) + bytes([0, 0, 0, 0]) # masked, zero-length | ||
| 65 | + opcode, decoded = ws_subscribe.decode_client_frame(_FakeSocket(raw)) | ||
| 66 | + self.assertEqual(opcode, ws_subscribe.OPCODE_CLOSE) | ||
| 67 | + self.assertEqual(decoded, b"") | ||
| 68 | + | ||
| 69 | + def test_decode_raises_connection_error_on_truncated_socket(self): | ||
| 70 | + with self.assertRaises(ConnectionError): | ||
| 71 | + ws_subscribe.decode_client_frame(_FakeSocket(b"\x81")) | ||
| 72 | + | ||
| 73 | + | ||
| 74 | +class PollNewEventsTest(unittest.TestCase): | ||
| 75 | + def setUp(self): | ||
| 76 | + self.conn = schema.connect(":memory:") | ||
| 77 | + schema.create_schema(self.conn) | ||
| 78 | + self.conn.execute( | ||
| 79 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | ||
| 80 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | ||
| 81 | + ) | ||
| 82 | + self.conn.execute( | ||
| 83 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | ||
| 84 | + "VALUES ('proj-2', 'P2', '/tmp/proj-2', 1, 'active')" | ||
| 85 | + ) | ||
| 86 | + self.conn.execute( | ||
| 87 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | ||
| 88 | + "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'queued', 'planner')" | ||
| 89 | + ) | ||
| 90 | + self.conn.commit() | ||
| 91 | + | ||
| 92 | + def tearDown(self): | ||
| 93 | + self.conn.close() | ||
| 94 | + | ||
| 95 | + def _insert_event(self, project_id, event_type="task_status_changed", task_id=None, payload=None): | ||
| 96 | + self.conn.execute( | ||
| 97 | + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " | ||
| 98 | + "VALUES (?, ?, ?, ?, ?)", | ||
| 99 | + (str(uuid.uuid4()), project_id, task_id, event_type, json.dumps(payload or {})), | ||
| 100 | + ) | ||
| 101 | + self.conn.commit() | ||
| 102 | + | ||
| 103 | + def test_no_new_events_returns_empty_list_and_same_rowid(self): | ||
| 104 | + after = ws_subscribe.latest_rowid(self.conn, "proj-1") | ||
| 105 | + events, new_after = ws_subscribe.poll_new_events(self.conn, "proj-1", after) | ||
| 106 | + self.assertEqual(events, []) | ||
| 107 | + self.assertEqual(new_after, after) | ||
| 108 | + | ||
| 109 | + def test_new_event_after_baseline_is_returned(self): | ||
| 110 | + after = ws_subscribe.latest_rowid(self.conn, "proj-1") | ||
| 111 | + self._insert_event("proj-1", event_type="budget_updated", payload={"x": 1}) | ||
| 112 | + events, new_after = ws_subscribe.poll_new_events(self.conn, "proj-1", after) | ||
| 113 | + self.assertEqual(len(events), 1) | ||
| 114 | + self.assertEqual(events[0]["event_type"], "budget_updated") | ||
| 115 | + self.assertEqual(events[0]["payload"], {"x": 1}) | ||
| 116 | + self.assertGreater(new_after, after) | ||
| 117 | + | ||
| 118 | + def test_events_from_other_project_are_not_returned(self): | ||
| 119 | + after = ws_subscribe.latest_rowid(self.conn, "proj-1") | ||
| 120 | + self._insert_event("proj-2") | ||
| 121 | + events, _ = ws_subscribe.poll_new_events(self.conn, "proj-1", after) | ||
| 122 | + self.assertEqual(events, []) | ||
| 123 | + | ||
| 124 | + def test_task_id_filter_narrows_results(self): | ||
| 125 | + after = ws_subscribe.latest_rowid(self.conn, "proj-1") | ||
| 126 | + self._insert_event("proj-1", task_id="task-1") | ||
| 127 | + self._insert_event("proj-1", task_id=None) | ||
| 128 | + events, _ = ws_subscribe.poll_new_events(self.conn, "proj-1", after, task_id="task-1") | ||
| 129 | + self.assertEqual(len(events), 1) | ||
| 130 | + self.assertEqual(events[0]["task_id"], "task-1") | ||
| 131 | + | ||
| 132 | + def test_already_seen_events_not_returned_again(self): | ||
| 133 | + after = ws_subscribe.latest_rowid(self.conn, "proj-1") | ||
| 134 | + self._insert_event("proj-1") | ||
| 135 | + events, after2 = ws_subscribe.poll_new_events(self.conn, "proj-1", after) | ||
| 136 | + self.assertEqual(len(events), 1) | ||
| 137 | + events_again, after3 = ws_subscribe.poll_new_events(self.conn, "proj-1", after2) | ||
| 138 | + self.assertEqual(events_again, []) | ||
| 139 | + self.assertEqual(after2, after3) | ||
| 140 | + | ||
| 141 | + | ||
| 142 | +if __name__ == "__main__": | ||
| 143 | + unittest.main() |
src/ws_subscribe.py
0 → 100644
| 1 | +"""c_ws_subscribe (03-C §3.14): real-time execution_event push over a raw | ||
| 2 | +WebSocket connection (RFC 6455), hand-rolled with hashlib/base64/socket -- | ||
| 3 | +pure stdlib, no `websockets`/`tornado` (05 §5.7). | ||
| 4 | + | ||
| 5 | +U-024 scope ("最小连通版"): a poll loop is what actually pushes events, not | ||
| 6 | +a write-side hook -- execution_event rows are inserted from many different | ||
| 7 | +call sites across this codebase (org_node_trigger, work_graph_execute, | ||
| 8 | +work_node_run, task_create, bug_record, ...) with no single write path to | ||
| 9 | +instrument; polling the table every POLL_INTERVAL_SECONDS decouples this | ||
| 10 | +from all of them and easily clears REQ-12's own <=2s target (framed there | ||
| 11 | +as a suggested non-functional goal, not a hard SLA). Reconnect catch-up is | ||
| 12 | +explicitly routed through REST snapshot endpoints per 03-C §3.14's own | ||
| 13 | +"断线重连约束" note, not WS-layer replay -- a fresh connection only sees | ||
| 14 | +events from its own connect time onward, no history replay to build here. | ||
| 15 | +REQ-12's boundary/failure cases are U-024.5's job once more event-generating | ||
| 16 | +Units exist to test against. | ||
| 17 | +""" | ||
| 18 | + | ||
| 19 | +import base64 | ||
| 20 | +import hashlib | ||
| 21 | +import json | ||
| 22 | + | ||
| 23 | +_WS_MAGIC_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" | ||
| 24 | +POLL_INTERVAL_SECONDS = 0.5 | ||
| 25 | + | ||
| 26 | +OPCODE_TEXT = 0x1 | ||
| 27 | +OPCODE_CLOSE = 0x8 | ||
| 28 | +OPCODE_PING = 0x9 | ||
| 29 | +OPCODE_PONG = 0xA | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +def compute_accept_key(client_key): | ||
| 33 | + """RFC 6455 §1.3 handshake: SHA1(client_key + magic GUID), base64-encoded.""" | ||
| 34 | + digest = hashlib.sha1((client_key + _WS_MAGIC_GUID).encode("ascii")).digest() | ||
| 35 | + return base64.b64encode(digest).decode("ascii") | ||
| 36 | + | ||
| 37 | + | ||
| 38 | +def encode_frame(payload_bytes, opcode=OPCODE_TEXT): | ||
| 39 | + """Server->client frames are never masked (RFC 6455 §5.1).""" | ||
| 40 | + length = len(payload_bytes) | ||
| 41 | + if length < 126: | ||
| 42 | + header = bytes([0x80 | opcode, length]) | ||
| 43 | + elif length < 65536: | ||
| 44 | + header = bytes([0x80 | opcode, 126]) + length.to_bytes(2, "big") | ||
| 45 | + else: | ||
| 46 | + header = bytes([0x80 | opcode, 127]) + length.to_bytes(8, "big") | ||
| 47 | + return header + payload_bytes | ||
| 48 | + | ||
| 49 | + | ||
| 50 | +def encode_text_frame(text): | ||
| 51 | + return encode_frame(text.encode("utf-8"), opcode=OPCODE_TEXT) | ||
| 52 | + | ||
| 53 | + | ||
| 54 | +def decode_client_frame(sock): | ||
| 55 | + """Reads one client->server frame (always masked per RFC 6455 §5.1). | ||
| 56 | + Returns (opcode, payload_bytes). Raises ConnectionError if the socket | ||
| 57 | + closes mid-frame; a plain read timeout propagates as socket.timeout, | ||
| 58 | + left for the caller to catch (that's the normal "nothing arrived this | ||
| 59 | + poll tick" case, not an error).""" | ||
| 60 | + first_two = _recv_exact(sock, 2) | ||
| 61 | + opcode = first_two[0] & 0x0F | ||
| 62 | + masked = first_two[1] & 0x80 | ||
| 63 | + length = first_two[1] & 0x7F | ||
| 64 | + if length == 126: | ||
| 65 | + length = int.from_bytes(_recv_exact(sock, 2), "big") | ||
| 66 | + elif length == 127: | ||
| 67 | + length = int.from_bytes(_recv_exact(sock, 8), "big") | ||
| 68 | + mask_key = _recv_exact(sock, 4) if masked else b"" | ||
| 69 | + payload = _recv_exact(sock, length) | ||
| 70 | + if masked: | ||
| 71 | + payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) | ||
| 72 | + return opcode, payload | ||
| 73 | + | ||
| 74 | + | ||
| 75 | +def _recv_exact(sock, n): | ||
| 76 | + if n == 0: | ||
| 77 | + return b"" | ||
| 78 | + chunks = [] | ||
| 79 | + remaining = n | ||
| 80 | + while remaining > 0: | ||
| 81 | + chunk = sock.recv(remaining) | ||
| 82 | + if not chunk: | ||
| 83 | + raise ConnectionError("socket closed while reading a WebSocket frame") | ||
| 84 | + chunks.append(chunk) | ||
| 85 | + remaining -= len(chunk) | ||
| 86 | + return b"".join(chunks) | ||
| 87 | + | ||
| 88 | + | ||
| 89 | +def latest_rowid(conn, project_id): | ||
| 90 | + """Starting point for a fresh subscription -- 'now', not full history.""" | ||
| 91 | + row = conn.execute( | ||
| 92 | + "SELECT MAX(rowid) FROM execution_event WHERE project_id = ?", (project_id,) | ||
| 93 | + ).fetchone() | ||
| 94 | + return row[0] or 0 | ||
| 95 | + | ||
| 96 | + | ||
| 97 | +def poll_new_events(conn, project_id, after_rowid, task_id=None): | ||
| 98 | + """Returns (events, new_after_rowid) for execution_event rows with | ||
| 99 | + rowid > after_rowid, matching project_id (and task_id if given, per | ||
| 100 | + 03-C §3.14's optional server-side filter), oldest first. Each event | ||
| 101 | + dict matches the output_contract field-for-field.""" | ||
| 102 | + query = ( | ||
| 103 | + "SELECT rowid, event_id, event_type, task_id, run_id, node_exec_id, payload, created_at " | ||
| 104 | + "FROM execution_event WHERE project_id = ? AND rowid > ?" | ||
| 105 | + ) | ||
| 106 | + params = [project_id, after_rowid] | ||
| 107 | + if task_id: | ||
| 108 | + query += " AND task_id = ?" | ||
| 109 | + params.append(task_id) | ||
| 110 | + query += " ORDER BY rowid" | ||
| 111 | + rows = conn.execute(query, params).fetchall() | ||
| 112 | + events = [ | ||
| 113 | + { | ||
| 114 | + "event_id": r[1], | ||
| 115 | + "event_type": r[2], | ||
| 116 | + "task_id": r[3], | ||
| 117 | + "run_id": r[4], | ||
| 118 | + "node_exec_id": r[5], | ||
| 119 | + "payload": json.loads(r[6]), | ||
| 120 | + "created_at": r[7], | ||
| 121 | + } | ||
| 122 | + for r in rows | ||
| 123 | + ] | ||
| 124 | + new_after_rowid = rows[-1][0] if rows else after_rowid | ||
| 125 | + return events, new_after_rowid |