Commit 82b088118693d288e839e9dd127670af159cb89f

Authored by bboonnee
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>
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
... ...