@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +48 -0
  3. package/cli.js +1 -1
  4. package/extensions/agents/assistant/entry.py +30 -81
  5. package/extensions/agents/assistant/module.md +1 -1
  6. package/extensions/agents/assistant/server.py +83 -122
  7. package/extensions/channels/acp_channel/entry.py +30 -81
  8. package/extensions/channels/acp_channel/module.md +1 -1
  9. package/extensions/channels/acp_channel/server.py +83 -122
  10. package/extensions/event_hub_bench/entry.py +81 -121
  11. package/extensions/services/backup/entry.py +213 -85
  12. package/extensions/services/model_service/entry.py +213 -85
  13. package/extensions/services/watchdog/entry.py +513 -460
  14. package/extensions/services/watchdog/monitor.py +55 -69
  15. package/extensions/services/web/entry.py +11 -108
  16. package/extensions/services/web/server.py +120 -77
  17. package/{core/registry → kernel}/entry.py +65 -37
  18. package/{core/event_hub/hub.py → kernel/event_hub.py} +61 -81
  19. package/kernel/module.md +33 -0
  20. package/{core/registry/store.py → kernel/registry_store.py} +13 -4
  21. package/kernel/rpc_router.py +388 -0
  22. package/kernel/server.py +267 -0
  23. package/launcher/__init__.py +10 -0
  24. package/launcher/__main__.py +6 -0
  25. package/launcher/count_lines.py +258 -0
  26. package/{core/launcher → launcher}/entry.py +693 -767
  27. package/launcher/logging_setup.py +289 -0
  28. package/{core/launcher → launcher}/module_scanner.py +11 -6
  29. package/main.py +11 -350
  30. package/package.json +6 -9
  31. package/__init__.py +0 -1
  32. package/__main__.py +0 -15
  33. package/core/event_hub/BENCHMARK.md +0 -94
  34. package/core/event_hub/__init__.py +0 -0
  35. package/core/event_hub/bench.py +0 -459
  36. package/core/event_hub/bench_extreme.py +0 -308
  37. package/core/event_hub/bench_perf.py +0 -350
  38. package/core/event_hub/entry.py +0 -436
  39. package/core/event_hub/module.md +0 -20
  40. package/core/event_hub/server.py +0 -269
  41. package/core/kite_log.py +0 -241
  42. package/core/launcher/__init__.py +0 -0
  43. package/core/registry/__init__.py +0 -0
  44. package/core/registry/module.md +0 -30
  45. package/core/registry/server.py +0 -339
  46. package/extensions/services/backup/server.py +0 -244
  47. package/extensions/services/model_service/server.py +0 -236
  48. package/extensions/services/watchdog/server.py +0 -229
  49. /package/{core → kernel}/__init__.py +0 -0
  50. /package/{core/event_hub → kernel}/dedup.py +0 -0
  51. /package/{core/event_hub → kernel}/router.py +0 -0
  52. /package/{core/launcher → launcher}/module.md +0 -0
  53. /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
+
@@ -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")
@@ -0,0 +1,10 @@
1
+ """
2
+ Launcher package — Kite system entry point.
3
+
4
+ Can be started in two ways:
5
+ 1. Via main.py (with code stats): python main.py
6
+ 2. Directly (no stats): python -m launcher
7
+ """
8
+ from .entry import start_launcher
9
+
10
+ __all__ = ["start_launcher"]
@@ -0,0 +1,6 @@
1
+ """
2
+ Direct launcher entry point: python -m launcher
3
+ """
4
+ if __name__ == "__main__":
5
+ from .entry import start_launcher
6
+ start_launcher()