@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.
- package/__init__.py +1 -0
- package/__main__.py +15 -0
- package/cli.js +70 -0
- package/core/__init__.py +0 -0
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/event_hub/BENCHMARK.md +94 -0
- package/core/event_hub/__init__.py +0 -0
- package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
- package/core/event_hub/bench.py +459 -0
- package/core/event_hub/bench_extreme.py +308 -0
- package/core/event_hub/bench_perf.py +350 -0
- package/core/event_hub/bench_results/.gitkeep +0 -0
- package/core/event_hub/bench_results/2026-02-28_13-26-48.json +51 -0
- package/core/event_hub/bench_results/2026-02-28_13-44-45.json +51 -0
- package/core/event_hub/bench_results/2026-02-28_13-45-39.json +51 -0
- package/core/event_hub/dedup.py +31 -0
- package/core/event_hub/entry.py +113 -0
- package/core/event_hub/hub.py +263 -0
- package/core/event_hub/module.md +21 -0
- package/core/event_hub/router.py +21 -0
- package/core/event_hub/server.py +138 -0
- package/core/event_hub_bench/entry.py +371 -0
- package/core/event_hub_bench/module.md +25 -0
- package/core/launcher/__init__.py +0 -0
- package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
- package/core/launcher/data/log/lifecycle.jsonl +1045 -0
- package/core/launcher/data/processes_14752.json +32 -0
- package/core/launcher/data/token.txt +1 -0
- package/core/launcher/entry.py +965 -0
- package/core/launcher/module.md +37 -0
- package/core/launcher/module_scanner.py +253 -0
- package/core/launcher/process_manager.py +435 -0
- package/core/registry/__init__.py +0 -0
- package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
- package/core/registry/data/port.txt +1 -0
- package/core/registry/data/port_14752.txt +1 -0
- package/core/registry/data/port_484.txt +1 -0
- package/core/registry/entry.py +73 -0
- package/core/registry/module.md +30 -0
- package/core/registry/server.py +256 -0
- package/core/registry/store.py +232 -0
- package/extensions/__init__.py +0 -0
- package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/__init__.py +0 -0
- package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__init__.py +0 -0
- package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/server.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/entry.py +143 -0
- package/extensions/services/watchdog/module.md +25 -0
- package/extensions/services/watchdog/monitor.py +420 -0
- package/extensions/services/watchdog/server.py +167 -0
- package/main.py +17 -0
- 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)
|