@agentunion/kite 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/__init__.py +1 -0
  2. package/__main__.py +15 -0
  3. package/cli.js +70 -0
  4. package/core/__init__.py +0 -0
  5. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/core/event_hub/BENCHMARK.md +94 -0
  7. package/core/event_hub/__init__.py +0 -0
  8. package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
  9. package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
  10. package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
  11. package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
  12. package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
  13. package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
  14. package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
  15. package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
  16. package/core/event_hub/bench.py +459 -0
  17. package/core/event_hub/bench_extreme.py +308 -0
  18. package/core/event_hub/bench_perf.py +350 -0
  19. package/core/event_hub/bench_results/.gitkeep +0 -0
  20. package/core/event_hub/bench_results/2026-02-28_13-26-48.json +51 -0
  21. package/core/event_hub/bench_results/2026-02-28_13-44-45.json +51 -0
  22. package/core/event_hub/bench_results/2026-02-28_13-45-39.json +51 -0
  23. package/core/event_hub/dedup.py +31 -0
  24. package/core/event_hub/entry.py +113 -0
  25. package/core/event_hub/hub.py +263 -0
  26. package/core/event_hub/module.md +21 -0
  27. package/core/event_hub/router.py +21 -0
  28. package/core/event_hub/server.py +138 -0
  29. package/core/event_hub_bench/entry.py +371 -0
  30. package/core/event_hub_bench/module.md +25 -0
  31. package/core/launcher/__init__.py +0 -0
  32. package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
  33. package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
  34. package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
  35. package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
  36. package/core/launcher/data/log/lifecycle.jsonl +1045 -0
  37. package/core/launcher/data/processes_14752.json +32 -0
  38. package/core/launcher/data/token.txt +1 -0
  39. package/core/launcher/entry.py +965 -0
  40. package/core/launcher/module.md +37 -0
  41. package/core/launcher/module_scanner.py +253 -0
  42. package/core/launcher/process_manager.py +435 -0
  43. package/core/registry/__init__.py +0 -0
  44. package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
  45. package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
  46. package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
  47. package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
  48. package/core/registry/data/port.txt +1 -0
  49. package/core/registry/data/port_14752.txt +1 -0
  50. package/core/registry/data/port_484.txt +1 -0
  51. package/core/registry/entry.py +73 -0
  52. package/core/registry/module.md +30 -0
  53. package/core/registry/server.py +256 -0
  54. package/core/registry/store.py +232 -0
  55. package/extensions/__init__.py +0 -0
  56. package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
  57. package/extensions/services/__init__.py +0 -0
  58. package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
  59. package/extensions/services/watchdog/__init__.py +0 -0
  60. package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
  61. package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
  62. package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
  63. package/extensions/services/watchdog/__pycache__/server.cpython-313.pyc +0 -0
  64. package/extensions/services/watchdog/entry.py +143 -0
  65. package/extensions/services/watchdog/module.md +25 -0
  66. package/extensions/services/watchdog/monitor.py +420 -0
  67. package/extensions/services/watchdog/server.py +167 -0
  68. package/main.py +17 -0
  69. package/package.json +27 -0
