@agentunion/kite 1.2.0 → 1.3.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/CHANGELOG.md +208 -0
- package/README.md +48 -0
- package/cli.js +1 -1
- package/extensions/agents/assistant/entry.py +30 -81
- package/extensions/agents/assistant/module.md +1 -1
- package/extensions/agents/assistant/server.py +83 -122
- package/extensions/channels/acp_channel/entry.py +30 -81
- package/extensions/channels/acp_channel/module.md +1 -1
- package/extensions/channels/acp_channel/server.py +83 -122
- package/extensions/event_hub_bench/entry.py +81 -121
- package/extensions/services/backup/entry.py +213 -85
- package/extensions/services/model_service/entry.py +213 -85
- package/extensions/services/watchdog/entry.py +513 -460
- package/extensions/services/watchdog/monitor.py +55 -69
- package/extensions/services/web/entry.py +11 -108
- package/extensions/services/web/server.py +120 -77
- package/{core/registry → kernel}/entry.py +65 -37
- package/{core/event_hub/hub.py → kernel/event_hub.py} +61 -81
- package/kernel/module.md +33 -0
- package/{core/registry/store.py → kernel/registry_store.py} +13 -4
- package/kernel/rpc_router.py +388 -0
- package/kernel/server.py +267 -0
- package/launcher/__init__.py +10 -0
- package/launcher/__main__.py +6 -0
- package/launcher/count_lines.py +258 -0
- package/{core/launcher → launcher}/entry.py +693 -767
- package/launcher/logging_setup.py +289 -0
- package/{core/launcher → launcher}/module_scanner.py +11 -6
- package/main.py +11 -350
- package/package.json +6 -9
- package/__init__.py +0 -1
- package/__main__.py +0 -15
- package/core/event_hub/BENCHMARK.md +0 -94
- package/core/event_hub/__init__.py +0 -0
- package/core/event_hub/bench.py +0 -459
- package/core/event_hub/bench_extreme.py +0 -308
- package/core/event_hub/bench_perf.py +0 -350
- package/core/event_hub/entry.py +0 -436
- package/core/event_hub/module.md +0 -20
- package/core/event_hub/server.py +0 -269
- package/core/kite_log.py +0 -241
- package/core/launcher/__init__.py +0 -0
- package/core/registry/__init__.py +0 -0
- package/core/registry/module.md +0 -30
- package/core/registry/server.py +0 -339
- package/extensions/services/backup/server.py +0 -244
- package/extensions/services/model_service/server.py +0 -236
- package/extensions/services/watchdog/server.py +0 -229
- /package/{core → kernel}/__init__.py +0 -0
- /package/{core/event_hub → kernel}/dedup.py +0 -0
- /package/{core/event_hub → kernel}/router.py +0 -0
- /package/{core/launcher → launcher}/module.md +0 -0
- /package/{core/launcher → launcher}/process_manager.py +0 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JSON-RPC 2.0 message dispatcher for Kernel.
|
|
3
|
+
Routes builtin methods (registry.*, event.*, kernel.*) and forwards
|
|
4
|
+
cross-module RPC requests with timeout tracking.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
from starlette.websockets import WebSocket
|
|
15
|
+
|
|
16
|
+
from .registry_store import RegistryStore
|
|
17
|
+
from .event_hub import EventHub
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── JSON-RPC 2.0 error codes ──
|
|
21
|
+
|
|
22
|
+
PARSE_ERROR = -32700
|
|
23
|
+
INVALID_REQUEST = -32600
|
|
24
|
+
METHOD_NOT_FOUND = -32601
|
|
25
|
+
INVALID_PARAMS = -32602
|
|
26
|
+
INTERNAL_ERROR = -32603
|
|
27
|
+
|
|
28
|
+
# Kite custom error codes
|
|
29
|
+
MODULE_OFFLINE = -32001
|
|
30
|
+
RPC_TIMEOUT = -32002
|
|
31
|
+
AUTH_FAILED = -32003
|
|
32
|
+
PERMISSION_DENIED = -32004
|
|
33
|
+
DUPLICATE_EVENT = -32005
|
|
34
|
+
|
|
35
|
+
DEFAULT_FORWARD_TIMEOUT = 5.0 # seconds
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class PendingForward:
|
|
40
|
+
"""Tracks a forwarded cross-module RPC awaiting response."""
|
|
41
|
+
caller_ws: WebSocket
|
|
42
|
+
original_id: str
|
|
43
|
+
caller_id: str
|
|
44
|
+
target_id: str
|
|
45
|
+
created_at: float = field(default_factory=time.time)
|
|
46
|
+
timeout_handle: asyncio.TimerHandle | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _result_msg(msg_id: str, result: dict) -> str:
|
|
50
|
+
return json.dumps({"jsonrpc": "2.0", "id": msg_id, "result": result})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _error_msg(msg_id: str, code: int, message: str, data: dict = None) -> str:
|
|
54
|
+
err = {"code": code, "message": message}
|
|
55
|
+
if data:
|
|
56
|
+
err["data"] = data
|
|
57
|
+
return json.dumps({"jsonrpc": "2.0", "id": msg_id, "error": err})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RpcRouter:
|
|
61
|
+
"""JSON-RPC 2.0 message dispatcher.
|
|
62
|
+
|
|
63
|
+
Handles:
|
|
64
|
+
- 13 builtin methods (registry.*, event.*, kernel.*)
|
|
65
|
+
- Cross-module RPC forwarding with timeout
|
|
66
|
+
- RPC response matching for forwarded calls
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, registry: RegistryStore, event_hub: EventHub,
|
|
70
|
+
connections: dict[str, WebSocket], kernel_server):
|
|
71
|
+
self.registry = registry
|
|
72
|
+
self.event_hub = event_hub
|
|
73
|
+
self.connections = connections
|
|
74
|
+
self.kernel_server = kernel_server # Direct reference to KernelServer
|
|
75
|
+
|
|
76
|
+
# Builtin method dispatch table
|
|
77
|
+
self.methods: dict[str, callable] = {
|
|
78
|
+
"registry.register": self._registry_register,
|
|
79
|
+
"registry.deregister": self._registry_deregister,
|
|
80
|
+
"registry.heartbeat": self._registry_heartbeat,
|
|
81
|
+
"registry.lookup": self._registry_lookup,
|
|
82
|
+
"registry.get": self._registry_get,
|
|
83
|
+
"registry.verify": self._registry_verify,
|
|
84
|
+
"event.publish": self._event_publish,
|
|
85
|
+
"event.subscribe": self._event_subscribe,
|
|
86
|
+
"event.unsubscribe": self._event_unsubscribe,
|
|
87
|
+
"kernel.ping": self._kernel_ping,
|
|
88
|
+
"kernel.stats": self._kernel_stats,
|
|
89
|
+
"kernel.health": self._kernel_health,
|
|
90
|
+
"kernel.generate_tokens": self._kernel_generate_tokens,
|
|
91
|
+
"kernel.register_tokens": self._kernel_register_tokens,
|
|
92
|
+
"kernel.shutdown": self._kernel_shutdown,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Pending cross-module forwards: internal_id -> PendingForward
|
|
96
|
+
self._pending: dict[str, PendingForward] = {}
|
|
97
|
+
|
|
98
|
+
# ── Main dispatch ──
|
|
99
|
+
|
|
100
|
+
async def dispatch(self, caller_id: str, ws: WebSocket, msg: dict):
|
|
101
|
+
"""Route a parsed JSON-RPC message to builtin handler or cross-module forward."""
|
|
102
|
+
method = msg.get("method", "")
|
|
103
|
+
msg_id = msg.get("id")
|
|
104
|
+
|
|
105
|
+
# JSON-RPC Notification (no id) — currently not handled from clients
|
|
106
|
+
if msg_id is None:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
params = msg.get("params") or {}
|
|
110
|
+
|
|
111
|
+
# Builtin method
|
|
112
|
+
handler = self.methods.get(method)
|
|
113
|
+
if handler:
|
|
114
|
+
try:
|
|
115
|
+
result = await handler(caller_id, params)
|
|
116
|
+
await ws.send_text(_result_msg(msg_id, result))
|
|
117
|
+
except Exception as e:
|
|
118
|
+
print(f"[kernel] RPC handler error ({method}): {e}")
|
|
119
|
+
await ws.send_text(_error_msg(msg_id, INTERNAL_ERROR, str(e)))
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# Cross-module forward: method prefix is target module_id
|
|
123
|
+
dot_idx = method.find(".")
|
|
124
|
+
if dot_idx > 0:
|
|
125
|
+
target = method[:dot_idx]
|
|
126
|
+
if target in self.connections:
|
|
127
|
+
await self._forward(caller_id, ws, msg_id, target, method, params)
|
|
128
|
+
return
|
|
129
|
+
# Target not connected — check if registered but offline
|
|
130
|
+
if target in self.registry.modules:
|
|
131
|
+
await ws.send_text(_error_msg(
|
|
132
|
+
msg_id, MODULE_OFFLINE, f"Module offline: {target}"))
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
# Method not found
|
|
136
|
+
await ws.send_text(_error_msg(msg_id, METHOD_NOT_FOUND, f"Method not found: {method}"))
|
|
137
|
+
|
|
138
|
+
async def handle_response(self, module_id: str, msg: dict):
|
|
139
|
+
"""Handle an RPC response from a module (matches pending forwards)."""
|
|
140
|
+
msg_id = msg.get("id")
|
|
141
|
+
if not msg_id:
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
pending = self._pending.pop(msg_id, None)
|
|
145
|
+
if not pending:
|
|
146
|
+
return # orphan response, ignore
|
|
147
|
+
|
|
148
|
+
# Cancel timeout
|
|
149
|
+
if pending.timeout_handle:
|
|
150
|
+
pending.timeout_handle.cancel()
|
|
151
|
+
|
|
152
|
+
# Forward response to original caller with original ID
|
|
153
|
+
response = {"jsonrpc": "2.0", "id": pending.original_id}
|
|
154
|
+
if "result" in msg:
|
|
155
|
+
response["result"] = msg["result"]
|
|
156
|
+
elif "error" in msg:
|
|
157
|
+
response["error"] = msg["error"]
|
|
158
|
+
else:
|
|
159
|
+
response["result"] = None
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
await pending.caller_ws.send_text(json.dumps(response))
|
|
163
|
+
except Exception:
|
|
164
|
+
pass # caller disconnected
|
|
165
|
+
|
|
166
|
+
# ── Cross-module forwarding ──
|
|
167
|
+
|
|
168
|
+
async def _forward(self, caller_id: str, caller_ws: WebSocket,
|
|
169
|
+
original_id: str, target: str, method: str, params: dict):
|
|
170
|
+
"""Forward RPC request to target module with timeout tracking."""
|
|
171
|
+
internal_id = f"fwd-{uuid.uuid4().hex[:12]}"
|
|
172
|
+
|
|
173
|
+
# Extract timeout from params (optional _timeout field)
|
|
174
|
+
timeout = DEFAULT_FORWARD_TIMEOUT
|
|
175
|
+
if isinstance(params, dict) and "_timeout" in params:
|
|
176
|
+
try:
|
|
177
|
+
timeout = float(params.pop("_timeout"))
|
|
178
|
+
except (ValueError, TypeError):
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
# Strip target prefix from method for the forwarded request
|
|
182
|
+
actual_method = method[len(target) + 1:] # e.g. "watchdog.get_status" -> "get_status"
|
|
183
|
+
|
|
184
|
+
# Record pending forward
|
|
185
|
+
loop = asyncio.get_event_loop()
|
|
186
|
+
pending = PendingForward(
|
|
187
|
+
caller_ws=caller_ws,
|
|
188
|
+
original_id=original_id,
|
|
189
|
+
caller_id=caller_id,
|
|
190
|
+
target_id=target,
|
|
191
|
+
)
|
|
192
|
+
pending.timeout_handle = loop.call_later(
|
|
193
|
+
timeout, lambda iid=internal_id: asyncio.ensure_future(self._handle_timeout(iid))
|
|
194
|
+
)
|
|
195
|
+
self._pending[internal_id] = pending
|
|
196
|
+
|
|
197
|
+
# Send to target
|
|
198
|
+
fwd_msg = json.dumps({
|
|
199
|
+
"jsonrpc": "2.0",
|
|
200
|
+
"id": internal_id,
|
|
201
|
+
"method": actual_method,
|
|
202
|
+
"params": params or {},
|
|
203
|
+
})
|
|
204
|
+
target_ws = self.connections.get(target)
|
|
205
|
+
if target_ws:
|
|
206
|
+
try:
|
|
207
|
+
await target_ws.send_text(fwd_msg)
|
|
208
|
+
except Exception:
|
|
209
|
+
# Target send failed — clean up and error to caller
|
|
210
|
+
self._pending.pop(internal_id, None)
|
|
211
|
+
if pending.timeout_handle:
|
|
212
|
+
pending.timeout_handle.cancel()
|
|
213
|
+
await caller_ws.send_text(_error_msg(
|
|
214
|
+
original_id, MODULE_OFFLINE, f"Failed to reach module: {target}"))
|
|
215
|
+
else:
|
|
216
|
+
# Target disconnected between check and send
|
|
217
|
+
self._pending.pop(internal_id, None)
|
|
218
|
+
if pending.timeout_handle:
|
|
219
|
+
pending.timeout_handle.cancel()
|
|
220
|
+
await caller_ws.send_text(_error_msg(
|
|
221
|
+
original_id, MODULE_OFFLINE, f"Module offline: {target}"))
|
|
222
|
+
|
|
223
|
+
async def _handle_timeout(self, internal_id: str):
|
|
224
|
+
"""Called when a forwarded RPC times out."""
|
|
225
|
+
pending = self._pending.pop(internal_id, None)
|
|
226
|
+
if not pending:
|
|
227
|
+
return
|
|
228
|
+
try:
|
|
229
|
+
await pending.caller_ws.send_text(_error_msg(
|
|
230
|
+
pending.original_id, RPC_TIMEOUT,
|
|
231
|
+
f"RPC timeout waiting for {pending.target_id}"))
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
# ── Builtin handlers: registry.* ──
|
|
236
|
+
|
|
237
|
+
async def _registry_register(self, caller_id: str, params: dict) -> dict:
|
|
238
|
+
mid = params.get("module_id")
|
|
239
|
+
if not mid:
|
|
240
|
+
return {"ok": False, "error": "module_id required"}
|
|
241
|
+
# Permission: only Launcher or the module itself
|
|
242
|
+
if caller_id != "launcher" and caller_id != mid:
|
|
243
|
+
return {"ok": False, "error": f"Module '{caller_id}' cannot register as '{mid}'"}
|
|
244
|
+
result = self.registry.register_module(params)
|
|
245
|
+
if result.get("ok"):
|
|
246
|
+
self.event_hub.publish_internal("module.registered", {"module_id": mid})
|
|
247
|
+
|
|
248
|
+
# When Launcher registers, Kernel publishes its own module.ready
|
|
249
|
+
if mid == "launcher" and self.kernel_server:
|
|
250
|
+
if not self.kernel_server._ready_published:
|
|
251
|
+
self.kernel_server.publish_ready()
|
|
252
|
+
self.kernel_server._ready_published = True
|
|
253
|
+
print(f"[kernel] launcher registered → kernel module.ready published")
|
|
254
|
+
return result
|
|
255
|
+
|
|
256
|
+
async def _registry_deregister(self, caller_id: str, params: dict) -> dict:
|
|
257
|
+
mid = params.get("module_id")
|
|
258
|
+
if not mid:
|
|
259
|
+
return {"ok": False, "error": "module_id required"}
|
|
260
|
+
if caller_id != "launcher" and caller_id != mid:
|
|
261
|
+
return {"ok": False, "error": f"Module '{caller_id}' cannot deregister '{mid}'"}
|
|
262
|
+
result = self.registry.deregister_module(mid)
|
|
263
|
+
if result.get("ok"):
|
|
264
|
+
self.event_hub.publish_internal("module.unregistered", {"module_id": mid})
|
|
265
|
+
return result
|
|
266
|
+
|
|
267
|
+
async def _registry_heartbeat(self, caller_id: str, params: dict) -> dict:
|
|
268
|
+
mid = params.get("module_id")
|
|
269
|
+
if not mid:
|
|
270
|
+
return {"ok": False, "error": "module_id required"}
|
|
271
|
+
if caller_id != "launcher" and caller_id != mid:
|
|
272
|
+
return {"ok": False, "error": f"Module '{caller_id}' cannot heartbeat for '{mid}'"}
|
|
273
|
+
return self.registry.heartbeat(mid)
|
|
274
|
+
|
|
275
|
+
async def _registry_lookup(self, caller_id: str, params: dict) -> dict:
|
|
276
|
+
results = self.registry.lookup(
|
|
277
|
+
field=params.get("field"),
|
|
278
|
+
module=params.get("module"),
|
|
279
|
+
value=params.get("value"),
|
|
280
|
+
)
|
|
281
|
+
return {"ok": True, "results": results}
|
|
282
|
+
|
|
283
|
+
async def _registry_get(self, caller_id: str, params: dict) -> dict:
|
|
284
|
+
path = params.get("path", "")
|
|
285
|
+
if not path:
|
|
286
|
+
return {"ok": False, "error": "path required"}
|
|
287
|
+
val, found = self.registry.get_by_path(path)
|
|
288
|
+
if not found:
|
|
289
|
+
return {"ok": False, "error": f"Path not found: {path}"}
|
|
290
|
+
return {"ok": True, "value": val}
|
|
291
|
+
|
|
292
|
+
async def _registry_verify(self, caller_id: str, params: dict) -> dict:
|
|
293
|
+
token = params.get("token", "")
|
|
294
|
+
module_id = self.registry.verify_token(token)
|
|
295
|
+
if module_id:
|
|
296
|
+
return {"ok": True, "module_id": module_id}
|
|
297
|
+
return {"ok": False}
|
|
298
|
+
|
|
299
|
+
# ── Builtin handlers: event.* ──
|
|
300
|
+
|
|
301
|
+
async def _event_publish(self, caller_id: str, params: dict) -> dict:
|
|
302
|
+
event_id = params.get("event_id", "")
|
|
303
|
+
event_type = params.get("event", "")
|
|
304
|
+
data = params.get("data")
|
|
305
|
+
echo = params.get("echo", False)
|
|
306
|
+
return self.event_hub.publish_event(caller_id, event_id, event_type, data, echo)
|
|
307
|
+
|
|
308
|
+
async def _event_subscribe(self, caller_id: str, params: dict) -> dict:
|
|
309
|
+
events = params.get("events", [])
|
|
310
|
+
if not isinstance(events, list) or not events:
|
|
311
|
+
return {"ok": False, "error": "events must be a non-empty list"}
|
|
312
|
+
self.event_hub.handle_subscribe(caller_id, events)
|
|
313
|
+
return {"ok": True}
|
|
314
|
+
|
|
315
|
+
async def _event_unsubscribe(self, caller_id: str, params: dict) -> dict:
|
|
316
|
+
events = params.get("events", [])
|
|
317
|
+
if not isinstance(events, list) or not events:
|
|
318
|
+
return {"ok": False, "error": "events must be a non-empty list"}
|
|
319
|
+
self.event_hub.handle_unsubscribe(caller_id, events)
|
|
320
|
+
return {"ok": True}
|
|
321
|
+
|
|
322
|
+
# ── Builtin handlers: kernel.* ──
|
|
323
|
+
|
|
324
|
+
async def _kernel_ping(self, caller_id: str, params: dict) -> dict:
|
|
325
|
+
return {"pong": True, "timestamp": datetime.now(timezone.utc).isoformat()}
|
|
326
|
+
|
|
327
|
+
async def _kernel_stats(self, caller_id: str, params: dict) -> dict:
|
|
328
|
+
return self.event_hub.get_stats()
|
|
329
|
+
|
|
330
|
+
async def _kernel_health(self, caller_id: str, params: dict) -> dict:
|
|
331
|
+
eh_health = self.event_hub.get_health()
|
|
332
|
+
return {
|
|
333
|
+
"status": "healthy",
|
|
334
|
+
"module_count": len(self.registry.modules),
|
|
335
|
+
"online_count": sum(
|
|
336
|
+
1 for m in self.registry.modules.values()
|
|
337
|
+
if m.get("status") == "online"
|
|
338
|
+
),
|
|
339
|
+
"event_stats": eh_health.get("details", {}),
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async def _kernel_generate_tokens(self, caller_id: str, params: dict) -> dict:
|
|
343
|
+
"""Generate tokens for a list of module names.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
params: {"modules": ["mod1", "mod2", ...]}
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
{"ok": True, "tokens": {"mod1": "token1", "mod2": "token2", ...}}
|
|
350
|
+
"""
|
|
351
|
+
# Only Launcher may request token generation
|
|
352
|
+
if caller_id != "launcher":
|
|
353
|
+
return {"ok": False, "error": "Only Launcher may generate tokens"}
|
|
354
|
+
|
|
355
|
+
modules = params.get("modules", [])
|
|
356
|
+
if not isinstance(modules, list):
|
|
357
|
+
return {"ok": False, "error": "modules must be a list"}
|
|
358
|
+
|
|
359
|
+
import secrets
|
|
360
|
+
tokens = {}
|
|
361
|
+
for module_name in modules:
|
|
362
|
+
tokens[module_name] = secrets.token_hex(32)
|
|
363
|
+
|
|
364
|
+
# Register tokens in registry
|
|
365
|
+
self.registry.register_tokens(tokens)
|
|
366
|
+
|
|
367
|
+
return {"ok": True, "tokens": tokens}
|
|
368
|
+
|
|
369
|
+
async def _kernel_register_tokens(self, caller_id: str, params: dict) -> dict:
|
|
370
|
+
# Only Launcher may register tokens
|
|
371
|
+
if caller_id != "launcher":
|
|
372
|
+
return {"ok": False, "error": "Only Launcher may register tokens"}
|
|
373
|
+
self.registry.register_tokens(params)
|
|
374
|
+
return {"ok": True}
|
|
375
|
+
|
|
376
|
+
async def _kernel_shutdown(self, caller_id: str, params: dict) -> dict:
|
|
377
|
+
"""Shutdown Kernel. Only Launcher may call this."""
|
|
378
|
+
if caller_id != "launcher":
|
|
379
|
+
return {"ok": False, "error": "Only Launcher may shutdown Kernel"}
|
|
380
|
+
|
|
381
|
+
print("[kernel] Received shutdown request from Launcher")
|
|
382
|
+
|
|
383
|
+
# Schedule shutdown (don't block RPC response)
|
|
384
|
+
if self.kernel_server:
|
|
385
|
+
asyncio.create_task(self.kernel_server.shutdown())
|
|
386
|
+
|
|
387
|
+
return {"ok": True}
|
|
388
|
+
|
package/kernel/server.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kernel unified server.
|
|
3
|
+
FastAPI app with WebSocket endpoint (RPC + Event) and minimal HTTP endpoints (/health, /stats).
|
|
4
|
+
Merges Registry + Event Hub into a single process.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI
|
|
12
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
13
|
+
|
|
14
|
+
from .registry_store import RegistryStore
|
|
15
|
+
from .event_hub import EventHub
|
|
16
|
+
from .rpc_router import RpcRouter
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import orjson
|
|
20
|
+
def _loads(raw: str):
|
|
21
|
+
return orjson.loads(raw)
|
|
22
|
+
except ImportError:
|
|
23
|
+
def _loads(raw: str):
|
|
24
|
+
return json.loads(raw)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class KernelServer:
|
|
28
|
+
"""Merged Registry + Event Hub server.
|
|
29
|
+
|
|
30
|
+
Single WebSocket endpoint handles:
|
|
31
|
+
- JSON-RPC 2.0 requests (builtin + cross-module forward)
|
|
32
|
+
- JSON-RPC 2.0 responses (from forwarded calls)
|
|
33
|
+
- Event notifications (delivered to subscribers)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, launcher_token: str = None, advertise_ip: str = "127.0.0.1"):
|
|
37
|
+
self.advertise_ip = advertise_ip
|
|
38
|
+
self.port: int = 0 # set by entry.py before uvicorn.run
|
|
39
|
+
|
|
40
|
+
# Core components
|
|
41
|
+
self.registry = RegistryStore(launcher_token) # Can be None
|
|
42
|
+
self.event_hub = EventHub()
|
|
43
|
+
|
|
44
|
+
# Shared connection table (module_id -> WebSocket)
|
|
45
|
+
# RpcRouter and EventHub both reference this
|
|
46
|
+
self.connections: dict[str, WebSocket] = {}
|
|
47
|
+
|
|
48
|
+
# RPC router (pass self reference)
|
|
49
|
+
self.rpc_router = RpcRouter(
|
|
50
|
+
self.registry,
|
|
51
|
+
self.event_hub,
|
|
52
|
+
self.connections,
|
|
53
|
+
kernel_server=self
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Background tasks
|
|
57
|
+
self._ttl_task: asyncio.Task | None = None
|
|
58
|
+
self._dedup_task: asyncio.Task | None = None
|
|
59
|
+
self._uvicorn_server = None # set by entry.py for graceful shutdown
|
|
60
|
+
self._shutting_down = False
|
|
61
|
+
|
|
62
|
+
# Launcher connection tracking
|
|
63
|
+
self._launcher_connected = False
|
|
64
|
+
self._launcher_subscribed = False
|
|
65
|
+
self._ready_published = False
|
|
66
|
+
|
|
67
|
+
# Build FastAPI app
|
|
68
|
+
self.app = self._create_app()
|
|
69
|
+
|
|
70
|
+
# ── App factory ──
|
|
71
|
+
|
|
72
|
+
def _create_app(self) -> FastAPI:
|
|
73
|
+
app = FastAPI(title="Kite Kernel", docs_url=None, redoc_url=None)
|
|
74
|
+
server = self
|
|
75
|
+
|
|
76
|
+
@app.on_event("startup")
|
|
77
|
+
async def _startup():
|
|
78
|
+
server._ttl_task = asyncio.create_task(server._ttl_loop())
|
|
79
|
+
server._dedup_task = asyncio.create_task(server._dedup_loop())
|
|
80
|
+
|
|
81
|
+
@app.on_event("shutdown")
|
|
82
|
+
async def _shutdown():
|
|
83
|
+
if server._ttl_task:
|
|
84
|
+
server._ttl_task.cancel()
|
|
85
|
+
if server._dedup_task:
|
|
86
|
+
server._dedup_task.cancel()
|
|
87
|
+
|
|
88
|
+
# ── WebSocket endpoint ──
|
|
89
|
+
|
|
90
|
+
@app.websocket("/ws")
|
|
91
|
+
async def ws_endpoint(ws: WebSocket):
|
|
92
|
+
token = ws.query_params.get("token", "")
|
|
93
|
+
mid_hint = ws.query_params.get("id", "")
|
|
94
|
+
|
|
95
|
+
# Token verification (all modules including Launcher need token)
|
|
96
|
+
module_id = server.registry.verify_token(token)
|
|
97
|
+
if module_id is None:
|
|
98
|
+
# Must accept before close (Starlette requirement)
|
|
99
|
+
await ws.accept()
|
|
100
|
+
print(f"[kernel] Auth failed: token={token[:8]}... hint={mid_hint}")
|
|
101
|
+
try:
|
|
102
|
+
await ws.close(code=4001, reason="Authentication failed")
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
# Use id hint for debug mode
|
|
108
|
+
if module_id == "debug" and mid_hint:
|
|
109
|
+
module_id = mid_hint
|
|
110
|
+
|
|
111
|
+
await ws.accept()
|
|
112
|
+
|
|
113
|
+
# Register connection in both EventHub and shared connections table
|
|
114
|
+
server.event_hub.add_connection(module_id, ws)
|
|
115
|
+
server.connections[module_id] = ws
|
|
116
|
+
|
|
117
|
+
# Track Launcher connection
|
|
118
|
+
if module_id == "launcher":
|
|
119
|
+
server._launcher_connected = True
|
|
120
|
+
print(f"[kernel] launcher connected")
|
|
121
|
+
|
|
122
|
+
# Renew heartbeat on connect
|
|
123
|
+
if module_id in server.registry.modules:
|
|
124
|
+
server.registry.heartbeat(module_id)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
while True:
|
|
128
|
+
raw = await ws.receive_text()
|
|
129
|
+
try:
|
|
130
|
+
msg = _loads(raw)
|
|
131
|
+
except Exception:
|
|
132
|
+
# JSON parse error — send error if there's an id
|
|
133
|
+
try:
|
|
134
|
+
await ws.send_text(json.dumps({
|
|
135
|
+
"jsonrpc": "2.0", "id": None,
|
|
136
|
+
"error": {"code": -32700, "message": "Parse error"}
|
|
137
|
+
}))
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if not isinstance(msg, dict):
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
# Classify message type:
|
|
146
|
+
has_method = "method" in msg
|
|
147
|
+
has_id = "id" in msg
|
|
148
|
+
has_result = "result" in msg
|
|
149
|
+
has_error = "error" in msg
|
|
150
|
+
|
|
151
|
+
if has_method and has_id:
|
|
152
|
+
# RPC Request → dispatch to handler
|
|
153
|
+
await server.rpc_router.dispatch(module_id, ws, msg)
|
|
154
|
+
elif has_id and (has_result or has_error):
|
|
155
|
+
# RPC Response → match to pending forward
|
|
156
|
+
await server.rpc_router.handle_response(module_id, msg)
|
|
157
|
+
# else: notification or unknown — ignore
|
|
158
|
+
|
|
159
|
+
except WebSocketDisconnect:
|
|
160
|
+
pass
|
|
161
|
+
except Exception as e:
|
|
162
|
+
err = str(e).lower()
|
|
163
|
+
if "not connected" not in err and "closed" not in err:
|
|
164
|
+
print(f"[kernel] WebSocket error for {module_id}: {e}")
|
|
165
|
+
finally:
|
|
166
|
+
# Cleanup
|
|
167
|
+
server.event_hub.remove_connection(module_id)
|
|
168
|
+
server.connections.pop(module_id, None)
|
|
169
|
+
server.registry.set_offline(module_id)
|
|
170
|
+
server.event_hub.publish_internal(
|
|
171
|
+
"module.offline", {"module_id": module_id})
|
|
172
|
+
|
|
173
|
+
# ── HTTP endpoints (debug only) ──
|
|
174
|
+
|
|
175
|
+
@app.get("/health")
|
|
176
|
+
async def health():
|
|
177
|
+
eh_health = server.event_hub.get_health()
|
|
178
|
+
return {
|
|
179
|
+
"status": "healthy",
|
|
180
|
+
"module_count": len(server.registry.modules),
|
|
181
|
+
"online_count": sum(
|
|
182
|
+
1 for m in server.registry.modules.values()
|
|
183
|
+
if m.get("status") == "online"
|
|
184
|
+
),
|
|
185
|
+
"event_stats": eh_health.get("details", {}),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@app.get("/stats")
|
|
189
|
+
async def stats():
|
|
190
|
+
return {
|
|
191
|
+
"registry": {
|
|
192
|
+
"modules": {
|
|
193
|
+
mid: {
|
|
194
|
+
"status": data.get("status"),
|
|
195
|
+
"module_type": data.get("module_type"),
|
|
196
|
+
"registered_at": data.get("registered_at"),
|
|
197
|
+
}
|
|
198
|
+
for mid, data in server.registry.modules.items()
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
"event_hub": server.event_hub.get_stats(),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return app
|
|
205
|
+
|
|
206
|
+
# ── Background loops ──
|
|
207
|
+
|
|
208
|
+
async def _ttl_loop(self):
|
|
209
|
+
"""Check heartbeat TTL every 10s and publish offline events."""
|
|
210
|
+
while True:
|
|
211
|
+
await asyncio.sleep(10)
|
|
212
|
+
try:
|
|
213
|
+
expired = self.registry.check_ttl()
|
|
214
|
+
for mid in expired:
|
|
215
|
+
self.event_hub.publish_internal(
|
|
216
|
+
"module.offline", {"module_id": mid})
|
|
217
|
+
except Exception as e:
|
|
218
|
+
print(f"[kernel] TTL loop error: {e}")
|
|
219
|
+
|
|
220
|
+
async def _dedup_loop(self):
|
|
221
|
+
"""Clean up dedup table every 30s."""
|
|
222
|
+
while True:
|
|
223
|
+
await asyncio.sleep(30)
|
|
224
|
+
try:
|
|
225
|
+
await asyncio.get_event_loop().run_in_executor(
|
|
226
|
+
None, self.event_hub.dedup.cleanup)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
print(f"[kernel] Dedup cleanup error: {e}")
|
|
229
|
+
|
|
230
|
+
# ── Self-registration ──
|
|
231
|
+
|
|
232
|
+
def self_register(self):
|
|
233
|
+
"""Register Kernel itself in the registry (in-memory, no RPC needed)."""
|
|
234
|
+
self.registry.register_module({
|
|
235
|
+
"module_id": "kernel",
|
|
236
|
+
"module_type": "infrastructure",
|
|
237
|
+
"api_endpoint": f"http://{self.advertise_ip}:{self.port}",
|
|
238
|
+
"health_endpoint": "/health",
|
|
239
|
+
"metadata": {
|
|
240
|
+
"ws_endpoint": f"ws://{self.advertise_ip}:{self.port}/ws",
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
def publish_ready(self):
|
|
245
|
+
"""Publish module.ready event for Kernel (internal, no WS needed)."""
|
|
246
|
+
self.event_hub.publish_internal("module.ready", {
|
|
247
|
+
"module_id": "kernel",
|
|
248
|
+
"ws_endpoint": f"ws://{self.advertise_ip}:{self.port}/ws",
|
|
249
|
+
"graceful_shutdown": True,
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
async def shutdown(self):
|
|
253
|
+
"""Shutdown Kernel gracefully. Called by Launcher via RPC."""
|
|
254
|
+
if self._shutting_down:
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
self._shutting_down = True
|
|
258
|
+
print("[kernel] Shutting down...")
|
|
259
|
+
|
|
260
|
+
# Brief delay to ensure RPC response is sent
|
|
261
|
+
await asyncio.sleep(0.1)
|
|
262
|
+
|
|
263
|
+
# Trigger uvicorn shutdown
|
|
264
|
+
if self._uvicorn_server:
|
|
265
|
+
self._uvicorn_server.should_exit = True
|
|
266
|
+
else:
|
|
267
|
+
print("[kernel] Warning: uvicorn server reference not set")
|