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 | 231 | - **U-024 [Value Path]** `c_ws_subscribe` + `execution_event` 实时推送(最小连通版) |
| 232 | 232 | - 依赖:U-012, U-013(work graph 执行、work node run 是最早、最稳定产出 `execution_event` 的两个来源,足够验证推送链路本身是否通) |
| 233 | 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 | 235 | - 覆盖:01-M `execution_event`;03-C `c_ws_subscribe`;REQ-12(部分) |
| 235 | 236 | |
| 236 | 237 | - **U-024.5(2026-07-30 新增)[Value Path]** REQ-12 完整验收(事件多样性联调) | ... | ... |
docs/sdd/unit_matrix.json
| ... | ... | @@ -38,7 +38,7 @@ |
| 38 | 38 | |
| 39 | 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 | 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 | 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 | 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 | 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 | 10 | the U-002 `project_scaffold.create_project()` function that had no HTTP |
| 11 | 11 | route until now) for `v_project_setup`'s submit flow, `c_task_create`/ |
| 12 | 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 | 21 | import json |
| 20 | 22 | import os |
| 21 | 23 | import platform |
| 24 | +import socket | |
| 22 | 25 | import subprocess |
| 23 | 26 | import threading |
| 27 | +import urllib.parse | |
| 24 | 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 | 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 | 107 | raise DialogUnavailableError("neither zenity nor kdialog is installed") |
| 104 | 108 | |
| 105 | 109 | |
| 110 | +_WS_PROJECTS_PREFIX = "/api/ws/projects/" | |
| 111 | + | |
| 112 | + | |
| 106 | 113 | class _Handler(BaseHTTPRequestHandler): |
| 107 | 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 | 120 | entry = _STATIC_FILES.get(self.path) |
| 109 | 121 | if entry is None: |
| 110 | 122 | self._send_json(404, {"error": "NOT_FOUND"}) |
| ... | ... | @@ -209,6 +221,65 @@ class _Handler(BaseHTTPRequestHandler): |
| 209 | 221 | else: |
| 210 | 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 | 283 | def _send_json(self, status, obj): |
| 213 | 284 | body = json.dumps(obj).encode("utf-8") |
| 214 | 285 | self.send_response(status) |
| ... | ... | @@ -230,6 +301,10 @@ def _ensure_schema(conn): |
| 230 | 301 | |
| 231 | 302 | |
| 232 | 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 | 308 | def server_close(self): |
| 234 | 309 | super().server_close() |
| 235 | 310 | self.conn.close() | ... | ... |
src/test_backend_server.py
| ... | ... | @@ -6,18 +6,21 @@ Run: python3 -m unittest src.test_backend_server -v |
| 6 | 6 | """ |
| 7 | 7 | |
| 8 | 8 | import ast |
| 9 | +import base64 | |
| 9 | 10 | import http.client |
| 10 | 11 | import json |
| 11 | 12 | import os |
| 12 | 13 | import shutil |
| 14 | +import socket | |
| 13 | 15 | import subprocess |
| 14 | 16 | import sys |
| 15 | 17 | import tempfile |
| 16 | 18 | import threading |
| 19 | +import time | |
| 17 | 20 | import unittest |
| 18 | 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 | 26 | class ServerStartStopTest(unittest.TestCase): |
| ... | ... | @@ -389,6 +392,160 @@ class TaskAndBugReportRouteTest(unittest.TestCase): |
| 389 | 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 | 549 | class ZeroThirdPartyDependencyTest(unittest.TestCase): |
| 393 | 550 | """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this |
| 394 | 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 | ... | ... |