@@ -0,0 +1,51 @@
1
+ {
2
+ "timestamp": "2026-02-28T13:44:45.723676",
3
+ "env": {
4
+ "platform": "win32",
5
+ "python": "3.13.12"
6
+ },
7
+ "throughput": {
8
+ "events": 10000,
9
+ "send_rate": 7576,
10
+ "client_recv": 10000,
11
+ "hub_queued": 10000,
12
+ "hub_routed": 10000,
13
+ "send_time": 1.32,
14
+ "recv_time": 1.43
15
+ },
16
+ "latency": {
17
+ "samples": 200,
18
+ "avg_ms": 0.5,
19
+ "p50_ms": 0.48,
20
+ "p95_ms": 0.66,
21
+ "p99_ms": 0.78
22
+ },
23
+ "fanout_1": {
24
+ "subs": 1,
25
+ "events": 2000,
26
+ "send_rate": 8811,
27
+ "avg_recv": 2000,
28
+ "min_recv": 2000
29
+ },
30
+ "fanout_10": {
31
+ "subs": 10,
32
+ "events": 2000,
33
+ "send_rate": 10057,
34
+ "avg_recv": 2000,
35
+ "min_recv": 2000
36
+ },
37
+ "fanout_50": {
38
+ "subs": 50,
39
+ "events": 2000,
40
+ "send_rate": 22422,
41
+ "avg_recv": 2000,
42
+ "min_recv": 2000
43
+ },
44
+ "hub_counters": {
45
+ "events_received": 16200,
46
+ "events_routed": 132200,
47
+ "events_queued": 132200,
48
+ "events_deduplicated": 0,
49
+ "errors": 0
50
+ }
51
+ }
@@ -0,0 +1,51 @@
1
+ {
2
+ "timestamp": "2026-02-28T13:45:39.158400",
3
+ "env": {
4
+ "platform": "win32",
5
+ "python": "3.13.12"
6
+ },
7
+ "throughput": {
8
+ "events": 10000,
9
+ "send_rate": 7464,
10
+ "client_recv": 10000,
11
+ "hub_queued": 10000,
12
+ "hub_routed": 10000,
13
+ "send_time": 1.34,
14
+ "recv_time": 1.45
15
+ },
16
+ "latency": {
17
+ "samples": 200,
18
+ "avg_ms": 0.59,
19
+ "p50_ms": 0.53,
20
+ "p95_ms": 0.93,
21
+ "p99_ms": 1.68
22
+ },
23
+ "fanout_1": {
24
+ "subs": 1,
25
+ "events": 2000,
26
+ "send_rate": 8694,
27
+ "avg_recv": 2000,
28
+ "min_recv": 2000
29
+ },
30
+ "fanout_10": {
31
+ "subs": 10,
32
+ "events": 2000,
33
+ "send_rate": 7599,
34
+ "avg_recv": 2000,
35
+ "min_recv": 2000
36
+ },
37
+ "fanout_50": {
38
+ "subs": 50,
39
+ "events": 2000,
40
+ "send_rate": 6664,
41
+ "avg_recv": 2000,
42
+ "min_recv": 2000
43
+ },
44
+ "hub_counters": {
45
+ "events_received": 16200,
46
+ "events_routed": 132200,
47
+ "events_queued": 132200,
48
+ "events_deduplicated": 0,
49
+ "errors": 0
50
+ }
51
+ }
@@ -0,0 +1,31 @@
1
+ """
2
+ Event deduplication — 1-hour sliding window.
3
+ Tracks event_id to prevent duplicate forwarding from outbox replays.
4
+ """
5
+
6
+ import time
7
+
8
+
9
+ class EventDedup:
10
+
11
+ def __init__(self, ttl: float = 3600.0):
12
+ self.ttl = ttl # seconds to keep event_id (default 1h)
13
+ self.seen: dict[str, float] = {} # event_id -> first-seen timestamp
14
+
15
+ def is_duplicate(self, event_id: str) -> bool:
16
+ """Check if event_id was already seen. If new, record it."""
17
+ if event_id in self.seen:
18
+ return True
19
+ self.seen[event_id] = time.time()
20
+ return False
21
+
22
+ def cleanup(self):
23
+ """Remove entries older than TTL."""
24
+ cutoff = time.time() - self.ttl
25
+ expired = [eid for eid, ts in self.seen.items() if ts < cutoff]
26
+ for eid in expired:
27
+ del self.seen[eid]
28
+
29
+ @property
30
+ def size(self) -> int:
31
+ return len(self.seen)
@@ -0,0 +1,113 @@
1
+ """
2
+ Event Hub entry point.
3
+ Reads boot_info from stdin, starts FastAPI server, registers to Registry.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import socket
9
+ import sys
10
+
11
+ import httpx
12
+ import uvicorn
13
+
14
+ # Ensure project root is on sys.path
15
+ _this_dir = os.path.dirname(os.path.abspath(__file__))
16
+ _project_root = os.path.dirname(os.path.dirname(_this_dir))
17
+ if _project_root not in sys.path:
18
+ sys.path.insert(0, _project_root)
19
+
20
+ from core.event_hub.hub import EventHub
21
+ from core.event_hub.server import EventHubServer
22
+
23
+
24
+ def _get_free_port() -> int:
25
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
26
+ s.bind(("127.0.0.1", 0))
27
+ return s.getsockname()[1]
28
+
29
+
30
+ def _register_to_registry(token: str, registry_url: str, port: int, advertise_ip: str = "127.0.0.1"):
31
+ """Synchronous registration to Registry at startup."""
32
+ payload = {
33
+ "action": "register",
34
+ "module_id": "event_hub",
35
+ "module_type": "infrastructure",
36
+ "name": "Event Hub",
37
+ "api_endpoint": f"http://{advertise_ip}:{port}",
38
+ "health_endpoint": "/health",
39
+ "metadata": {
40
+ "ws_endpoint": f"ws://{advertise_ip}:{port}/ws",
41
+ },
42
+ }
43
+ headers = {"Authorization": f"Bearer {token}"}
44
+ resp = httpx.post(
45
+ f"{registry_url}/modules",
46
+ json=payload,
47
+ headers=headers,
48
+ timeout=5,
49
+ )
50
+ if resp.status_code == 200:
51
+ print(f"[event_hub] Registered to Registry")
52
+ else:
53
+ print(f"[event_hub] WARNING: Registry returned {resp.status_code}")
54
+
55
+
56
+ def _read_test_mode() -> bool:
57
+ """Read test_mode from module.md frontmatter."""
58
+ md_path = os.path.join(_this_dir, "module.md")
59
+ try:
60
+ with open(md_path, encoding="utf-8") as f:
61
+ text = f.read()
62
+ import re, yaml
63
+ m = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL)
64
+ if m:
65
+ fm = yaml.safe_load(m.group(1)) or {}
66
+ return bool(fm.get("test_mode", False))
67
+ except Exception:
68
+ pass
69
+ return False
70
+
71
+
72
+ def main():
73
+ # Read boot_info from stdin
74
+ token = ""
75
+ registry_port = 0
76
+ bind_host = "127.0.0.1"
77
+ advertise_ip = "127.0.0.1"
78
+ try:
79
+ line = sys.stdin.readline().strip()
80
+ if line:
81
+ boot_info = json.loads(line)
82
+ token = boot_info.get("token", "")
83
+ registry_port = boot_info.get("registry_port", 0)
84
+ bind_host = boot_info.get("bind", "127.0.0.1")
85
+ advertise_ip = boot_info.get("advertise_ip", "127.0.0.1")
86
+ except Exception:
87
+ pass
88
+
89
+ if not token or not registry_port:
90
+ print("[event_hub] ERROR: Missing token or registry_port in boot_info")
91
+ sys.exit(1)
92
+
93
+ print(f"[event_hub] Token received ({len(token)} chars), registry port: {registry_port}")
94
+
95
+ registry_url = f"http://127.0.0.1:{registry_port}"
96
+ port = _get_free_port()
97
+
98
+ # Register to Registry
99
+ _register_to_registry(token, registry_url, port, advertise_ip)
100
+
101
+ # Create hub and server
102
+ test_mode = _read_test_mode()
103
+ if test_mode:
104
+ print("[event_hub] WARNING: test_mode enabled, all tokens accepted")
105
+ hub = EventHub()
106
+ server = EventHubServer(hub, own_token=token, registry_url=registry_url, test_mode=test_mode)
107
+
108
+ print(f"[event_hub] Starting on {bind_host}:{port}")
109
+ uvicorn.run(server.app, host=bind_host, port=port, log_level="warning")
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
@@ -0,0 +1,263 @@
1
+ """
2
+ Core Event Hub logic.
3
+ WebSocket handling, event processing (validate → dedup → route → ack),
4
+ connection and subscription management.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import time
10
+ from datetime import datetime, timezone
11
+
12
+ try:
13
+ import orjson
14
+ except ImportError:
15
+ orjson = None
16
+
17
+ from fastapi import WebSocket, WebSocketDisconnect
18
+
19
+ from .dedup import EventDedup
20
+ from .router import match_parts
21
+
22
+
23
+ def _dumps(obj) -> str:
24
+ if orjson is not None:
25
+ return orjson.dumps(obj).decode()
26
+ return json.dumps(obj)
27
+
28
+
29
+ def _loads(raw: str):
30
+ if orjson is not None:
31
+ return orjson.loads(raw)
32
+ return json.loads(raw)
33
+
34
+ QUEUE_MAXSIZE = 10000
35
+
36
+
37
+ class EventHub:
38
+
39
+ def __init__(self):
40
+ # Connection table: module_id -> WebSocket
41
+ self.connections: dict[str, WebSocket] = {}
42
+ # Connection metadata: module_id -> {connected_at}
43
+ self.connection_info: dict[str, dict] = {}
44
+ # Subscription table: module_id -> set of (pattern_str, pattern_tuple)
45
+ self.subscriptions: dict[str, set[tuple]] = {}
46
+ # Per-subscriber delivery queue + sender task
47
+ self._queues: dict[str, asyncio.Queue] = {}
48
+ self._senders: dict[str, asyncio.Task] = {}
49
+ # Dedup
50
+ self.dedup = EventDedup()
51
+ # Counters (integer attrs for speed)
52
+ self._cnt_received = 0
53
+ self._cnt_routed = 0
54
+ self._cnt_queued = 0
55
+ self._cnt_dedup = 0
56
+ self._cnt_errors = 0
57
+ self._start_time = time.time()
58
+
59
+ # ── Connection lifecycle ──
60
+
61
+ def add_connection(self, module_id: str, ws: WebSocket):
62
+ """Add a new connection. If module_id already connected, close the old one."""
63
+ old_ws = self.connections.get(module_id)
64
+ if old_ws is not None:
65
+ asyncio.ensure_future(self._close_old(module_id, old_ws))
66
+
67
+ self.connections[module_id] = ws
68
+ self.connection_info[module_id] = {
69
+ "connected_at": datetime.now(timezone.utc).isoformat(),
70
+ }
71
+ self.subscriptions.pop(module_id, None)
72
+ # Create delivery queue + sender task
73
+ q = asyncio.Queue(maxsize=QUEUE_MAXSIZE)
74
+ self._queues[module_id] = q
75
+ self._senders[module_id] = asyncio.create_task(
76
+ self._sender_loop(module_id, ws, q)
77
+ )
78
+ print(f"[event_hub] {module_id} connected")
79
+
80
+ async def _close_old(self, module_id: str, ws: WebSocket):
81
+ try:
82
+ await ws.close(code=4000, reason="replaced by new connection")
83
+ except Exception:
84
+ pass
85
+ print(f"[event_hub] Closed old connection for {module_id}")
86
+
87
+ def remove_connection(self, module_id: str):
88
+ """Clean up on disconnect."""
89
+ self.connections.pop(module_id, None)
90
+ self.connection_info.pop(module_id, None)
91
+ self.subscriptions.pop(module_id, None)
92
+ self._queues.pop(module_id, None)
93
+ task = self._senders.pop(module_id, None)
94
+ if task:
95
+ task.cancel()
96
+ print(f"[event_hub] {module_id} disconnected")
97
+
98
+ # ── Sender loop (per-subscriber) ──
99
+
100
+ async def _sender_loop(self, mid: str, ws: WebSocket, queue: asyncio.Queue):
101
+ """Drain queue and send to subscriber. Exits on send failure."""
102
+ try:
103
+ while True:
104
+ raw = await queue.get()
105
+ try:
106
+ if len(raw) > 65536:
107
+ await asyncio.wait_for(ws.send_text(raw), timeout=5 + len(raw) / 102400)
108
+ else:
109
+ await ws.send_text(raw)
110
+ self._cnt_routed += 1
111
+ except Exception:
112
+ print(f"[event_hub] Send failed to {mid}, closing sender")
113
+ break
114
+ except asyncio.CancelledError:
115
+ pass
116
+
117
+ # ── Subscribe / Unsubscribe ──
118
+
119
+ def handle_subscribe(self, module_id: str, events: list[str]):
120
+ if module_id not in self.subscriptions:
121
+ self.subscriptions[module_id] = set()
122
+ self.subscriptions[module_id].update(
123
+ (p, tuple(p.split("."))) for p in events
124
+ )
125
+ print(f"[event_hub] {module_id} subscribed: {events}")
126
+
127
+ def handle_unsubscribe(self, module_id: str, events: list[str]):
128
+ subs = self.subscriptions.get(module_id)
129
+ if subs:
130
+ to_remove = {item for item in subs if item[0] in events}
131
+ subs.difference_update(to_remove)
132
+ print(f"[event_hub] {module_id} unsubscribed: {events}")
133
+
134
+ # ── Main message handler ──
135
+
136
+ async def handle_message(self, module_id: str, ws: WebSocket, raw: str):
137
+ try:
138
+ msg = _loads(raw)
139
+ except Exception:
140
+ await self._send_error(ws, "Invalid JSON")
141
+ return
142
+
143
+ msg_type = msg.get("type", "")
144
+
145
+ if msg_type == "subscribe":
146
+ events = msg.get("events", [])
147
+ if isinstance(events, list) and events:
148
+ self.handle_subscribe(module_id, events)
149
+ return
150
+
151
+ if msg_type == "unsubscribe":
152
+ events = msg.get("events", [])
153
+ if isinstance(events, list) and events:
154
+ self.handle_unsubscribe(module_id, events)
155
+ return
156
+
157
+ if msg_type == "event":
158
+ await self._handle_event(module_id, ws, msg)
159
+ return
160
+
161
+ await self._send_error(ws, f"Unknown message type: {msg_type}")
162
+
163
+ # ── Event processing ──
164
+
165
+ async def _handle_event(self, module_id: str, ws: WebSocket, msg: dict):
166
+ """Validate → dedup → auto-fill → route → ack."""
167
+ self._cnt_received += 1
168
+
169
+ event_id = msg.get("event_id")
170
+ event_type = msg.get("event")
171
+ if not event_id or not event_type:
172
+ self._cnt_errors += 1
173
+ await self._send_error(ws, "Missing required field: event_id or event")
174
+ return
175
+
176
+ if self.dedup.is_duplicate(event_id):
177
+ self._cnt_dedup += 1
178
+ await self._send_ack(ws, event_id)
179
+ return
180
+
181
+ if not msg.get("source"):
182
+ msg["source"] = module_id
183
+ if not msg.get("timestamp"):
184
+ msg["timestamp"] = datetime.now(timezone.utc).isoformat()
185
+
186
+ await self._route_event(module_id, msg)
187
+ await self._send_ack(ws, event_id)
188
+
189
+ # ── Routing ──
190
+
191
+ async def _route_event(self, sender_id: str, msg: dict):
192
+ """Enqueue event to all matching subscribers' delivery queues."""
193
+ event_type = msg["event"]
194
+ e_parts = tuple(event_type.split("."))
195
+ echo = msg.get("echo", False)
196
+ raw = None # lazy serialization
197
+
198
+ for mid, patterns in self.subscriptions.items():
199
+ if mid == sender_id and not echo:
200
+ continue
201
+ for _pat_str, p_parts in patterns:
202
+ if match_parts(p_parts, e_parts):
203
+ queue = self._queues.get(mid)
204
+ if queue:
205
+ if raw is None:
206
+ raw = _dumps(msg)
207
+ try:
208
+ queue.put_nowait(raw)
209
+ except asyncio.QueueFull:
210
+ await queue.put(raw)
211
+ self._cnt_queued += 1
212
+ break
213
+
214
+ # ── Helpers ──
215
+
216
+ @staticmethod
217
+ async def _send_ack(ws: WebSocket, event_id: str):
218
+ try:
219
+ await ws.send_text('{"type":"ack","event_id":"' + event_id + '"}')
220
+ except Exception:
221
+ pass
222
+
223
+ @staticmethod
224
+ async def _send_error(ws: WebSocket, message: str):
225
+ try:
226
+ await ws.send_json({"type": "error", "message": message})
227
+ except Exception:
228
+ pass
229
+
230
+ # ── Stats ──
231
+
232
+ def _counters_dict(self) -> dict:
233
+ return {
234
+ "events_received": self._cnt_received,
235
+ "events_routed": self._cnt_routed,
236
+ "events_queued": self._cnt_queued,
237
+ "events_deduplicated": self._cnt_dedup,
238
+ "errors": self._cnt_errors,
239
+ }
240
+
241
+ def get_stats(self) -> dict:
242
+ return {
243
+ "connections": dict(self.connection_info),
244
+ "subscriptions": {
245
+ mid: sorted(p[0] for p in patterns)
246
+ for mid, patterns in self.subscriptions.items()
247
+ },
248
+ "counters": self._counters_dict(),
249
+ }
250
+
251
+ def get_health(self) -> dict:
252
+ return {
253
+ "status": "healthy",
254
+ "details": {
255
+ "connections": len(self.connections),
256
+ "subscriptions": sum(
257
+ len(p) for p in self.subscriptions.values()
258
+ ),
259
+ "events_routed": self._cnt_routed,
260
+ "dedup_size": self.dedup.size,
261
+ "uptime_seconds": round(time.time() - self._start_time),
262
+ },
263
+ }
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: event_hub
3
+ display_name: Event Hub
4
+ version: "1.0"
5
+ type: infrastructure
6
+ state: enabled
7
+ runtime: python
8
+ entry: entry.py
9
+ events: []
10
+ subscriptions: []
11
+ test_mode: true
12
+ ---
13
+
14
+ # Event Hub
15
+
16
+ Kite 系统的实时事件路由器。
17
+
18
+ - 接收模块通过 WebSocket 发送的事件
19
+ - 根据订阅关系(支持 NATS 风格通配符)转发给匹配的模块
20
+ - 事件去重(1h 滑动窗口)防止 outbox 重放导致重复转发
21
+ - ACK 确认机制,模块据此清理本地 outbox
@@ -0,0 +1,21 @@
1
+ """
2
+ Subscription table management + NATS-style wildcard matching.
3
+ * matches exactly one segment, > matches one or more (tail only).
4
+ """
5
+
6
+
7
+ def match_event(pattern: str, event_type: str) -> bool:
8
+ """NATS-style: * matches one segment, > matches one or more (tail only)."""
9
+ return match_parts(tuple(pattern.split(".")), tuple(event_type.split(".")))
10
+
11
+
12
+ def match_parts(p_parts: tuple, e_parts: tuple) -> bool:
13
+ """Same logic as match_event but on pre-split tuples."""
14
+ for i, p in enumerate(p_parts):
15
+ if p == ">":
16
+ return i < len(e_parts)
17
+ if i >= len(e_parts):
18
+ return False
19
+ if p != "*" and p != e_parts[i]:
20
+ return False
21
+ return len(p_parts) == len(e_parts)
@@ -0,0 +1,138 @@
1
+ """
2
+ Event Hub HTTP + WebSocket server.
3
+ FastAPI app: /ws (WebSocket), /health, /stats.
4
+ 30s timer for heartbeat renewal + dedup cleanup.
5
+ """
6
+
7
+ import asyncio
8
+
9
+ import httpx
10
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
11
+
12
+ from .hub import EventHub
13
+
14
+
15
+ class EventHubServer:
16
+
17
+ def __init__(self, hub: EventHub, own_token: str, registry_url: str, test_mode: bool = False):
18
+ self.hub = hub
19
+ self.own_token = own_token
20
+ self.registry_url = registry_url
21
+ self.test_mode = test_mode
22
+ self._timer_task: asyncio.Task | None = None
23
+ self.app = self._create_app()
24
+
25
+ # ── Token verification via Registry ──
26
+
27
+ async def _verify_token(self, token: str, module_id_hint: str = "") -> str | None:
28
+ """Call Registry POST /verify to validate a module token. Returns module_id or None."""
29
+ if self.test_mode:
30
+ return module_id_hint or token[:32] or "test_module"
31
+ try:
32
+ async with httpx.AsyncClient() as client:
33
+ resp = await client.post(
34
+ f"{self.registry_url}/verify",
35
+ json={"token": token},
36
+ headers={"Authorization": f"Bearer {self.own_token}"},
37
+ timeout=5,
38
+ )
39
+ if resp.status_code == 200:
40
+ body = resp.json()
41
+ if body.get("ok"):
42
+ return body.get("module_id")
43
+ except Exception as e:
44
+ print(f"[event_hub] Token verification failed: {e}")
45
+ return None
46
+
47
+ # ── App factory ──
48
+
49
+ def _create_app(self) -> FastAPI:
50
+ app = FastAPI(title="Kite Event Hub", docs_url=None, redoc_url=None)
51
+ server = self
52
+
53
+ @app.on_event("startup")
54
+ async def _startup():
55
+ server._timer_task = asyncio.create_task(server._timer_loop())
56
+ server._test_task = asyncio.create_task(server._test_event_loop())
57
+
58
+ @app.on_event("shutdown")
59
+ async def _shutdown():
60
+ if server._timer_task:
61
+ server._timer_task.cancel()
62
+ if hasattr(server, '_test_task') and server._test_task:
63
+ server._test_task.cancel()
64
+
65
+ # ── WebSocket endpoint ──
66
+
67
+ @app.websocket("/ws")
68
+ async def ws_endpoint(ws: WebSocket):
69
+ token = ws.query_params.get("token", "")
70
+ mid_hint = ws.query_params.get("id", "")
71
+ module_id = await server._verify_token(token, mid_hint)
72
+ if not module_id:
73
+ await ws.close(code=4001, reason="Authentication failed")
74
+ return
75
+
76
+ await ws.accept()
77
+ server.hub.add_connection(module_id, ws)
78
+
79
+ try:
80
+ while True:
81
+ raw = await ws.receive_text()
82
+ await server.hub.handle_message(module_id, ws, raw)
83
+ except WebSocketDisconnect:
84
+ pass
85
+ except Exception as e:
86
+ print(f"[event_hub] WebSocket error for {module_id}: {e}")
87
+ finally:
88
+ server.hub.remove_connection(module_id)
89
+
90
+ # ── HTTP endpoints ──
91
+
92
+ @app.get("/health")
93
+ async def health():
94
+ return server.hub.get_health()
95
+
96
+ @app.get("/stats")
97
+ async def stats():
98
+ return server.hub.get_stats()
99
+
100
+ return app
101
+
102
+ # ── 30s timer: heartbeat + dedup cleanup ──
103
+
104
+ async def _timer_loop(self):
105
+ while True:
106
+ await asyncio.sleep(30)
107
+ # Dedup cleanup (offload to thread to avoid blocking event loop)
108
+ await asyncio.get_event_loop().run_in_executor(None, self.hub.dedup.cleanup)
109
+ # Heartbeat to Registry
110
+ await self._heartbeat()
111
+
112
+ async def _heartbeat(self):
113
+ try:
114
+ async with httpx.AsyncClient() as client:
115
+ await client.post(
116
+ f"{self.registry_url}/modules",
117
+ json={"action": "heartbeat", "module_id": "event_hub"},
118
+ headers={"Authorization": f"Bearer {self.own_token}"},
119
+ timeout=5,
120
+ )
121
+ except Exception:
122
+ pass
123
+
124
+ async def _test_event_loop(self):
125
+ """Publish a test event every 5 seconds via internal routing."""
126
+ import uuid
127
+ from datetime import datetime, timezone
128
+ while True:
129
+ await asyncio.sleep(5)
130
+ msg = {
131
+ "type": "event",
132
+ "event_id": str(uuid.uuid4()),
133
+ "event": "event_hub.test",
134
+ "source": "event_hub",
135
+ "timestamp": datetime.now(timezone.utc).isoformat(),
136
+ "data": {"message": "heartbeat from event_hub"},
137
+ }
138
+ await self.hub._route_event("event_hub", msg)